Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c0695f8f2 | ||
|
|
300864a15e |
@@ -16,7 +16,7 @@ Syntax highlighting and language support for **EFTL (ElixForms Template Language
|
|||||||
- `[EFTL]...[/EFTL]` - Main EFTL block
|
- `[EFTL]...[/EFTL]` - Main EFTL block
|
||||||
- `[VAR name="..." type="..."]...[/VAR]` - Variable declarations
|
- `[VAR name="..." type="..."]...[/VAR]` - Variable declarations
|
||||||
- `[IF]...[/IF]` - Conditionals
|
- `[IF]...[/IF]` - Conditionals
|
||||||
- `[WHILE]...[/WHILE]` - Loops
|
- `[WHILE threshold="..."]...[/WHILE]` - Loops with an optional string threshold
|
||||||
|
|
||||||
### Expressions
|
### Expressions
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Syntax highlighting and language support for **EFTL (ElixForms Template Language
|
|||||||
### Control Structures
|
### Control Structures
|
||||||
|
|
||||||
- `[IF]`, `[CONDITION]`, `[THEN]`, `[ELSE]`, `[ELSE IF]`
|
- `[IF]`, `[CONDITION]`, `[THEN]`, `[ELSE]`, `[ELSE IF]`
|
||||||
- `[WHILE]`, `[DO]`
|
- `[WHILE]`, `[WHILE threshold="..."]`, `[DO]`
|
||||||
|
|
||||||
### Comments
|
### Comments
|
||||||
|
|
||||||
@@ -105,25 +105,26 @@ If you want to debug the extension or the language server:
|
|||||||
|
|
||||||
## Snippets
|
## Snippets
|
||||||
|
|
||||||
| Prefix | Description |
|
| Prefix | Description |
|
||||||
| ----------- | ---------------------- |
|
| ---------------- | ------------------------- |
|
||||||
| `eftl` | EFTL block with header |
|
| `eftl` | EFTL block with header |
|
||||||
| `var` | Variable declaration |
|
| `var` | Variable declaration |
|
||||||
| `vartag` | Variable from TAG |
|
| `vartag` | Variable from TAG |
|
||||||
| `varschema` | Variable from SCHEMAID |
|
| `varschema` | Variable from SCHEMAID |
|
||||||
| `if` | IF statement |
|
| `if` | IF statement |
|
||||||
| `ifelse` | IF-ELSE statement |
|
| `ifelse` | IF-ELSE statement |
|
||||||
| `while` | WHILE loop |
|
| `while` | WHILE loop |
|
||||||
| `split` | Split function |
|
| `whilethreshold` | WHILE loop with threshold |
|
||||||
| `trim` | Trim function |
|
| `split` | Split function |
|
||||||
| `valueof` | VALUE_OF function |
|
| `trim` | Trim function |
|
||||||
| `sizeof` | SIZE_OF function |
|
| `valueof` | VALUE_OF function |
|
||||||
| `contains` | CONTAINS function |
|
| `sizeof` | SIZE_OF function |
|
||||||
| `formatnum` | Format number |
|
| `contains` | CONTAINS function |
|
||||||
| `tag` | GETVALUEBYTAG tag |
|
| `formatnum` | Format number |
|
||||||
| `schema` | SCHEMAID tag |
|
| `tag` | GETVALUEBYTAG tag |
|
||||||
| `out` | Output expression |
|
| `schema` | SCHEMAID tag |
|
||||||
| `comment` | Comment block |
|
| `out` | Output expression |
|
||||||
|
| `comment` | Comment block |
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
[VAR name="CONTRAENTI" type="iterable"][SPLIT regex="\n"][TRIM][TAG]SCHEMAID,296,COL0004,IUQOID, , [/TAG][/TRIM][/SPLIT][/VAR]
|
[VAR name="CONTRAENTI" type="iterable"][SPLIT regex="\n"][TRIM][TAG]SCHEMAID,296,COL0004,IUQOID, , [/TAG][/TRIM][/SPLIT][/VAR]
|
||||||
[VAR name="CONTRAENTI_NUM" type="number"][SIZE_OF varname="CONTRAENTI" /][/VAR]
|
[VAR name="CONTRAENTI_NUM" type="number"][SIZE_OF varname="CONTRAENTI" /][/VAR]
|
||||||
|
|
||||||
[WHILE]
|
[WHILE threshold="99"]
|
||||||
[CONDITION][% IDX < CONTRAENTI_NUM %][/CONDITION]
|
[CONDITION][% IDX < CONTRAENTI_NUM %][/CONDITION]
|
||||||
[DO]
|
[DO]
|
||||||
[VAR name="CONTRAENTE" type="string"][VALUE_OF varname="CONTRAENTI" index="IDX" /][/VAR]
|
[VAR name="CONTRAENTE" type="string"][VALUE_OF varname="CONTRAENTI" index="IDX" /][/VAR]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
["[EFTL]", "[/EFTL]"],
|
["[EFTL]", "[/EFTL]"],
|
||||||
["[VAR", "[/VAR]"],
|
["[VAR", "[/VAR]"],
|
||||||
["[IF]", "[/IF]"],
|
["[IF]", "[/IF]"],
|
||||||
["[WHILE]", "[/WHILE]"],
|
["[WHILE", "[/WHILE]"],
|
||||||
["[FORMAT", "[/FORMAT]"],
|
["[FORMAT", "[/FORMAT]"],
|
||||||
["[CONDITION]", "[/CONDITION]"],
|
["[CONDITION]", "[/CONDITION]"],
|
||||||
["[THEN]", "[/THEN]"],
|
["[THEN]", "[/THEN]"],
|
||||||
|
|||||||
Generated
+17
-1
@@ -16,7 +16,9 @@
|
|||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
"@types/vscode": "^1.75.0",
|
"@types/vscode": "^1.75.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"typescript": "^5.3.0"
|
"typescript": "^5.3.0",
|
||||||
|
"vscode-oniguruma": "^2.0.1",
|
||||||
|
"vscode-textmate": "^9.3.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.75.0"
|
"vscode": "^1.75.0"
|
||||||
@@ -1217,6 +1219,20 @@
|
|||||||
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
|
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/vscode-oniguruma": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/vscode-textmate": {
|
||||||
|
"version": "9.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.2.tgz",
|
||||||
|
"integrity": "sha512-n2uGbUcrjhUEBH16uGA0TvUfhWwliFZ1e3+pTjrkim1Mt7ydB41lV08aUvsi70OlzDWp6X7Bx3w/x3fAXIsN0Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
+3
-1
@@ -70,7 +70,9 @@
|
|||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
"@types/vscode": "^1.75.0",
|
"@types/vscode": "^1.75.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"typescript": "^5.3.0"
|
"typescript": "^5.3.0",
|
||||||
|
"vscode-oniguruma": "^2.0.1",
|
||||||
|
"vscode-textmate": "^9.3.2"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -79,6 +79,18 @@
|
|||||||
],
|
],
|
||||||
"description": "Create a WHILE loop"
|
"description": "Create a WHILE loop"
|
||||||
},
|
},
|
||||||
|
"While Loop with Threshold": {
|
||||||
|
"prefix": "whilethreshold",
|
||||||
|
"body": [
|
||||||
|
"[WHILE threshold=\"${1:99}\"]",
|
||||||
|
" [CONDITION][% ${2:condition} %][/CONDITION]",
|
||||||
|
" [DO]",
|
||||||
|
" $0",
|
||||||
|
" [/DO]",
|
||||||
|
"[/WHILE]"
|
||||||
|
],
|
||||||
|
"description": "Create a WHILE loop with a threshold"
|
||||||
|
},
|
||||||
"Split": {
|
"Split": {
|
||||||
"prefix": "split",
|
"prefix": "split",
|
||||||
"body": [
|
"body": [
|
||||||
|
|||||||
+126
@@ -17,6 +17,13 @@ export interface ParserError {
|
|||||||
length: number;
|
length: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IfPhase = 'condition' | 'then' | 'branches' | 'afterElse';
|
||||||
|
|
||||||
|
interface IfContext {
|
||||||
|
token: Token;
|
||||||
|
phase: IfPhase;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EFTL Parser - validates structure and reports errors
|
* EFTL Parser - validates structure and reports errors
|
||||||
*/
|
*/
|
||||||
@@ -25,6 +32,8 @@ export class EftlParser {
|
|||||||
private pos: number = 0;
|
private pos: number = 0;
|
||||||
private errors: ParserError[] = [];
|
private errors: ParserError[] = [];
|
||||||
private blockStack: { type: TokenType; token: Token }[] = [];
|
private blockStack: { type: TokenType; token: Token }[] = [];
|
||||||
|
private ifContexts: IfContext[] = [];
|
||||||
|
private eftlRootCount: number = 0;
|
||||||
|
|
||||||
constructor(tokens: Token[]) {
|
constructor(tokens: Token[]) {
|
||||||
this.tokens = tokens;
|
this.tokens = tokens;
|
||||||
@@ -34,6 +43,8 @@ export class EftlParser {
|
|||||||
this.pos = 0;
|
this.pos = 0;
|
||||||
this.errors = [];
|
this.errors = [];
|
||||||
this.blockStack = [];
|
this.blockStack = [];
|
||||||
|
this.ifContexts = [];
|
||||||
|
this.eftlRootCount = 0;
|
||||||
|
|
||||||
while (!this.isAtEnd()) {
|
while (!this.isAtEnd()) {
|
||||||
this.parseTopLevel();
|
this.parseTopLevel();
|
||||||
@@ -50,14 +61,32 @@ export class EftlParser {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.eftlRootCount === 0) {
|
||||||
|
const eof = this.current();
|
||||||
|
this.errors.push({
|
||||||
|
message: 'Missing required [EFTL] root block',
|
||||||
|
line: eof.line,
|
||||||
|
column: eof.column,
|
||||||
|
length: eof.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { errors: this.errors };
|
return { errors: this.errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseTopLevel(): void {
|
private parseTopLevel(): void {
|
||||||
const token = this.current();
|
const token = this.current();
|
||||||
|
this.validateOutsideEftl(token);
|
||||||
|
this.validateDirectIfContent(token);
|
||||||
|
|
||||||
switch (token.type) {
|
switch (token.type) {
|
||||||
case TokenType.EFTL_OPEN:
|
case TokenType.EFTL_OPEN:
|
||||||
|
if (this.blockStack.some(block => block.type === TokenType.EFTL_OPEN)) {
|
||||||
|
this.addError(token, 'EFTL root blocks cannot be nested');
|
||||||
|
} else if (this.eftlRootCount > 0) {
|
||||||
|
this.addError(token, 'Only one [EFTL] root block is allowed');
|
||||||
|
}
|
||||||
|
this.eftlRootCount++;
|
||||||
this.pushBlock(token);
|
this.pushBlock(token);
|
||||||
this.advance();
|
this.advance();
|
||||||
break;
|
break;
|
||||||
@@ -68,6 +97,14 @@ export class EftlParser {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case TokenType.VAR_OPEN:
|
case TokenType.VAR_OPEN:
|
||||||
|
if (this.blockStack.some(block => block.type === TokenType.VAR_OPEN)) {
|
||||||
|
this.errors.push({
|
||||||
|
message: 'VAR tags cannot be nested inside another VAR tag',
|
||||||
|
line: token.line,
|
||||||
|
column: token.column,
|
||||||
|
length: token.length
|
||||||
|
});
|
||||||
|
}
|
||||||
this.pushBlock(token);
|
this.pushBlock(token);
|
||||||
this.advance();
|
this.advance();
|
||||||
break;
|
break;
|
||||||
@@ -78,12 +115,17 @@ export class EftlParser {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case TokenType.IF_OPEN:
|
case TokenType.IF_OPEN:
|
||||||
|
this.ifContexts.push({ token, phase: 'condition' });
|
||||||
this.pushBlock(token);
|
this.pushBlock(token);
|
||||||
this.advance();
|
this.advance();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TokenType.IF_CLOSE:
|
case TokenType.IF_CLOSE:
|
||||||
|
const closesIf = this.blockStack[this.blockStack.length - 1]?.type === TokenType.IF_OPEN;
|
||||||
this.popBlock(TokenType.IF_OPEN, token);
|
this.popBlock(TokenType.IF_OPEN, token);
|
||||||
|
if (closesIf) {
|
||||||
|
this.ifContexts.pop();
|
||||||
|
}
|
||||||
this.advance();
|
this.advance();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -128,6 +170,9 @@ export class EftlParser {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case TokenType.WHILE_OPEN:
|
case TokenType.WHILE_OPEN:
|
||||||
|
if (!/^\[WHILE(?:\s+threshold\s*=\s*"[^"]*")?\s*\]$/.test(token.value)) {
|
||||||
|
this.addError(token, 'WHILE accepts only the optional string attribute threshold="..."');
|
||||||
|
}
|
||||||
this.pushBlock(token);
|
this.pushBlock(token);
|
||||||
this.advance();
|
this.advance();
|
||||||
break;
|
break;
|
||||||
@@ -252,6 +297,87 @@ export class EftlParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private validateOutsideEftl(token: Token): void {
|
||||||
|
if (this.blockStack.some(block => block.type === TokenType.EFTL_OPEN)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === TokenType.EFTL_OPEN || token.type === TokenType.EFTL_CLOSE ||
|
||||||
|
token.type === TokenType.COMMENT_OPEN || token.type === TokenType.COMMENT_CLOSE ||
|
||||||
|
token.type === TokenType.EOF || (token.type === TokenType.TEXT && token.value.trim() === '')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addError(token, 'Only comments and whitespace are allowed outside the [EFTL] root block');
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateDirectIfContent(token: Token): void {
|
||||||
|
const parent = this.blockStack[this.blockStack.length - 1];
|
||||||
|
if (!parent || parent.type !== TokenType.IF_OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = this.ifContexts[this.ifContexts.length - 1];
|
||||||
|
if (!context || context.token !== parent.token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === TokenType.TEXT && token.value.trim() === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === TokenType.IF_OPEN) {
|
||||||
|
this.addError(token, 'IF cannot be nested directly inside another IF; place it inside a branch block');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.phase === 'condition' && token.type === TokenType.CONDITION_OPEN) {
|
||||||
|
context.phase = 'then';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.phase === 'then' && token.type === TokenType.THEN_OPEN) {
|
||||||
|
context.phase = 'branches';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.phase === 'branches' && token.type === TokenType.ELSE_IF_OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.phase === 'branches' && token.type === TokenType.ELSE_OPEN) {
|
||||||
|
context.phase = 'afterElse';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === TokenType.IF_CLOSE) {
|
||||||
|
if (context.phase === 'condition') {
|
||||||
|
this.addError(token, 'Expected [CONDITION] immediately after [IF]');
|
||||||
|
} else if (context.phase === 'then') {
|
||||||
|
this.addError(token, 'Expected [THEN] immediately after [/CONDITION]');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = context.phase === 'condition'
|
||||||
|
? '[CONDITION]'
|
||||||
|
: context.phase === 'then'
|
||||||
|
? '[THEN]'
|
||||||
|
: context.phase === 'branches'
|
||||||
|
? '[ELSE IF], [ELSE], or [/IF]'
|
||||||
|
: '[/IF]';
|
||||||
|
this.addError(token, `Expected ${expected} as the next direct child of [IF], got ${token.value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addError(token: Token, message: string): void {
|
||||||
|
this.errors.push({
|
||||||
|
message,
|
||||||
|
line: token.line,
|
||||||
|
column: token.column,
|
||||||
|
length: token.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private pushBlock(token: Token): void {
|
private pushBlock(token: Token): void {
|
||||||
if (token.value.endsWith('/]')) {
|
if (token.value.endsWith('/]')) {
|
||||||
return; // It's self-closing, don't push to stack
|
return; // It's self-closing, don't push to stack
|
||||||
|
|||||||
+6
-1
@@ -21,7 +21,7 @@ export enum TokenType {
|
|||||||
ELSE_CLOSE = 'ELSE_CLOSE', // [/ELSE]
|
ELSE_CLOSE = 'ELSE_CLOSE', // [/ELSE]
|
||||||
ELSE_IF_OPEN = 'ELSE_IF_OPEN', // [ELSE IF]
|
ELSE_IF_OPEN = 'ELSE_IF_OPEN', // [ELSE IF]
|
||||||
ELSE_IF_CLOSE = 'ELSE_IF_CLOSE', // [/ELSE IF]
|
ELSE_IF_CLOSE = 'ELSE_IF_CLOSE', // [/ELSE IF]
|
||||||
WHILE_OPEN = 'WHILE_OPEN', // [WHILE]
|
WHILE_OPEN = 'WHILE_OPEN', // [WHILE ...]
|
||||||
WHILE_CLOSE = 'WHILE_CLOSE', // [/WHILE]
|
WHILE_CLOSE = 'WHILE_CLOSE', // [/WHILE]
|
||||||
DO_OPEN = 'DO_OPEN', // [DO]
|
DO_OPEN = 'DO_OPEN', // [DO]
|
||||||
DO_CLOSE = 'DO_CLOSE', // [/DO]
|
DO_CLOSE = 'DO_CLOSE', // [/DO]
|
||||||
@@ -192,6 +192,11 @@ export class EftlTokenizer {
|
|||||||
this.addToken(TokenType.WHILE_OPEN, '[WHILE]', startLine, startColumn);
|
this.addToken(TokenType.WHILE_OPEN, '[WHILE]', startLine, startColumn);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.source.startsWith('[WHILE', this.pos) && /\s/.test(this.source[this.pos + '[WHILE'.length] || '')) {
|
||||||
|
this.match('[WHILE');
|
||||||
|
this.scanTagWithAttributes(TokenType.WHILE_OPEN, 'WHILE', startLine, startColumn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.match('[/WHILE]')) {
|
if (this.match('[/WHILE]')) {
|
||||||
this.addToken(TokenType.WHILE_CLOSE, '[/WHILE]', startLine, startColumn);
|
this.addToken(TokenType.WHILE_CLOSE, '[/WHILE]', startLine, startColumn);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -174,7 +174,7 @@
|
|||||||
"patterns": [
|
"patterns": [
|
||||||
{
|
{
|
||||||
"name": "meta.tag.block.eftl",
|
"name": "meta.tag.block.eftl",
|
||||||
"begin": "(\\[)(TAG)(?:\\s+[^\\x00]*?)?(?<!/)(\\])",
|
"begin": "(\\[)(TAG)(?:\\s+[^\\]]*?)?(?<!/)(\\])",
|
||||||
"end": "(\\[/)(TAG)(\\])",
|
"end": "(\\[/)(TAG)(\\])",
|
||||||
"beginCaptures": {
|
"beginCaptures": {
|
||||||
"1": { "name": "entity.name.tag.eftl" },
|
"1": { "name": "entity.name.tag.eftl" },
|
||||||
@@ -236,9 +236,24 @@
|
|||||||
"name": "keyword.control.conditional.eftl",
|
"name": "keyword.control.conditional.eftl",
|
||||||
"match": "\\[(IF|ELSE IF|ELSE|/IF|CONDITION|/CONDITION|THEN|/THEN|/ELSE IF|/ELSE)\\]"
|
"match": "\\[(IF|ELSE IF|ELSE|/IF|CONDITION|/CONDITION|THEN|/THEN|/ELSE IF|/ELSE)\\]"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "meta.control.loop.eftl",
|
||||||
|
"begin": "(\\[)(WHILE)\\b",
|
||||||
|
"end": "(\\])",
|
||||||
|
"beginCaptures": {
|
||||||
|
"1": { "name": "keyword.control.loop.eftl" },
|
||||||
|
"2": { "name": "keyword.control.loop.eftl" }
|
||||||
|
},
|
||||||
|
"endCaptures": {
|
||||||
|
"1": { "name": "keyword.control.loop.eftl" }
|
||||||
|
},
|
||||||
|
"patterns": [
|
||||||
|
{ "include": "#attributes" }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "keyword.control.loop.eftl",
|
"name": "keyword.control.loop.eftl",
|
||||||
"match": "\\[(WHILE|/WHILE|DO|/DO)\\]"
|
"match": "\\[(/WHILE|DO|/DO)\\]"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -246,7 +261,7 @@
|
|||||||
"patterns": [
|
"patterns": [
|
||||||
{
|
{
|
||||||
"name": "meta.variable.block.eftl",
|
"name": "meta.variable.block.eftl",
|
||||||
"begin": "(\\[)(VAR)(?:\\s+[^\\x00]*?)?(?<!/)(\\])",
|
"begin": "(\\[)(VAR)(?:\\s+[^\\]]*?)?(?<!/)(\\])",
|
||||||
"end": "(\\[/)(VAR)(\\])",
|
"end": "(\\[/)(VAR)(\\])",
|
||||||
"beginCaptures": {
|
"beginCaptures": {
|
||||||
"1": { "name": "storage.type.variable.eftl" },
|
"1": { "name": "storage.type.variable.eftl" },
|
||||||
@@ -286,7 +301,7 @@
|
|||||||
"patterns": [
|
"patterns": [
|
||||||
{
|
{
|
||||||
"name": "meta.function.block.eftl",
|
"name": "meta.function.block.eftl",
|
||||||
"begin": "(\\[)(VALUE_OF|SIZE_OF|SPLIT|FORMAT|CONTAINS|TRIM)(?:\\s+[^\\x00]*?)?(?<!/)(\\])",
|
"begin": "(\\[)(VALUE_OF|SIZE_OF|SPLIT|FORMAT|CONTAINS|TRIM)(?:\\s+[^\\]]*?)?(?<!/)(\\])",
|
||||||
"end": "(\\[/)(VALUE_OF|SIZE_OF|SPLIT|FORMAT|CONTAINS|TRIM)(\\])",
|
"end": "(\\[/)(VALUE_OF|SIZE_OF|SPLIT|FORMAT|CONTAINS|TRIM)(\\])",
|
||||||
"beginCaptures": {
|
"beginCaptures": {
|
||||||
"1": { "name": "support.function.eftl" },
|
"1": { "name": "support.function.eftl" },
|
||||||
|
|||||||
@@ -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
@@ -10,24 +10,45 @@ function parse(source) {
|
|||||||
return new EftlParser(tokens).parse().errors;
|
return new EftlParser(tokens).parse().errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function messages(source) {
|
||||||
|
return parse(source).map(error => error.message);
|
||||||
|
}
|
||||||
|
|
||||||
test('accepts a correctly closed VAR tag', () => {
|
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', () => {
|
test('accepts sibling VAR tags', () => {
|
||||||
assert.deepEqual(parse('[VAR]'), [{
|
assert.deepEqual(parse('[EFTL][VAR][/VAR][VAR][/VAR][/EFTL]'), []);
|
||||||
message: 'Unclosed tag: [VAR] (expected [/VAR])',
|
});
|
||||||
|
|
||||||
|
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,
|
line: 1,
|
||||||
column: 1,
|
column: 12,
|
||||||
length: 5
|
length: 5
|
||||||
}]);
|
}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reports an unexpected closing tag', () => {
|
test('reports a VAR nested indirectly inside another VAR', () => {
|
||||||
const errors = parse('[/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.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', () => {
|
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
|
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]);
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user