diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 39bc2b092..99b35d7e3 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -984,6 +984,20 @@ mod tests { assert_eq!(res.line_comment.as_deref(), Some(";")); } + #[test] + fn go_enables_semantic_tokens_when_the_server_initializes() { + let reg = reg(); + let go = reg.get(&LanguageId::new("go")).unwrap(); + assert_eq!( + go.language_servers[0] + .initialization_options + .as_ref() + .and_then(|options| options.get("semanticTokens")) + .and_then(toml::Value::as_bool), + Some(true) + ); + } + #[test] fn astro_server_reads_the_project_typescript() { let reg = reg(); diff --git a/e2e/goHighlight.fixture.ts b/e2e/goHighlight.fixture.ts new file mode 100644 index 000000000..f3ac195f3 --- /dev/null +++ b/e2e/goHighlight.fixture.ts @@ -0,0 +1,43 @@ +import { EditorView } from "@codemirror/view"; +import { syntaxHighlighting } from "@codemirror/language"; +import { commands } from "../src/platform/ipc"; +import { setInvokeRoute } from "../src/platform/tauriCore"; +import { schemeCss } from "../src/platform/colorScheme"; +import { openideaHighlightStyle } from "../src/editor/core/cm/highlight"; +import { findLanguageDescription, loadSupport } from "../src/editor/core/cm/languages"; +import { lspDoc } from "../src/editor/lsp/lsp"; +import { semanticExtension } from "../src/editor/lsp/semantic"; +import { lspTheme } from "../src/editor/lsp/theme"; + +setInvokeRoute(() => null); +commands.lspSemanticTokens = async () => ({ + status: "ok", + data: { + tokenTypes: ["variable", "namespace", "function"], + tokenModifiers: ["readonly", "static", "declaration", "deprecated"], + data: [0, 6, 5, 0, 1, 1, 4, 6, 0, 2, 2, 0, 3, 1, 0, 3, 5, 5, 2, 14], + }, +}); +const style = document.createElement("style"); +style.textContent = schemeCss("dark", { + dark: { + "go.constant": { foreground: "#224466" }, + "go.globalVariable": { foreground: "#335577" }, + }, +}); +document.head.append(style); +const host = document.createElement("section"); +host.dataset.language = "Go"; +document.body.append(host); +const support = await loadSupport(findLanguageDescription("Go", "demo.go")!); +new EditorView({ + parent: host, + doc: "const Limit = 3\nvar Global = 1\nfmt.Println(Global)\nfunc Greet() {}", + extensions: [ + support!, + syntaxHighlighting(openideaHighlightStyle), + lspTheme, + lspDoc.of({ doc: 1, path: "demo.go", language: "go", uiMode: "Go", readOnly: false }), + semanticExtension(), + ], +}); diff --git a/e2e/goHighlight.html b/e2e/goHighlight.html new file mode 100644 index 000000000..ca5b1b2fc --- /dev/null +++ b/e2e/goHighlight.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/e2e/goHighlight.spec.ts b/e2e/goHighlight.spec.ts new file mode 100644 index 000000000..81a000a24 --- /dev/null +++ b/e2e/goHighlight.spec.ts @@ -0,0 +1,10 @@ +import { expect, test } from "@playwright/test"; + +test("gopls constants and globals use the Go scheme attributes", async ({ page }) => { + await page.goto("/e2e/goHighlight.html"); + await expect(page.locator(".cm-sem-constant")).toHaveText("Limit"); + await expect(page.locator(".cm-sem-constant")).toHaveCSS("color", "rgb(34, 68, 102)"); + await expect(page.locator(".cm-sem-global")).toHaveText("Global"); + await expect(page.locator(".cm-sem-global")).toHaveCSS("color", "rgb(51, 85, 119)"); + await expect(page.locator(".cm-sem-namespace")).toHaveText("fmt"); +}); diff --git a/languages/go.toml b/languages/go.toml index 30dbabb6c..59cd7662e 100644 --- a/languages/go.toml +++ b/languages/go.toml @@ -17,6 +17,7 @@ func main() { [[language-servers]] name = "gopls" command = "gopls" +initialization-options = { semanticTokens = true } root-markers = ["go.work", "go.mod"] install-hint = "go install golang.org/x/tools/gopls@latest" diff --git a/src/editor/lsp/semantic/go/index.test.ts b/src/editor/lsp/semantic/go/index.test.ts new file mode 100644 index 000000000..eec56a47a --- /dev/null +++ b/src/editor/lsp/semantic/go/index.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { Text } from "@codemirror/state"; +import { semanticRanges } from "../index"; + +const doc = Text.of(["name"]); +describe("Go semantic tokens", () => { + it.each([ + [["readonly"], ["cm-sem-constant"]], + [["static"], ["cm-sem-global", "cm-sem-static"]], + [ + ["readonly", "static"], + ["cm-sem-constant", "cm-sem-static"], + ], + [ + ["constant", "static"], + ["cm-sem-constant", "cm-sem-static"], + ], + ])("classifies variable modifiers %j", (modifiers, classes) => { + const ranges = semanticRanges( + doc, + { + tokenTypes: ["variable"], + tokenModifiers: modifiers, + data: [0, 0, 4, 0, (1 << modifiers.length) - 1], + }, + false, + "go", + ); + expect(ranges[0]?.className.split(" ").sort()).toEqual(classes); + }); + + it("keeps local constants out of rainbow coloring and scopes readonly to Go", () => { + const tokens = { tokenTypes: ["variable"], tokenModifiers: ["readonly"], data: [0, 0, 4, 0, 1] }; + expect(semanticRanges(doc, tokens, true, "go")[0]?.className).toBe("cm-sem-constant"); + expect(semanticRanges(doc, tokens, false, "typescript")).toEqual([]); + expect(semanticRanges(doc, { ...tokens, data: [0, 0, 4, 0, 0] }, true, "go")[0]?.className).toContain( + "cm-sem-rainbow-", + ); + expect(semanticRanges(doc, { ...tokens, tokenTypes: ["parameter"] }, true, "go")[0]?.className).toContain( + "cm-sem-rainbow-", + ); + }); + + it("retains package, field and function roles from the common decoder", () => { + for (const [type, expected] of [ + ["namespace", "cm-sem-namespace"], + ["property", "cm-sem-property"], + ["function", "cm-sem-function"], + ["type", "cm-sem-type"], + ["typeParameter", "cm-sem-typeParameter"], + ]) { + expect( + semanticRanges(doc, { tokenTypes: [type!], tokenModifiers: [], data: [0, 0, 4, 0, 0] }, false, "go")[0] + ?.className, + ).toBe(expected); + } + }); + + it("leaves branch labels and missing server tokens to syntax highlighting", () => { + expect( + semanticRanges(doc, { tokenTypes: ["label"], tokenModifiers: [], data: [0, 0, 4, 0, 0] }, false, "go"), + ).toEqual([]); + expect(semanticRanges(doc, { tokenTypes: [], tokenModifiers: [], data: [] }, true, "go")).toEqual([]); + }); +}); diff --git a/src/editor/lsp/semantic/go/index.ts b/src/editor/lsp/semantic/go/index.ts new file mode 100644 index 000000000..39a8d05c9 --- /dev/null +++ b/src/editor/lsp/semantic/go/index.ts @@ -0,0 +1,13 @@ +import { registerLanguageSemantics, type LanguageSemantics } from "../languages"; + +const goSemantics: LanguageSemantics = { + tokenClasses({ type, modifiers, classes }) { + if (type === "variable") { + if (modifiers.includes("readonly") && !classes.includes("cm-sem-constant")) classes.push("cm-sem-constant"); + else if (modifiers.includes("static") && !classes.includes("cm-sem-constant")) classes.push("cm-sem-global"); + } + }, + isLocal: ({ type, modifiers }) => type === "parameter" || (type === "variable" && !modifiers.includes("readonly")), +}; + +registerLanguageSemantics("go", goSemantics); diff --git a/src/editor/lsp/semantic/index.ts b/src/editor/lsp/semantic/index.ts index 7bb309c47..523612693 100644 --- a/src/editor/lsp/semantic/index.ts +++ b/src/editor/lsp/semantic/index.ts @@ -29,6 +29,7 @@ export { languageSemantics } from "./languages"; import "./kotlin"; import "./python"; import "./php"; +import "./go"; const semanticBackoff = featureBackoff(); diff --git a/src/features/settings/__tests__/colorScheme.test.tsx b/src/features/settings/__tests__/colorScheme.test.tsx index 3f7b51f2a..2a961f287 100644 --- a/src/features/settings/__tests__/colorScheme.test.tsx +++ b/src/features/settings/__tests__/colorScheme.test.tsx @@ -1,8 +1,9 @@ import { readFileSync } from "node:fs"; -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { executeAction } from "@/platform/actions"; import { CLEAR_COLOR } from "@/platform/colorScheme"; +import { setLocale } from "@/platform/i18n"; import { useProject } from "@/platform/project"; import { DEFAULT_SETTINGS } from "../model/schema"; import { openSettings, stage, stagedValue, useSettingsDialog } from "../model/staging"; @@ -120,6 +121,49 @@ describe("scheme actions", () => { }); }); +describe("Go scheme attributes", () => { + it("translates the attribute tree and preview selection in Chinese", async () => { + await act(async () => { + await setLocale("zh"); + }); + try { + schemePage("editor.colorScheme.go"); + expect(screen.getByRole("treeitem", { name: "标识符" })).toBeInTheDocument(); + const preview = screen.getByLabelText("配色方案预览"); + act(() => fireEvent.click(within(preview).getByText("fmt"))); + expect(screen.getByRole("treeitem", { name: "包引用" })).toHaveAttribute("aria-selected", "true"); + } finally { + await act(async () => { + await setLocale("en"); + }); + } + }); + + it("selects package references from the preview, inherits defaults and restores overrides", () => { + act(() => { + stage("editor.colorScheme", "global", "editor.colorScheme", "dark"); + stage("editor.colorScheme", "global", "editor.colorOverrides", { + dark: { packageReference: { foreground: "#139839" } }, + }); + }); + schemePage("editor.colorScheme.go"); + const preview = screen.getByLabelText("Color scheme preview"); + act(() => fireEvent.click(within(preview).getByText("fmt"))); + expect(screen.getByRole("treeitem", { name: "Package reference" })).toHaveAttribute("aria-selected", "true"); + expect(preview.style.getPropertyValue("--syn-namespace")).toBe("#139839"); + + act(() => fireEvent.click(screen.getByRole("checkbox", { name: "Bold" }))); + expect(value("colorOverrides")).toEqual({ + dark: { packageReference: { foreground: "#139839" }, "go.packageReference": { bold: true } }, + }); + expect(preview.style.getPropertyValue("--syn-namespace-bold")).toBe("var(--fw-code-bold, 700)"); + + act(() => fireEvent.click(screen.getByRole("button", { name: "Restore Defaults" }))); + expect(value("colorOverrides")).toEqual({}); + expect(screen.getByRole("checkbox", { name: "Bold" })).not.toBeChecked(); + }); +}); + describe("scheme attribute state", () => { beforeEach(() => schemePage("editor.colorScheme.language-defaults")); it("shows checked attributes and inherited colours from the selected scheme", () => { diff --git a/src/features/settings/pages/editor/colorScheme.tsx b/src/features/settings/pages/editor/colorScheme.tsx index 24b7cf875..9f3359009 100644 --- a/src/features/settings/pages/editor/colorScheme.tsx +++ b/src/features/settings/pages/editor/colorScheme.tsx @@ -29,7 +29,7 @@ import { executeAction, registerActions } from "@/platform/actions"; import { call, commands } from "@/platform/ipc"; import { ask, confirm, showError } from "@/workbench/messages"; import { parseIcls, importScheme, deleteScheme, exportIcls } from "@/platform/icls"; -import { tx } from "@/platform/i18n"; +import { tx, useTx } from "@/platform/i18n"; import { selectPage } from "../../model/staging"; type Overrides = Record>; @@ -689,6 +689,74 @@ const SAMPLES: Record = { [["}", "java.operator"]], ], }, + Go: { + code: [ + [["package ", "go.keyword"], ["palette"]], + [ + ["import ", "go.keyword"], + ['"fmt"', "go.string"], + ], + [["// Retry a greeting", "go.lineComment"]], + [ + ["const ", "go.keyword"], + ["MaxRetries", "go.constant"], + [" = ", "go.operator"], + ["3", "go.number"], + ], + [ + ["var ", "go.keyword"], + ["DefaultName", "go.globalVariable"], + [" = ", "go.operator"], + ['"Go"', "go.string"], + ], + [ + ["type ", "go.keyword"], + ["Greeter", "go.className"], + [" struct ", "go.keyword"], + ["{", "go.operator"], + ], + [ + [" Name", "go.field"], + [" string", "go.predefined"], + ], + [["}", "go.operator"]], + [ + ["func ", "go.keyword"], + ["(", "go.operator"], + ["g", "go.parameter"], + [" Greeter", "go.className"], + [") ", "go.operator"], + ["Greet", "go.functionDeclaration"], + ["(", "go.operator"], + ["count", "go.parameter"], + [" int", "go.predefined"], + [") ", "go.operator"], + ["string", "go.predefined"], + [" {", "go.operator"], + ], + [ + [" message", "go.identifier"], + [" := ", "go.operator"], + ["fmt", "go.packageReference"], + [".", "go.operator"], + ["Sprintf", "go.functionCall"], + ["(", "go.operator"], + ['"Hello, %s', "go.string"], + ["\\n", "go.escape"], + ['"', "go.string"], + [", ", "go.operator"], + ["g", "go.parameter"], + [".", "go.operator"], + ["Name", "go.field"], + [")", "go.operator"], + ], + [ + [" return ", "go.keyword"], + ["message", "go.identifier"], + ], + [["}", "go.operator"]], + ], + }, Kotlin: { code: [ [["// Kotlin", "kotlin.lineComment"]], @@ -979,6 +1047,7 @@ function ColorRow({ } function ColorSchemePage({ scopes, landing }: { scopes: readonly string[]; landing?: boolean }) { + const translate = useTx(); const [scheme, setScheme] = useSetting("editor.colorScheme", "global"); const [overrides, setOverrides] = useSetting("editor.colorOverrides", "global"); const [weight] = useSetting("editor.fontWeight"); @@ -1219,7 +1288,7 @@ function ColorSchemePage({ scopes, landing }: { scopes: readonly string[]; landi aria-label="Color scheme attributes" roots={roots} getKey={(n) => n.key} - getLabel={(n) => n.label} + getLabel={(n) => translate(n.label)} getChildren={(n) => n.children} isExpandable={(n) => !!n.children?.length} expanded={expanded} @@ -1232,7 +1301,7 @@ function ColorSchemePage({ scopes, landing }: { scopes: readonly string[]; landi - {n.label} + {translate(n.label)} )} speedSearch diff --git a/src/platform/colorScheme.test.ts b/src/platform/colorScheme.test.ts index 47987ce7c..a3082dc06 100644 --- a/src/platform/colorScheme.test.ts +++ b/src/platform/colorScheme.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { beforeAll, describe, expect, it } from "vitest"; import { + ATTRIBUTES, CLEAR_COLOR, _setSchemeSource, resolvedAttribute, @@ -44,6 +45,13 @@ describe("color schemes", () => { expect(imported).toMatch(/\.cm-editor\[data-language="PHP"\] \{\n {2}--syn-variable: #48d8ee !important;/); }); + it("offers a Go scheme page and scopes its overrides to Go editors", () => { + expect(ATTRIBUTES.find((a) => a.id === "go.constant")?.path).toEqual(["Go"]); + const css = schemeCss("dark", { dark: { "go.constant": { foreground: "#224466" } } }); + expect(css).toContain('.cm-editor[data-language="Go"]'); + expect(css).toContain("--syn-constant: #224466 !important"); + }); + it("resolves scheme attribute state and editor defaults", () => { expect(resolvedAttribute("light", "lineComment")).toMatchObject({ foreground: "#8c8c8c", diff --git a/src/platform/colorScheme.ts b/src/platform/colorScheme.ts index c9d622bc6..8a7ef88c9 100644 --- a/src/platform/colorScheme.ts +++ b/src/platform/colorScheme.ts @@ -182,7 +182,7 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [ syn("regexp", "Regular expression", ["RegExp"], "regexp"), ]; -const SCHEME_LANGUAGES = { java: "Java", kotlin: "Kotlin", php: "PHP" } as const; +const SCHEME_LANGUAGES = { go: "Go", java: "Java", kotlin: "Kotlin", php: "PHP" } as const; export const ATTRIBUTES: readonly AttributeDef[] = [ ...BASE_ATTRIBUTES, diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 70262d01a..f784b9136 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1,4 +1,30 @@ { + "Keyword": "Keyword", + "Identifiers": "Identifiers", + "Local variable": "Local variable", + "Parameter": "Parameter", + "Global variable": "Global variable", + "Package reference": "Package reference", + "Constant": "Constant", + "Instance field": "Instance field", + "Static symbol": "Static symbol", + "Function declaration": "Function declaration", + "Function call": "Function call", + "Predefined symbol": "Predefined symbol", + "Classes": "Classes", + "Class name": "Class name", + "Number": "Number", + "String": "String", + "String text": "String text", + "Valid escape sequence": "Valid escape sequence", + "Comments": "Comments", + "Line comment": "Line comment", + "Doc comment": "Doc comment", + "Metadata": "Metadata", + "Braces and Operators": "Braces and Operators", + "Markup": "Markup", + "Tag": "Tag", + "Attribute": "Attribute", "Contents differ only in line separators": "Contents differ only in line separators", "Cannot remove a worktree containing the open project.": "Cannot remove a worktree containing the open project.", "Delete Remote Branches": "Delete Remote Branches", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index e529fb272..b5fa5068a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,24 @@ { + "Identifiers": "标识符", + "Parameter": "参数", + "Package reference": "包引用", + "Instance field": "实例字段", + "Static symbol": "静态符号", + "Function declaration": "函数声明", + "Function call": "函数调用", + "Predefined symbol": "预定义符号", + "Class name": "类名", + "Number": "数字", + "String": "字符串", + "String text": "字符串文本", + "Valid escape sequence": "有效转义序列", + "Comments": "注释", + "Line comment": "行注释", + "Doc comment": "文档注释", + "Metadata": "元数据", + "Braces and Operators": "括号与运算符", + "Markup": "标记", + "Attribute": "属性", "Identifiers › Package reference": "标识符 › 包引用", "Cannot remove a worktree containing the open project.": "无法移除包含当前打开项目的工作树。", "Select Python Environment Folder": "选择 Python 环境文件夹", diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 194f56066..f7b04bbf3 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -534,6 +534,8 @@ function scanTypeScript(): Scan { for (const text of texts) { literals.add(shape(text)); literals.add(shape(text.trim())); + // Color scheme trees display the individual segments of grouped attribute names. + for (const part of text.split(" › ")) literals.add(shape(part)); } const sink = checked ? sinkOf(node, exact, template) : undefined; if (sink) {