refine tag management

This commit is contained in:
2026-08-19 14:12:48 +02:00
parent 300864a15e
commit 3c0695f8f2
6 changed files with 380 additions and 13 deletions
+77
View File
@@ -0,0 +1,77 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const test = require('node:test');
const oniguruma = require('vscode-oniguruma');
const textmate = require('vscode-textmate');
async function loadGrammar() {
await oniguruma.loadWASM(fs.readFileSync(require.resolve('vscode-oniguruma/release/onig.wasm')).buffer);
const registry = new textmate.Registry({
onigLib: Promise.resolve({
createOnigScanner: patterns => new oniguruma.OnigScanner(patterns),
createOnigString: value => new oniguruma.OnigString(value)
}),
loadGrammar: async scopeName => scopeName === 'source.eftl'
? textmate.parseRawGrammar(
fs.readFileSync('syntaxes/eftl.tmLanguage.json', 'utf8'),
'eftl.tmLanguage.json'
)
: null
});
return registry.loadGrammar('source.eftl');
}
test('highlights nested VAR blocks, self-closing functions, and WHILE attributes', async () => {
const grammar = await loadGrammar();
const source = [
'[EFTL]',
'[VAR name="count" type="number"][SIZE_OF varname="items" /][/VAR]',
'[WHILE threshold="99"][CONDITION][% count > 0 %][/CONDITION][DO]',
'[VAR name="current" type="string"][VALUE_OF varname="items" index="count" /][/VAR]',
'[/DO][/WHILE]',
'[IF][CONDITION][% count == 0 %][/CONDITION][THEN]',
'[VAR name="empty" type="boolean"][% empty = true; %][/VAR]',
'[/THEN][/IF]',
'[/EFTL]'
];
let ruleStack = textmate.INITIAL;
const variableTokens = [];
const selfClosingFunctionTokens = [];
const thresholdTokens = [];
const thresholdValueTokens = [];
const whileTokens = [];
for (const line of source) {
const result = grammar.tokenizeLine(line, ruleStack);
ruleStack = result.ruleStack;
for (const token of result.tokens) {
const text = line.slice(token.startIndex, token.endIndex);
if (text === 'VAR' && line[token.startIndex - 1] !== '/') variableTokens.push(token);
if (text === 'SIZE_OF' || text === 'VALUE_OF') selfClosingFunctionTokens.push(token);
if (text === 'threshold') thresholdTokens.push(token);
if (text === '"99"') thresholdValueTokens.push(token);
if (text === 'WHILE') whileTokens.push(token);
}
}
assert.equal(variableTokens.length, 3);
for (const token of variableTokens) {
assert.ok(token.scopes.includes('storage.type.variable.eftl'));
}
for (const token of selfClosingFunctionTokens) {
assert.ok(token.scopes.includes('meta.function.eftl'));
assert.ok(!token.scopes.includes('meta.function.block.eftl'));
}
assert.equal(thresholdTokens.length, 1);
assert.ok(thresholdTokens[0].scopes.includes('entity.other.attribute-name.eftl'));
assert.equal(thresholdValueTokens.length, 1);
assert.ok(thresholdValueTokens[0].scopes.includes('string.quoted.double.eftl'));
assert.equal(whileTokens.length, 1);
assert.ok(whileTokens[0].scopes.includes('keyword.control.loop.eftl'));
});
+157 -8
View File
@@ -10,24 +10,45 @@ function parse(source) {
return new EftlParser(tokens).parse().errors;
}
function messages(source) {
return parse(source).map(error => error.message);
}
test('accepts a correctly closed VAR tag', () => {
assert.deepEqual(parse('[VAR][/VAR]'), []);
assert.deepEqual(parse('[EFTL][VAR][/VAR][/EFTL]'), []);
});
test('reports a VAR tag without its closing tag', () => {
assert.deepEqual(parse('[VAR]'), [{
message: 'Unclosed tag: [VAR] (expected [/VAR])',
test('accepts sibling VAR tags', () => {
assert.deepEqual(parse('[EFTL][VAR][/VAR][VAR][/VAR][/EFTL]'), []);
});
test('reports a VAR nested directly inside another VAR', () => {
assert.deepEqual(parse('[EFTL][VAR][VAR][/VAR][/VAR][/EFTL]'), [{
message: 'VAR tags cannot be nested inside another VAR tag',
line: 1,
column: 1,
column: 12,
length: 5
}]);
});
test('reports an unexpected closing tag', () => {
const errors = parse('[/VAR]');
test('reports a VAR nested indirectly inside another VAR', () => {
assert.deepEqual(parse('[EFTL][VAR][SPLIT][VAR][/VAR][/SPLIT][/VAR][/EFTL]'), [{
message: 'VAR tags cannot be nested inside another VAR tag',
line: 1,
column: 19,
length: 5
}]);
});
test('reports a VAR tag without its closing tag', () => {
assert.ok(messages('[EFTL][VAR]').includes('Unclosed tag: [VAR] (expected [/VAR])'));
});
test('reports a mismatched closing tag', () => {
const errors = parse('[EFTL][/VAR][/EFTL]');
assert.equal(errors.length, 1);
assert.match(errors[0].message, /Unexpected closing tag/);
assert.equal(errors[0].message, 'Mismatched closing tag: expected [/EFTL], got [/VAR]');
});
test('reports every unclosed nested tag at its opening token', () => {
@@ -45,4 +66,132 @@ test('reports every unclosed nested tag at its opening token', () => {
length: 5
}
]);
});
test('requires exactly one EFTL root block', () => {
assert.deepEqual(parse(''), [{
message: 'Missing required [EFTL] root block',
line: 1,
column: 1,
length: 0
}]);
assert.deepEqual(parse('[EFTL][/EFTL]\n[EFTL][/EFTL]'), [{
message: 'Only one [EFTL] root block is allowed',
line: 2,
column: 1,
length: 6
}]);
});
test('allows comments and multiline whitespace around the EFTL root', () => {
assert.deepEqual(parse(' \n[!-- before --]\n[EFTL][/EFTL]\n[!-- after --]\n '), []);
});
test('rejects instructions outside the EFTL root', () => {
assert.deepEqual(messages('[VAR][/VAR][EFTL][/EFTL]'), [
'Only comments and whitespace are allowed outside the [EFTL] root block',
'Only comments and whitespace are allowed outside the [EFTL] root block'
]);
});
test('rejects a nested EFTL root', () => {
assert.deepEqual(parse('[EFTL][EFTL][/EFTL][/EFTL]'), [{
message: 'EFTL root blocks cannot be nested',
line: 1,
column: 7,
length: 6
}]);
});
test('accepts CONDITION, THEN, repeated ELSE IF, and one final ELSE in an IF', () => {
const source = [
'[EFTL][IF]',
' [CONDITION][% true %][/CONDITION]',
' [THEN][/THEN]',
' [ELSE IF][CONDITION][% false %][/CONDITION][THEN][/THEN][/ELSE IF]',
' [ELSE IF][CONDITION][% false %][/CONDITION][THEN][/THEN][/ELSE IF]',
' [ELSE][/ELSE]',
'[/IF][/EFTL]'
].join('\n');
assert.deepEqual(parse(source), []);
});
test('allows an IF nested inside a THEN block', () => {
const source = '[EFTL][IF][CONDITION][/CONDITION][THEN]'
+ '[IF][CONDITION][/CONDITION][THEN][/THEN][/IF]'
+ '[/THEN][/IF][/EFTL]';
assert.deepEqual(parse(source), []);
});
test('rejects an IF nested directly inside another IF', () => {
const source = '[EFTL][IF]'
+ '[IF][CONDITION][/CONDITION][THEN][/THEN][/IF]'
+ '[CONDITION][/CONDITION][THEN][/THEN][/IF][/EFTL]';
assert.deepEqual(messages(source), [
'IF cannot be nested directly inside another IF; place it inside a branch block'
]);
});
test('requires CONDITION followed by THEN as the first direct IF children', () => {
assert.deepEqual(messages('[EFTL][IF][/IF][/EFTL]'), [
'Expected [CONDITION] immediately after [IF]'
]);
assert.deepEqual(messages('[EFTL][IF][THEN][/THEN][CONDITION][/CONDITION][THEN][/THEN][/IF][/EFTL]'), [
'Expected [CONDITION] as the next direct child of [IF], got [THEN]'
]);
assert.deepEqual(messages('[EFTL][IF][CONDITION][/CONDITION][/IF][/EFTL]'), [
'Expected [THEN] immediately after [/CONDITION]'
]);
});
test('allows only whitespace between direct IF child blocks', () => {
const source = '[EFTL][IF][CONDITION][/CONDITION]'
+ '[VAR][/VAR][!-- comment --]text'
+ '[THEN][/THEN][/IF][/EFTL]';
assert.deepEqual(messages(source), [
'Expected [THEN] as the next direct child of [IF], got [VAR]',
'Expected [THEN] as the next direct child of [IF], got [!--',
'Expected [THEN] as the next direct child of [IF], got text'
]);
});
test('allows ELSE IF only before the optional ELSE', () => {
const source = '[EFTL][IF][CONDITION][/CONDITION][THEN][/THEN]'
+ '[ELSE][/ELSE][ELSE IF][/ELSE IF][/IF][/EFTL]';
assert.deepEqual(messages(source), [
'Expected [/IF] as the next direct child of [IF], got [ELSE IF]'
]);
assert.deepEqual(messages(
'[EFTL][IF][CONDITION][/CONDITION][THEN][/THEN][ELSE][/ELSE][ELSE][/ELSE][/IF][/EFTL]'
), [
'Expected [/IF] as the next direct child of [IF], got [ELSE]'
]);
});
test('accepts WHILE with an optional string threshold attribute', () => {
assert.deepEqual(parse('[EFTL][WHILE][/WHILE][/EFTL]'), []);
assert.deepEqual(parse('[EFTL][WHILE threshold="99"][/WHILE][/EFTL]'), []);
const { tokens } = new EftlTokenizer('[WHILE threshold="99"][/WHILE]').tokenize();
assert.equal(tokens[0].type, 'WHILE_OPEN');
assert.equal(tokens[0].value, '[WHILE threshold="99"]');
});
test('rejects invalid WHILE attributes', () => {
const expectedMessage = 'WHILE accepts only the optional string attribute threshold="..."';
assert.deepEqual(messages('[EFTL][WHILE threshold=99][/WHILE][/EFTL]'), [expectedMessage]);
assert.deepEqual(messages('[EFTL][WHILE limit="99"][/WHILE][/EFTL]'), [expectedMessage]);
assert.deepEqual(messages(
'[EFTL][WHILE threshold="10" threshold="20"][/WHILE][/EFTL]'
), [expectedMessage]);
});