Fix compiler failing with nested with expression

The previous implementation of `WithParser` used regex, which struggles
with parsing nested structures correctly. This commit improves
`WithParser` to track and parse all nested `with` expressions.

Other improvements:

- Throw meaningful errors when syntax is wrong. Replacing the prior
  behavior of silently ignoring such issues.
- Remove `I` prefix from related interfaces to align with newer code
  conventions.
- Add more unit tests for `with` expression.
- Improve documentation for templating.
- `ExpressionRegexBuilder`:
  - Use words `capture` and `match` correctly.
  - Fix minor issues revealed by new and improved tests:
     - Change regex for matching anything except surrounding
       whitespaces. The new regex ensures that it works even without
       having any preceeding text.
     - Change regex for capturing pipelines. The old regex was only
       matching (non-greedy) first character of the pipeline in tests,
       new regex matches the full pipeline.
- `ExpressionRegexBuilder.spec.ts`:
  - Ensure consistent way to define `describe` and `it` blocks.
  - Replace `expectRegex` tests, regex expectations test internal
    behavior of the class, not the external.
  - Simplified tests by eliminating the need for UUID suffixes/prefixes.
This commit is contained in:
undergroundwires
2023-10-25 19:39:12 +02:00
parent dfd4451561
commit 80821fca07
7 changed files with 976 additions and 421 deletions

View File

@@ -7,15 +7,15 @@ import { SyntaxParserTestsRunner } from './SyntaxParserTestsRunner';
describe('WithParser', () => {
const sut = new WithParser();
const runner = new SyntaxParserTestsRunner(sut);
describe('finds as expected', () => {
describe('correctly identifies `with` syntax', () => {
runner.expectPosition(
{
name: 'when no scope is not used',
name: 'when no context variable is not used',
code: 'hello {{ with $parameter }}no usage{{ end }} here',
expected: [new ExpressionPosition(6, 44)],
},
{
name: 'when scope is used',
name: 'when context variable is used',
code: 'used here ({{ with $parameter }}value: {{.}}{{ end }})',
expected: [new ExpressionPosition(11, 53)],
},
@@ -25,38 +25,70 @@ describe('WithParser', () => {
expected: [new ExpressionPosition(7, 51), new ExpressionPosition(61, 99)],
},
{
name: 'tolerate lack of whitespaces',
name: 'when nested',
code: 'outer: {{ with $outer }}outer value with context variable: {{ . }}, inner: {{ with $inner }}inner value{{ end }}.{{ end }}',
expected: [
/* outer: */ new ExpressionPosition(7, 122),
/* inner: */ new ExpressionPosition(77, 112),
],
},
{
name: 'whitespaces: tolerate lack of whitespaces',
code: 'no whitespaces {{with $parameter}}value: {{ . }}{{end}}',
expected: [new ExpressionPosition(15, 55)],
},
{
name: 'match multiline text',
name: 'newlines: match multiline text',
code: 'non related line\n{{ with $middleLine }}\nline before value\n{{ . }}\nline after value\n{{ end }}\nnon related line',
expected: [new ExpressionPosition(17, 92)],
},
{
name: 'newlines: does not match newlines before',
code: '\n{{ with $unimportant }}Text{{ end }}',
expected: [new ExpressionPosition(1, 37)],
},
{
name: 'newlines: does not match newlines after',
code: '{{ with $unimportant }}Text{{ end }}\n',
expected: [new ExpressionPosition(0, 36)],
},
);
});
describe('throws with incorrect `with` syntax', () => {
runner.expectThrows(
{
name: 'incorrect `with`: whitespace after dollar sign inside `with` statement',
code: '{{with $ parameter}}value: {{ . }}{{ end }}',
expectedError: 'Context variable before `with` statement.',
},
{
name: 'incorrect `with`: whitespace before dollar sign inside `with` statement',
code: '{{ with$parameter}}value: {{ . }}{{ end }}',
expectedError: 'Context variable before `with` statement.',
},
{
name: 'incorrect `with`: missing `with` statement',
code: '{{ when $parameter}}value: {{ . }}{{ end }}',
expectedError: 'Context variable before `with` statement.',
},
{
name: 'incorrect `end`: missing `end` statement',
code: '{{ with $parameter}}value: {{ . }}{{ fin }}',
expectedError: 'Missing `end` statement, forgot `{{ end }}?',
},
{
name: 'incorrect `end`: used without `with`',
code: 'Value {{ end }}',
expectedError: 'Redundant `end` statement, missing `with`?',
},
{
name: 'incorrect "context variable": used without `with`',
code: 'Value: {{ . }}',
expectedError: 'Context variable before `with` statement.',
},
);
});
describe('ignores when syntax is wrong', () => {
describe('ignores expression if "with" syntax is wrong', () => {
runner.expectNoMatch(
{
name: 'does not tolerate whitespace after with',
code: '{{with $ parameter}}value: {{ . }}{{ end }}',
},
{
name: 'does not tolerate whitespace before dollar',
code: '{{ with$parameter}}value: {{ . }}{{ end }}',
},
{
name: 'wrong text at scope end',
code: '{{ with$parameter}}value: {{ . }}{{ fin }}',
},
{
name: 'wrong text at expression start',
code: '{{ when $parameter}}value: {{ . }}{{ end }}',
},
);
});
describe('does not render argument if substitution syntax is wrong', () => {
runner.expectResults(
{
@@ -83,54 +115,73 @@ describe('WithParser', () => {
);
});
});
describe('renders scope conditionally', () => {
describe('does not render scope if argument is undefined', () => {
runner.expectResults(
...getAbsentStringTestCases().map((testCase) => ({
name: `does not render when value is "${testCase.valueName}"`,
code: '{{ with $parameter }}dark{{ end }} ',
args: (args) => args
.withArgument('parameter', testCase.absentValue),
expected: [''],
})),
{
name: 'does not render when argument is not provided',
code: '{{ with $parameter }}dark{{ end }}',
args: (args) => args,
expected: [''],
},
);
describe('scope rendering', () => {
describe('conditional rendering based on argument value', () => {
describe('does not render scope', () => {
runner.expectResults(
...getAbsentStringTestCases().map((testCase) => ({
name: `does not render when value is "${testCase.valueName}"`,
code: '{{ with $parameter }}dark{{ end }} ',
args: (args) => args
.withArgument('parameter', testCase.absentValue),
expected: [''],
})),
{
name: 'does not render when argument is not provided',
code: '{{ with $parameter }}dark{{ end }}',
args: (args) => args,
expected: [''],
},
);
});
describe('renders scope', () => {
runner.expectResults(
...getAbsentStringTestCases().map((testCase) => ({
name: `does not render when value is "${testCase.valueName}"`,
code: '{{ with $parameter }}dark{{ end }} ',
args: (args) => args
.withArgument('parameter', testCase.absentValue),
expected: [''],
})),
{
name: 'does not render when argument is not provided',
code: '{{ with $parameter }}dark{{ end }}',
args: (args) => args,
expected: [''],
},
{
name: 'renders scope even if value is not used',
code: '{{ with $parameter }}Hello world!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello'),
expected: ['Hello world!'],
},
{
name: 'renders value when it has value',
code: '{{ with $parameter }}{{ . }} world!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello'),
expected: ['Hello world!'],
},
{
name: 'renders value when whitespaces around brackets are missing',
code: '{{ with $parameter }}{{.}} world!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello'),
expected: ['Hello world!'],
},
{
name: 'renders value multiple times when it\'s used multiple times',
code: '{{ with $letterL }}He{{ . }}{{ . }}o wor{{ . }}d!{{ end }}',
args: (args) => args
.withArgument('letterL', 'l'),
expected: ['Hello world!'],
},
);
});
});
describe('render scope when variable has value', () => {
describe('whitespace handling inside scope', () => {
runner.expectResults(
{
name: 'renders scope even if value is not used',
code: '{{ with $parameter }}Hello world!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello'),
expected: ['Hello world!'],
},
{
name: 'renders value when it has value',
code: '{{ with $parameter }}{{ . }} world!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello'),
expected: ['Hello world!'],
},
{
name: 'renders value when whitespaces around brackets are missing',
code: '{{ with $parameter }}{{.}} world!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello'),
expected: ['Hello world!'],
},
{
name: 'renders value multiple times when it\'s used multiple times',
code: '{{ with $letterL }}He{{ . }}{{ . }}o wor{{ . }}d!{{ end }}',
args: (args) => args
.withArgument('letterL', 'l'),
expected: ['Hello world!'],
},
{
name: 'renders value in multi-lined text',
code: '{{ with $middleLine }}line before value\n{{ . }}\nline after value{{ end }}',
@@ -145,42 +196,71 @@ describe('WithParser', () => {
.withArgument('middleLine', 'value line'),
expected: ['line before value\nvalue line\nline after value'],
},
{
name: 'does not render trailing whitespace after value',
code: '{{ with $parameter }}{{ . }}! {{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
{
name: 'does not render trailing newline after value',
code: '{{ with $parameter }}{{ . }}!\r\n{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
{
name: 'does not render leading newline before value',
code: '{{ with $parameter }}\r\n{{ . }}!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
{
name: 'does not render leading whitespaces before value',
code: '{{ with $parameter }} {{ . }}!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
{
name: 'does not render leading newline and whitespaces before value',
code: '{{ with $parameter }}\r\n {{ . }}!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
);
});
describe('nested with statements', () => {
runner.expectResults(
{
name: 'renders nested with statements correctly',
code: '{{ with $outer }}Outer: {{ with $inner }}Inner: {{ . }}{{ end }}, Outer again: {{ . }}{{ end }}',
args: (args) => args
.withArgument('outer', 'OuterValue')
.withArgument('inner', 'InnerValue'),
expected: [
'Inner: InnerValue',
'Outer: {{ with $inner }}Inner: {{ . }}{{ end }}, Outer again: OuterValue',
],
},
{
name: 'renders nested with statements with context variables',
code: '{{ with $outer }}{{ with $inner }}{{ . }}{{ . }}{{ end }}{{ . }}{{ end }}',
args: (args) => args
.withArgument('outer', 'O')
.withArgument('inner', 'I'),
expected: [
'II',
'{{ with $inner }}{{ . }}{{ . }}{{ end }}O',
],
},
);
});
});
describe('ignores trailing and leading whitespaces and newlines inside scope', () => {
runner.expectResults(
{
name: 'does not render trailing whitespace after value',
code: '{{ with $parameter }}{{ . }}! {{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
{
name: 'does not render trailing newline after value',
code: '{{ with $parameter }}{{ . }}!\r\n{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
{
name: 'does not render leading newline before value',
code: '{{ with $parameter }}\r\n{{ . }}!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
{
name: 'does not render leading whitespace before value',
code: '{{ with $parameter }} {{ . }}!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
);
});
describe('compiles pipes in scope as expected', () => {
describe('pipe behavior', () => {
runner.expectPipeHits({
codeBuilder: (pipeline) => `{{ with $argument }} {{ .${pipeline}}} {{ end }}`,
parameterName: 'argument',