diff --git a/README.md b/README.md index 06eee47..0966411 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,98 @@ -# vscode-eftl-language - +# EFTL Language Extension for VS Code + +Syntax highlighting and language support for **EFTL (ElixForms Template Language)**. + +## Features + +- **Syntax Highlighting** for all EFTL constructs +- **Code Snippets** for common patterns +- **Bracket Matching** for EFTL blocks +- **Code Folding** for block structures + +## Supported Syntax + +### Block Delimiters +- `[EFTL]...[/EFTL]` - Main EFTL block +- `[VAR name="..." type="..."]...[/VAR]` - Variable declarations +- `[IF]...[/IF]` - Conditionals +- `[WHILE]...[/WHILE]` - Loops + +### Expressions +- `[% expression %]` - Execute expression +- `[%= expression %]` - Output expression value + +### Data Tags +- `[TAG]GETVALUEBYTAG,TAG_NAME,REQUEST,IUQOID[/TAG]` +- `[TAG]SCHEMAID,schemaId,COL0001,IUQOID, , [/TAG]` + +### Functions +- `[SPLIT regex="..."]...[/SPLIT]` - Split string +- `[TRIM]...[/TRIM]` - Trim whitespace +- `[VALUE_OF varname="..." index="..." /]` - Get array element +- `[SIZE_OF varname="..." /]` - Get array size +- `[CONTAINS varname="..."]...[/CONTAINS]` - Check containment +- `[FORMAT type="..." pattern="..."]...[/FORMAT]` - Format values + +### Control Structures +- `[IF]`, `[CONDITION]`, `[THEN]`, `[ELSE]`, `[ELSE IF]` +- `[WHILE]`, `[DO]` + +### Comments +- `[!-- comment --]` + +## Installation + +### From VSIX (Local) + +1. Package the extension: `npx vsce package` +2. Install in VS Code: `code --install-extension eftl-language-0.1.0.vsix` + +### Development Mode + +1. Open this folder in VS Code +2. Press `F5` to launch Extension Development Host +3. Open any `.eftl` file to see syntax highlighting + +## Snippets + +| Prefix | Description | +|--------|-------------| +| `eftl` | EFTL block with header | +| `var` | Variable declaration | +| `vartag` | Variable from TAG | +| `varschema` | Variable from SCHEMAID | +| `if` | IF statement | +| `ifelse` | IF-ELSE statement | +| `while` | WHILE loop | +| `split` | Split function | +| `trim` | Trim function | +| `valueof` | VALUE_OF function | +| `sizeof` | SIZE_OF function | +| `contains` | CONTAINS function | +| `formatnum` | Format number | +| `tag` | GETVALUEBYTAG tag | +| `schema` | SCHEMAID tag | +| `out` | Output expression | +| `comment` | Comment block | + +## Example + +```eftl +[EFTL][HEADER name="trimDocument" value="true" type="boolean" /] +[VAR name="total" type="number"][% total = 0; %][/VAR] +[VAR name="items" type="iterable"][SPLIT regex=";"][TAG]GETVALUEBYTAG,ITEMS,REQUEST,IUQOID[/TAG][/SPLIT][/VAR] + +[!-- Calculate total --] +[VAR name="count" type="number"][SIZE_OF varname="items" /][/VAR] + +[IF] + [CONDITION][% count > 0 %][/CONDITION] + [THEN][%= count %] items found[/THEN] + [ELSE]No items[/ELSE] +[/IF] +[/EFTL] +``` + +## License + +MIT diff --git a/examples/sample.eftl b/examples/sample.eftl new file mode 100644 index 0000000..cba686b --- /dev/null +++ b/examples/sample.eftl @@ -0,0 +1,49 @@ +[!-- Example EFTL file for testing syntax highlighting --] + +[EFTL][HEADER name="trimDocument" value="true" type="boolean" /] +[VAR name="isRichiedenteInPartecipanti" type="string"][% isRichiedenteInPartecipanti = "Utente non ammesso!"; %][/VAR] +[VAR name="partecipanti" type="string"][TAG]GETVALUEBYTAG,CONTRATTO_PARTECIPANTI,REQUEST,IUQOID[/TAG][/VAR] +[VAR name="nominativoUtente" type="string"][TAG]GETVALUEBYTAG,RICHIEDENTE_NOMINATIVO,REQUEST,IUQOID[/TAG][/VAR] + +[!-- Check if user is in participants list --] +[% nominativoUtente = " " + nominativoUtente + ", CF: "; %] + +[IF] + [CONDITION][CONTAINS varname="partecipanti"][VALUE_OF varname="nominativoUtente" /][/CONTAINS][/CONDITION] + [THEN][% isRichiedenteInPartecipanti = ""; %][/THEN] + [ELSE IF] + [CONDITION][% partecipanti == "" %][/CONDITION] + [THEN][% isRichiedenteInPartecipanti = "Nessun partecipante trovato"; %][/THEN] + [/ELSE IF] +[/IF] + +[%= isRichiedenteInPartecipanti %] +[/EFTL] + +[!-- Another example with loops and calculations --] + +[EFTL][HEADER name="trimDocument" value="true" type="boolean" /] +[VAR name="IDX_NOME" type="number"][% IDX_NOME = 0; %][/VAR] +[VAR name="IDX_SEDE" type="number"][% IDX_SEDE = 2; %][/VAR] +[VAR name="ELENCO" type="string"][% ELENCO = ""; %][/VAR] + +[VAR name="IDX" type="number"][% IDX = 0; %][/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] + +[WHILE] + [CONDITION][% IDX < CONTRAENTI_NUM %][/CONDITION] + [DO] + [VAR name="CONTRAENTE" type="string"][VALUE_OF varname="CONTRAENTI" index="IDX" /][/VAR] + [VAR name="DATI" type="iterable"][SPLIT regex=" - "][TRIM][% CONTRAENTE %][/TRIM][/SPLIT][/VAR] + [VAR name="NOME" type="string"][VALUE_OF varname="DATI" index="IDX_NOME" /][/VAR] + [VAR name="SEDE" type="string"][VALUE_OF varname="DATI" index="IDX_SEDE" /][/VAR] + [% ELENCO = ELENCO + NOME + " (" + SEDE + "); "; %] + [% IDX = IDX + 1; %] + [/DO] +[/WHILE] + +[FORMAT type="number" pattern="#,##0.00"][% 12345.67 %][/FORMAT] + +[%= ELENCO %] +[/EFTL] diff --git a/language-configuration.json b/language-configuration.json new file mode 100644 index 0000000..ccaee28 --- /dev/null +++ b/language-configuration.json @@ -0,0 +1,43 @@ +{ + "comments": { + "blockComment": ["[!--", "--]"] + }, + "brackets": [ + ["[EFTL]", "[/EFTL]"], + ["[VAR", "[/VAR]"], + ["[IF]", "[/IF]"], + ["[WHILE]", "[/WHILE]"], + ["[FORMAT", "[/FORMAT]"], + ["[CONDITION]", "[/CONDITION]"], + ["[THEN]", "[/THEN]"], + ["[ELSE]", "[/ELSE]"], + ["[ELSE IF]", "[/ELSE IF]"], + ["[DO]", "[/DO]"], + ["[TAG]", "[/TAG]"], + ["[SPLIT", "[/SPLIT]"], + ["[TRIM]", "[/TRIM]"], + ["[CONTAINS", "[/CONTAINS]"], + ["[HEADER", "/]"], + ["[%", "%]"], + ["[%=", "%]"] + ], + "autoClosingPairs": [ + { "open": "[EFTL]", "close": "[/EFTL]" }, + { "open": "[!--", "close": "--]" }, + { "open": "[%", "close": " %]" }, + { "open": "\"", "close": "\"" }, + { "open": "(", "close": ")" } + ], + "surroundingPairs": [ + ["[EFTL]", "[/EFTL]"], + ["[%", "%]"], + ["\"", "\""], + ["(", ")"] + ], + "folding": { + "markers": { + "start": "^\\s*\\[(EFTL|IF|WHILE|VAR|FORMAT|CONDITION|THEN|ELSE|DO)\\b", + "end": "^\\s*\\[/(EFTL|IF|WHILE|VAR|FORMAT|CONDITION|THEN|ELSE|DO)\\]" + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ac87b7d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,159 @@ +{ + "name": "eftl-language", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "eftl-language", + "version": "0.2.0", + "dependencies": { + "vscode-languageclient": "^9.0.1", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-textdocument": "^1.0.11" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/vscode": "^1.75.0", + "typescript": "^5.3.0" + }, + "engines": { + "vscode": "^1.75.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", + "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.107.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.107.0.tgz", + "integrity": "sha512-XS8YE1jlyTIowP64+HoN30OlC1H9xqSlq1eoLZUgFEC8oUTO6euYZxti1xRiLSfZocs4qytTzR6xCBYtioQTCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageclient": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz", + "integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==", + "license": "MIT", + "dependencies": { + "minimatch": "^5.1.0", + "semver": "^7.3.7", + "vscode-languageserver-protocol": "3.17.5" + }, + "engines": { + "vscode": "^1.82.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..22e7be1 --- /dev/null +++ b/package.json @@ -0,0 +1,77 @@ +{ + "name": "eftl-language", + "displayName": "EFTL Language", + "description": "Syntax highlighting and language support for EFTL (ElixForms Template Language)", + "version": "0.2.0", + "publisher": "elixforms", + "engines": { + "vscode": "^1.75.0" + }, + "categories": [ + "Programming Languages" + ], + "activationEvents": [ + "onLanguage:eftl" + ], + "main": "./out/extension.js", + "contributes": { + "languages": [ + { + "id": "eftl", + "aliases": [ + "EFTL", + "ElixForms Template Language" + ], + "extensions": [ + ".eftl" + ], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "eftl", + "scopeName": "source.eftl", + "path": "./syntaxes/eftl.tmLanguage.json" + } + ], + "snippets": [ + { + "language": "eftl", + "path": "./snippets/eftl.json" + } + ], + "configuration": { + "type": "object", + "title": "EFTL", + "properties": { + "eftl.maxNumberOfProblems": { + "scope": "resource", + "type": "number", + "default": 100, + "description": "Controls the maximum number of problems produced by the server." + } + } + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "lint": "eslint src --ext ts" + }, + "dependencies": { + "vscode-languageclient": "^9.0.1", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-textdocument": "^1.0.11" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/vscode": "^1.75.0", + "typescript": "^5.3.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/elixforms/vscode-eftl-language" + } +} diff --git a/snippets/eftl.json b/snippets/eftl.json new file mode 100644 index 0000000..6cc3d1d --- /dev/null +++ b/snippets/eftl.json @@ -0,0 +1,152 @@ +{ + "EFTL Block": { + "prefix": "eftl", + "body": [ + "[EFTL][HEADER name=\"trimDocument\" value=\"true\" type=\"boolean\" /]", + "$0", + "[/EFTL]" + ], + "description": "Create an EFTL block with header" + }, + "Variable Declaration": { + "prefix": "var", + "body": [ + "[VAR name=\"${1:varName}\" type=\"${2|string,number,boolean,date,object,iterable|}\"][% ${1:varName} = ${3:value}; %][/VAR]" + ], + "description": "Declare a variable" + }, + "Variable with Unique": { + "prefix": "varunique", + "body": [ + "[VAR name=\"${1:varName}\" type=\"${2|string,number,boolean,date,object,iterable|}\" unique=\"${3|true,false|}\"][% ${1:varName} = ${4:value}; %][/VAR]" + ], + "description": "Declare a unique variable" + }, + "Variable from TAG": { + "prefix": "vartag", + "body": [ + "[VAR name=\"${1:varName}\" type=\"${2|string,number,boolean,date,object,iterable|}\"][TAG]GETVALUEBYTAG,${3:TAG_NAME},REQUEST,IUQOID[/TAG][/VAR]" + ], + "description": "Declare a variable from a TAG" + }, + "Variable from SCHEMAID": { + "prefix": "varschema", + "body": [ + "[VAR name=\"${1:varName}\" type=\"${2|string,number,boolean,date,object,iterable|}\"][TAG]SCHEMAID,${3:schemaId},${4:COL0001},IUQOID, , [/TAG][/VAR]" + ], + "description": "Declare a variable from a SCHEMAID" + }, + "If Statement": { + "prefix": "if", + "body": [ + "[IF]", + " [CONDITION][% ${1:condition} %][/CONDITION]", + " [THEN]${2:// then block}[/THEN]", + "[/IF]" + ], + "description": "Create an IF statement" + }, + "If-Else Statement": { + "prefix": "ifelse", + "body": [ + "[IF]", + " [CONDITION][% ${1:condition} %][/CONDITION]", + " [THEN]${2:// then block}[/THEN]", + " [ELSE]${3:// else block}[/ELSE]", + "[/IF]" + ], + "description": "Create an IF-ELSE statement" + }, + "Else If": { + "prefix": "elseif", + "body": [ + "[ELSE IF]", + " [CONDITION][% ${1:condition} %][/CONDITION]", + "[THEN]${2:// then block}[/THEN]", + "[/ELSE IF]" + ], + "description": "Add an ELSE IF clause" + }, + "While Loop": { + "prefix": "while", + "body": [ + "[WHILE]", + " [CONDITION][% ${1:condition} %][/CONDITION]", + " [DO]", + " $0", + " [/DO]", + "[/WHILE]" + ], + "description": "Create a WHILE loop" + }, + "Split": { + "prefix": "split", + "body": [ + "[SPLIT regex=\"${1:\\\\n}\"]${2:content}[/SPLIT]" + ], + "description": "Split a string by regex" + }, + "Trim": { + "prefix": "trim", + "body": [ + "[TRIM]${1:content}[/TRIM]" + ], + "description": "Trim whitespace" + }, + "Value Of": { + "prefix": "valueof", + "body": [ + "[VALUE_OF varname=\"${1:varName}\" index=\"${2:0}\" /]" + ], + "description": "Get value from iterable at index" + }, + "Size Of": { + "prefix": "sizeof", + "body": [ + "[SIZE_OF varname=\"${1:varName}\" /]" + ], + "description": "Get size of iterable" + }, + "Contains": { + "prefix": "contains", + "body": [ + "[CONTAINS varname=\"${1:varName}\"][VALUE_OF varname=\"${2:searchVar}\" /][/CONTAINS]" + ], + "description": "Check if variable contains value" + }, + "Format Number": { + "prefix": "formatnum", + "body": [ + "[FORMAT type=\"number\" pattern=\"${1:#,##0.00}\"][% ${2:varName} %][/FORMAT]" + ], + "description": "Format a number" + }, + "TAG GETVALUEBYTAG": { + "prefix": "tag", + "body": [ + "[TAG]GETVALUEBYTAG,${1:TAG_NAME},REQUEST,IUQOID[/TAG]" + ], + "description": "Get value by tag name" + }, + "TAG SCHEMAID": { + "prefix": "schema", + "body": [ + "[TAG]SCHEMAID,${1:schemaId},${2:COL0001},IUQOID, , [/TAG]" + ], + "description": "Get value from schema field" + }, + "Output Expression": { + "prefix": "out", + "body": [ + "[%= ${1:varName} %]" + ], + "description": "Output a variable value" + }, + "Comment": { + "prefix": "comment", + "body": [ + "[!-- ${1:comment} --]" + ], + "description": "Insert a comment" + } +} diff --git a/src/extension.ts b/src/extension.ts new file mode 100644 index 0000000..9634f8b --- /dev/null +++ b/src/extension.ts @@ -0,0 +1,49 @@ +import * as path from 'path'; +import { ExtensionContext } from 'vscode'; +import { + LanguageClient, + LanguageClientOptions, + ServerOptions, + TransportKind +} from 'vscode-languageclient/node'; + +let client: LanguageClient; + +export function activate(context: ExtensionContext) { + // The server is implemented in node + const serverModule = context.asAbsolutePath(path.join('out', 'server.js')); + + // Server options - run the server in Node + const serverOptions: ServerOptions = { + run: { module: serverModule, transport: TransportKind.ipc }, + debug: { + module: serverModule, + transport: TransportKind.ipc, + options: { execArgv: ['--nolazy', '--inspect=6009'] } + } + }; + + // Options to control the language client + const clientOptions: LanguageClientOptions = { + // Register the server for EFTL documents + documentSelector: [{ scheme: 'file', language: 'eftl' }] + }; + + // Create the language client and start the client + client = new LanguageClient( + 'eftlLanguageServer', + 'EFTL Language Server', + serverOptions, + clientOptions + ); + + // Start the client. This will also launch the server + client.start(); +} + +export function deactivate(): Thenable | undefined { + if (!client) { + return undefined; + } + return client.stop(); +} diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..c1743a8 --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,319 @@ +import { Token, TokenType, TokenizerError } from './tokenizer'; + +/** + * AST Node types for EFTL + */ +export interface AstNode { + type: string; + line: number; + column: number; + children?: AstNode[]; +} + +export interface ParserError { + message: string; + line: number; + column: number; + length: number; +} + +/** + * EFTL Parser - validates structure and reports errors + */ +export class EftlParser { + private tokens: Token[]; + private pos: number = 0; + private errors: ParserError[] = []; + private blockStack: { type: TokenType; token: Token }[] = []; + + constructor(tokens: Token[]) { + this.tokens = tokens; + } + + parse(): { errors: ParserError[] } { + this.pos = 0; + this.errors = []; + this.blockStack = []; + + while (!this.isAtEnd()) { + this.parseTopLevel(); + } + + // Check for unclosed blocks + for (const block of this.blockStack) { + this.errors.push({ + message: `Unclosed block: ${block.token.value}`, + line: block.token.line, + column: block.token.column, + length: block.token.length + }); + } + + return { errors: this.errors }; + } + + private parseTopLevel(): void { + const token = this.current(); + + switch (token.type) { + case TokenType.EFTL_OPEN: + this.pushBlock(token); + this.advance(); + break; + + case TokenType.EFTL_CLOSE: + this.popBlock(TokenType.EFTL_OPEN, token); + this.advance(); + break; + + case TokenType.VAR_OPEN: + this.pushBlock(token); + this.advance(); + break; + + case TokenType.VAR_CLOSE: + this.popBlock(TokenType.VAR_OPEN, token); + this.advance(); + break; + + case TokenType.IF_OPEN: + this.pushBlock(token); + this.advance(); + break; + + case TokenType.IF_CLOSE: + this.popBlock(TokenType.IF_OPEN, token); + this.advance(); + break; + + case TokenType.CONDITION_OPEN: + this.pushBlock(token); + this.advance(); + break; + + case TokenType.CONDITION_CLOSE: + this.popBlock(TokenType.CONDITION_OPEN, token); + this.advance(); + break; + + case TokenType.THEN_OPEN: + this.pushBlock(token); + this.advance(); + break; + + case TokenType.THEN_CLOSE: + this.popBlock(TokenType.THEN_OPEN, token); + this.advance(); + break; + + case TokenType.ELSE_OPEN: + this.pushBlock(token); + this.advance(); + break; + + case TokenType.ELSE_CLOSE: + this.popBlock(TokenType.ELSE_OPEN, token); + this.advance(); + break; + + case TokenType.ELSE_IF_OPEN: + this.pushBlock(token); + this.advance(); + break; + + case TokenType.ELSE_IF_CLOSE: + this.popBlock(TokenType.ELSE_IF_OPEN, token); + this.advance(); + break; + + case TokenType.WHILE_OPEN: + this.pushBlock(token); + this.advance(); + break; + + case TokenType.WHILE_CLOSE: + this.popBlock(TokenType.WHILE_OPEN, token); + this.advance(); + break; + + case TokenType.DO_OPEN: + this.pushBlock(token); + this.advance(); + break; + + case TokenType.DO_CLOSE: + this.popBlock(TokenType.DO_OPEN, token); + this.advance(); + break; + + case TokenType.TAG_OPEN: + this.pushBlock(token); + this.advance(); + break; + + case TokenType.TAG_CLOSE: + this.popBlock(TokenType.TAG_OPEN, token); + this.advance(); + break; + + case TokenType.SPLIT_OPEN: + this.pushBlock(token); + this.advance(); + break; + + case TokenType.SPLIT_CLOSE: + this.popBlock(TokenType.SPLIT_OPEN, token); + this.advance(); + break; + + case TokenType.TRIM_OPEN: + this.pushBlock(token); + this.advance(); + break; + + case TokenType.TRIM_CLOSE: + this.popBlock(TokenType.TRIM_OPEN, token); + this.advance(); + break; + + case TokenType.CONTAINS_OPEN: + this.pushBlock(token); + this.advance(); + break; + + case TokenType.CONTAINS_CLOSE: + this.popBlock(TokenType.CONTAINS_OPEN, token); + this.advance(); + break; + + case TokenType.FORMAT_OPEN: + this.pushBlock(token); + this.advance(); + break; + + case TokenType.FORMAT_CLOSE: + this.popBlock(TokenType.FORMAT_OPEN, token); + this.advance(); + break; + + case TokenType.EXPR_OPEN: + case TokenType.EXPR_OUTPUT_OPEN: + this.pushBlock(token); + this.advance(); + break; + + case TokenType.EXPR_CLOSE: + // Can close either EXPR_OPEN or EXPR_OUTPUT_OPEN + const exprBlock = this.blockStack.pop(); + if (!exprBlock || (exprBlock.type !== TokenType.EXPR_OPEN && exprBlock.type !== TokenType.EXPR_OUTPUT_OPEN)) { + this.errors.push({ + message: `Unexpected closing: ${token.value}`, + line: token.line, + column: token.column, + length: token.length + }); + if (exprBlock) { + this.blockStack.push(exprBlock); + } + } + this.advance(); + break; + + case TokenType.COMMENT_OPEN: + this.pushBlock(token); + this.advance(); + break; + + case TokenType.COMMENT_CLOSE: + this.popBlock(TokenType.COMMENT_OPEN, token); + this.advance(); + break; + + // Self-closing tags - no block needed + case TokenType.VALUE_OF: + case TokenType.SIZE_OF: + case TokenType.HEADER: + this.advance(); + break; + + case TokenType.TEXT: + case TokenType.EOF: + this.advance(); + break; + + default: + // Unknown token, skip + this.advance(); + break; + } + } + + private pushBlock(token: Token): void { + this.blockStack.push({ type: token.type, token }); + } + + private popBlock(expectedOpenType: TokenType, closeToken: Token): void { + if (this.blockStack.length === 0) { + this.errors.push({ + message: `Unexpected closing tag: ${closeToken.value} (no matching opening tag)`, + line: closeToken.line, + column: closeToken.column, + length: closeToken.length + }); + return; + } + + const top = this.blockStack[this.blockStack.length - 1]; + if (top.type !== expectedOpenType) { + // Find the expected closing tag name + const expectedClose = this.getMatchingClose(top.type); + this.errors.push({ + message: `Mismatched closing tag: expected ${expectedClose}, got ${closeToken.value}`, + line: closeToken.line, + column: closeToken.column, + length: closeToken.length + }); + return; + } + + this.blockStack.pop(); + } + + private getMatchingClose(openType: TokenType): string { + const closeMap: { [key: string]: string } = { + [TokenType.EFTL_OPEN]: '[/EFTL]', + [TokenType.VAR_OPEN]: '[/VAR]', + [TokenType.IF_OPEN]: '[/IF]', + [TokenType.CONDITION_OPEN]: '[/CONDITION]', + [TokenType.THEN_OPEN]: '[/THEN]', + [TokenType.ELSE_OPEN]: '[/ELSE]', + [TokenType.ELSE_IF_OPEN]: '[/ELSE IF]', + [TokenType.WHILE_OPEN]: '[/WHILE]', + [TokenType.DO_OPEN]: '[/DO]', + [TokenType.TAG_OPEN]: '[/TAG]', + [TokenType.SPLIT_OPEN]: '[/SPLIT]', + [TokenType.TRIM_OPEN]: '[/TRIM]', + [TokenType.CONTAINS_OPEN]: '[/CONTAINS]', + [TokenType.FORMAT_OPEN]: '[/FORMAT]', + [TokenType.EXPR_OPEN]: '%]', + [TokenType.EXPR_OUTPUT_OPEN]: '%]', + [TokenType.COMMENT_OPEN]: '--]' + }; + return closeMap[openType] || 'unknown'; + } + + private current(): Token { + return this.tokens[this.pos] || { type: TokenType.EOF, value: '', line: 0, column: 0, length: 0 }; + } + + private advance(): Token { + if (!this.isAtEnd()) { + this.pos++; + } + return this.tokens[this.pos - 1]; + } + + private isAtEnd(): boolean { + return this.current().type === TokenType.EOF; + } +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..5fb5339 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,199 @@ +import { + createConnection, + TextDocuments, + Diagnostic, + DiagnosticSeverity, + ProposedFeatures, + InitializeParams, + InitializeResult, + TextDocumentSyncKind, + Location, + Range, + Position, + DefinitionParams +} from 'vscode-languageserver/node'; + +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { EftlTokenizer, TokenType } from './tokenizer'; +import { EftlParser } from './parser'; + +// Create a connection for the server +const connection = createConnection(ProposedFeatures.all); + +// Create a document manager +const documents: TextDocuments = new TextDocuments(TextDocument); + +// Store variable definitions per document +interface VariableDefinition { + name: string; + line: number; + column: number; + length: number; +} + +const documentVariables: Map = new Map(); + +connection.onInitialize((params: InitializeParams): InitializeResult => { + return { + capabilities: { + textDocumentSync: TextDocumentSyncKind.Incremental, + definitionProvider: true + } + }; +}); + +// Handle "Go to Definition" requests +connection.onDefinition((params: DefinitionParams): Location | null => { + const document = documents.get(params.textDocument.uri); + if (!document) { + return null; + } + + const text = document.getText(); + const offset = document.offsetAt(params.position); + + // Find the word at the current position + const wordRange = getWordRangeAtPosition(text, offset); + if (!wordRange) { + return null; + } + + const word = text.substring(wordRange.start, wordRange.end); + + // Look for variable definition + const variables = documentVariables.get(params.textDocument.uri) || []; + const variable = variables.find(v => v.name === word); + + if (variable) { + return { + uri: params.textDocument.uri, + range: { + start: { line: variable.line - 1, character: variable.column - 1 }, + end: { line: variable.line - 1, character: variable.column - 1 + variable.length } + } + }; + } + + return null; +}); + +function getWordRangeAtPosition(text: string, offset: number): { start: number; end: number } | null { + // Find word boundaries (alphanumeric and underscore) + let start = offset; + let end = offset; + + // Move start backwards + while (start > 0 && /[a-zA-Z0-9_]/.test(text[start - 1])) { + start--; + } + + // Move end forwards + while (end < text.length && /[a-zA-Z0-9_]/.test(text[end])) { + end++; + } + + if (start === end) { + return null; + } + + return { start, end }; +} + +// Validate documents when they change +documents.onDidChangeContent(change => { + validateDocument(change.document); +}); + +async function validateDocument(textDocument: TextDocument): Promise { + const text = textDocument.getText(); + const diagnostics: Diagnostic[] = []; + + // Tokenize + const tokenizer = new EftlTokenizer(text); + const { tokens, errors: tokenErrors } = tokenizer.tokenize(); + + // Valid types for VAR + const validTypes = ['string', 'boolean', 'number', 'date', 'object', 'iterable']; + + // Extract variable definitions and validate types + const variables: VariableDefinition[] = []; + for (const token of tokens) { + if (token.type === TokenType.VAR_OPEN) { + // Parse name="varName" from the token value + const nameMatch = token.value.match(/name="([^"]+)"/); + if (nameMatch) { + variables.push({ + name: nameMatch[1], + line: token.line, + column: token.column, + length: token.length + }); + } + + // Validate type attribute + const typeMatch = token.value.match(/type="([^"]*)"/); + if (typeMatch) { + const typeValue = typeMatch[1]; + if (!validTypes.includes(typeValue)) { + // Find position of the type value in the token + const typeIndex = token.value.indexOf(`type="${typeValue}"`); + const typeValueStart = typeIndex + 6; // 'type="'.length + diagnostics.push({ + severity: DiagnosticSeverity.Error, + range: { + start: { line: token.line - 1, character: token.column - 1 + typeValueStart }, + end: { line: token.line - 1, character: token.column - 1 + typeValueStart + typeValue.length } + }, + message: `Invalid type "${typeValue}". Must be one of: ${validTypes.join(', ')}`, + source: 'eftl' + }); + } + } + } + } + documentVariables.set(textDocument.uri, variables); + + // Add tokenizer errors + for (const error of tokenErrors) { + diagnostics.push({ + severity: DiagnosticSeverity.Error, + range: { + start: { line: error.line - 1, character: error.column - 1 }, + end: { line: error.line - 1, character: error.column - 1 + error.length } + }, + message: error.message, + source: 'eftl' + }); + } + + // Parse + const parser = new EftlParser(tokens); + const { errors: parseErrors } = parser.parse(); + + // Add parser errors + for (const error of parseErrors) { + diagnostics.push({ + severity: DiagnosticSeverity.Error, + range: { + start: { line: error.line - 1, character: error.column - 1 }, + end: { line: error.line - 1, character: error.column - 1 + error.length } + }, + message: error.message, + source: 'eftl' + }); + } + + // Send diagnostics to VS Code + connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }); +} + +// Clean up when documents are closed +documents.onDidClose(e => { + documentVariables.delete(e.document.uri); +}); + +// Make the text document manager listen on the connection +documents.listen(connection); + +// Listen on the connection +connection.listen(); diff --git a/src/tokenizer.ts b/src/tokenizer.ts new file mode 100644 index 0000000..d472ff2 --- /dev/null +++ b/src/tokenizer.ts @@ -0,0 +1,401 @@ +/** + * EFTL Token Types + */ +export enum TokenType { + // Block delimiters + EFTL_OPEN = 'EFTL_OPEN', // [EFTL] + EFTL_CLOSE = 'EFTL_CLOSE', // [/EFTL] + + // Variable + VAR_OPEN = 'VAR_OPEN', // [VAR ...] + VAR_CLOSE = 'VAR_CLOSE', // [/VAR] + + // Control structures + IF_OPEN = 'IF_OPEN', // [IF] + IF_CLOSE = 'IF_CLOSE', // [/IF] + CONDITION_OPEN = 'CONDITION_OPEN', // [CONDITION] + CONDITION_CLOSE = 'CONDITION_CLOSE', // [/CONDITION] + THEN_OPEN = 'THEN_OPEN', // [THEN] + THEN_CLOSE = 'THEN_CLOSE', // [/THEN] + ELSE_OPEN = 'ELSE_OPEN', // [ELSE] + ELSE_CLOSE = 'ELSE_CLOSE', // [/ELSE] + ELSE_IF_OPEN = 'ELSE_IF_OPEN', // [ELSE IF] + ELSE_IF_CLOSE = 'ELSE_IF_CLOSE', // [/ELSE IF] + WHILE_OPEN = 'WHILE_OPEN', // [WHILE] + WHILE_CLOSE = 'WHILE_CLOSE', // [/WHILE] + DO_OPEN = 'DO_OPEN', // [DO] + DO_CLOSE = 'DO_CLOSE', // [/DO] + + // Functions + TAG_OPEN = 'TAG_OPEN', // [TAG] + TAG_CLOSE = 'TAG_CLOSE', // [/TAG] + SPLIT_OPEN = 'SPLIT_OPEN', // [SPLIT ...] + SPLIT_CLOSE = 'SPLIT_CLOSE', // [/SPLIT] + TRIM_OPEN = 'TRIM_OPEN', // [TRIM] + TRIM_CLOSE = 'TRIM_CLOSE', // [/TRIM] + CONTAINS_OPEN = 'CONTAINS_OPEN', // [CONTAINS ...] + CONTAINS_CLOSE = 'CONTAINS_CLOSE', // [/CONTAINS] + FORMAT_OPEN = 'FORMAT_OPEN', // [FORMAT ...] + FORMAT_CLOSE = 'FORMAT_CLOSE', // [/FORMAT] + VALUE_OF = 'VALUE_OF', // [VALUE_OF ... /] + SIZE_OF = 'SIZE_OF', // [SIZE_OF ... /] + HEADER = 'HEADER', // [HEADER ... /] + + // Expressions + EXPR_OPEN = 'EXPR_OPEN', // [% + EXPR_CLOSE = 'EXPR_CLOSE', // %] + EXPR_OUTPUT_OPEN = 'EXPR_OUTPUT_OPEN', // [%= + + // Comments + COMMENT_OPEN = 'COMMENT_OPEN', // [!-- + COMMENT_CLOSE = 'COMMENT_CLOSE', // --] + + // Content + TEXT = 'TEXT', // Plain text + IDENTIFIER = 'IDENTIFIER', // Variable names + STRING = 'STRING', // "..." + NUMBER = 'NUMBER', // 123, 45.67 + + // Other + EOF = 'EOF', + ERROR = 'ERROR' +} + +export interface Token { + type: TokenType; + value: string; + line: number; + column: number; + length: number; +} + +export interface TokenizerError { + message: string; + line: number; + column: number; + length: number; +} + +/** + * EFTL Tokenizer - converts source text into tokens + */ +export class EftlTokenizer { + private source: string; + private pos: number = 0; + private line: number = 1; + private column: number = 1; + private tokens: Token[] = []; + private errors: TokenizerError[] = []; + + constructor(source: string) { + this.source = source; + } + + tokenize(): { tokens: Token[]; errors: TokenizerError[] } { + this.tokens = []; + this.errors = []; + this.pos = 0; + this.line = 1; + this.column = 1; + + while (this.pos < this.source.length) { + this.scanToken(); + } + + this.tokens.push({ + type: TokenType.EOF, + value: '', + line: this.line, + column: this.column, + length: 0 + }); + + return { tokens: this.tokens, errors: this.errors }; + } + + private scanToken(): void { + const startLine = this.line; + const startColumn = this.column; + + // Check for EFTL constructs starting with [ + if (this.peek() === '[') { + if (this.match('[EFTL]')) { + this.addToken(TokenType.EFTL_OPEN, '[EFTL]', startLine, startColumn); + return; + } + if (this.match('[/EFTL]')) { + this.addToken(TokenType.EFTL_CLOSE, '[/EFTL]', startLine, startColumn); + return; + } + if (this.match('[!--')) { + this.addToken(TokenType.COMMENT_OPEN, '[!--', startLine, startColumn); + this.scanComment(); + return; + } + if (this.match('[%=')) { + this.addToken(TokenType.EXPR_OUTPUT_OPEN, '[%=', startLine, startColumn); + return; + } + if (this.match('[%')) { + this.addToken(TokenType.EXPR_OPEN, '[%', startLine, startColumn); + return; + } + if (this.match('[VAR')) { + this.scanVarOpen(startLine, startColumn); + return; + } + if (this.match('[/VAR]')) { + this.addToken(TokenType.VAR_CLOSE, '[/VAR]', startLine, startColumn); + return; + } + if (this.match('[IF]')) { + this.addToken(TokenType.IF_OPEN, '[IF]', startLine, startColumn); + return; + } + if (this.match('[/IF]')) { + this.addToken(TokenType.IF_CLOSE, '[/IF]', startLine, startColumn); + return; + } + if (this.match('[CONDITION]')) { + this.addToken(TokenType.CONDITION_OPEN, '[CONDITION]', startLine, startColumn); + return; + } + if (this.match('[/CONDITION]')) { + this.addToken(TokenType.CONDITION_CLOSE, '[/CONDITION]', startLine, startColumn); + return; + } + if (this.match('[THEN]')) { + this.addToken(TokenType.THEN_OPEN, '[THEN]', startLine, startColumn); + return; + } + if (this.match('[/THEN]')) { + this.addToken(TokenType.THEN_CLOSE, '[/THEN]', startLine, startColumn); + return; + } + if (this.match('[ELSE IF]')) { + this.addToken(TokenType.ELSE_IF_OPEN, '[ELSE IF]', startLine, startColumn); + return; + } + if (this.match('[/ELSE IF]')) { + this.addToken(TokenType.ELSE_IF_CLOSE, '[/ELSE IF]', startLine, startColumn); + return; + } + if (this.match('[ELSE]')) { + this.addToken(TokenType.ELSE_OPEN, '[ELSE]', startLine, startColumn); + return; + } + if (this.match('[/ELSE]')) { + this.addToken(TokenType.ELSE_CLOSE, '[/ELSE]', startLine, startColumn); + return; + } + if (this.match('[WHILE]')) { + this.addToken(TokenType.WHILE_OPEN, '[WHILE]', startLine, startColumn); + return; + } + if (this.match('[/WHILE]')) { + this.addToken(TokenType.WHILE_CLOSE, '[/WHILE]', startLine, startColumn); + return; + } + if (this.match('[DO]')) { + this.addToken(TokenType.DO_OPEN, '[DO]', startLine, startColumn); + return; + } + if (this.match('[/DO]')) { + this.addToken(TokenType.DO_CLOSE, '[/DO]', startLine, startColumn); + return; + } + if (this.match('[TAG]')) { + this.addToken(TokenType.TAG_OPEN, '[TAG]', startLine, startColumn); + return; + } + if (this.match('[/TAG]')) { + this.addToken(TokenType.TAG_CLOSE, '[/TAG]', startLine, startColumn); + return; + } + if (this.match('[SPLIT')) { + this.scanTagWithAttributes(TokenType.SPLIT_OPEN, 'SPLIT', startLine, startColumn); + return; + } + if (this.match('[/SPLIT]')) { + this.addToken(TokenType.SPLIT_CLOSE, '[/SPLIT]', startLine, startColumn); + return; + } + if (this.match('[TRIM]')) { + this.addToken(TokenType.TRIM_OPEN, '[TRIM]', startLine, startColumn); + return; + } + if (this.match('[/TRIM]')) { + this.addToken(TokenType.TRIM_CLOSE, '[/TRIM]', startLine, startColumn); + return; + } + if (this.match('[CONTAINS')) { + this.scanTagWithAttributes(TokenType.CONTAINS_OPEN, 'CONTAINS', startLine, startColumn); + return; + } + if (this.match('[/CONTAINS]')) { + this.addToken(TokenType.CONTAINS_CLOSE, '[/CONTAINS]', startLine, startColumn); + return; + } + if (this.match('[FORMAT')) { + this.scanTagWithAttributes(TokenType.FORMAT_OPEN, 'FORMAT', startLine, startColumn); + return; + } + if (this.match('[/FORMAT]')) { + this.addToken(TokenType.FORMAT_CLOSE, '[/FORMAT]', startLine, startColumn); + return; + } + if (this.match('[VALUE_OF')) { + this.scanSelfClosingTag(TokenType.VALUE_OF, 'VALUE_OF', startLine, startColumn); + return; + } + if (this.match('[SIZE_OF')) { + this.scanSelfClosingTag(TokenType.SIZE_OF, 'SIZE_OF', startLine, startColumn); + return; + } + if (this.match('[HEADER')) { + this.scanSelfClosingTag(TokenType.HEADER, 'HEADER', startLine, startColumn); + return; + } + } + + // Check for expression close + if (this.match('%]')) { + this.addToken(TokenType.EXPR_CLOSE, '%]', startLine, startColumn); + return; + } + + // Check for comment close + if (this.match('--]')) { + this.addToken(TokenType.COMMENT_CLOSE, '--]', startLine, startColumn); + return; + } + + // Plain text - consume until we hit a special character + this.scanText(startLine, startColumn); + } + + private scanComment(): void { + const startLine = this.line; + const startColumn = this.column; + let content = ''; + + while (this.pos < this.source.length) { + if (this.match('--]')) { + this.addToken(TokenType.COMMENT_CLOSE, '--]', this.line, this.column - 3); + return; + } + content += this.advance(); + } + + // Unclosed comment + this.errors.push({ + message: 'Unclosed comment: expected "--]"', + line: startLine, + column: startColumn, + length: content.length + }); + } + + private scanVarOpen(startLine: number, startColumn: number): void { + let value = '[VAR'; + // Scan until we find ] or ] + while (this.pos < this.source.length && this.peek() !== ']') { + value += this.advance(); + } + if (this.peek() === ']') { + value += this.advance(); + } + this.addToken(TokenType.VAR_OPEN, value, startLine, startColumn); + } + + private scanTagWithAttributes(type: TokenType, tagName: string, startLine: number, startColumn: number): void { + let value = '[' + tagName; + while (this.pos < this.source.length && this.peek() !== ']') { + value += this.advance(); + } + if (this.peek() === ']') { + value += this.advance(); + } + this.addToken(type, value, startLine, startColumn); + } + + private scanSelfClosingTag(type: TokenType, tagName: string, startLine: number, startColumn: number): void { + let value = '[' + tagName; + while (this.pos < this.source.length) { + const ch = this.advance(); + value += ch; + if (value.endsWith('/]')) { + break; + } + if (ch === ']') { + // Not self-closing, error + this.errors.push({ + message: `Expected self-closing tag: [${tagName} ... /]`, + line: startLine, + column: startColumn, + length: value.length + }); + break; + } + } + this.addToken(type, value, startLine, startColumn); + } + + private scanText(startLine: number, startColumn: number): void { + let text = ''; + while (this.pos < this.source.length) { + const ch = this.peek(); + // Stop at special characters + if (ch === '[' || (ch === '%' && this.peekNext() === ']') || (ch === '-' && this.peekNext() === '-' && this.peekAt(2) === ']')) { + break; + } + text += this.advance(); + } + if (text.length > 0) { + this.addToken(TokenType.TEXT, text, startLine, startColumn); + } + } + + private peek(): string { + return this.source[this.pos] || '\0'; + } + + private peekNext(): string { + return this.source[this.pos + 1] || '\0'; + } + + private peekAt(offset: number): string { + return this.source[this.pos + offset] || '\0'; + } + + private advance(): string { + const ch = this.source[this.pos++]; + if (ch === '\n') { + this.line++; + this.column = 1; + } else { + this.column++; + } + return ch; + } + + private match(expected: string): boolean { + if (this.source.substring(this.pos, this.pos + expected.length) === expected) { + for (let i = 0; i < expected.length; i++) { + this.advance(); + } + return true; + } + return false; + } + + private addToken(type: TokenType, value: string, line: number, column: number): void { + this.tokens.push({ + type, + value, + line, + column, + length: value.length + }); + } +} diff --git a/syntaxes/eftl.tmLanguage.json b/syntaxes/eftl.tmLanguage.json new file mode 100644 index 0000000..4c90163 --- /dev/null +++ b/syntaxes/eftl.tmLanguage.json @@ -0,0 +1,364 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "EFTL", + "scopeName": "source.eftl", + "patterns": [ + { "include": "#comments" }, + { "include": "#eftl-block" }, + { "include": "#expressions" }, + { "include": "#tags" }, + { "include": "#control-structures" }, + { "include": "#variables" }, + { "include": "#functions" }, + { "include": "#strings" } + ], + "repository": { + "comments": { + "patterns": [ + { + "name": "comment.block.eftl", + "begin": "\\[!--", + "end": "--\\]", + "captures": { + "0": { "name": "punctuation.definition.comment.eftl" } + } + } + ] + }, + "eftl-block": { + "patterns": [ + { + "name": "meta.block.eftl", + "begin": "(\\[EFTL\\])", + "end": "(\\[/EFTL\\])", + "beginCaptures": { + "1": { "name": "keyword.control.eftl.begin" } + }, + "endCaptures": { + "1": { "name": "keyword.control.eftl.end" } + }, + "patterns": [ + { "include": "#comments" }, + { "include": "#expressions" }, + { "include": "#tags" }, + { "include": "#control-structures" }, + { "include": "#variables" }, + { "include": "#functions" }, + { "include": "#header" }, + { "include": "#strings" } + ] + } + ] + }, + "expressions": { + "patterns": [ + { + "name": "meta.expression.output.eftl", + "begin": "\\[%=", + "end": "%\\]", + "beginCaptures": { + "0": { "name": "punctuation.section.expression.begin.eftl" } + }, + "endCaptures": { + "0": { "name": "punctuation.section.expression.end.eftl" } + }, + "patterns": [ + { "include": "#expression-content" } + ] + }, + { + "name": "meta.expression.eftl", + "begin": "\\[%", + "end": "%\\]", + "beginCaptures": { + "0": { "name": "punctuation.section.expression.begin.eftl" } + }, + "endCaptures": { + "0": { "name": "punctuation.section.expression.end.eftl" } + }, + "patterns": [ + { "include": "#expression-content" } + ] + } + ] + }, + "expression-content": { + "patterns": [ + { + "name": "constant.numeric.eftl", + "match": "\\b[0-9]+\\.?[0-9]*\\b" + }, + { + "name": "keyword.operator.assignment.eftl", + "match": "=" + }, + { + "name": "keyword.operator.arithmetic.eftl", + "match": "[+\\-*/%]" + }, + { + "name": "keyword.operator.comparison.eftl", + "match": "(!=|==|<=|>=|<|>)" + }, + { + "name": "keyword.operator.logical.eftl", + "match": "(&&|\\|\\|)" + }, + { + "name": "constant.language.boolean.eftl", + "match": "\\b(true|false)\\b" + }, + { + "name": "variable.other.eftl", + "match": "\\b[a-zA-Z_][a-zA-Z0-9_]*\\b" + }, + { + "name": "string.quoted.double.eftl", + "begin": "\"", + "end": "\"", + "patterns": [ + { + "name": "constant.character.escape.eftl", + "match": "\\\\." + } + ] + }, + { + "name": "punctuation.terminator.statement.eftl", + "match": ";" + } + ] + }, + "tags": { + "patterns": [ + { + "name": "meta.tag.data.eftl", + "begin": "(\\[TAG\\])", + "end": "(\\[/TAG\\])", + "beginCaptures": { + "1": { "name": "entity.name.tag.eftl" } + }, + "endCaptures": { + "1": { "name": "entity.name.tag.eftl" } + }, + "patterns": [ + { "include": "#tag-content" } + ] + } + ] + }, + "tag-content": { + "patterns": [ + { + "name": "support.function.builtin.eftl", + "match": "\\b(GETVALUEBYTAG|SCHEMAID|PLUGIN_DOM_PARAM)\\b" + }, + { + "name": "variable.parameter.eftl", + "match": "\\b(REQUEST|MODULE|IUQOID)\\b" + }, + { + "name": "constant.numeric.eftl", + "match": "\\b[0-9]+\\b" + }, + { + "name": "variable.other.eftl", + "match": "\\b(COL[0-9]+)\\b" + }, + { + "name": "punctuation.separator.eftl", + "match": "," + } + ] + }, + "header": { + "patterns": [ + { + "name": "meta.header.eftl", + "match": "(\\[HEADER)\\s+(name=\"[^\"]*\")\\s+(value=\"[^\"]*\")\\s+(type=\"[^\"]*\")\\s*(/\\])", + "captures": { + "1": { "name": "keyword.other.header.eftl" }, + "2": { "name": "entity.other.attribute-name.eftl" }, + "3": { "name": "string.quoted.double.eftl" }, + "4": { "name": "entity.other.attribute-name.eftl" }, + "5": { "name": "punctuation.definition.tag.end.eftl" } + } + } + ] + }, + "control-structures": { + "patterns": [ + { + "name": "keyword.control.conditional.eftl", + "match": "\\[(IF|ELSE IF|ELSE|/IF|CONDITION|/CONDITION|THEN|/THEN|/ELSE IF|/ELSE)\\]" + }, + { + "name": "keyword.control.loop.eftl", + "match": "\\[(WHILE|/WHILE|DO|/DO)\\]" + } + ] + }, + "variables": { + "patterns": [ + { + "name": "meta.variable.declaration.eftl", + "begin": "(\\[VAR)", + "end": "(\\[/VAR\\])", + "beginCaptures": { + "1": { "name": "storage.type.variable.eftl" } + }, + "endCaptures": { + "1": { "name": "storage.type.variable.eftl" } + }, + "patterns": [ + { "include": "#var-attributes" }, + { "include": "#expressions" }, + { "include": "#functions" }, + { "include": "#tags" } + ] + } + ] + }, + "var-attributes": { + "patterns": [ + { + "comment": "name attribute - variable name gets special color", + "match": "(name)(=)(\"([^\"]*)\")", + "captures": { + "1": { "name": "entity.other.attribute-name.name.eftl" }, + "2": { "name": "punctuation.separator.key-value.eftl" }, + "3": { "name": "string.quoted.double.eftl" }, + "4": { "name": "variable.other.declaration.eftl" } + } + }, + { + "comment": "type attribute with valid type values", + "match": "(type)(=)(\"(string|boolean|number|date|object|iterable)\")", + "captures": { + "1": { "name": "entity.other.attribute-name.type.eftl" }, + "2": { "name": "punctuation.separator.key-value.eftl" }, + "3": { "name": "string.quoted.double.eftl" }, + "4": { "name": "support.type.primitive.eftl" } + } + }, + { + "comment": "unique attribute - boolean value", + "match": "(unique)(=)(\"(true|false)\")", + "captures": { + "1": { "name": "entity.other.attribute-name.unique.eftl" }, + "2": { "name": "punctuation.separator.key-value.eftl" }, + "3": { "name": "string.quoted.double.eftl" }, + "4": { "name": "constant.language.boolean.eftl" } + } + }, + { + "comment": "closing bracket of VAR open tag", + "match": "\\]", + "name": "punctuation.definition.tag.end.eftl" + } + ] + }, + "functions": { + "patterns": [ + { + "name": "meta.function.split.eftl", + "begin": "(\\[SPLIT)\\s+(regex=\"[^\"]*\")(\\])?", + "end": "(\\[/SPLIT\\])", + "beginCaptures": { + "1": { "name": "support.function.eftl" }, + "2": { "name": "string.regexp.eftl" } + }, + "endCaptures": { + "1": { "name": "support.function.eftl" } + }, + "patterns": [ + { "include": "#expressions" }, + { "include": "#tags" }, + { "include": "#functions" } + ] + }, + { + "name": "meta.function.trim.eftl", + "begin": "(\\[TRIM\\])", + "end": "(\\[/TRIM\\])", + "beginCaptures": { + "1": { "name": "support.function.eftl" } + }, + "endCaptures": { + "1": { "name": "support.function.eftl" } + }, + "patterns": [ + { "include": "#expressions" }, + { "include": "#tags" }, + { "include": "#functions" } + ] + }, + { + "name": "meta.function.contains.eftl", + "begin": "(\\[CONTAINS)\\s+(varname=\"[^\"]*\")(\\])?", + "end": "(\\[/CONTAINS\\])", + "beginCaptures": { + "1": { "name": "support.function.eftl" }, + "2": { "name": "entity.other.attribute-name.eftl" } + }, + "endCaptures": { + "1": { "name": "support.function.eftl" } + }, + "patterns": [ + { "include": "#functions" } + ] + }, + { + "name": "meta.function.value-of.eftl", + "match": "(\\[VALUE_OF)\\s+(varname=\"[^\"]*\")\\s*(index=\"?[^\"]*\"?)?\\s*(/\\])", + "captures": { + "1": { "name": "support.function.eftl" }, + "2": { "name": "entity.other.attribute-name.eftl" }, + "3": { "name": "entity.other.attribute-name.eftl" }, + "4": { "name": "punctuation.definition.tag.end.eftl" } + } + }, + { + "name": "meta.function.size-of.eftl", + "match": "(\\[SIZE_OF)\\s+(varname=\"[^\"]*\")\\s*(/\\])", + "captures": { + "1": { "name": "support.function.eftl" }, + "2": { "name": "entity.other.attribute-name.eftl" }, + "3": { "name": "punctuation.definition.tag.end.eftl" } + } + }, + { + "name": "meta.function.format.eftl", + "begin": "(\\[FORMAT)\\s+(type=\"[^\"]*\")\\s*(pattern=\"[^\"]*\")?(\\])?", + "end": "(\\[/FORMAT\\])", + "beginCaptures": { + "1": { "name": "support.function.eftl" }, + "2": { "name": "entity.other.attribute-name.eftl" }, + "3": { "name": "string.other.pattern.eftl" } + }, + "endCaptures": { + "1": { "name": "support.function.eftl" } + }, + "patterns": [ + { "include": "#expressions" } + ] + } + ] + }, + "strings": { + "patterns": [ + { + "name": "string.quoted.double.eftl", + "begin": "\"", + "end": "\"", + "patterns": [ + { + "name": "constant.character.escape.eftl", + "match": "\\\\." + } + ] + } + ] + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ea00286 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "lib": ["ES2020"], + "outDir": "out", + "rootDir": "src", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", ".vscode-test"] +}