Skip to content

Commit 68d1072

Browse files
committed
Display location in context for lex/parse errors
Also add tests for various parsing scenarios.
1 parent ffb3496 commit 68d1072

4 files changed

Lines changed: 278 additions & 11 deletions

File tree

src/ErrorContext.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace DevTheorem\HandlebarsParser;
4+
5+
class ErrorContext
6+
{
7+
public static function getErrorContext(string $before, string $after): string
8+
{
9+
// truncate to max of 20 chars before removing line breaks
10+
$after = substr($after, 0, 20);
11+
12+
if (strlen($before) > 20) {
13+
$before = '...' . substr($before, -20);
14+
}
15+
16+
$before = str_replace(["\r\n", "\n"], '', $before);
17+
$after = str_replace(["\r\n", "\n"], '', $after);
18+
19+
return $before . $after . "\n" . str_repeat('-', strlen($before)) . '^';
20+
}
21+
}

src/ParserAbstract.php

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ protected function doParse(): Program
317317
switch ($this->errorState) {
318318
case 0:
319319
$token = $this->tokens[$this->tokenPos];
320-
throw new \Exception($this->getErrorMessage($symbol, $state, $token->line));
320+
throw new \Exception($this->getErrorMessage($symbol, $state, $token->line, $token->column));
321321
// Break missing intentionally
322322
case 1:
323323
case 2:
@@ -379,14 +379,15 @@ protected function doParse(): Program
379379
*
380380
* @return string Formatted error message
381381
*/
382-
protected function getErrorMessage(int $symbol, int $state, int $line): string
382+
protected function getErrorMessage(int $symbol, int $state, int $line, int $column): string
383383
{
384-
$expectedString = '';
385-
if ($expected = $this->getExpectedTokens($state)) {
386-
$expectedString = ': Expecting ' . implode(', ', $expected);
387-
}
384+
$expected = $this->getExpectedTokens($state);
385+
$expectedString = "Expecting " . implode(', ', $expected) . ', got';
386+
387+
[$before, $after] = $this->lexer->getPositionContext($line, $column);
388+
$context = ErrorContext::getErrorContext($before, $after);
388389

389-
return "Parse error on line {$line}{$expectedString}, got {$this->symbolToName[$symbol]}";
390+
return "Parse error on line {$line}:\n{$context}\n{$expectedString} {$this->symbolToName[$symbol]}";
390391
}
391392

392393
private function getNodeError(string $message, Node $node): string
@@ -398,7 +399,7 @@ private function getNodeError(string $message, Node $node): string
398399
/**
399400
* Get limited number of expected tokens in given state.
400401
*
401-
* @return string[] Expected tokens. If too many, an empty array is returned.
402+
* @return string[] Expected tokens. Returns a max of 10 items.
402403
*/
403404
protected function getExpectedTokens(int $state): array
404405
{
@@ -416,9 +417,9 @@ protected function getExpectedTokens(int $state): array
416417
&& $this->action[$idx] !== $this->defaultAction
417418
&& $symbol !== $this->errorSymbol
418419
) {
419-
if (count($expected) === 4) {
420+
if (count($expected) === 10) {
420421
/* Too many expected tokens */
421-
return [];
422+
return $expected;
422423
}
423424

424425
$expected[] = $name;

src/Phlexer/Phlexer.php

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace DevTheorem\HandlebarsParser\Phlexer;
44

5+
use DevTheorem\HandlebarsParser\ErrorContext;
6+
57
abstract class Phlexer
68
{
79
public const INITIAL_STATE = 'INITIAL';
@@ -79,7 +81,38 @@ public function getNextToken(): ?Token
7981
}
8082
}
8183

82-
throw new \Exception('Unexpected token: "' . $subject[0] . '"');
84+
$line = $this->getPosition()[0];
85+
$context = ErrorContext::getErrorContext(substr($this->text, 0, $this->cursor), $subject);
86+
87+
throw new \Exception("Lexical error on line $line. Unrecognized text.\n{$context}");
88+
}
89+
90+
/**
91+
* @return array{string, string}
92+
*/
93+
public function getPositionContext(int $line, int $column): array
94+
{
95+
$lineNum = 1;
96+
$cursor = 0;
97+
$textLen = strlen($this->text);
98+
99+
while ($lineNum < $line && $cursor < $textLen) {
100+
if ($this->text[$cursor] === "\n") {
101+
$lineNum++;
102+
}
103+
$cursor++;
104+
}
105+
106+
$cursor += $column;
107+
108+
if ($lineNum !== $line || $cursor > $textLen) {
109+
throw new \Exception("Invalid position $line:$column");
110+
}
111+
112+
return [
113+
substr($this->text, 0, $cursor),
114+
substr($this->text, $cursor),
115+
];
83116
}
84117

85118
/**

tests/ParserTest.php

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use PHPUnit\Framework\TestCase;
99

1010
/**
11+
* @phpstan-type ErrorCase array{template: string, expected: string}
1112
* @phpstan-type SpecToken array{name: string, text: string}
1213
* @phpstan-type SpecArr array{it: string, number?: string, template: string, expected?: string, exception?: string|true}
1314
*/
@@ -31,6 +32,217 @@ public function testParse(): void
3132
$this->assertSame(file_get_contents('tests/test1.json'), $actual);
3233
}
3334

35+
/**
36+
* @return list<array{string}>
37+
*/
38+
public static function successProvider(): array
39+
{
40+
return [
41+
['{{winner.[.test6]}}'],
42+
['{{winner.[#te.st7]}}'],
43+
['{{test8}}'],
44+
['{{[testD]}}'],
45+
['{{te.[est].endK}}'],
46+
['{{te.[est]o.endN}}'],
47+
['{{te.[e.st].endO}}'],
48+
['{{te.[e.s[t].endP}}'],
49+
['{{te.[e[s.t].endQ}}'],
50+
['{{#with items}}OK!{{/with}}'],
51+
];
52+
}
53+
54+
#[DataProvider("successProvider")]
55+
public function testExpectedSuccess(string $template): void
56+
{
57+
$parser = (new ParserFactory())->create();
58+
59+
try {
60+
$parser->parse($template);
61+
$this->expectNotToPerformAssertions();
62+
} catch (\Exception $e) {
63+
$this->fail("Unexpected exception: {$e->getMessage()}");
64+
}
65+
}
66+
67+
/**
68+
* @return list<ErrorCase>
69+
*/
70+
public static function errorProvider(): array
71+
{
72+
return [
73+
[
74+
'template' => "some\nlong\ncontext\n{{{foo}} additional\nsentence",
75+
'expected' => "Parse error on line 4:\n...longcontext{{{foo}} additionalsenten"
76+
. "\n--------------------^\nExpecting CLOSE_UNESCAPED, got CLOSE",
77+
],
78+
[
79+
'template' => '{{testerr1}}}',
80+
'expected' => "Parse error on line 1:\n{{testerr1}}}\n----------^\nExpecting CLOSE, got CLOSE_UNESCAPED",
81+
],
82+
[
83+
'template' => '{{{testerr2}}',
84+
'expected' => "Parse error on line 1:\n{{{testerr2}}\n-----------^\nExpecting CLOSE_UNESCAPED, got CLOSE",
85+
],
86+
[
87+
'template' => '{{{#testerr3}}}',
88+
'expected' => "Parse error on line 1:\n{{{#testerr3}}}\n---^\nExpecting BOOLEAN, DATA, ID, NULL, NUMBER, OPEN_SEXPR, STRING, UNDEFINED, got INVALID",
89+
],
90+
[
91+
'template' => '{{{!testerr4}}}',
92+
'expected' => "Parse error on line 1:\n{{{!testerr4}}}\n---^\nExpecting BOOLEAN, DATA, ID, NULL, NUMBER, OPEN_SEXPR, STRING, UNDEFINED, got INVALID",
93+
],
94+
[
95+
'template' => '{{{^testerr5}}}',
96+
'expected' => "Parse error on line 1:\n{{{^testerr5}}}\n---^\nExpecting BOOLEAN, DATA, ID, NULL, NUMBER, OPEN_SEXPR, STRING, UNDEFINED, got INVALID",
97+
],
98+
[
99+
'template' => '{{{/testerr6}}}',
100+
'expected' => "Parse error on line 1:\n{{{/testerr6}}}\n---^\nExpecting BOOLEAN, DATA, ID, NULL, NUMBER, OPEN_SEXPR, STRING, UNDEFINED, got SEP",
101+
],
102+
[
103+
'template' => '{{win[ner.test1}}',
104+
'expected' => "Parse error on line 1:\n{{win[ner.test1}}\n--^\nExpecting BOOLEAN, DATA, ID, NULL, NUMBER, OPEN_SEXPR, STRING, UNDEFINED, got INVALID",
105+
],
106+
[
107+
'template' => '{{win]ner.test2}}',
108+
'expected' => "Parse error on line 1:\n{{win]ner.test2}}\n--^\nExpecting BOOLEAN, DATA, ID, NULL, NUMBER, OPEN_SEXPR, STRING, UNDEFINED, got INVALID",
109+
],
110+
[
111+
'template' => '{{wi[n]ner.test3}}',
112+
'expected' => "Parse error on line 1:\n{{wi[n]ner.test3}}\n--^\nExpecting BOOLEAN, DATA, ID, NULL, NUMBER, OPEN_SEXPR, STRING, UNDEFINED, got INVALID",
113+
],
114+
[
115+
'template' => '{{winner].[test4]}}',
116+
'expected' => "Parse error on line 1:\n{{winner].[test4]}}\n--^\nExpecting BOOLEAN, DATA, ID, NULL, NUMBER, OPEN_SEXPR, STRING, UNDEFINED, got INVALID",
117+
],
118+
[
119+
'template' => '{{winner[.test5]}}',
120+
'expected' => "Parse error on line 1:\n{{winner[.test5]}}\n--^\nExpecting BOOLEAN, DATA, ID, NULL, NUMBER, OPEN_SEXPR, STRING, UNDEFINED, got INVALID",
121+
],
122+
[
123+
'template' => '{{test9]}}',
124+
'expected' => "Parse error on line 1:\n{{test9]}}\n--^\nExpecting BOOLEAN, DATA, ID, NULL, NUMBER, OPEN_SEXPR, STRING, UNDEFINED, got INVALID",
125+
],
126+
[
127+
'template' => '{{testA[}}',
128+
'expected' => "Parse error on line 1:\n{{testA[}}\n--^\nExpecting BOOLEAN, DATA, ID, NULL, NUMBER, OPEN_SEXPR, STRING, UNDEFINED, got INVALID",
129+
],
130+
[
131+
'template' => '{{[testB}}',
132+
'expected' => "Parse error on line 1:\n{{[testB}}\n--^\nExpecting BOOLEAN, DATA, ID, NULL, NUMBER, OPEN_SEXPR, STRING, UNDEFINED, got INVALID",
133+
],
134+
[
135+
'template' => '{{]testC}}',
136+
'expected' => "Parse error on line 1:\n{{]testC}}\n--^\nExpecting BOOLEAN, DATA, ID, NULL, NUMBER, OPEN_SEXPR, STRING, UNDEFINED, got INVALID",
137+
],
138+
[
139+
'template' => '{{te]stE}}',
140+
'expected' => "Parse error on line 1:\n{{te]stE}}\n--^\nExpecting BOOLEAN, DATA, ID, NULL, NUMBER, OPEN_SEXPR, STRING, UNDEFINED, got INVALID",
141+
],
142+
[
143+
'template' => '{{tee[stF}}',
144+
'expected' => "Parse error on line 1:\n{{tee[stF}}\n--^\nExpecting BOOLEAN, DATA, ID, NULL, NUMBER, OPEN_SEXPR, STRING, UNDEFINED, got INVALID",
145+
],
146+
[
147+
'template' => '{{te.e[stG}}',
148+
'expected' => "Parse error on line 1:\n{{te.e[stG}}\n-----^\nExpecting ID, got INVALID",
149+
],
150+
[
151+
'template' => '{{te.e]stH}}',
152+
'expected' => "Parse error on line 1:\n{{te.e]stH}}\n-----^\nExpecting ID, got INVALID",
153+
],
154+
[
155+
'template' => '{{te.e[st.endI}}',
156+
'expected' => "Parse error on line 1:\n{{te.e[st.endI}}\n-----^\nExpecting ID, got INVALID",
157+
],
158+
[
159+
'template' => '{{te.e]st.endJ}}',
160+
'expected' => "Parse error on line 1:\n{{te.e]st.endJ}}\n-----^\nExpecting ID, got INVALID",
161+
],
162+
[
163+
'template' => '{{te.t[est].endL}}',
164+
'expected' => "Parse error on line 1:\n{{te.t[est].endL}}\n-----^\nExpecting ID, got INVALID",
165+
],
166+
[
167+
'template' => '{{te.t[est]o.endM}}',
168+
'expected' => "Parse error on line 1:\n{{te.t[est]o.endM}}\n-----^\nExpecting ID, got INVALID",
169+
],
170+
[
171+
'template' => '<ul>{{#each item}}<li>{{name}}</li>',
172+
'expected' => "Parse error on line 1:\n...m}}<li>{{name}}</li>\n-----------------------^\nExpecting OPEN_ENDBLOCK, got EOF",
173+
],
174+
[
175+
'template' => 'issue63: {{test_join}} Test! {{this}} {{/test_join}}',
176+
'expected' => "Parse error on line 1:\n...in}} Test! {{this}} {{/test_join}}\n-----------------------^\nExpecting EOF, got OPEN_ENDBLOCK",
177+
],
178+
[
179+
'template' => '{{#if a}}TEST{{/with}}',
180+
'expected' => "if doesn't match with - 1:3",
181+
],
182+
[
183+
'template' => '{{#foo}}error{{/bar}}',
184+
'expected' => "foo doesn't match bar - 1:3",
185+
],
186+
[
187+
'template' => '{{{{foo}}}} {{ {{{{/bar}}}}',
188+
'expected' => "foo doesn't match bar - 1:4",
189+
],
190+
[
191+
'template' => '{{a=b}}',
192+
'expected' => "Parse error on line 1:\n{{a=b}}\n---^\nExpecting CLOSE, got EQUALS",
193+
],
194+
[
195+
'template' => '{{#with a}OK!{{/with}}',
196+
'expected' => "Parse error on line 1:\n{{#with a}OK!{{/with}}\n---------^\nExpecting CLOSE, got INVALID",
197+
],
198+
[
199+
'template' => '{{#each a}OK!{{/each}}',
200+
'expected' => "Parse error on line 1:\n{{#each a}OK!{{/each}}\n---------^\nExpecting CLOSE, got INVALID",
201+
],
202+
[
203+
'template' => '{{1 + 2}}',
204+
'expected' => "Parse error on line 1:\n{{1 + 2}}\n----^\nExpecting CLOSE, got INVALID",
205+
],
206+
[
207+
'template' => '{{{{#foo}}}',
208+
'expected' => "Parse error on line 1:\n{{{{#foo}}}\n----^\nExpecting BOOLEAN, DATA, ID, NULL, NUMBER, OPEN_SEXPR, STRING, UNDEFINED, got INVALID",
209+
],
210+
[
211+
'template' => '{{foo (foo (foo 1 2) 3))}}',
212+
'expected' => "Parse error on line 1:\n...oo (foo (foo 1 2) 3))}}\n-----------------------^\nExpecting CLOSE, got CLOSE_SEXPR",
213+
],
214+
[
215+
'template' => '{{{{foo}}}} {{ {{{{#foo}}}}',
216+
'expected' => "Lexical error on line 1. Unrecognized text.\n{{{{foo}}}} {{ {{{{#foo}}}}\n-------------------^",
217+
],
218+
[
219+
'template' => '{{else}}',
220+
'expected' => "Parse error on line 1:\n{{else}}\n^\nExpecting EOF, got INVERSE",
221+
],
222+
[
223+
'template' => '{{#>foo}}bar',
224+
'expected' => "Parse error on line 1:\n{{#>foo}}bar\n------------^\nExpecting OPEN_ENDBLOCK, got EOF",
225+
],
226+
[
227+
'template' => '{{ #2 }}',
228+
'expected' => "Parse error on line 1:\n{{ #2 }}\n---^\nExpecting BOOLEAN, DATA, ID, NULL, NUMBER, OPEN_SEXPR, STRING, UNDEFINED, got INVALID",
229+
],
230+
];
231+
}
232+
233+
#[DataProvider("errorProvider")]
234+
public function testErrors(string $template, string $expected): void
235+
{
236+
$parser = (new ParserFactory())->create();
237+
238+
try {
239+
$parser->parse($template);
240+
$this->fail("Expected to throw exception: {$expected}");
241+
} catch (\Exception $e) {
242+
$this->assertSame($expected, $e->getMessage());
243+
}
244+
}
245+
34246
/**
35247
* @return \Generator<array{0: SpecArr}>
36248
*/

0 commit comments

Comments
 (0)