Skip to content

Commit d60d472

Browse files
committed
added Feature::Dedent
1 parent 994e3ba commit d60d472

4 files changed

Lines changed: 181 additions & 1 deletion

File tree

src/Latte/Compiler/TemplateParser.php

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
use Latte\Policy;
1616
use Latte\Runtime\Template;
1717
use Latte\SecurityViolationException;
18-
use function array_keys, array_splice, count, end, in_array, preg_match, str_ends_with, str_starts_with, substr, trim, ucfirst;
18+
use function array_keys, array_splice, count, end, explode, implode, in_array, preg_match, str_ends_with, str_starts_with, strlen, substr, trim, ucfirst;
1919

2020

2121
/**
@@ -28,6 +28,7 @@ final class TemplateParser
2828
public int $blockLayer = Template::LayerTop;
2929
public bool $inHead = true;
3030
public bool $strict = false;
31+
public bool $dedent = false;
3132
public ?Nodes\TextNode $lastIndentation = null;
3233

3334
/** @var array<string, \Closure(Tag, self): (Node|\Generator|void)> */
@@ -212,6 +213,9 @@ public function parseLatteStatement(?\Closure $resolver = null): ?Node
212213
while ($res->valid()) {
213214
$this->lookFor[$startTag] = $res->current() ?: null;
214215
$content = $this->parseFragment($resolver ?? $this->lastResolver);
216+
if ($this->dedent) {
217+
$this->applyDedent($content);
218+
}
215219

216220
if (!$this->stream->is(Token::Latte_TagOpen)) {
217221
$this->checkEndTag($startTag, null);
@@ -490,4 +494,63 @@ public function isTagAllowed(string $name): bool
490494
{
491495
return !$this->policy || $this->policy->isTagAllowed($name);
492496
}
497+
498+
499+
private function applyDedent(FragmentNode $fragment): void
500+
{
501+
$baseIndent = null;
502+
$atLineStart = true;
503+
504+
foreach ($fragment->children as $i => $child) {
505+
if (!$child instanceof Nodes\TextNode) {
506+
$atLineStart = false;
507+
continue;
508+
}
509+
510+
$lines = explode("\n", $child->content);
511+
$lineCount = count($lines);
512+
513+
foreach ($lines as $j => &$line) {
514+
$isLineStart = $j === 0 ? $atLineStart : true;
515+
if (!$isLineStart || $line === '') {
516+
continue;
517+
}
518+
519+
$hasContent = trim($line) !== '';
520+
$continuesWithExpr = !$hasContent
521+
&& $j === $lineCount - 1
522+
&& !str_ends_with($child->content, "\n")
523+
&& $i + 1 < count($fragment->children);
524+
525+
if ($baseIndent === null) {
526+
if ($hasContent) {
527+
preg_match('/^([ \t]+)/', $line, $m);
528+
$baseIndent = $m[1] ?? null;
529+
if ($baseIndent === null) {
530+
return; // first content line has no indent
531+
}
532+
533+
} elseif ($continuesWithExpr) {
534+
$baseIndent = $line;
535+
536+
} else {
537+
continue; // blank line before detection
538+
}
539+
540+
} elseif (!str_starts_with($line, $baseIndent)) {
541+
if ($hasContent || $continuesWithExpr) {
542+
throw new CompileException('Inconsistent indentation.', $child->position ? new Position($child->position->line + $j, 1) : null);
543+
}
544+
545+
continue; // blank line — strip silently
546+
}
547+
548+
$line = substr($line, strlen((string) $baseIndent));
549+
}
550+
551+
unset($line);
552+
$child->content = implode("\n", $lines);
553+
$atLineStart = str_ends_with($child->content, "\n");
554+
}
555+
}
493556
}

src/Latte/Engine.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ public function parse(string $template): TemplateNode
158158
$parser = new Compiler\TemplateParser;
159159
$parser->getLexer()->setSyntax($this->syntax);
160160
$parser->strict = $this->hasFeature(Feature::StrictParsing);
161+
$parser->dedent = $this->hasFeature(Feature::Dedent);
161162

162163
foreach ($this->extensions as $extension) {
163164
$extension->beforeCompile($this);

src/Latte/Feature.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,7 @@ enum Feature
2424

2525
/** Variables from {foreach} exist only within the loop */
2626
case ScopedLoopVariables;
27+
28+
/** Removes structural indentation from paired tag content */
29+
case Dedent;
2730
}

tests/common/dedent.phpt

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php declare(strict_types=1);
2+
3+
/**
4+
* Test: Feature::Dedent
5+
*/
6+
7+
use Tester\Assert;
8+
9+
require __DIR__ . '/../bootstrap.php';
10+
11+
12+
function dedent(string $template, array $params = []): string
13+
{
14+
$latte = createLatte();
15+
$latte->setFeature(Latte\Feature::Dedent);
16+
return $latte->renderToString($template, $params);
17+
}
18+
19+
20+
test('feature disabled by default', function () {
21+
$latte = createLatte();
22+
Assert::same(" Hello\n", $latte->renderToString("{if true}\n Hello\n{/if}"));
23+
});
24+
25+
26+
test('basic dedent with spaces', function () {
27+
Assert::same("Hello\n", dedent("{if true}\n Hello\n{/if}"));
28+
});
29+
30+
31+
test('basic dedent with tab', function () {
32+
Assert::same("Hello\n", dedent("{if true}\n\tHello\n{/if}"));
33+
});
34+
35+
36+
test('multiple lines', function () {
37+
Assert::same("Hello\nWorld\n", dedent("{if true}\n\tHello\n\tWorld\n{/if}"));
38+
});
39+
40+
41+
test('deeper indentation preserved', function () {
42+
Assert::same("Hello\n\tIndented\n", dedent("{if true}\n\tHello\n\t\tIndented\n{/if}"));
43+
});
44+
45+
46+
test('nested tags', function () {
47+
Assert::same("Hello\n", dedent("{if true}\n\t{if true}\n\t\tHello\n\t{/if}\n{/if}"));
48+
});
49+
50+
51+
test('nested tags with expression', function () {
52+
$result = dedent("{if true}\n\t{if true}\n\t\t{=\$x}\n\t{/if}\n{/if}", ['x' => 'val']);
53+
Assert::same("val\n", $result);
54+
});
55+
56+
57+
test('if/else branches dedented independently', function () {
58+
Assert::same("A\n", dedent("{if true}\n\tA\n{else}\n\tB\n{/if}"));
59+
Assert::same("B\n", dedent("{if false}\n\tA\n{else}\n\tB\n{/if}"));
60+
});
61+
62+
63+
test('foreach', function () {
64+
$result = dedent("{foreach \$items as \$item}\n\t{\$item}\n{/foreach}", ['items' => ['a', 'b']]);
65+
Assert::same("a\nb\n", $result);
66+
});
67+
68+
69+
test('no indentation - no change', function () {
70+
Assert::same("Hello\n", dedent("{if true}\nHello\n{/if}"));
71+
});
72+
73+
74+
test('expression on indented line', function () {
75+
$result = dedent("{if true}\n\t{=\$x}\n{/if}", ['x' => 'val']);
76+
Assert::same("val\n", $result);
77+
});
78+
79+
80+
test('mixed text and expression on same line', function () {
81+
$result = dedent("{if true}\n\tHello {=\$x} World\n{/if}", ['x' => 'dear']);
82+
Assert::same("Hello dear World\n", $result);
83+
});
84+
85+
86+
test('block tag', function () {
87+
Assert::same("Hello\n", dedent("{block test}\n\tHello\n{/block}"));
88+
});
89+
90+
91+
test('capture tag', function () {
92+
Assert::same("Hello\n", dedent("{capture \$var}\n\tHello\n{/capture}{\$var}"));
93+
});
94+
95+
96+
test('inconsistent indentation throws exception', function () {
97+
Assert::exception(
98+
fn() => dedent("{if true}\n\tHello\nWorld\n{/if}"),
99+
Latte\CompileException::class,
100+
'Inconsistent indentation%a%',
101+
);
102+
});
103+
104+
105+
test('inconsistent indentation reports line number', function () {
106+
try {
107+
dedent("{if true}\n\tHello\nWorld\n{/if}");
108+
} catch (Latte\CompileException $e) {
109+
Assert::same(3, $e->position->line);
110+
return;
111+
}
112+
Assert::fail('Exception expected');
113+
});

0 commit comments

Comments
 (0)