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,