diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 0a0d7102..10806cab 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -11,6 +11,7 @@ "src/bindings.ts", "src/editor/core/cm/angular/parser*.ts", "src/editor/core/cm/jsp/parser*.ts", + "src/editor/core/cm/astro/parser*.ts", "docs/plugins/themes.md", "pnpm-lock.yaml", "**/*.toml", diff --git a/.oxlintrc.json b/.oxlintrc.json index 51778e9c..6acbb45d 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -14,6 +14,7 @@ "crates", "src/bindings.ts", "src/editor/core/cm/angular/parser*.ts", + "src/editor/core/cm/astro/parser*.ts", "fixtures", "plugins", "test-results", diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 2ebfd19a..7bae95f1 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -33,7 +33,8 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor - [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin carries its own licence): `protobuf/protoeditor-core` (`newui/protoFile` → `fileTypes/protoFile`, Apache-2.0); `terraform` (`newui/hashiCorp` → - `fileTypes/hashiCorp`, Apache-2.0); `makefile` (`expui/makefile` → + `fileTypes/hashiCorp`, Apache-2.0); `Astro` (`icons/astro` → `fileTypes/astro`, + Apache-2.0); `makefile` (`expui/makefile` → `fileTypes/makefile`, MIT, Copyright (c) 2016 Victor Kropp — full text below). The read/write usage filter assets (`actions/showReadAccess{,_dark}.svg` and diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 1c314bca..f8546aaa 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -10,6 +10,7 @@ use std::sync::{Arc, OnceLock}; /// Built-in definitions, embedded at compile time. Add new languages here. const BUILTIN: &[(&str, &str)] = &[ ("asciidoc", include_str!("../../../languages/asciidoc.toml")), + ("astro", include_str!("../../../languages/astro.toml")), ("bat", include_str!("../../../languages/bat.toml")), ("c", include_str!("../../../languages/c.toml")), ("clojure", include_str!("../../../languages/clojure.toml")), @@ -602,6 +603,7 @@ mod tests { "Angular Template", "ERB", "JSP", + "Astro", ]; fn reg() -> LanguageRegistry { @@ -675,7 +677,7 @@ mod tests { let root = tmp.path(); std::fs::create_dir_all(root.join("apps/web/src")).unwrap(); std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap(); - for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] { + for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte", "astro"] { let cfg = reg.get(&LanguageId::new(lang)).unwrap(); let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap(); assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice())); @@ -889,6 +891,7 @@ mod tests { ("App.svelte", "svelte"), ("index.jsp", "jsp"), ("WEB-INF/tags/card.tag", "jsp"), + ("index.astro", "astro"), ("package.json", "json"), ("tsconfig.json", "jsonc"), ("tsconfig.app.json", "jsonc"), @@ -950,6 +953,14 @@ mod tests { assert_eq!(res.line_comment.as_deref(), Some(";")); } + #[test] + fn astro_server_reads_the_project_typescript() { + let reg = reg(); + let ls = ®.get(&LanguageId::new("astro")).unwrap().language_servers[0]; + let tsdk = ls.initialization_options.as_ref().and_then(|o| o.get("typescript")?.get("tsdk")?.as_str()); + assert_eq!(tsdk, Some("node_modules/typescript/lib")); + } + #[test] fn detects_shebangs() { let reg = reg(); diff --git a/knip.ts b/knip.ts index 9fab7673..98b47d66 100644 --- a/knip.ts +++ b/knip.ts @@ -7,6 +7,7 @@ const config: KnipConfig = { "src/**/*.{ts,tsx,css}", "!src/editor/core/cm/angular/parser.terms.ts", "!src/editor/core/cm/jsp/parser.terms.ts", + "!src/editor/core/cm/astro/parser.terms.ts", "scripts/**/*.ts", "perf/**/*.ts", "e2e/**/*.ts", diff --git a/languages/astro.toml b/languages/astro.toml new file mode 100644 index 00000000..77be8d99 --- /dev/null +++ b/languages/astro.toml @@ -0,0 +1,27 @@ +id = "astro" +name = "Astro" +extensions = ["astro"] +block-comment = [""] +indent = { tab-size = 2, insert-spaces = true } +ui-mode = "Astro" + +[[language-servers]] +name = "astro-ls" +command = "astro-ls" +args = ["--stdio"] +root-markers = ["astro.config.mjs", "astro.config.js", "astro.config.ts", "astro.config.mts", "astro.config.cjs", "astro.config.cts"] +# astro-ls ships no TypeScript and refuses to start without `tsdk`. Servers start +# in their workspace root, so this relative path is the project's own TypeScript +# (6 or older: 7 has no tsserverlibrary.js). A TypeScript elsewhere, such as an +# npm workspace root, goes in Settings › Language Servers › astro-ls › +# Initialization options as an absolute `typescript.tsdk`. +initialization-options = { typescript = { tsdk = "node_modules/typescript/lib" } } +install-hint = "npm i -g @astrojs/language-server, and add typescript@6 to the project" + +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" diff --git a/package.json b/package.json index 538d9de5..1e6dee1f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "gen:windows-icons": "node scripts/gen-windows-icons.ts", "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:astro-grammar": "lezer-generator src/editor/core/cm/astro/astro.grammar --typeScript -o src/editor/core/cm/astro/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", diff --git a/src/editor/core/cm/angular/index.ts b/src/editor/core/cm/angular/index.ts index 544880f4..658e8e2e 100644 --- a/src/editor/core/cm/angular/index.ts +++ b/src/editor/core/cm/angular/index.ts @@ -11,10 +11,10 @@ import { html } from "@codemirror/lang-html"; import { javascriptLanguage } from "@codemirror/lang-javascript"; import { styleTags, tags as t } from "@lezer/highlight"; import { IterMode, parseMixed, type Input, type NestedParse, type SyntaxNode, type SyntaxNodeRef } from "@lezer/common"; +import { JS_TAGS } from "../jsTags"; import { parser } from "./parser"; -// @lezer/javascript styles `.` as a deref operator, but not `?.`. -const jsParser = javascriptLanguage.parser.configure({ props: [styleTags({ "?.": t.derefOperator })] }); +const jsParser = javascriptLanguage.parser.configure({ props: [styleTags(JS_TAGS)] }); const exprParser = jsParser.configure({ top: "SingleExpression" }); const baseParser = parser.configure({ diff --git a/src/editor/core/cm/astro/astro.grammar b/src/editor/core/cm/astro/astro.grammar new file mode 100644 index 00000000..875bc9f0 --- /dev/null +++ b/src/editor/core/cm/astro/astro.grammar @@ -0,0 +1,10 @@ +// Astro components: the frontmatter, a TypeScript script between `---` fences, +// then the template. `index.ts` parses the script as TypeScript and the +// template as HTML with `{…}` expressions. Regenerate with +// `pnpm gen:astro-grammar`. + +@top Document { Frontmatter? Template? } + +Frontmatter { FrontmatterFence FrontmatterScript? FrontmatterFence? } + +@external tokens component from "./tokens" { FrontmatterFence, FrontmatterScript, Template } diff --git a/src/editor/core/cm/astro/astro.test.ts b/src/editor/core/cm/astro/astro.test.ts new file mode 100644 index 00000000..36708d2f --- /dev/null +++ b/src/editor/core/cm/astro/astro.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { EditorState } from "@codemirror/state"; +import { indentUnit } from "@codemirror/language"; +import { buildParserFile } from "@lezer/generator"; +import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight"; +import { findLanguageDescription, loadSupport } from "../languages"; +import { pasteReindentFilter } from "../prefs"; +import { astro, astroLanguage } from "./index"; + +const highlighter = tagHighlighter([ + { tag: t.processingInstruction, class: "fence" }, + { tag: t.definitionKeyword, class: "def-kw" }, + { tag: t.keyword, class: "keyword" }, + { tag: t.typeName, class: "type" }, + { tag: t.function(t.variableName), class: "call" }, + { 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.operator, class: "op" }, + { tag: t.brace, class: "brace" }, + { tag: t.tagName, class: "tag" }, + { tag: t.comment, class: "comment" }, + { tag: t.invalid, class: "invalid" }, +]); + +/** `text:class` of the styled tokens of `doc`. */ +function tokens(doc: string): string[] { + const out: string[] = []; + highlightTree(astroLanguage.parser.parse(doc), highlighter, (from, to, cls) => + out.push(`${doc.slice(from, to)}:${cls}`), + ); + return out; +} + +/** A layout with what made HTML mode misread Astro components. */ +const LAYOUT = `--- +import '@/styles/global.css' +import Header from '@/components/Header.astro' + +interface Props { + title: string + visibility?: 'public' | 'private' +} + +const { title, visibility = 'private' } = Astro.props +// The canonical URL drops the trailing slash, as expects. +const pageUrl = new URL(Astro.url.pathname.replace(/(.)\\/$/u, '$1'), Astro.url).href +const image = { path: '/og.png', width: 1200 } +--- + + + + + + {visibility === 'private' && } + {visibility === 'public' && ( + <> + + + + )} + {title} + + +
+ + + + + + + +`; + +describe("Astro components", () => { + it("parses the frontmatter as TypeScript, markup in its comments included", () => { + const doc = "---\ninterface P { title: string }\nconst a = 'x' // on \n---\n

{a}

"; + const out = tokens(doc); + expect(out).toEqual( + expect.arrayContaining([ + "---:fence", + "interface:def-kw", + "string:type", + "const:def-kw", + "'x':str", + "// on :comment", + "p:tag", + "a:var", + ]), + ); + expect(out).not.toContain("html:tag"); + }); + + it("parses expressions in text, fragments and markup inside them included", () => { + const out = tokens( + "\n {v === 'p' && }\n {ok && (\n <>\n {n}\n \n )}\n", + ); + expect(out).toEqual( + expect.arrayContaining(["v:var", "===:op", "'p':str", "&&:op", "meta:tag", "name:attr", "n:var"]), + ); + expect(out.filter((s) => s.endsWith(":invalid"))).toEqual([]); + }); + + it("parses expressions in tags, with spaces, shorthands and spreads", () => { + const out = tokens( + '\n', + ); + expect(out).toEqual(expect.arrayContaining(["content:attr", "new:keyword", "img:var", "title:var", "rest:var"])); + expect(out).not.toContain("URL(img.path,:attr"); + expect(out).toContain('"{literal}":value'); + }); + + it("parses scripts as TypeScript and leaves styles to CSS", () => { + const out = tokens("\n"); + expect(out).toEqual(expect.arrayContaining(["const:def-kw", "number:type", "script:tag", "style:tag"])); + }); + + it("keeps a well-indented component unchanged when it is pasted", () => { + // CodeMirror parses for at most 20 ms per transaction and indents with the partial tree. + // A frozen clock lets a slow CI runner parse the whole paste. + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const state = EditorState.create({ + extensions: [astro(), indentUnit.of(" "), EditorState.tabSize.of(2), pasteReindentFilter("indentEachLine")], + }); + const pasted = state.update({ changes: { from: 0, insert: LAYOUT }, userEvent: "input.paste" }).state; + expect(pasted.doc.toString()).toBe(LAYOUT); + } finally { + vi.useRealTimers(); + } + }); + + it("is generated from astro.grammar", () => { + const dir = import.meta.dirname; + const { parser, terms } = buildParserFile(readFileSync(resolve(dir, "astro.grammar"), "utf8"), { + fileName: "src/editor/core/cm/astro/astro.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 Astro mode", async () => { + const desc = findLanguageDescription("Astro", "/site/src/pages/index.astro"); + expect((await loadSupport(desc!))?.language).toBe(astroLanguage); + }); +}); diff --git a/src/editor/core/cm/astro/index.ts b/src/editor/core/cm/astro/index.ts new file mode 100644 index 00000000..b8bd6886 --- /dev/null +++ b/src/editor/core/cm/astro/index.ts @@ -0,0 +1,134 @@ +/** + * Astro components: a TypeScript frontmatter between `---` fences + * (`astro.grammar`), then an HTML template whose `{…}` expressions, in text + * and in tags, are TSX. `