diff --git a/crates/ide-language/queries/kotlin/highlights.scm b/crates/ide-language/queries/kotlin/highlights.scm new file mode 100644 index 000000000..c16d4c957 --- /dev/null +++ b/crates/ide-language/queries/kotlin/highlights.scm @@ -0,0 +1,21 @@ +(class_declaration name: (identifier) @type) +(object_declaration name: (identifier) @type) +(companion_object name: (identifier) @type) +(type_alias type: (identifier) @type) +(user_type (identifier) @type) +(type_parameter (identifier) @type) +(function_declaration name: (identifier) @function.definition) +(parameter (identifier) @parameter.kotlin) +(class_parameter (identifier) @parameter.kotlin ":") +(property_declaration (variable_declaration (identifier) @variable)) +(class_body (property_declaration (variable_declaration (identifier) @property))) +(class_parameter ["val" "var"] (identifier) @property ":") +(enum_entry (identifier) @constant) +(annotation (user_type (identifier) @attribute)) +(annotation (constructor_invocation (user_type (identifier) @attribute))) +(call_expression . (identifier) @function) +(call_expression . (navigation_expression (identifier) @function .)) +(class_modifier) @keyword +(block_comment) @comment +((block_comment) @comment.documentation (#match? @comment.documentation "^/\\*\\*")) +(escape_sequence) @string.escape diff --git a/crates/ide-language/src/syntax/grammars.rs b/crates/ide-language/src/syntax/grammars.rs index b6bdfe45f..af8b96807 100644 --- a/crates/ide-language/src/syntax/grammars.rs +++ b/crates/ide-language/src/syntax/grammars.rs @@ -120,7 +120,7 @@ pub(crate) static SPECS: &[Spec] = &[ name: "kotlin", language: tree_sitter_kotlin_ng::LANGUAGE, outline: outline!("kotlin"), - highlights: None, + highlights: Some(Highlights { query: highlights!("kotlin"), first_wins: false }), fold_nodes: &["string_literal"], fold_bodies: NONE, imports: &["import"], diff --git a/crates/ide-language/src/syntax/highlight.rs b/crates/ide-language/src/syntax/highlight.rs index fc43637b4..f8a894104 100644 --- a/crates/ide-language/src/syntax/highlight.rs +++ b/crates/ide-language/src/syntax/highlight.rs @@ -42,11 +42,12 @@ pub enum SyntaxHighlightKind { Tag, Operator, Punctuation, + Parameter, } impl SyntaxHighlightKind { /// Every kind, in `as u32` order: the legend for [`SyntaxHighlights::data`]. - pub const ALL: [Self; 24] = [ + pub const ALL: [Self; 25] = [ Self::Keyword, Self::String, Self::StringSpecial, @@ -71,6 +72,7 @@ impl SyntaxHighlightKind { Self::Tag, Self::Operator, Self::Punctuation, + Self::Parameter, ]; /// The kebab-case name plugins use (`doc-comment`, `function-call`, …). @@ -162,6 +164,7 @@ const CAPTURES: &[(&str, Paint)] = { ("variable.member", Kind(K::Property)), ("variable.field", Kind(K::Property)), ("variable", Kind(K::Variable)), + ("parameter.kotlin", Kind(K::Parameter)), ("parameter", Kind(K::Variable)), ("property", Kind(K::Property)), ("field", Kind(K::Property)), @@ -361,6 +364,9 @@ mod tests { assert_eq!(paint_for("string.special.path"), Some(Paint::Kind(K::StringSpecial))); assert_eq!(paint_for("comment.doc.__attribute__"), Some(Paint::Kind(K::DocComment))); assert_eq!(paint_for("punctuation.bracket"), Some(Paint::Kind(K::Punctuation))); + assert_eq!(paint_for("parameter.kotlin"), Some(Paint::Kind(K::Parameter))); + assert_eq!(paint_for("parameter"), Some(Paint::Kind(K::Variable))); + assert_eq!(paint_for("variable.parameter"), Some(Paint::Kind(K::Variable))); assert_eq!(paint_for("embedded"), Some(Paint::Clear)); assert_eq!(paint_for("__name__"), None); assert_eq!(paint_for("spell"), None); @@ -376,6 +382,59 @@ mod tests { .collect() } + #[test] + fn kotlin_supplements_stream_highlighting_with_declaration_roles() { + use SyntaxHighlightKind as K; + let src = "package palette\n/** Docs */\n@Deprecated(\"old\")\nclass Greeter(val name: String) {\n var count: Int = 0\n fun greet(prefix: String): String { val message = \"hello\\n\"; println(message); return prefix }\n}\ntypealias Handler = (String) -> Unit\nvalue class UserId(val value: String)\n"; + let got = spans("kotlin", src); + for (text, expected) in [ + ("Deprecated", K::Attribute), + ("Greeter", K::Type), + ("name", K::Property), + ("count", K::Property), + ("greet", K::Function), + ("prefix", K::Parameter), + ("message", K::Variable), + ("println", K::FunctionCall), + ("Handler", K::Type), + ("value", K::Keyword), + ("/** Docs */", K::DocComment), + ("\\n", K::Escape), + ] { + assert!( + got.iter().any(|(word, kind)| word == text && *kind == expected), + "missing {text}: {expected:?} in {got:?}" + ); + } + } + + #[test] + fn non_kotlin_parameters_keep_variable_highlighting() { + for (lang, src) in [ + ("graphql", "query Hero { hero(id: 1) { name } }"), + ("gdscript", "func greet(id: int):\n\tpass\n"), + ("zig", "fn greet(id: u32) void {}"), + ("nix", "{ id }: id"), + ] { + let got = spans(lang, src); + assert!( + got.iter().any(|(text, kind)| text == "id" && *kind == SyntaxHighlightKind::Variable), + "{lang}: {got:?}" + ); + assert!(!got.iter().any(|(_, kind)| *kind == SyntaxHighlightKind::Parameter), "{lang}: {got:?}"); + } + } + + #[test] + fn kotlin_constructor_defaults_are_not_declarations() { + let got = spans("kotlin", "class Example(val name: String = fallback, count: Int = defaultCount)"); + assert!(got.iter().any(|(text, kind)| text == "name" && *kind == SyntaxHighlightKind::Property)); + assert!(got.iter().any(|(text, kind)| text == "count" && *kind == SyntaxHighlightKind::Parameter)); + for reference in ["fallback", "defaultCount"] { + assert!(!got.iter().any(|(text, _)| text == reference), "{got:?}"); + } + } + #[test] fn highlights_elixir() { use SyntaxHighlightKind as K; diff --git a/e2e/kotlinHighlight.fixture.ts b/e2e/kotlinHighlight.fixture.ts new file mode 100644 index 000000000..0ef2ad6ae --- /dev/null +++ b/e2e/kotlinHighlight.fixture.ts @@ -0,0 +1,52 @@ +import { EditorView } from "@codemirror/view"; +import { syntaxHighlighting } from "@codemirror/language"; +import { schemeCss } from "../src/platform/colorScheme"; +import { openideaHighlightStyle } from "../src/editor/core/cm/highlight"; +import { findLanguageDescription, loadSupport } from "../src/editor/core/cm/languages"; +import { syntaxHighlightField, setSyntaxHighlights } from "../src/editor/core/cm/syntaxHighlight"; +import { commands } from "../src/platform/ipc"; +import { setInvokeRoute } from "../src/platform/tauriCore"; +import { lspDoc } from "../src/editor/lsp/lsp"; +import { semanticExtension } from "../src/editor/lsp/semantic"; +import { lspTheme } from "../src/editor/lsp/theme"; + +const semantic = new URLSearchParams(location.search).has("semantic"); +setInvokeRoute(() => null); +commands.lspSemanticTokens = async () => ({ + status: "ok", + data: { + tokenTypes: ["operator", "parameter"], + tokenModifiers: ["declaration", "modification"], + data: [0, 4, 5, 0, 1, 0, 10, 6, 1, 2], + }, +}); + +const style = document.createElement("style"); +style.textContent = schemeCss("dark", { + dark: { + "kotlin.functionDeclaration": { foreground: "#557799", background: "#ddeeff", bold: true }, + "kotlin.parameter": { foreground: "#6688aa" }, + }, +}); +document.head.append(style); +const host = document.createElement("section"); +host.dataset.language = "Kotlin"; +document.body.append(host); +const support = await loadSupport(findLanguageDescription("Kotlin", "Demo.kt")!); +const view = new EditorView({ + parent: host, + doc: "fun greet(prefix: String) {}", + extensions: [ + support!, + syntaxHighlightField, + syntaxHighlighting(openideaHighlightStyle), + ...(semantic + ? [ + lspTheme, + lspDoc.of({ doc: 1, path: "Demo.kt", language: "kotlin", uiMode: "Kotlin", readOnly: false }), + semanticExtension(), + ] + : []), + ], +}); +view.dispatch({ effects: setSyntaxHighlights.of({ kinds: ["function", "parameter"], data: [4, 9, 0, 10, 16, 1] }) }); diff --git a/e2e/kotlinHighlight.html b/e2e/kotlinHighlight.html new file mode 100644 index 000000000..7e35d4b48 --- /dev/null +++ b/e2e/kotlinHighlight.html @@ -0,0 +1,9 @@ + + +
+ + + + + + diff --git a/e2e/kotlinHighlight.spec.ts b/e2e/kotlinHighlight.spec.ts new file mode 100644 index 000000000..6bac8fe0f --- /dev/null +++ b/e2e/kotlinHighlight.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from "@playwright/test"; + +test("Kotlin backend roles override lexer styles", async ({ page }) => { + await page.goto("/e2e/kotlinHighlight.html"); + const greet = page + .locator(".cm-line span") + .filter({ hasText: /^greet$/ }) + .last(); + await expect(greet).toHaveCSS("color", "rgb(85, 119, 153)"); + await expect(greet).toHaveCSS("background-color", "rgb(221, 238, 255)"); + await expect(greet).toHaveCSS("font-weight", "700"); + await expect( + page + .locator(".cm-line span") + .filter({ hasText: /^prefix$/ }) + .last(), + ).toHaveCSS("color", "rgb(102, 136, 170)"); +}); + +test("Kotlin semantic roles stay inside backend and lexer marks", async ({ page }) => { + await page.goto("/e2e/kotlinHighlight.html?semantic"); + const declaration = page.locator(".cm-sem-function.cm-sem-declaration"); + await expect(declaration).toHaveText("greet"); + await expect(declaration).toHaveCSS("color", "rgb(85, 119, 153)"); + await expect(declaration).toHaveCSS("background-color", "rgb(221, 238, 255)"); + await expect(declaration).toHaveCSS("font-weight", "700"); + await expect(declaration.locator("span")).toHaveCount(0); + const parameter = page.locator(".cm-sem-parameter.cm-sem-mutable"); + await expect(parameter).toHaveText("prefix"); + await expect(parameter).toHaveCSS("color", "rgb(102, 136, 170)"); + await expect(parameter).toHaveCSS("text-decoration-line", "underline"); + await expect(parameter.locator("span")).toHaveCount(0); +}); diff --git a/src/bindings.ts b/src/bindings.ts index b9ebfdcd4..bf305f289 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -7805,7 +7805,7 @@ export type SyntaxErrors = { }; /** Highlight classes sent to the UI, which maps each onto a Lezer tag. */ -export type SyntaxHighlightKind = "keyword" | "string" | "stringSpecial" | "escape" | "regexp" | "atom" | "number" | "constant" | "comment" | "docComment" | "function" | "functionCall" | "builtin" | "type" | "namespace" | "variable" | "selfVariable" | "property" | "attribute" | "macro" | "label" | "tag" | "operator" | "punctuation"; +export type SyntaxHighlightKind = "keyword" | "string" | "stringSpecial" | "escape" | "regexp" | "atom" | "number" | "constant" | "comment" | "docComment" | "function" | "functionCall" | "builtin" | "type" | "namespace" | "variable" | "selfVariable" | "property" | "attribute" | "macro" | "label" | "tag" | "operator" | "punctuation" | "parameter"; /** Highlighted ranges of a whole document, packed like LSP semantic tokens. */ export type SyntaxHighlights = { diff --git a/src/editor/core/cm/highlight.ts b/src/editor/core/cm/highlight.ts index 81be365ae..78285557d 100644 --- a/src/editor/core/cm/highlight.ts +++ b/src/editor/core/cm/highlight.ts @@ -8,6 +8,7 @@ import { Tag, tags as t } from "@lezer/highlight"; /** Log warnings: the UI warning colour, since no syntax token reads as one in every scheme. */ export const logWarning = Tag.define("logWarning"); +export const parameter = Tag.define(t.variableName); function token( tag: Tag | Tag[], @@ -72,6 +73,7 @@ export const openideaHighlightStyle = HighlightStyle.define([ token(t.labelName, "constant"), token([t.variableName, t.definition(t.variableName), t.local(t.variableName)], "variable"), token([t.propertyName, t.definition(t.propertyName)], "property"), + token(parameter, "param"), token(t.attributeName, "attribute"), token(t.special(t.brace), "decorator"), token([t.tagName, t.angleBracket], "tag"), diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index 87e6df7d8..4f52a4d7f 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -362,8 +362,8 @@ export function baseExtensions(o: SetupOptions): Extension { clipboardDocumentPath.of(o.ctx.path), clipboardFilesExtension, indentOnInput(), - syntaxHighlighting(openideaHighlightStyle), syntaxHighlightField, + syntaxHighlighting(openideaHighlightStyle), closeBrackets(), caretRowHighlight, fontZoom, diff --git a/src/editor/core/cm/syntaxHighlight.test.ts b/src/editor/core/cm/syntaxHighlight.test.ts index e1ea84de2..6b1ca5639 100644 --- a/src/editor/core/cm/syntaxHighlight.test.ts +++ b/src/editor/core/cm/syntaxHighlight.test.ts @@ -1,10 +1,15 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; import { highlightingFor, syntaxHighlighting } from "@codemirror/language"; import { tags as t } from "@lezer/highlight"; -import { openideaHighlightStyle } from "./highlight"; +import { openideaHighlightStyle, parameter } from "./highlight"; +import { findLanguageDescription, loadSupport } from "./languages"; import { setSyntaxHighlights, syntaxHighlightField } from "./syntaxHighlight"; +const views: EditorView[] = []; +afterEach(() => views.splice(0).forEach((view) => view.destroy())); + function marks(state: EditorState) { const out: { text: string; cls: string }[] = []; state.field(syntaxHighlightField).between(0, state.doc.length, (from, to, d) => { @@ -40,4 +45,26 @@ describe("syntaxHighlightField", () => { state = state.update({ changes: { from: 0, insert: " " } }).state; expect(marks(state).map((m) => m.text)).toEqual(["def", "hi", ":ok"]); }); + + it("lets Kotlin declaration roles override the stream lexer's generic definition style", async () => { + const support = await loadSupport(findLanguageDescription("Kotlin", "demo.kt")!); + const view = new EditorView({ + parent: document.body, + doc: "fun greet(prefix: String) {}", + extensions: [support!, syntaxHighlightField, syntaxHighlighting(openideaHighlightStyle)], + }); + views.push(view); + view.dispatch({ + effects: setSyntaxHighlights.of({ kinds: ["function", "parameter"], data: [4, 9, 0, 10, 16, 1] }), + }); + for (const [text, tag] of [ + ["greet", t.function(t.definition(t.variableName))], + ["prefix", parameter], + ] as const) { + const cls = highlightingFor(view.state, [tag])!; + const span = view.contentDOM.querySelector(`.${cls}`)!; + expect(span.textContent).toBe(text); + expect(span.children.length).toBe(0); + } + }); }); diff --git a/src/editor/core/cm/syntaxHighlight.ts b/src/editor/core/cm/syntaxHighlight.ts index 8ced4899d..273362dea 100644 --- a/src/editor/core/cm/syntaxHighlight.ts +++ b/src/editor/core/cm/syntaxHighlight.ts @@ -1,14 +1,15 @@ /** * Tree-sitter highlighting (`doc_highlights`) for languages without a - * CodeMirror mode (Elixir, ...): backend kinds → Lezer tags, so the colours + * CodeMirror mode (Elixir, ...) or a limited stream mode (Kotlin): backend kinds → Lezer tags, so the colours * come from the same highlight style and Color Scheme settings as Lezer * highlighting. Refreshed with folding; in between, marks are mapped through * the changes. */ -import { StateEffect, StateField, type EditorState } from "@codemirror/state"; +import { Prec, StateEffect, StateField, type EditorState } from "@codemirror/state"; import { Decoration, EditorView, type DecorationSet } from "@codemirror/view"; import { highlightingFor } from "@codemirror/language"; import { tags as t, type Tag } from "@lezer/highlight"; +import { parameter } from "./highlight"; import type { SyntaxHighlightKind, SyntaxHighlights } from "@/platform/ipc"; const TAGS: Record