From ec465964d278749ccc82c99eccdb3bab9963ad03 Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:29:59 +0800 Subject: [PATCH] feat(editor): highlight JSP pages (#937) JSP files had no highlighting and showed a `?` icon; the HTML mode left directives, scriptlets, JSP comments and EL as plain text and flagged `` / `` as unclosed elements. - A JSP grammar layer (comments, directives, scriptlets, expressions, declarations, EL) with HTML and Java overlays, so markup split by JSP elements still nests and braces match across scriptlets. - EL as its own grammar top: member/index/method calls, namespaced functions, keyword operators, ternaries, lambdas and collection literals. - Block comments use `<%-- --%>` in markup and `//` in scriptlets; JSP comments and scriptlets fold. - `.jsp .jspf .jspx .jsph .tag .tagf .tagx` map to the JSP language with a file icon; `gen:jsp-grammar` regenerates the parser and a test checks it matches the grammar. Closes #870. Co-authored-by: jaw52 <53564781+jaw52@users.noreply.github.com> --- .oxfmtrc.json | 1 + crates/ide-language/src/registry.rs | 4 + crates/ide-language/src/syntax/mod.rs | 4 +- knip.ts | 1 + languages/jsp.toml | 6 + package.json | 2 + pnpm-lock.yaml | 3 + src-tauri/tauri.macos.conf.json | 1 + src/editor/core/cm/jsp/index.ts | 176 +++++++++++++++++ src/editor/core/cm/jsp/jsp.grammar | 132 +++++++++++++ src/editor/core/cm/jsp/jsp.test.ts | 252 +++++++++++++++++++++++++ src/editor/core/cm/jsp/parser.terms.ts | 39 ++++ src/editor/core/cm/jsp/parser.ts | 24 +++ src/editor/core/cm/jsp/tokens.ts | 101 ++++++++++ src/editor/core/cm/languages.ts | 9 +- src/ui/icons.test.tsx | 3 + src/ui/icons.tsx | 2 + src/ui/openidea/fileTypes/jsp.svg | 4 + src/ui/openidea/fileTypes/jsp_dark.svg | 4 + 19 files changed, 764 insertions(+), 4 deletions(-) create mode 100644 languages/jsp.toml create mode 100644 src/editor/core/cm/jsp/index.ts create mode 100644 src/editor/core/cm/jsp/jsp.grammar create mode 100644 src/editor/core/cm/jsp/jsp.test.ts create mode 100644 src/editor/core/cm/jsp/parser.terms.ts create mode 100644 src/editor/core/cm/jsp/parser.ts create mode 100644 src/editor/core/cm/jsp/tokens.ts create mode 100644 src/ui/openidea/fileTypes/jsp.svg create mode 100644 src/ui/openidea/fileTypes/jsp_dark.svg diff --git a/.oxfmtrc.json b/.oxfmtrc.json index d2730f45..70f64132 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -10,6 +10,7 @@ "target", "src/bindings.ts", "src/editor/core/cm/angular/parser*.ts", + "src/editor/core/cm/jsp/parser*.ts", "docs/plugins/themes.md", "pnpm-lock.yaml", "**/*.toml", diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 984c082a..34caf951 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -45,6 +45,7 @@ const BUILTIN: &[(&str, &str)] = &[ ("javascriptreact", include_str!("../../../languages/javascriptreact.toml")), ("json", include_str!("../../../languages/json.toml")), ("jsonc", include_str!("../../../languages/jsonc.toml")), + ("jsp", include_str!("../../../languages/jsp.toml")), ("julia", include_str!("../../../languages/julia.toml")), ("kotlin", include_str!("../../../languages/kotlin.toml")), ("latex", include_str!("../../../languages/latex.toml")), @@ -522,6 +523,7 @@ mod tests { "Vue", "Angular Template", "ERB", + "JSP", ]; fn reg() -> LanguageRegistry { @@ -803,6 +805,8 @@ mod tests { ("a.less", "less"), ("App.vue", "vue"), ("App.svelte", "svelte"), + ("index.jsp", "jsp"), + ("WEB-INF/tags/card.tag", "jsp"), ("package.json", "json"), ("tsconfig.json", "jsonc"), ("tsconfig.app.json", "jsonc"), diff --git a/crates/ide-language/src/syntax/mod.rs b/crates/ide-language/src/syntax/mod.rs index c578dbba..e961eb35 100644 --- a/crates/ide-language/src/syntax/mod.rs +++ b/crates/ide-language/src/syntax/mod.rs @@ -201,11 +201,11 @@ impl Syntax { /// The grammar web features parse `lang` with: its own for HTML and CSS, the /// CSS grammar for SCSS / Less (selectors and custom properties parse alike), - /// HTML for Vue / Svelte single-file components. + /// HTML for Vue / Svelte single-file components and JSP pages. fn web_grammar(&self, lang: &LanguageId) -> Option<&Grammar> { let name = match lang.as_str() { "scss" | "less" => "css", - "vue" | "svelte" => "html", + "vue" | "svelte" | "jsp" => "html", _ => self.grammar(lang).map(|g| g.spec.name)?, }; web::is_web_grammar(name).then(|| self.grammar(&LanguageId::new(name))).flatten() diff --git a/knip.ts b/knip.ts index 50822935..cce08398 100644 --- a/knip.ts +++ b/knip.ts @@ -5,6 +5,7 @@ const config: KnipConfig = { project: [ "src/**/*.{ts,tsx,css}", "!src/editor/core/cm/angular/parser.terms.ts", + "!src/editor/core/cm/jsp/parser.terms.ts", "scripts/**/*.ts", "perf/**/*.ts", "e2e/**/*.ts", diff --git a/languages/jsp.toml b/languages/jsp.toml new file mode 100644 index 00000000..1489eca5 --- /dev/null +++ b/languages/jsp.toml @@ -0,0 +1,6 @@ +id = "jsp" +name = "JSP" +extensions = ["jsp", "jspx", "jspf", "jsph", "tag", "tagf", "tagx"] +block-comment = ["<%--", "--%>"] +indent = { tab-size = 2, insert-spaces = true } +ui-mode = "JSP" diff --git a/package.json b/package.json index 6bad3b8c..5426f9f6 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit", "gen:bindings": "cargo test -p openidea --test export_bindings", "gen:angular-grammar": "lezer-generator src/editor/core/cm/angular/angular.grammar --typeScript -o src/editor/core/cm/angular/parser.ts", + "gen:jsp-grammar": "lezer-generator src/editor/core/cm/jsp/jsp.grammar --typeScript -o src/editor/core/cm/jsp/parser.ts", "gen:theme-docs": "node scripts/theme/gen-docs.ts", "theme:import-intellij": "node scripts/theme/import-intellij-theme.ts", "dev:app": "node scripts/tauri.ts dev --config src-tauri/tauri.dev.conf.json", @@ -39,6 +40,7 @@ "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.11.1", "@codemirror/lang-html": "^6.4.12", + "@codemirror/lang-java": "^6.0.2", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/language": "^6.13.1", "@codemirror/language-data": "^6.5.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e2995a3..6c8cb996 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@codemirror/lang-html': specifier: ^6.4.12 version: 6.4.12 + '@codemirror/lang-java': + specifier: ^6.0.2 + version: 6.0.2 '@codemirror/lang-javascript': specifier: ^6.2.5 version: 6.2.5 diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index 3993b304..2bf5a414 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -46,6 +46,7 @@ "jsx", "vue", "svelte", + "jsp", "dart", "cs", "fs", diff --git a/src/editor/core/cm/jsp/index.ts b/src/editor/core/cm/jsp/index.ts new file mode 100644 index 00000000..5662174a --- /dev/null +++ b/src/editor/core/cm/jsp/index.ts @@ -0,0 +1,176 @@ +/** + * JSP pages, layered like IntelliJ's JSP view provider: `jsp.grammar` parses + * the JSP layer, and the template text around its elements is one HTML + * document. That is an overlay, so markup split by a scriptlet still nests + * and `value="<%= id %>"` is an attribute with a hole. The scriptlets are + * one Java method body, an overlay too (`<% for (…) { %> … <% } %>` balances + * its braces), each expression and declaration Java of its own. + */ +import { LRLanguage, LanguageSupport, foldInside, foldNodeProp } from "@codemirror/language"; +import { html } from "@codemirror/lang-html"; +import { javaLanguage } from "@codemirror/lang-java"; +import { styleTags, tags as t } from "@lezer/highlight"; +import { + Parser, + parseMixed, + type Input, + type NestedParse, + type PartialParse, + type SyntaxNode, + type SyntaxNodeRef, + type TreeFragment, +} from "@lezer/common"; +import { JAVA_TAGS } from "../languages"; +import { parser } from "./parser"; + +const baseHTML = html({ selfClosingTags: true }); + +const JSP_COMMENT = { block: { open: "<%--", close: "--%>" } }; + +const baseParser = parser.configure({ + props: [ + styleTags({ + JspComment: t.blockComment, + '"<%" "<%=" "<%!" "<%@" "%>"': t.processingInstruction, + // Not `ELExpression/"}"`: the cursor highlighting a page does not match + // that context once the expression's content is mounted. + '"${" "#{" ELEnd/...': t.special(t.brace), + DirectiveName: t.keyword, + AttributeName: t.attributeName, + AttributeValue: t.attributeValue, + Is: t.definitionOperator, + VariableName: t.variableName, + PropertyName: t.propertyName, + "CallExpression/MemberExpression/PropertyName": t.function(t.propertyName), + "CallExpression/VariableName FunctionName": t.function(t.variableName), + Namespace: t.namespace, + Number: t.number, + String: t.string, + BooleanLiteral: t.bool, + null: t.null, + "not empty div mod lt gt le ge instanceof eq ne and or": t.operatorKeyword, + ArithOp: t.arithmeticOperator, + CompareOp: t.compareOperator, + LogicOp: t.logicOperator, + '"->"': t.operator, + '"."': t.derefOperator, + "( )": t.paren, + "[ ]": t.squareBracket, + 'SetOrMapLiteral/"{" SetOrMapLiteral/"}"': t.brace, + ", ;": t.separator, + '":"': t.punctuation, + }), + foldNodeProp.add({ + "Scriptlet Declaration JspExpression": foldInside, + JspComment: (node, state) => ({ + from: node.from + 4, + to: state.sliceDoc(node.to - 4, node.to) == "--%>" ? node.to - 4 : node.to, + }), + }), + ], +}); + +const javaParser = javaLanguage.parser.configure({ props: [styleTags(JAVA_TAGS)] }); + +/** `input` with a `;` at `at`. */ +class TerminatedInput implements Input { + constructor( + private readonly input: Input, + private readonly at: number, + ) {} + + get length() { + return this.input.length; + } + + get lineChunks() { + return this.input.lineChunks; + } + + chunk(from: number): string { + if (from == this.at) return ";"; + const text = this.input.chunk(from); + return from < this.at && this.at < from + text.length ? text.slice(0, this.at - from) : text; + } + + read(from: number, to: number): string { + const text = this.input.read(from, to); + return from <= this.at && this.at < to + ? text.slice(0, this.at - from) + ";" + text.slice(this.at - from + 1) + : text; + } +} + +/** + * Java has no expression top: an expression is parsed as a statement, its + * trailing space read as the `;` (`<%= name %>`), so that a lone name is not + * a call missing its arguments. Without one, it ends in an error node. + */ +class ExpressionParser extends Parser { + private readonly java = javaParser.configure({}); + + createParse( + input: Input, + fragments: readonly TreeFragment[], + ranges: readonly { from: number; to: number }[], + ): PartialParse { + const end = ranges[ranges.length - 1]!.to; + const terminated = /\s/.test(input.read(end - 1, end)) ? new TerminatedInput(input, end - 1) : input; + return this.java.createParse(terminated, fragments, ranges); + } +} + +// To reuse old inner trees, parseMixed takes those that the node at a mount's +// start and its ancestors had with the same parser, so each kind of mount has +// its own instance: an expression at the start of a page would otherwise take +// the scriptlets' tree. +const htmlMixed: NestedParse = { parser: baseHTML.language.parser, overlay: (node) => node.name == "Text" }, + scriptletParser = javaParser.configure({}), + expressionMixed = { parser: new ExpressionParser() }, + declarationMixed = { parser: javaParser.configure({ top: "ClassContent" }) }, + elMixed = { parser: baseParser.configure({ top: "EL" }) }; + +/** The code of the scriptlets in `template`. */ +function scriptletCode(template: SyntaxNode): { from: number; to: number }[] { + const code: { from: number; to: number }[] = []; + for (const scriptlet of template.getChildren("Scriptlet")) { + const java = scriptlet.getChild("JavaCode"); + if (java) code.push({ from: java.from, to: java.to }); + } + return code; +} + +// The HTML overlay is on the document and the scriptlets' on the template it +// holds: a node mounts one tree, and an overlay's predicate does not see the +// nodes of an overlay inside it. +function mixJsp(node: SyntaxNodeRef): NestedParse | null { + switch (node.name) { + case "Document": + return htmlMixed; + case "Template": { + const overlay = scriptletCode(node.node); + return overlay.length ? { parser: scriptletParser, overlay } : null; + } + case "JavaCode": { + const element = node.node.parent?.name; + return element == "JspExpression" ? expressionMixed : element == "Declaration" ? declarationMixed : null; + } + case "ELContent": + return elMixed; + } + return null; +} + +export const jspLanguage = LRLanguage.define({ + name: "jsp", + parser: baseParser.configure({ wrap: parseMixed(mixJsp) }), + languageData: { commentTokens: JSP_COMMENT }, +}); + +export function jsp(): LanguageSupport { + return new LanguageSupport(jspLanguage, [ + baseHTML.support, + // Markup comments out of the page, with a JSP comment, as in IntelliJ. + baseHTML.language.data.of({ commentTokens: JSP_COMMENT }), + ]); +} diff --git a/src/editor/core/cm/jsp/jsp.grammar b/src/editor/core/cm/jsp/jsp.grammar new file mode 100644 index 00000000..3181b948 --- /dev/null +++ b/src/editor/core/cm/jsp/jsp.grammar @@ -0,0 +1,132 @@ +// JSP pages: the JSP layer of a page, as IntelliJ's JSP view provider sees it. +// Comments, directives, scripting elements and EL expressions are parsed here; +// the template text between them is HTML and the scripting elements' code is +// Java, both mounted by `./index.ts`. EL expressions (`${…}`, `#{…}`) are their +// own top. Regenerate with `pnpm gen:jsp-grammar`. + +@top Document { Template } + +// The page's content, a node of its own: the HTML overlay is mounted on the +// document and the scriptlets' Java overlay on this. +Template { + (Text | JspComment | Directive | Declaration | JspExpression | Scriptlet | ELExpression)* +} + +Scriptlet { "<%" JavaCode? "%>" } + +JspExpression { "<%=" JavaCode? "%>" } + +Declaration { "<%!" JavaCode? "%>" } + +ELExpression { ("${" | "#{") ELContent? ELEnd { "}" } } + +@skip { space } { + Directive { "<%@" DirectiveName { name } Attribute* "%>" } + + Attribute { AttributeName { name } (Is AttributeValue)? } + + @top EL { expression? (";" expression?)* } + + expression[@isGroup=Expression] { + literal | + VariableName | + ParenthesizedExpression { "(" (expression ("," expression)*)? ")" } | + ListLiteral { "[" (expression ("," expression)*)? "]" } | + SetOrMapLiteral { "{" (entry ("," entry)*)? "}" } | + MemberExpression { expression !member ("." PropertyName | "[" expression "]") } | + CallExpression { expression !call ArgList } | + FunctionCall { Namespace ":" FunctionName ArgList } | + UnaryExpression { (kw<"not"> | kw<"empty"> | LogicOp<"!"> | ArithOp<"-">) !prefix expression } | + BinaryExpression { + expression !mult (ArithOp<"*" | "/" | "%"> | kw<"div"> | kw<"mod">) expression | + expression !add ArithOp<"+" | "-"> expression | + expression !concat ArithOp<"+="> expression | + expression !rel (CompareOp<"<" | ">" | "<=" | ">="> | kw<"lt"> | kw<"gt"> | kw<"le"> | kw<"ge"> | kw<"instanceof">) expression | + expression !equal (CompareOp<"==" | "!="> | kw<"eq"> | kw<"ne">) expression | + expression !and (LogicOp<"&&"> | kw<"and">) expression | + expression !or (LogicOp<"||"> | kw<"or">) expression + } | + ConditionalExpression { expression !ternary LogicOp<"?"> expression LogicOp<":"> expression } | + LambdaExpression { expression !lambda "->" expression } | + AssignmentExpression { expression !assign Is expression } + } + + entry { expression (":" expression)? } + + ArgList { "(" (expression ("," expression)*)? ")" } +} + +@precedence { + member, + call, + prefix @right, + mult @left, + add @left, + concat @left, + rel @left, + equal @left, + and @left, + or @left, + ternary @right, + lambda @right, + assign @right +} + +literal { + Number | + String | + @specialize[@name=BooleanLiteral] | + kw<"null"> +} + +VariableName { identifier } + +PropertyName { identifier } + +FunctionName { identifier } + +Is { "=" } + +kw { @specialize[@name={term}] } + +ArithOp { expr } +CompareOp { expr } +LogicOp { expr } + +// Tried in this order, before the tokens below: a comment before `<%`. +@external tokens jspComment from "./tokens" { JspComment } +@external tokens text from "./tokens" { Text } +@external tokens javaCode from "./tokens" { JavaCode } +@external tokens elContent from "./tokens" { ELContent } +// `fn` of `fn:join(…)`, so that an identifier before a `:` stays a ternary's. +@external tokens namespace from "./tokens" { Namespace } + +@tokens { + space { $[ \t\n\r]+ } + + identifier { $[a-zA-Z_$\u{a1}-\u{10ffff}] $[a-zA-Z0-9_$\u{a1}-\u{10ffff}]* } + + // Directive and attribute names: `include`, `pageEncoding`, `name-given`. + name { $[a-zA-Z_] $[a-zA-Z0-9_\-]* } + + Number { + (@digit+ ("." @digit*)? | "." @digit+) ($[eE] $[+\-]? @digit+)? + } + + String { + '"' (!["\\] | "\\" _)* '"'? | + "'" (!['\\] | "\\" _)* "'"? + } + + AttributeValue { + '"' !["]* '"'? | + "'" ![']* "'"? + } + + @precedence { Number, "." } + + "<%" "<%=" "<%!" "<%@" "%>" "${" "#{" + "(" ")" "[" "]" "{" "}" "." "," ";" ":" "->" +} + +@detectDelim diff --git a/src/editor/core/cm/jsp/jsp.test.ts b/src/editor/core/cm/jsp/jsp.test.ts new file mode 100644 index 00000000..d18ff2b5 --- /dev/null +++ b/src/editor/core/cm/jsp/jsp.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { EditorState } from "@codemirror/state"; +import { ensureSyntaxTree, foldable, matchBrackets } from "@codemirror/language"; +import type { Tree } from "@lezer/common"; +import { buildParserFile } from "@lezer/generator"; +import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight"; +import { findLanguageDescription, loadSupport } from "../languages"; +import { jsp, jspLanguage } from "./index"; + +const highlighter = tagHighlighter([ + { tag: t.processingInstruction, class: "pi" }, + { tag: t.special(t.brace), class: "el" }, + { tag: t.blockComment, class: "comment" }, + { tag: t.operatorKeyword, class: "opkw" }, + { tag: t.keyword, class: "keyword" }, + { tag: t.bool, class: "bool" }, + { tag: t.namespace, class: "ns" }, + { tag: t.typeName, class: "type" }, + { tag: t.definition(t.variableName), class: "def" }, + { tag: t.function(t.variableName), class: "call" }, + { tag: t.function(t.propertyName), class: "method" }, + { tag: t.variableName, class: "var" }, + { tag: t.propertyName, class: "prop" }, + { tag: t.attributeName, class: "attr" }, + { tag: t.attributeValue, class: "value" }, + { tag: t.string, class: "str" }, + { tag: t.number, class: "num" }, + { tag: t.tagName, class: "tag" }, +]); + +/** `text:class` of the styled tokens of `doc`, from `tree` or a fresh parse. */ +function tokens(doc: string, tree: Tree = jspLanguage.parser.parse(doc)): string[] { + const out: string[] = []; + highlightTree(tree, highlighter, (from, to, cls) => out.push(`${doc.slice(from, to)}:${cls}`)); + return out; +} + +/** The error nodes of `doc`'s tree, with the text they start. */ +function errors(doc: string): string[] { + const out: string[] = []; + jspLanguage.parser.parse(doc).iterate({ + enter: (node) => void (node.type.isError && out.push(doc.slice(node.from, node.from + 8))), + }); + return out; +} + +/** The tree of `state`, parsed to the end. */ +function stateTree(state: EditorState): Tree { + const tree = ensureSyntaxTree(state, state.doc.length, 5000); + if (!tree) throw new Error("parse did not finish"); + return tree; +} + +/** A ~8 KB page with directives, scriptlets around markup, expressions and EL. */ +function page(): string { + let doc = `<%@ page contentType="text/html;charset=UTF-8" %>\n<%@ taglib prefix="c" uri="jakarta.tags.core" %>\n`; + for (let i = 0; i < 40; i++) { + doc += + `<%-- row ${i} --%>\n<% if (rows${i} != null) { %>\n` + + ` <%= rows${i}.size() %>\${fn:length(names${i})}\n` + + `<% } %>\n\n`; + } + return doc; +} + +describe("JSP pages", () => { + it("highlights directives and JSP comments", () => { + expect(tokens(`<%@ page import="java.util.*" %><%-- off --%>`)).toEqual([ + "<%@:pi", + "page:keyword", + "import:attr", + '"java.util.*":value', + "%>:pi", + "<%-- off --%>:comment", + ]); + }); + + it("parses the scriptlets as one Java body, around the markup", () => { + const doc = `<% for (String s : list) { %>
  • <%= s %>
  • <% } %>`; + expect(errors(doc)).toEqual([]); + expect(tokens(doc)).toEqual([ + "<%:pi", + "for:keyword", + "String:type", + "s:def", + "list:var", + "%>:pi", + "li:tag", + "<%=:pi", + "s:var", + "%>:pi", + "li:tag", + "<%:pi", + "%>:pi", + ]); + const state = EditorState.create({ doc, extensions: jsp() }); + stateTree(state); + const open = doc.indexOf("{"); + expect(matchBrackets(state, open, 1)?.end?.from).toBe(doc.lastIndexOf("}")); + }); + + it("keeps markup in a scriptlet's strings out of the page", () => { + const doc = `<% String cell = ""; %>

    x

    `; + expect(errors(doc)).toEqual([]); + expect(tokens(doc)).toContain('"":str'); + expect(tokens(doc).filter((s) => s.endsWith(":tag"))).toEqual(["p:tag", "p:tag"]); + }); + + it("highlights expressions and declarations as Java", () => { + const doc = `<%= name %><%= req.getParameter("q") %><%! private int count = 0; %>`; + expect(errors(doc)).toEqual([]); + expect(tokens(doc)).toEqual([ + "<%=:pi", + "name:var", + "%>:pi", + "<%=:pi", + "req:var", + "getParameter:call", + '"q":str', + "%>:pi", + "<%!:pi", + "private:keyword", + "int:type", + "count:def", + "0:num", + "%>:pi", + ]); + }); + + it("highlights EL expressions", () => { + const doc = `

    \${fn:escapeXml(item.title)} #{a ? b : not empty c.d[0] and x.size() gt 2}

    `; + expect(errors(doc)).toEqual([]); + expect(tokens(doc)).toEqual([ + "p:tag", + "${:el", + "fn:ns", + "escapeXml:call", + "item:var", + "title:prop", + "}:el", + "#{:el", + "a:var", + "b:var", + "not:opkw", + "empty:opkw", + "c:var", + "d:prop", + "0:num", + "and:opkw", + "x:var", + "size:method", + "gt:opkw", + "2:num", + "}:el", + "p:tag", + ]); + }); + + it("ends an EL expression at its own brace, past nested ones and strings", () => { + const doc = `\${ {'k': '}'}.k == true }`; + expect(errors(doc)).toEqual([]); + expect(tokens(doc)).toEqual(["${:el", "'k':str", "'}':str", "k:prop", "true:bool", "}:el", "b:tag"]); + }); + + it("parses markup with elements in attribute values and custom tags", () => { + const doc = + `` + + `

    `; + expect(errors(doc)).toEqual([]); + expect(tokens(doc).slice(0, 8)).toEqual([ + "c:forEach:tag", + "var:attr", + '"i":value', + "items:attr", + '":value', + "${:el", + "items:var", + "}:el", + ]); + }); + + it("parses EL in a script as EL, like the container", () => { + expect(tokens(``)).toContain("user:var"); + }); + + it("keeps escaped elements as text", () => { + expect(tokens(`

    \\\${a} <\\% b %>

    `)).toEqual(["p:tag", "p:tag"]); + }); + + it("comments with JSP comments in the markup and Java ones in scriptlets", () => { + const doc = `

    x

    <% int a; %>`; + const state = EditorState.create({ doc, extensions: jsp() }); + stateTree(state); + const at = (pos: number) => + state.languageDataAt<{ line?: string; block?: { open: string } }>("commentTokens", pos)[0]; + expect(at(4)?.block?.open).toBe("<%--"); + expect(at(doc.indexOf("int") + 1)?.line).toBe("//"); + }); + + it("folds JSP comments and scriptlets", () => { + const doc = `<%--\n a\n--%>\n<%\n int a;\n%>`; + const state = EditorState.create({ doc, extensions: jsp() }); + stateTree(state); + expect(foldable(state, 0, 4)).toEqual({ from: 4, to: doc.indexOf("--%>") }); + const line = state.doc.line(4); + expect(foldable(state, line.from, line.to)).toEqual({ from: line.to, to: doc.lastIndexOf("%>") }); + }); + + it("highlights like a fresh parse after resuming and editing", () => { + const doc = page(); + // Opening parses the first 3000 characters; the rest resumes from that tree. + let state = EditorState.create({ doc, extensions: jsp() }); + expect(tokens(doc, stateTree(state))).toEqual(tokens(doc)); + const at = (text: string, n = 1) => { + let pos = -1; + for (let i = 0; i < n; i++) pos = state.doc.toString().indexOf(text, pos + 1); + return pos; + }; + const edits = [ + () => ({ from: at("rows12.size()") + 4, insert: "x" }), + () => ({ from: at(".size() %>", 15) + 7, insert: "+1" }), + () => ({ from: at("<% } %>", 20), insert: "<% int k = 0; %>\n" }), + () => ({ from: at("<%-- row 7"), to: at("<%-- row 7") + 4 }), + () => ({ from: at("names3"), to: at("names3") + 6, insert: "a.b[0]" }), + () => ({ from: 0, insert: "${top}\n" }), + () => ({ from: at(" { + const dir = import.meta.dirname; + const { parser, terms } = buildParserFile(readFileSync(resolve(dir, "jsp.grammar"), "utf8"), { + fileName: "src/editor/core/cm/jsp/jsp.grammar", + moduleStyle: "es", + typeScript: true, + }); + expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8")); + expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8")); + }); + + it("is the JSP mode", async () => { + const desc = findLanguageDescription("JSP", "/p/index.jsp"); + expect((await loadSupport(desc!))?.language).toBe(jspLanguage); + }); +}); diff --git a/src/editor/core/cm/jsp/parser.terms.ts b/src/editor/core/cm/jsp/parser.terms.ts new file mode 100644 index 00000000..b2f03afd --- /dev/null +++ b/src/editor/core/cm/jsp/parser.terms.ts @@ -0,0 +1,39 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +export const + JspComment = 1, + Text = 2, + JavaCode = 3, + ELContent = 4, + Namespace = 5, + Document = 6, + Template = 7, + Directive = 10, + Attribute = 12, + Is = 14, + AttributeValue = 15, + Declaration = 17, + JspExpression = 19, + Scriptlet = 21, + ELExpression = 22, + EL = 27, + Number = 28, + String = 29, + BooleanLiteral = 30, + _null = 31, + VariableName = 32, + PropertyName = 45, + ArgList = 47, + FunctionName = 49, + not = 51, + empty = 52, + div = 57, + mod = 58, + lt = 62, + gt = 63, + le = 64, + ge = 65, + _instanceof = 66, + eq = 68, + ne = 69, + and = 71, + or = 73 diff --git a/src/editor/core/cm/jsp/parser.ts b/src/editor/core/cm/jsp/parser.ts new file mode 100644 index 00000000..ad5275ef --- /dev/null +++ b/src/editor/core/cm/jsp/parser.ts @@ -0,0 +1,24 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +import {LRParser} from "@lezer/lr" +import {jspComment, text, javaCode, elContent, namespace} from "./tokens" +const spec_identifier = {__proto__:null,true:60, false:60, null:62, not:102, empty:104, div:114, mod:116, lt:124, gt:126, le:128, ge:130, instanceof:132, eq:136, ne:138, and:142, or:146} +export const parser = LRParser.deserialize({ + version: 14, + states: "1WOqOsOOQ!sQ!rOOO!zQpO'#CfO#POtO'#CmO#XOtO'#CoO#aOtO'#CqO#iOxO'#CrOOOR'#EP'#EPO#qOsO'#CcQOOOOOOOQO'#E['#E[OOQO'#C|'#C|O#xQ!rO'#DPO$PQ!rO'#DTO$WQ!rO'#DVQ&bQ#tOOO&iQpO'#D^OOQ`'#Dc'#DcOOQ`'#Dd'#DdOxQ!rO'#D`OOQO'#EZ'#EZO&nQ!rO'#ETQ&xQpOOOOQO'#Cg'#CgO&}QpO,59QOOOR,59X,59XO'VOpO,59XOOOR,59Z,59ZO'[OpO,59ZOOOR,59],59]O'aOpO,59]OOOR'#Cu'#CuOOOR,59^,59^O#lOpO,59^OOOR-E7}-E7}OOQO,59k,59kO'fQ#tO,59kOOQO,59o,59oO'pQ#tO,59oO'zQ#tO'#E^OOQO,59q,59qO(XQpO,59qOOQ`'#Cj'#CjO(aQ!bO,59sOxQ!rO,59sO(fQ!rO'#D]OOQO,59v,59vOOQ`'#Df'#DfOOQ`'#Di'#DiOOQ`'#Dj'#DjOOQ`'#Dk'#DkOOQ`'#Dq'#DqOOQ`'#Dt'#DtOOQ`'#Dv'#DvOxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POOQ`'#Dy'#DyOxQ!rO,5:dOxQ!rO,5:gOxQ!rO,5:iO(mQ!bO,59xO(rQ#tO,59zO+[Q#tO,5:oOOQO-E8R-E8ROOQO'#Ci'#CiO+fQpO'#ChOOQO'#EQ'#EQO+qQpO1G.lOOOR1G.l1G.lOOOR1G.s1G.sOOOR1G.u1G.uOOOR1G.w1G.wOOOR1G.x1G.xOxQ!rO'#ERO+yQpO1G/VOOQO1G/V1G/VO,RQpO1G/ZOOQO1G/Z1G/ZOxQ!rO,5:xOxQ!rO'#ESO,ZQpO1G/]OOQO1G/]1G/]OOQO'#DZ'#DZOOQO1G/_1G/_O,cQ#tO1G/_O,jQ#tO,59wOOQO,59w,59wO.}Q#tO1G/kO/bQ#tO1G/kO2[Q#tO1G/kO2cQ#tO1G/kO4vQ#tO1G/kO5WQ#tO1G/kO7[Q#tO1G/kO8UQ#tO1G0OO8]Q#tO1G0RO9VQ#tO1G0TOOQO'#D_'#D_O9pQpO1G/dO9uQpO,59SOOQO-E8O-E8OOOOR7+$W7+$WO9zQ#tO,5:mOOQO-E8P-E8POOQO7+$q7+$qOOQO7+$u7+$uO:XQ#tO1G0dOOQO,5:n,5:nOOQO-E8Q-E8QOOQO7+$w7+$wOOQO7+$y7+$yO:cQpO1G/cOOQO1G/c1G/cOOQ`'#Dz'#DzOxQ!rO7+%jOOQO7+%O7+%OOOQO1G.n1G.nOOQO7+$}7+$}O:kQ#tO< <%@ Directive DirectiveName Attribute AttributeName Is AttributeValue <%! Declaration <%= JspExpression <% Scriptlet ELExpression ${ #{ ELEnd } EL Number String BooleanLiteral null VariableName ) ( ParenthesizedExpression , ] [ ListLiteral { SetOrMapLiteral : MemberExpression . PropertyName CallExpression ArgList FunctionCall FunctionName UnaryExpression not empty LogicOp ArithOp BinaryExpression ArithOp div mod ArithOp ArithOp CompareOp lt gt le ge instanceof CompareOp eq ne LogicOp and LogicOp or ConditionalExpression LogicOp LogicOp LambdaExpression -> AssignmentExpression ;", + maxTerm: 110, + nodeProps: [ + ["openedBy", 8,"<%@ <%! <%= <%",26,"{",33,"(",37,"["], + ["closedBy", -4,9,16,18,20,"%>",34,")",38,"]",40,"}"], + ["group", -16,28,29,30,31,32,35,39,41,43,46,48,50,55,74,77,79,"Expression"] + ], + skippedNodes: [0], + repeatNodeCount: 5, + tokenData: "5S~RuXY#fYZ#f]^#fpq#fqr#wrs$Ust(etu(puv*[vw*iwx*txy.wyz.|z{/R{|/W|}/e}!O/j!O!P/w!P!Q0y!Q![1O![!]1a!]!^1f!^!_1k!_!`2j!`!a2w!a!b3U!c!}3Z!}#O4c#P#Q4h#R#S3Z#T#o3Z#o#p4m#p#q4r#q#r4}$g;'S)b;'S;=`*P<%lO)b~#kS!z~XY#fYZ#f]^#fpq#fU#|P#RQ!_!`$PS$UO#_SR$]V_PmQOr$Urs$rs#O$U#O#P$y#P;'S$U;'S;=`(_<%lO$UR$yO_PmQR%OU_POr$Urs%bs;'S$U;'S;=`'p;=`<%l&O<%lO$UR%iV_PmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j<%lO&OQ&TVmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j<%lO&OQ&oOmQQ&rRO;'S&O;'S;=`&{;=`O&OQ'QWmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j;=`<%l&O<%lO&OQ'mP;=`<%l&OR'uWmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j;=`<%l$U<%lO&OR(bP;=`<%l$U~(hP#o#p(k~(pOh~V(uX#PUtu)b!Q![)b!c!})b#R#S)b#T#o)b#o#p*V$g;'S)b;'S;=`*P<%lO)bU)gW#PUtu)b!Q![)b!c!})b#R#S)b#T#o)b$g;'S)b;'S;=`*P<%lO)bU*SP;=`<%l)bP*[OgPT*aP#VS!`!a*dP*iOWP~*lPvw*o~*tO#`~R*{V_PmQOw*twx$rx#O*t#O#P+b#P;'S*t;'S;=`.q<%lO*tR+gU_POw*twx+yx;'S*t;'S;=`.S;=`<%l,g<%lO*tR,QV_PmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|<%lO,gQ,lVmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|<%lO,gQ-URO;'S,g;'S;=`-_;=`O,gQ-dWmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|;=`<%l,g<%lO,gQ.PP;=`<%l,gR.XWmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|;=`<%l*t<%lO,gR.tP;=`<%l*t~.|Or~~/ROq~~/WO#T~~/]P#W~!_!`/`~/eO#X~~/jOt~~/oP#S~!`!a/r~/wO!p~~/|P|~!Q![0P~0URl~!Q![0P!g!h0_#X#Y0_~0bR{|0k}!O0k!Q![0q~0nP!Q![0q~0vPl~!Q![0q~1OO#U~~1TSl~!O!P0P!Q![1O!g!h0_#X#Y0_~1fOz~~1kO!r~~1pQ#YSuv1v!_!`2eP1{RdPqr2U!_!`2Z!b!c2`P2ZO`PP2`ObPP2eOXP~2jO#[~~2oP!|~!_!`2r~2wO#^~~2|P#Z~!_!`3P~3UO#]~~3ZO#b~V3bX#PU!{Ptu)b}!O3}!Q![3Z!c!}3Z#R#S3Z#T#o3Z$g;'S)b;'S;=`*P<%lO)bP4ST!{P}!O3}!Q![3}!c!}3}#R#S3}#T#o3}~4hOv~~4mOu~~4rOx~~4uP#p#q4x~4}O#a~~5SOj~", + tokenizers: [jspComment, text, javaCode, elContent, namespace, 0, 1, 2], + topRules: {"Document":[0,6],"EL":[1,27]}, + specialized: [{term: 93, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1}], + tokenPrec: 1217 +}) diff --git a/src/editor/core/cm/jsp/tokens.ts b/src/editor/core/cm/jsp/tokens.ts new file mode 100644 index 00000000..20288eba --- /dev/null +++ b/src/editor/core/cm/jsp/tokens.ts @@ -0,0 +1,101 @@ +/** + * External tokens of `jsp.grammar`: the runs the JSP layer leaves to HTML and + * Java, the comments, and the extent of EL expressions. + */ +import { ExternalTokenizer, type InputStream } from "@lezer/lr"; +import { ELContent, JavaCode, JspComment, Namespace, Text } from "./parser.terms"; + +const DOUBLE_QUOTE = 34, + HASH = 35, + DOLLAR = 36, + PERCENT = 37, + SINGLE_QUOTE = 39, + PAREN_L = 40, + DASH = 45, + COLON = 58, + LT = 60, + GT = 62, + BACKSLASH = 92, + UNDERSCORE = 95, + BRACE_L = 123, + BRACE_R = 125; + +/** Whether `<%` starts `offset` code units ahead. */ +function scriptStart(input: InputStream, offset = 0): boolean { + return input.peek(offset) == LT && input.peek(offset + 1) == PERCENT; +} + +/** Whether `${` or `#{` starts `offset` code units ahead. */ +function elStart(input: InputStream, offset = 0): boolean { + const ch = input.peek(offset); + return (ch == DOLLAR || ch == HASH) && input.peek(offset + 1) == BRACE_L; +} + +/** `<%-- … --%>`, to the end of the page when unterminated. */ +export const jspComment = new ExternalTokenizer((input) => { + if (!scriptStart(input) || input.peek(2) != DASH || input.peek(3) != DASH) return; + input.advance(4); + while (input.next >= 0) { + if (input.next == DASH && input.peek(1) == DASH && input.peek(2) == PERCENT && input.peek(3) == GT) { + input.advance(4); + break; + } + input.advance(); + } + input.acceptToken(JspComment); +}); + +/** + * Template text: up to a scripting element or an EL expression. Escaped ones + * (`<\%`, `\${`, `\#{`) are text. + */ +export const text = new ExternalTokenizer((input) => { + const start = input.pos; + while (input.next >= 0 && !scriptStart(input) && !elStart(input)) { + input.advance(input.next == BACKSLASH && elStart(input, 1) ? 2 : 1); + } + if (input.pos > start) input.acceptToken(Text); +}); + +/** The code of a scripting element, up to its `%>` (`%\>` quotes one). */ +export const javaCode = new ExternalTokenizer((input) => { + const start = input.pos; + while (input.next >= 0 && !(input.next == PERCENT && input.peek(1) == GT)) { + input.advance(input.next == PERCENT && input.peek(1) == BACKSLASH && input.peek(2) == GT ? 3 : 1); + } + if (input.pos > start) input.acceptToken(JavaCode); +}); + +/** An EL expression's content: up to the `}` closing it, past nested braces and strings. */ +export const elContent = new ExternalTokenizer((input) => { + const start = input.pos; + let depth = 0; + for (let ch; (ch = input.next) >= 0; input.advance()) { + if (ch == BRACE_R) { + if (!depth) break; + depth--; + } else if (ch == BRACE_L) { + depth++; + } else if (ch == DOUBLE_QUOTE || ch == SINGLE_QUOTE) { + input.advance(); + while (input.next >= 0 && input.next != ch) input.advance(input.next == BACKSLASH ? 2 : 1); + if (input.next < 0) break; + } + } + if (input.pos > start) input.acceptToken(ELContent); +}); + +const isIdentifierStart = (ch: number) => + (ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) || ch == UNDERSCORE || ch == DOLLAR || ch >= 0xa1; +const isIdentifier = (ch: number) => isIdentifierStart(ch) || (ch >= 48 && ch <= 57); + +/** An identifier followed by `:name(`, a function's namespace (`fn:join(…)`). */ +export const namespace = new ExternalTokenizer((input, stack) => { + if (!isIdentifierStart(input.next) || !stack.canShift(Namespace)) return; + let i = 1; + while (isIdentifier(input.peek(i))) i++; + const end = i; + if (input.peek(i++) != COLON || !isIdentifierStart(input.peek(i++))) return; + while (isIdentifier(input.peek(i))) i++; + if (input.peek(i) == PAREN_L) input.acceptToken(Namespace, end); +}); diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index f6c01d56..2b8231d7 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -102,6 +102,7 @@ const OWN_MODES = [ extensions: ["erb", "rhtml"], load: () => import("./erb").then((m) => m.erb()), }), + LanguageDescription.of({ name: "JSP", load: () => import("./jsp").then((m) => m.jsp()) }), ]; const BATCH_KEYWORDS = new Set([ @@ -222,14 +223,18 @@ const CSS_TAGS: Parameters[0] = { // @lezer/javascript styles `.` as a deref operator, but not `?.`. const JS_TAGS: Parameters[0] = { "?.": t.derefOperator }; +// @lezer/java tags annotations as plain identifiers. +export const JAVA_TAGS: Parameters[0] = { + "MarkerAnnotation/Identifier Annotation/Identifier MarkerAnnotation Annotation": t.annotation, +}; + /** Highlighting the stock grammars lack or colour unlike IntelliJ. */ const EXTRA_TAGS: Record[0]> = { JavaScript: JS_TAGS, TypeScript: JS_TAGS, JSX: JS_TAGS, TSX: JS_TAGS, - // @lezer/java tags annotations as plain identifiers. - Java: { "MarkerAnnotation/Identifier Annotation/Identifier MarkerAnnotation Annotation": t.annotation }, + Java: JAVA_TAGS, CSS: CSS_TAGS, SCSS: CSS_TAGS, Sass: CSS_TAGS, diff --git a/src/ui/icons.test.tsx b/src/ui/icons.test.tsx index 0e1a7063..8ad1b2e0 100644 --- a/src/ui/icons.test.tsx +++ b/src/ui/icons.test.tsx @@ -172,6 +172,9 @@ describe("fileIconName", () => { ["main.dart", "file-dart"], ["App.vue", "file-vue"], ["App.svelte", "file-svelte"], + ["index.jsp", "file-jsp"], + ["footer.jspf", "file-jsp"], + ["card.tag", "file-jsp"], ["styles.scss", "file-scss"], ["styles.sass", "file-sass"], ["styles.less", "file-less"], diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 047091b3..51f45b46 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -76,6 +76,7 @@ const ICONS = { "file-npm": "openidea/fileTypes/npm", "file-vue": "fileTypes/vue", "file-svelte": "openidea/fileTypes/svelte", + "file-jsp": "openidea/fileTypes/jsp", "file-yaml": "fileTypes/yaml", "file-toml": "fileTypes/toml", "file-html": "fileTypes/html", @@ -822,6 +823,7 @@ const EXT_ICONS = table({ "file-tsx": "tsx", "file-vue": "vue", "file-svelte": "svelte", + "file-jsp": "jsp jspx jspf jsph tag tagf tagx", "file-json": "json jsonc json5 jsonl ndjson geojson topojson webmanifest har avsc code-workspace code-snippets map", "file-md": "md mdx markdown mdown mkd mkdn mdwn ronn workbook", "file-rst": "rst", diff --git a/src/ui/openidea/fileTypes/jsp.svg b/src/ui/openidea/fileTypes/jsp.svg new file mode 100644 index 00000000..3e1a529a --- /dev/null +++ b/src/ui/openidea/fileTypes/jsp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/openidea/fileTypes/jsp_dark.svg b/src/ui/openidea/fileTypes/jsp_dark.svg new file mode 100644 index 00000000..d8443015 --- /dev/null +++ b/src/ui/openidea/fileTypes/jsp_dark.svg @@ -0,0 +1,4 @@ + + + +