diff --git a/src-web/components/core/Editor/twig/twig.grammar b/src-web/components/core/Editor/twig/twig.grammar index 5706e200..6ca447c4 100644 --- a/src-web/components/core/Editor/twig/twig.grammar +++ b/src-web/components/core/Editor/twig/twig.grammar @@ -11,7 +11,7 @@ } @tokens { - Text { ![$] Text? | "$" (@eof | ![{] Text?) } + Text { ![$] Text? | "$" (@eof | ![{] Text? | "{" ![[] Text?) } } @external propSource highlight from "./highlight" diff --git a/src-web/components/core/Editor/twig/twig.terms.ts b/src-web/components/core/Editor/twig/twig.terms.ts index 7ac3c791..d6bda1a3 100644 --- a/src-web/components/core/Editor/twig/twig.terms.ts +++ b/src-web/components/core/Editor/twig/twig.terms.ts @@ -1,7 +1,8 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. -export const Template = 1, +export const + Template = 1, Tag = 2, TagOpen = 3, TagContent = 4, TagClose = 5, - Text = 6; + Text = 6 diff --git a/src-web/components/core/Editor/twig/twig.test.ts b/src-web/components/core/Editor/twig/twig.test.ts new file mode 100644 index 00000000..706cb4db --- /dev/null +++ b/src-web/components/core/Editor/twig/twig.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from 'vitest'; +import { parser } from './twig'; + +function getNodeNames(input: string): string[] { + const tree = parser.parse(input); + const nodes: string[] = []; + const cursor = tree.cursor(); + do { + if (cursor.name !== 'Template') { + nodes.push(cursor.name); + } + } while (cursor.next()); + return nodes; +} + +function hasTag(input: string): boolean { + return getNodeNames(input).includes('Tag'); +} + +function hasError(input: string): boolean { + return getNodeNames(input).includes('⚠'); +} + +describe('twig grammar', () => { + describe('${[var]} format (valid template tags)', () => { + test('parses simple variable as Tag', () => { + expect(hasTag('${[var]}')).toBe(true); + expect(hasError('${[var]}')).toBe(false); + }); + + test('parses variable with whitespace as Tag', () => { + expect(hasTag('${[ var ]}')).toBe(true); + expect(hasError('${[ var ]}')).toBe(false); + }); + + test('parses embedded variable as Tag', () => { + expect(hasTag('hello ${[name]} world')).toBe(true); + expect(hasError('hello ${[name]} world')).toBe(false); + }); + + test('parses function call as Tag', () => { + expect(hasTag('${[fn()]}')).toBe(true); + expect(hasError('${[fn()]}')).toBe(false); + }); + }); + + describe('${var} format (should be plain text, not tags)', () => { + test('parses ${var} as plain Text without errors', () => { + expect(hasTag('${var}')).toBe(false); + expect(hasError('${var}')).toBe(false); + }); + + test('parses embedded ${var} as plain Text', () => { + expect(hasTag('hello ${name} world')).toBe(false); + expect(hasError('hello ${name} world')).toBe(false); + }); + + test('parses JSON with ${var} as plain Text', () => { + const json = '{"key": "${value}"}'; + expect(hasTag(json)).toBe(false); + expect(hasError(json)).toBe(false); + }); + + test('parses multiple ${var} as plain Text', () => { + expect(hasTag('${a} and ${b}')).toBe(false); + expect(hasError('${a} and ${b}')).toBe(false); + }); + }); + + describe('mixed content', () => { + test('distinguishes ${var} from ${[var]} in same string', () => { + const input = '${plain} and ${[tag]}'; + expect(hasTag(input)).toBe(true); + expect(hasError(input)).toBe(false); + }); + + test('parses JSON with ${[var]} as having Tag', () => { + const json = '{"key": "${[value]}"}'; + expect(hasTag(json)).toBe(true); + expect(hasError(json)).toBe(false); + }); + }); + + describe('edge cases', () => { + test('handles $ at end of string', () => { + expect(hasError('hello$')).toBe(false); + expect(hasTag('hello$')).toBe(false); + }); + + test('handles ${ at end of string without crash', () => { + // Incomplete syntax may produce errors, but should not crash + expect(() => parser.parse('hello${')).not.toThrow(); + }); + + test('handles ${[ without closing without crash', () => { + // Unclosed tag may produce partial match, but should not crash + expect(() => parser.parse('${[unclosed')).not.toThrow(); + }); + + test('handles empty ${[]}', () => { + // Empty tags may or may not be valid depending on grammar + // Just ensure no crash + expect(() => parser.parse('${[]}')).not.toThrow(); + }); + }); +}); diff --git a/src-web/components/core/Editor/twig/twig.ts b/src-web/components/core/Editor/twig/twig.ts index 20c2ff32..41f91be7 100644 --- a/src-web/components/core/Editor/twig/twig.ts +++ b/src-web/components/core/Editor/twig/twig.ts @@ -1,20 +1,18 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. -import { LocalTokenGroup, LRParser } from '@lezer/lr'; -import { highlight } from './highlight'; +import {LRParser, LocalTokenGroup} from "@lezer/lr" +import {highlight} from "./highlight" export const parser = LRParser.deserialize({ version: 14, - states: - "!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d", - stateData: 'g~OUROYPO~OSTO~OSTOTXO~O', - goto: 'nXPPY^PPPbhTROSTQOSQSORVSQUQRWU', - nodeNames: '⚠ Template Tag TagOpen TagContent TagClose Text', + states: "!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d", + stateData: "g~OUROYPO~OSTO~OSTOTXO~O", + goto: "nXPPY^PPPbhTROSTQOSQSORVSQUQRWU", + nodeNames: "⚠ Template Tag TagOpen TagContent TagClose Text", maxTerm: 10, propSources: [highlight], skippedNodes: [0], repeatNodeCount: 2, - tokenData: - "#]~RTOtbtu!hu;'Sb;'S;=`!]<%lOb~gTU~Otbtuvu;'Sb;'S;=`!]<%lOb~yUO#ob#p;'Sb;'S;=`!]<%l~b~Ob~~!c~!`P;=`<%lb~!hOU~~!kVO#ob#o#p#Q#p;'Sb;'S;=`!]<%l~b~Ob~~!c~#TP!}#O#W~#]OY~", - tokenizers: [1, new LocalTokenGroup('b~RP#P#QU~XP#q#r[~aOT~~', 17, 4)], - topRules: { Template: [0, 1] }, - tokenPrec: 0, -}); + tokenData: "#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTU~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOU~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OY~", + tokenizers: [1, new LocalTokenGroup("b~RP#P#QU~XP#q#r[~aOT~~", 17, 4)], + topRules: {"Template":[0,1]}, + tokenPrec: 0 +})