Skip to content

Commit 8ef2d13

Browse files
committed
Dedent: fixed inline content and atLineStart tracking [Closes #412][Closes #413][Closes #414]
1 parent 85f496e commit 8ef2d13

3 files changed

Lines changed: 286 additions & 68 deletions

File tree

src/Latte/Compiler/Dedent.php

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
<?php declare(strict_types=1);
2+
3+
/**
4+
* This file is part of the Latte (https://latte.nette.org)
5+
* Copyright (c) 2008 David Grudl (https://davidgrudl.com)
6+
*/
7+
8+
namespace Latte\Compiler;
9+
10+
use Latte\CompileException;
11+
use Latte\Compiler\Nodes\AreaNode;
12+
use Latte\Compiler\Nodes\FragmentNode;
13+
use function count, explode, implode, preg_match, str_ends_with, str_starts_with, strlen, substr, trim;
14+
15+
16+
/**
17+
* Removes one level of indentation introduced by paired Latte tags.
18+
*
19+
* Each paired tag ({if}, {foreach}, …) strips exactly one indent level
20+
* from its content. The indent unit is auto-detected from the first
21+
* content line; the tag's source column determines how much structural
22+
* indent to preserve.
23+
*/
24+
final class Dedent
25+
{
26+
public static function apply(FragmentNode $fragment, Tag $startTag): void
27+
{
28+
$tagIndentLen = $startTag->position->column - 1;
29+
30+
$textNodes = [];
31+
self::collectTextNodes($fragment, $textNodes);
32+
33+
$baseIndent = self::detectIndent($textNodes, $startTag);
34+
if ($baseIndent === null || strlen($baseIndent) <= $tagIndentLen) {
35+
return;
36+
}
37+
38+
self::stripIndent($textNodes, $baseIndent, $tagIndentLen);
39+
}
40+
41+
42+
/** @param list<Nodes\TextNode> $out */
43+
private static function collectTextNodes(Node $node, array &$out): void
44+
{
45+
if ($node instanceof Nodes\TextNode) {
46+
$out[] = $node;
47+
} elseif ($node instanceof Nodes\Html\AttributeNode) {
48+
// skip attribute values
49+
} elseif ($node instanceof Nodes\Html\ElementNode) {
50+
if ($node->content) {
51+
self::collectTextNodes($node->content, $out);
52+
}
53+
} else {
54+
foreach ($node as $child) {
55+
if ($child instanceof AreaNode) {
56+
self::collectTextNodes($child, $out);
57+
}
58+
}
59+
}
60+
}
61+
62+
63+
/** @param list<Nodes\TextNode> $textNodes */
64+
private static function detectIndent(array $textNodes, Tag $startTag): ?string
65+
{
66+
$inlineChecked = false;
67+
$lastNonEmpty = -1;
68+
for ($k = count($textNodes) - 1; $k >= 0; $k--) {
69+
if ($textNodes[$k]->content !== '') {
70+
$lastNonEmpty = $k;
71+
break;
72+
}
73+
}
74+
75+
foreach ($textNodes as $idx => $node) {
76+
if ($node->content === '') {
77+
continue;
78+
}
79+
80+
if (!$inlineChecked) {
81+
$inlineChecked = true;
82+
if ($node->position?->line === $startTag->position->line) {
83+
return null;
84+
}
85+
}
86+
87+
$firstLineAtStart = $node->position?->column === 1;
88+
$lines = explode("\n", $node->content);
89+
$lineCount = count($lines);
90+
$lastContinues = !str_ends_with($node->content, "\n") && $idx < $lastNonEmpty;
91+
92+
foreach ($lines as $j => $line) {
93+
$isLineStart = $j === 0 ? $firstLineAtStart : true;
94+
if (!$isLineStart || $line === '') {
95+
continue;
96+
}
97+
98+
$hasContent = trim($line) !== '';
99+
$continuesWithExpr = !$hasContent && $j === $lineCount - 1 && $lastContinues;
100+
101+
if ($hasContent) {
102+
preg_match('/^(\t+| +)/', $line, $m);
103+
return $m[1] ?? null;
104+
} elseif ($continuesWithExpr) {
105+
return $line;
106+
}
107+
}
108+
}
109+
110+
return null;
111+
}
112+
113+
114+
/** @param list<Nodes\TextNode> $textNodes */
115+
private static function stripIndent(array $textNodes, string $baseIndent, int $tagIndentLen): void
116+
{
117+
foreach ($textNodes as $node) {
118+
if ($node->content === '') {
119+
continue;
120+
}
121+
122+
$firstLineAtStart = $node->position?->column === 1;
123+
$lines = explode("\n", $node->content);
124+
$modified = false;
125+
126+
foreach ($lines as $j => &$line) {
127+
$isLineStart = $j === 0 ? $firstLineAtStart : true;
128+
if (!$isLineStart || $line === '') {
129+
continue;
130+
}
131+
132+
if (str_starts_with($line, $baseIndent)) {
133+
$line = substr($line, 0, $tagIndentLen) . substr($line, strlen($baseIndent));
134+
$modified = true;
135+
} elseif (trim($line) !== '') {
136+
throw new CompileException('Inconsistent indentation.', $node->position ? new Position($node->position->line + $j, 1) : null);
137+
}
138+
}
139+
140+
unset($line);
141+
if ($modified) {
142+
$node->content = implode("\n", $lines);
143+
}
144+
}
145+
}
146+
}

src/Latte/Compiler/TemplateParser.php

Lines changed: 2 additions & 61 deletions
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, explode, implode, in_array, preg_match, str_ends_with, str_starts_with, strlen, substr, trim, ucfirst;
18+
use function array_keys, array_splice, count, end, in_array, preg_match, str_ends_with, str_starts_with, substr, trim, ucfirst;
1919

2020

2121
/**
@@ -214,7 +214,7 @@ public function parseLatteStatement(?\Closure $resolver = null): ?Node
214214
$this->lookFor[$startTag] = $res->current() ?: null;
215215
$content = $this->parseFragment($resolver ?? $this->lastResolver);
216216
if ($this->dedent) {
217-
$this->applyDedent($content);
217+
Dedent::apply($content, $startTag);
218218
}
219219

220220
if (!$this->stream->is(Token::Latte_TagOpen)) {
@@ -494,63 +494,4 @@ public function isTagAllowed(string $name): bool
494494
{
495495
return !$this->policy || $this->policy->isTagAllowed($name);
496496
}
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-
}
556497
}

0 commit comments

Comments
 (0)