diff --git a/package-lock.json b/package-lock.json
index 0c7fb81..3c7642f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,18 +1,21 @@
{
"name": "acode-additional-langmodes",
- "version": "1.1.0",
+ "version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "acode-additional-langmodes",
- "version": "1.1.0",
+ "version": "1.0.1",
"license": "MIT",
"dependencies": {
"@citedrive/codemirror-lang-bibtex": "^6.4.1",
"@codemirror/autocomplete": "^6.20.3",
+ "@codemirror/lang-html": "^6.4.11",
+ "@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.3",
"@iizukak/codemirror-lang-wgsl": "^0.3.0",
+ "@lezer/common": "^1.5.2",
"@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.10",
"@replit/codemirror-lang-svelte": "^6.0.0",
@@ -78,7 +81,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
@@ -92,7 +94,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
@@ -110,7 +111,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
@@ -643,7 +643,6 @@
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz",
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
@@ -689,7 +688,6 @@
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
@@ -701,7 +699,6 @@
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
diff --git a/package.json b/package.json
index cf5f9cb..1ef6802 100644
--- a/package.json
+++ b/package.json
@@ -9,8 +9,11 @@
"dependencies": {
"@citedrive/codemirror-lang-bibtex": "^6.4.1",
"@codemirror/autocomplete": "^6.20.3",
+ "@codemirror/lang-html": "^6.4.11",
+ "@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.3",
"@iizukak/codemirror-lang-wgsl": "^0.3.0",
+ "@lezer/common": "^1.5.2",
"@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.10",
"@replit/codemirror-lang-svelte": "^6.0.0",
@@ -33,7 +36,7 @@
},
"scripts": {
"dev": "node esbuild.config.mjs --serve",
- "generate": "lezer-generator src/languages/asciidoc/asciidoc.grammar -o src/languages/asciidoc/parser.js && lezer-generator src/languages/assembly/assembly.grammar -o src/languages/assembly/parser.js && lezer-generator src/languages/autohotkey/autohotkey.grammar -o src/languages/autohotkey/parser.js && lezer-generator src/languages/zig/zig.grammar -o src/languages/zig/parser.js && lezer-generator src/languages/jsonc/jsonc.grammar -o src/languages/jsonc/parser.js",
+ "generate": "lezer-generator src/languages/asciidoc/asciidoc.grammar -o src/languages/asciidoc/parser.js && lezer-generator src/languages/assembly/assembly.grammar -o src/languages/assembly/parser.js && lezer-generator src/languages/autohotkey/autohotkey.grammar -o src/languages/autohotkey/parser.js && lezer-generator src/languages/zig/zig.grammar -o src/languages/zig/parser.js && lezer-generator src/languages/jsonc/jsonc.grammar -o src/languages/jsonc/parser.js && lezer-generator src/languages/ejs/ejs.grammar -o src/languages/ejs/parser.js",
"build": "npm run generate && node esbuild.config.mjs",
"test": "npm run generate && node scripts/test-parser.mjs",
"check": "npm run generate && tsc --noEmit && node scripts/test-parser.mjs && npm run build && node scripts/test-plugin-runtime.cjs"
diff --git a/plugin.zip b/plugin.zip
index 30e94fb..520b168 100644
Binary files a/plugin.zip and b/plugin.zip differ
diff --git a/readme.md b/readme.md
index b96d5a7..672c84a 100644
--- a/readme.md
+++ b/readme.md
@@ -12,6 +12,7 @@ Adds CodeMirror language support for languages that are not bundled with Acode.
- JSONC (`.jsonc`)
- BibTeX (`.bib`)
- Elixir (`.ex`, `.exs`, `.eex`, `.heex`, `.leex`)
+- EJS (`.ejs`)
- GolfScript (`.gs`)
- GraphQL (`.graphql`, `.graphqls`, `.gql`)
- Graphviz DOT (`.dot`, `.gv`)
diff --git a/scripts/test-parser.mjs b/scripts/test-parser.mjs
index de65011..7017b74 100644
--- a/scripts/test-parser.mjs
+++ b/scripts/test-parser.mjs
@@ -1,24 +1,34 @@
import assert from "node:assert/strict";
+import { CompletionContext } from "@codemirror/autocomplete";
+import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
+import { pathToFileURL } from "node:url";
import { build } from "esbuild";
+import { fileTests } from "@lezer/generator/test";
-const outfile = path.join(os.tmpdir(), "acode-autohotkey-parser-test.cjs");
+const require = createRequire(import.meta.url);
-await build({
- entryPoints: ["src/languages/autohotkey/parser.js"],
- bundle: true,
- format: "cjs",
- platform: "node",
- outfile,
- logLevel: "silent",
-});
+async function bundledRequire(entryPoint, outfileName) {
+ const outfile = path.join(os.tmpdir(), outfileName);
+ await build({
+ entryPoints: [entryPoint],
+ bundle: true,
+ format: "cjs",
+ platform: "node",
+ outfile,
+ logLevel: "silent",
+ });
+ return require(outfile);
+}
-const require = createRequire(import.meta.url);
-const { parser } = require(outfile);
+const { parser: autoHotkeyParser } = await bundledRequire(
+ "src/languages/autohotkey/parser.js",
+ "acode-autohotkey-parser-test.cjs",
+);
-const samples = [
+const autoHotkeySamples = [
{
name: "AutoHotkey v2",
source: `#Requires AutoHotkey v2.0
@@ -87,17 +97,15 @@ comment */
},
];
-for (const sample of samples) {
- const tree = parser.parse(sample.source);
+for (const sample of autoHotkeySamples) {
+ const tree = autoHotkeyParser.parse(sample.source);
const names = new Set();
const errors = [];
tree.iterate({
enter(node) {
names.add(node.name);
- if (node.type.isError) {
- errors.push([node.from, node.to]);
- }
+ if (node.type.isError) errors.push([node.from, node.to]);
},
});
@@ -108,21 +116,13 @@ for (const sample of samples) {
}
}
-console.log(`Validated ${samples.length} AutoHotkey parser fixtures.`);
+console.log(`Validated ${autoHotkeySamples.length} AutoHotkey parser fixtures.`);
// Test JSONC Parser
-const jsoncOutfile = path.join(os.tmpdir(), "acode-jsonc-parser-test.cjs");
-
-await build({
- entryPoints: ["src/languages/jsonc/parser.js"],
- bundle: true,
- format: "cjs",
- platform: "node",
- outfile: jsoncOutfile,
- logLevel: "silent",
-});
-
-const { parser: jsoncParser } = require(jsoncOutfile);
+const { parser: jsoncParser } = await bundledRequire(
+ "src/languages/jsonc/parser.js",
+ "acode-jsonc-parser-test.cjs",
+);
const jsoncSamples = [
{
@@ -179,3 +179,166 @@ for (const sample of jsoncSamples) {
console.log(`Validated ${jsoncSamples.length} JSONC parser fixtures.`);
+const { parser: ejsParser } = await bundledRequire(
+ "src/languages/ejs/parser.js",
+ "acode-ejs-parser-test.cjs",
+);
+
+const ejsFixturePath = "src/languages/ejs/test.txt";
+const ejsFixtures = fileTests(fs.readFileSync(ejsFixturePath, "utf8"), ejsFixturePath);
+for (const test of ejsFixtures) test.run(ejsParser);
+console.log(`Validated ${ejsFixtures.length} EJS Lezer parser fixtures.`);
+
+const ejsBehaviorBundle = path.join(os.tmpdir(), "acode-ejs-behavior-test-bundle.mjs");
+const ejsBehaviorSource = String.raw`
+import assert from "node:assert/strict";
+import { CompletionContext } from "@codemirror/autocomplete";
+import { EditorSelection, EditorState } from "@codemirror/state";
+import { insertNewlineAndIndent, toggleComment } from "@codemirror/commands";
+import { foldable, getIndentation, indentUnit, matchBrackets, syntaxTree } from "@codemirror/language";
+import { classHighlighter, highlightTree } from "@lezer/highlight";
+import { ejs, ejsLanguage } from "./src/languages/ejs/index.js";
+
+function spansFor(doc) {
+ const state = EditorState.create({ doc, extensions: [ejsLanguage] });
+ syntaxTree(state);
+ const spans = [];
+ highlightTree(syntaxTree(state), classHighlighter, (from, to, cls) => {
+ spans.push({ from, to, cls, text: state.doc.sliceString(from, to) });
+ });
+ return { state, tree: syntaxTree(state), spans };
+}
+
+{
+ const source = "<%# <% if (user) { %>\n
<%= user.name %>
\n<% } %> %>";
+ const result = spansFor(source);
+ assert.equal(result.tree.toString(), "Template(EjsComment)");
+ assert.equal(result.spans.length, 1);
+ assert.equal(result.spans[0].cls, "tok-comment");
+ assert.equal(result.spans[0].text, source);
+}
+
+{
+ const result = spansFor("<% if (user) { %>\n \n<% } %>");
+ assert(result.spans.some((span) => span.cls === "tok-comment" && span.text.includes("<%= user.name %>")));
+ assert(!result.spans.some((span) => /user|name|<%=/.test(span.text) && span.cls !== "tok-comment" && span.from >= 20 && span.to <= 54));
+}
+
+{
+ const source = "<% if (user) { %>\n <%= user.name %>
\n<% } %>";
+ const result = spansFor(source);
+ assert(result.spans.some((span) => span.text === "h2" && span.cls.includes("typeName")));
+ assert(result.spans.some((span) => span.text === "user" && span.cls.includes("variableName")));
+ const open = result.state.doc.toString().indexOf("{");
+ const close = result.state.doc.toString().lastIndexOf("}");
+ assert.notEqual(matchBrackets(result.state, open + 1, 1)?.matched, false);
+ assert.notEqual(matchBrackets(result.state, close + 1, -1)?.matched, false);
+}
+
+{
+ assert.equal(spansFor("my title").spans.length, 0);
+}
+
+function enter(doc, extensions = []) {
+ const cursor = doc.indexOf("%>\n <% }") + 2;
+ const state = EditorState.create({
+ doc,
+ selection: EditorSelection.cursor(cursor),
+ extensions: [ejs(), ...extensions],
+ });
+ syntaxTree(state);
+ let transaction = null;
+ insertNewlineAndIndent({ state, dispatch(tr) { transaction = tr; } });
+ const line = transaction.newDoc.lineAt(transaction.newSelection.main.head);
+ return line.text;
+}
+
+{
+ const source = "<% if (user) { %>\n <%= user.name %>
\n <% if('id' in user) { %>\n <% } %>\n<% } %>";
+ assert.equal(enter(source), " ");
+ assert.equal(enter(source, [indentUnit.of(" "), EditorState.tabSize.of(4)]), " ");
+ assert.equal(enter(source, [indentUnit.of(" "), EditorState.tabSize.of(2)]), " ");
+}
+
+{
+ const source = "<% if (user) { %>\n <%= user.name %>
\n <% if('id' in user) { %>\n\n <% } %>\n<% } %>";
+ const state = EditorState.create({ doc: source, extensions: [ejs()] });
+ syntaxTree(state);
+ assert.equal(getIndentation(state, state.doc.line(4).from), 4);
+ assert.equal(getIndentation(state, state.doc.line(5).from), 2);
+ assert.deepEqual(foldable(state, state.doc.line(1).from, state.doc.line(1).to), {
+ from: state.doc.line(1).to,
+ to: state.doc.line(6).from,
+ });
+}
+
+
+{
+ const state = EditorState.create({ doc: "", extensions: [ejs()] });
+ syntaxTree(state);
+ assert.equal(state.update({ changes: { from: 3, insert: "2" } }).newDoc.toString(), "");
+}
+
+{
+ const state = EditorState.create({ doc: "", extensions: [ejs()] });
+ syntaxTree(state);
+ assert.equal(state.update({ changes: { from: 3, to: 4 } }).newDoc.toString(), "");
+}
+
+{
+ const state = EditorState.create({ doc: "", extensions: [ejs()] });
+ syntaxTree(state);
+ const transaction = state.update({ changes: [{ from: 3, insert: "2" }, { from: 8, insert: "2" }] });
+ assert.equal(transaction.newDoc.toString(), "");
+}
+
+{
+ const state = EditorState.create({ doc: "fo", extensions: [ejs()] });
+ const source = state.languageDataAt("autocomplete", 2)[0];
+ const result = source(new CompletionContext(state, 2, true));
+ const completion = result.options.find((option) => option.label === "for");
+ assert(completion?.apply, "EJS for completion should be a snippet");
+
+ let next = state;
+ const fakeView = {
+ state: next,
+ dispatch(transaction) {
+ next = transaction.state;
+ this.state = next;
+ },
+ };
+ completion.apply(fakeView, completion, result.from, 2);
+ assert.equal(next.doc.toString(), "<% for (const item of items) { %>\n \n<% } %>");
+ assert.equal(next.selection.main.from, 14);
+ assert.equal(next.selection.main.to, 18);
+}
+{
+ let updated = "";
+ const source = " ";
+ const state = EditorState.create({ doc: source, selection: { anchor: 2, head: source.length }, extensions: [ejs()] });
+ assert(toggleComment({ state, dispatch(tr) { updated = tr.newDoc.toString(); } }));
+ assert.equal(updated.trim(), "<%= user.name %>
");
+}
+
+console.log("Validated EJS CodeMirror behavior fixtures.");
+`;
+
+await build({
+ stdin: {
+ contents: ejsBehaviorSource,
+ resolveDir: process.cwd(),
+ sourcefile: "ejs-behavior-test.mjs",
+ loader: "js",
+ },
+ bundle: true,
+ format: "esm",
+ platform: "node",
+ outfile: ejsBehaviorBundle,
+ logLevel: "silent",
+});
+
+try {
+ await import(pathToFileURL(ejsBehaviorBundle).href + `?t=${Date.now()}`);
+} finally {
+ fs.rmSync(ejsBehaviorBundle, { force: true });
+}
diff --git a/scripts/test-plugin-runtime.cjs b/scripts/test-plugin-runtime.cjs
index afcd399..829d7b4 100644
--- a/scripts/test-plugin-runtime.cjs
+++ b/scripts/test-plugin-runtime.cjs
@@ -67,6 +67,7 @@ async function test() {
"pkl",
"svelte",
"wgsl",
+ "ejs",
];
assert.deepEqual([...modes.keys()], expectedNames);
@@ -122,6 +123,7 @@ message:
pkl: 'name = "example"',
svelte: "{x}
",
wgsl: "@vertex fn main() -> @builtin(position) vec4f { return vec4f(); }",
+ ejs: "<% if (user) { %>\n<%= user.name %>
\n<% } %>",
};
for (const [name, source] of Object.entries(samples)) {
diff --git a/src/languages/ejs/ejs.grammar b/src/languages/ejs/ejs.grammar
new file mode 100644
index 0000000..1ea4d00
--- /dev/null
+++ b/src/languages/ejs/ejs.grammar
@@ -0,0 +1,46 @@
+@top Template { (EjsComment | HtmlComment | Text | EjsTag | EscapedTag)* }
+
+EjsTag {
+ EjsExpression |
+ EjsBlock
+}
+
+
+EjsExpression {
+ ExprStart Content? TagEnd
+}
+
+EjsBlock {
+ BlockStart Content? TagEnd
+}
+
+EscapedTag {
+ LiteralStart
+}
+
+@external tokens ejsComment from "./tokens.js" {
+ EjsComment
+}
+
+@tokens {
+ ExprStart { "<%" ("=" | "-") }
+ BlockStart { "<%" "_"? }
+ LiteralStart { "<%%" }
+
+ // Keep trim markers available to TagEnd while allowing them in JS content.
+ Content { (![%\-_] | "%" ![>] | "-" ![>%] | "_" ![>%])+ }
+
+ TagEnd { ("-" | "_")? "%>" }
+
+ HtmlComment { "" }
+
+ Text { (![<] | "<" ![%!])+ }
+
+ @precedence {
+ LiteralStart,
+ ExprStart,
+ BlockStart
+ }
+}
+
+@detectDelim
diff --git a/src/languages/ejs/index.js b/src/languages/ejs/index.js
new file mode 100644
index 0000000..456af69
--- /dev/null
+++ b/src/languages/ejs/index.js
@@ -0,0 +1,426 @@
+import { completeFromList, snippetCompletion } from "@codemirror/autocomplete";
+import { html } from "@codemirror/lang-html";
+import { javascript, javascriptLanguage } from "@codemirror/lang-javascript";
+import { Prec, EditorState } from "@codemirror/state";
+import {
+ foldInside,
+ foldNodeProp,
+ foldService,
+ indentNodeProp,
+ indentService,
+ LRLanguage,
+ LanguageSupport,
+ syntaxTree,
+} from "@codemirror/language";
+import { parseMixed } from "@lezer/common";
+import { styleTags, tags as t } from "@lezer/highlight";
+import { parser } from "./parser";
+
+// Follows the Codemirror Language Pattern.
+// Uses the following grammars (with certain modifications to support other things):
+// 1. https://github.com/dannyhw/ejs-language-tools/blob/main/syntaxes/js-ejs-injection.tmLanguage.json
+// 2. https://github.com/Digitalbrainstem/ejs-grammar/blob/master/syntaxes/ejs.json
+// other things such as html comments inside ejs block, ejs comments special cases, and ...
+// Eyes on Javascript Highlighting, html autocompletion inside ejs blocks.
+// Written by [UnschooledGamer](https://github.com/UnschooledGamer) & Handled by Acode-Foundation.
+// Under the Same License as Project's License File at https://github.com/Acode-Foundation/acode-additional-langmodes.
+
+const htmlSupport = html({ matchClosingTags: false });
+const javascriptSupport = javascript();
+
+function tagIndent(context) {
+ return /^\s*%>/.test(context.textAfter)
+ ? context.baseIndent
+ : context.baseIndent + context.unit;
+}
+
+function foldDelimitedToken(openLength, closeLength) {
+ return (node, state) => {
+ const from = node.from + openLength;
+ const to = node.to - closeLength;
+ return state.doc.lineAt(from).number < state.doc.lineAt(to).number
+ ? { from, to }
+ : null;
+ };
+}
+
+const foldEjsComment = foldDelimitedToken(3, 2);
+const foldHtmlComment = foldDelimitedToken(4, 3);
+
+function ejsBlockContent(state, node) {
+ const content = node.getChild("Content");
+ return content ? state.sliceDoc(content.from, content.to).trim() : "";
+}
+
+function braceStats(source) {
+ let opens = 0;
+ let closes = 0;
+ for (let index = 0; index < source.length; index++) {
+ const char = source[index];
+ if (char === "{") opens++;
+ else if (char === "}") closes++;
+ }
+ return { opens, closes, delta: opens - closes };
+}
+
+function isClosingBlock(source) {
+ return /^(?:}|else\b|catch\b|finally\b)/.test(source);
+}
+
+function ejsBlocks(state) {
+ const blocks = [];
+ const cursor = syntaxTree(state).cursor();
+
+ do {
+ if (cursor.name !== "EjsBlock") continue;
+ const node = cursor.node;
+ const content = ejsBlockContent(state, node);
+ const stats = braceStats(content);
+ blocks.push({
+ from: node.from,
+ to: node.to,
+ lineFrom: state.doc.lineAt(node.from).from,
+ lineTo: state.doc.lineAt(node.to).to,
+ content,
+ opens: stats.opens,
+ closesCount: stats.closes,
+ delta: stats.delta,
+ closes: isClosingBlock(content),
+ });
+ } while (cursor.next());
+
+ return blocks;
+}
+
+function columnAt(state, pos) {
+ const line = state.doc.lineAt(pos);
+ let column = 0;
+ for (const char of line.text.slice(0, pos - line.from)) {
+ column += char === "\t" ? state.tabSize - (column % state.tabSize) : 1;
+ }
+ return column;
+}
+
+function pushOpenBlocks(stack, block) {
+ for (let count = 0; count < block.opens; count++) stack.push(block);
+}
+
+function applyBlockToStack(stack, block) {
+ for (let count = 0; count < block.closesCount; count++) stack.pop();
+ pushOpenBlocks(stack, block);
+}
+
+function ejsIndentAt(state, pos, simulatedBreak) {
+ const line = state.doc.lineAt(pos);
+ const before = simulatedBreak ?? line.from;
+ const blocks = ejsBlocks(state);
+ const stack = [];
+
+ for (const block of blocks) {
+ if (block.from >= before) break;
+ applyBlockToStack(stack, block);
+ }
+
+ const firstBlock = blocks.find((block) => block.lineFrom === line.from);
+ if (firstBlock?.closes) {
+ const opener = stack[stack.length - 1];
+ return opener ? columnAt(state, opener.from) : 0;
+ }
+
+ const opener = stack[stack.length - 1];
+ return opener ? columnAt(state, opener.from + 2) : 0;
+}
+
+function ejsFold(state, lineStart, lineEnd) {
+ const blocks = ejsBlocks(state);
+ const startIndex = blocks.findIndex((block) => {
+ return block.lineFrom === lineStart && block.delta > 0;
+ });
+ if (startIndex < 0) return null;
+
+ let depth = 0;
+ for (let index = startIndex; index < blocks.length; index++) {
+ const block = blocks[index];
+ depth += block.delta;
+ if (depth <= 0 && index > startIndex) {
+ return block.lineFrom > lineEnd ? { from: lineEnd, to: block.lineFrom } : null;
+ }
+ }
+
+ return null;
+}
+
+const ejsIndentService = indentService.of((context, pos) => {
+ return ejsIndentAt(context.state, pos, context.simulatedBreak);
+});
+const ejsFoldService = foldService.of(ejsFold);
+
+function containsHtmlMarkup(input, from, to) {
+ return /<\/?[A-Za-z][\w:-]*(?:\s|\/?>)|" } } }]
+ : [];
+}));
+
+function ejsContentScriptRanges(input, from, to) {
+ const source = input.read(from, to);
+ const openBraces = [];
+ const excluded = new Set();
+
+ for (let index = 0; index < source.length; index++) {
+ const char = source[index];
+ if (char === "{") {
+ openBraces.push(index);
+ } else if (char === "}") {
+ if (openBraces.length) openBraces.pop();
+ else excluded.add(index);
+ }
+ }
+
+ for (const index of openBraces) excluded.add(index);
+ if (!excluded.size) return [{ from, to }];
+
+ const ranges = [];
+ let start = 0;
+ for (let index = 0; index < source.length; index++) {
+ if (!excluded.has(index)) continue;
+ if (start < index) ranges.push({ from: from + start, to: from + index });
+ start = index + 1;
+ }
+ if (start < source.length) ranges.push({ from: from + start, to });
+ return ranges.filter((range) => /\S/.test(input.read(range.from, range.to)));
+}
+
+/**
+ * EJS Language Definition for Lezer
+ *
+ * Patterns:
+ * - <%# ... %> : Comments
+ * - <%= ... %> : Output (escaped)
+ * - <%- ... %>: Output (unescaped)
+ * - <% ... %> : Code blocks
+ * - <%% : Literal <%
+ */
+
+export const ejsLanguage = LRLanguage.define({
+ name: "ejs",
+ parser: parser.configure({
+ wrap: parseMixed((node, input) => {
+ if (node.name === "Template") {
+ return {
+ parser: htmlSupport.language.parser,
+ overlay: (overlayNode) => {
+ return overlayNode.name === "Text" &&
+ containsHtmlMarkup(input, overlayNode.from, overlayNode.to);
+ },
+ };
+ }
+
+ if (node.name === "Content" && node.node.parent.name !== "EjsComment") {
+ const overlay = ejsContentScriptRanges(input, node.from, node.to);
+ return overlay.length ? { parser: javascriptLanguage.parser, overlay } : null;
+ }
+
+ return null;
+ }),
+ props: [
+ styleTags({
+ "EjsComment HtmlComment": t.comment,
+ "ExprStart BlockStart TagEnd": t.keyword,
+ LiteralStart: t.string,
+ EscapedTag: t.string,
+ }),
+ indentNodeProp.add({
+ EjsExpression: tagIndent,
+ EjsBlock: tagIndent,
+ }),
+ foldNodeProp.add({
+ EjsExpression: foldInside,
+ EjsBlock: foldInside,
+ EjsComment: foldEjsComment,
+ HtmlComment: foldHtmlComment,
+ })
+ ]
+ }),
+ languageData: {
+ commentTokens: {
+ block: { open: "<%#", close: "%>" }
+ },
+ indentOnInput: /^\s*(?:else|catch|finally)\b/,
+ }
+});
+
+/**
+ * EJS Language Support for CodeMirror
+ */
+export function ejs() {
+ return new LanguageSupport(ejsLanguage, [
+ ejsIndentService,
+ ejsFoldService,
+ htmlTagNameSync,
+ htmlCommentTokens,
+ htmlSupport.support,
+ javascriptSupport.support,
+ ejsLanguage.data.of({ autocomplete: ejsCompletionSource })
+ ]);
+}
+
+/**
+ * Autocomplete suggestions for EJS tags
+ */
+const ejsAutocomplete = [
+ snippetCompletion("<% ${} %>", { label: "<% %>", type: "keyword", boost: 99 }),
+ snippetCompletion("<%= ${} %>", { label: "<%= %>", type: "keyword", boost: 98 }),
+ snippetCompletion("<%- ${} %>", { label: "<%- %>", type: "keyword", boost: 97 }),
+ snippetCompletion("<%# ${} %>", { label: "<%# %>", type: "comment", boost: 96 }),
+ snippetCompletion("<% for (const ${item} of ${items}) { %>\n\t${}\n<% } %>", {
+ label: "for",
+ type: "keyword",
+ detail: "EJS loop",
+ boost: 95,
+ }),
+ snippetCompletion("<% if (${condition}) { %>\n\t${}\n<% } %>", {
+ label: "if",
+ type: "keyword",
+ detail: "EJS if statement",
+ boost: 94,
+ }),
+ snippetCompletion("<% if (${condition}) { %>\n\t${}\n<% } else { %>\n\t${}\n<% } %>", {
+ label: "ifelse",
+ type: "keyword",
+ detail: "EJS if-else statement",
+ boost: 93,
+ }),
+ snippetCompletion("<% } else { %>", {
+ label: "else",
+ type: "keyword",
+ detail: "EJS else block",
+ boost: 92,
+ }),
+ snippetCompletion("<% } else if (${condition}) { %>", {
+ label: "elseif",
+ type: "keyword",
+ detail: "EJS else-if block",
+ boost: 91,
+ }),
+ snippetCompletion("<% ${items}.forEach((${item}) => { %>\n\t${}\n<% }) %>", {
+ label: "foreach",
+ type: "keyword",
+ detail: "EJS forEach loop",
+ boost: 90,
+ }),
+ snippetCompletion("<% while (${condition}) { %>\n\t${}\n<% } %>", {
+ label: "while",
+ type: "keyword",
+ detail: "EJS while loop",
+ boost: 89,
+ }),
+ snippetCompletion("<%- include('${path}') %>", {
+ label: "include",
+ type: "keyword",
+ detail: "EJS include",
+ boost: 88,
+ }),
+];
+
+const ejsSnippetCompletions = completeFromList(ejsAutocomplete);
+
+function insideEjsTag(state, pos) {
+ for (let node = syntaxTree(state).resolveInner(pos, -1); node; node = node.parent) {
+ if (node.name === "EjsExpression" || node.name === "EjsBlock" || node.name === "EjsComment") return true;
+ if (node.name === "Template") break;
+ }
+ return false;
+}
+
+function ejsCompletionSource(context) {
+ return insideEjsTag(context.state, context.pos) ? null : ejsSnippetCompletions(context);
+}
+
+export const ejsMode = {
+ name: "ejs",
+ caption: "EJS",
+ extensions: ["ejs"],
+ load: ejs,
+};
diff --git a/src/languages/ejs/parser.js b/src/languages/ejs/parser.js
new file mode 100644
index 0000000..685ae93
--- /dev/null
+++ b/src/languages/ejs/parser.js
@@ -0,0 +1,17 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+import {LRParser} from "@lezer/lr"
+import {ejsComment} from "./tokens.js"
+export const parser = LRParser.deserialize({
+ version: 14,
+ states: "!vQQOROOOfOSO'#CbOnOSO'#CfOOOP'#Ca'#CaOOOP'#Ch'#ChOOOP'#Cj'#CjQQOROOOOOP,58|,58|OvOSO,58|OOOP,59Q,59QO{OSO,59QOOOP-E6h-E6hOOOP1G.h1G.hOOOP1G.l1G.l",
+ stateData: "!V~OPTORTOSTOVPOZQO]SO~OWWOXVO~OWYOXXO~OX[O~OX]O~O]VZV~",
+ goto: "n_PPPPP`dPPPdP`PhTTOUTROUQUORZU",
+ nodeNames: "⚠ EjsComment Template HtmlComment Text EjsTag EjsExpression ExprStart Content TagEnd EjsBlock BlockStart EscapedTag LiteralStart",
+ maxTerm: 15,
+ skippedNodes: [0],
+ repeatNodeCount: 1,
+ tokenData: "5lRRZOutuv'av}t}!O(c!O!^t!^!_)o!_#Rt#R#S(c#S;'St;'S;=`'Z<%lOtR{ZWQSPOutuv!nv}t}!O%k!O!^t!^!_#Y!_#Rt#R#S%k#S;'St;'S;=`'Z<%lOtR!sVSPO!^t!^!_#Y!_!`t!`!a&]!a;'St;'S;=`'Z<%lOtR#_ZWQOqtqr$Qrutuv$rv}t}!O%k!O#Rt#R#S%k#S;'St;'S;=`'Z<%lOtQ$VXWQOu$Quv$rv}$Q}!O%X!O#R$Q#R#S%X#S;'S$Q;'S;=`%R<%lO$QQ$uSO!`$Q!a;'S$Q;'S;=`%R<%lO$QQ%UP;=`<%l$QQ%[TOu$Qv!`$Q!a;'S$Q;'S;=`%R<%lO$QR%pXSPOutuv&]v!^t!^!_#Y!_!`t!`!a&]!a;'St;'S;=`'Z<%lOtP&bTSPO!^&]!^!_&q!_;'S&];'S;=`'T<%lO&]P&tTOq&]ru&]v;'S&];'S;=`'T<%lO&]P'WP;=`<%l&]R'^P;=`<%ltR'fVSPO!^t!^!_#Y!_!`t!`!a'{!a;'St;'S;=`'Z<%lOtR(STXQSPO!^&]!^!_&q!_;'S&];'S;=`'T<%lO&]R(hXSPOutuv)Tv!^t!^!_#Y!_!`t!`!a&]!a;'St;'S;=`'Z<%lOtR)YVSPO!^&]!^!_&q!_!`&]!`!a'{!a;'S&];'S;=`'T<%lO&]R)tZWQOqtqr*grutuv2jv}t}!O%k!O#Rt#R#S%k#S;'St;'S;=`'Z<%lOtR*lXWQOu$Quv$rv}$Q}!O+X!O#R$Q#R#S%X#S;'S$Q;'S;=`%R<%lO$QR+[VOu$Qv}$Q}!O+q!O!`$Q!a;'S$Q;'S;=`%R<%lO$QR+vXWQOu+quv,cv}+q}!O0`!O#R+q#R#S/p#S;'S+q;'S;=`/j<%lO+qR,fVO}+q}!O,{!O!`+q!`!a.V!a;'S+q;'S;=`/j<%lO+qR-QXWQOu+quv,cv}+q}!O-m!O#R+q#R#S/p#S;'S+q;'S;=`/j<%lO+qR-pVOu+quv.Vv!`+q!`!a/_!a;'S+q;'S;=`/j<%lO+qP.YTO}.V}!O.i!O;'S.V;'S;=`/d<%lO.VP.lTO}.V}!O.{!O;'S.V;'S;=`/d<%lO.VP/OTO!`.V!`!a/_!a;'S.V;'S;=`/d<%lO.VP/dORPP/gP;=`<%l.VR/mP;=`<%l+qR/sXOu+quv.Vv}+q}!O,{!O!`+q!`!a.V!a;'S+q;'S;=`/j<%lO+qR0cXOu+quv.Vv}+q}!O1O!O!`+q!`!a.V!a;'S+q;'S;=`/j<%lO+qR1TZWQOu+quv,cv}+q}!O/p!O!`+q!`!a1v!a#R+q#R#S/p#S;'S+q;'S;=`/j<%lO+qR1}XWQRPOu$Quv$rv}$Q}!O%X!O#R$Q#R#S%X#S;'S$Q;'S;=`%R<%lO$QR2oZZPOu$Quv3bv}$Q}!O4U!O!_$Q!_!`4U!a#R$Q#R#S4x#S;'S$Q;'S;=`%R<%lO$QR3iXWQ]POu$Quv$rv}$Q}!O%X!O#R$Q#R#S%X#S;'S$Q;'S;=`%R<%lO$QR4]XWQVPOu$Quv$rv}$Q}!O%X!O#R$Q#R#S%X#S;'S$Q;'S;=`%R<%lO$QR5PXWQZPOu$Quv$rv}$Q}!O%X!O#R$Q#R#S%X#S;'S$Q;'S;=`%R<%lO$Q",
+ tokenizers: [ejsComment, 0, 1],
+ topRules: {"Template":[0,2]},
+ tokenPrec: 48
+})
diff --git a/src/languages/ejs/parser.terms.js b/src/languages/ejs/parser.terms.js
new file mode 100644
index 0000000..4fb2e05
--- /dev/null
+++ b/src/languages/ejs/parser.terms.js
@@ -0,0 +1,15 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+export const
+ EjsComment = 1,
+ Template = 2,
+ HtmlComment = 3,
+ Text = 4,
+ EjsTag = 5,
+ EjsExpression = 6,
+ ExprStart = 7,
+ Content = 8,
+ TagEnd = 9,
+ EjsBlock = 10,
+ BlockStart = 11,
+ EscapedTag = 12,
+ LiteralStart = 13
diff --git a/src/languages/ejs/test.txt b/src/languages/ejs/test.txt
new file mode 100644
index 0000000..62fa239
--- /dev/null
+++ b/src/languages/ejs/test.txt
@@ -0,0 +1,41 @@
+# basic EJS block with expression
+<% if (user) { %>
+ <%= user.name %>
+<% } %>
+==>
+Template(EjsTag(EjsBlock(BlockStart,Content,TagEnd)),Text,EjsTag(EjsExpression(ExprStart,Content,TagEnd)),Text,EjsTag(EjsBlock(BlockStart,Content,TagEnd)))
+
+# HTML comment containing EJS stays inert
+<% if (user) { %>
+
+<% } %>
+==>
+Template(EjsTag(EjsBlock(BlockStart,Content,TagEnd)),Text,HtmlComment,Text,EjsTag(EjsBlock(BlockStart,Content,TagEnd)))
+
+# EJS comment wrapping EJS source
+<%# <% if (user) { %>
+ <%= user.name %>
+<% } %> %>
+==>
+Template(EjsComment)
+
+# nested EJS blocks around HTML
+<% if (user) { %>
+ <%= user.name %>
+ <% if('id' in user) { %>
+ <% } %>
+<% } %>
+==>
+Template(EjsTag(EjsBlock(BlockStart,Content,TagEnd)),Text,EjsTag(EjsExpression(ExprStart,Content,TagEnd)),Text,EjsTag(EjsBlock(BlockStart,Content,TagEnd)),Text,EjsTag(EjsBlock(BlockStart,Content,TagEnd)),Text,EjsTag(EjsBlock(BlockStart,Content,TagEnd)))
+
+# plain text
+my title
+==>
+Template(Text)
+
+# multiline expression
+<%=
+user.name
+%>
+==>
+Template(EjsTag(EjsExpression(ExprStart,Content,TagEnd)))
\ No newline at end of file
diff --git a/src/languages/ejs/tokens.js b/src/languages/ejs/tokens.js
new file mode 100644
index 0000000..106f5ff
--- /dev/null
+++ b/src/languages/ejs/tokens.js
@@ -0,0 +1,43 @@
+import { ExternalTokenizer } from "@lezer/lr";
+import { EjsComment } from "./parser.terms.js";
+
+const lessThan = 60;
+const percent = 37;
+const hash = 35;
+const greaterThan = 62;
+
+export const ejsComment = new ExternalTokenizer((input) => {
+ if (input.next !== lessThan || input.peek(1) !== percent || input.peek(2) !== hash) return;
+
+ input.advance();
+ input.advance();
+ input.advance();
+
+ let depth = 0;
+ for (;;) {
+ if (input.next < 0) {
+ input.acceptToken(EjsComment);
+ return;
+ }
+
+ if (input.next === lessThan && input.peek(1) === percent) {
+ depth++;
+ input.advance();
+ input.advance();
+ continue;
+ }
+
+ if (input.next === percent && input.peek(1) === greaterThan) {
+ input.advance();
+ input.advance();
+ if (depth === 0) {
+ input.acceptToken(EjsComment);
+ return;
+ }
+ depth--;
+ continue;
+ }
+
+ input.advance();
+ }
+});
\ No newline at end of file
diff --git a/src/languages/index.js b/src/languages/index.js
index 5bfeded..1943896 100644
--- a/src/languages/index.js
+++ b/src/languages/index.js
@@ -6,6 +6,8 @@ import { gitignoreMode } from "./gitignore";
import { zigMode } from "./zig";
import { jsoncMode } from "./jsonc";
+import { ejsMode } from "./ejs"
+
/**
* Add future language descriptors here. Each descriptor owns its metadata and
* lazy CodeMirror loader, so adding a mode doesn't require changing plugin
@@ -19,4 +21,5 @@ export const languageModes = [
gitignoreMode,
jsoncMode,
...communityLanguageModes,
+ ejsMode,
];
diff --git a/src/runtime/codemirror-language.js b/src/runtime/codemirror-language.js
index 20f540a..21928a7 100644
--- a/src/runtime/codemirror-language.js
+++ b/src/runtime/codemirror-language.js
@@ -11,12 +11,13 @@ export const {
defineLanguageFacet,
foldInside,
foldNodeProp,
+ foldService,
flatIndent,
getIndentation,
getIndentUnit,
- indentService,
indentString,
indentNodeProp,
+ indentService,
delimitedIndent,
sublanguageProp,
syntaxTree,