From 14a31fe8a1b9259186da0b991f5db816e4e416ce Mon Sep 17 00:00:00 2001 From: Kamal ElFadl Date: Sat, 7 Nov 2026 10:12:41 +0700 Subject: [PATCH] feat(editor): add syntax highlighting for go.mod and go.work files (#1695) go.mod and go.work opened as plain text: their language definitions had no ui-mode, so no CodeMirror language loaded. Add a GoMod mode (a small stream parser): directives as keywords, module paths as strings, versions as numbers, // comments; shared by go.mod and go.work. Co-authored-by: elfadl <15197158+elfadl@users.noreply.github.com> --- crates/ide-language/src/registry.rs | 1 + languages/gomod.toml | 1 + languages/gowork.toml | 1 + src/editor/core/cm/gomod.test.ts | 73 +++++++++++++++++++++ src/editor/core/cm/gomod.ts | 98 ++++++++++++++++++++++++++++ src/editor/core/cm/languages.test.ts | 14 ++++ src/editor/core/cm/languages.ts | 9 ++- 7 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 src/editor/core/cm/gomod.test.ts create mode 100644 src/editor/core/cm/gomod.ts diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index b4e5d0956..258539f0c 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -605,6 +605,7 @@ mod tests { "ERB", "JSP", "Astro", + "GoMod", ]; fn reg() -> LanguageRegistry { diff --git a/languages/gomod.toml b/languages/gomod.toml index 31e9ef406..9ebe98c2f 100644 --- a/languages/gomod.toml +++ b/languages/gomod.toml @@ -3,6 +3,7 @@ name = "Go Module" filenames = ["go.mod"] line-comment = "//" indent = { tab-size = 4, insert-spaces = false } +ui-mode = "GoMod" [[language-servers]] name = "gopls" diff --git a/languages/gowork.toml b/languages/gowork.toml index 2c4f297fe..0adaa46a3 100644 --- a/languages/gowork.toml +++ b/languages/gowork.toml @@ -3,6 +3,7 @@ name = "Go Workspace" filenames = ["go.work"] line-comment = "//" indent = { tab-size = 4, insert-spaces = false } +ui-mode = "GoMod" [[language-servers]] name = "gopls" diff --git a/src/editor/core/cm/gomod.test.ts b/src/editor/core/cm/gomod.test.ts new file mode 100644 index 000000000..959f35656 --- /dev/null +++ b/src/editor/core/cm/gomod.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { goModParser, initialGoModState, type GoModState } from "./gomod"; +import { StringStream } from "@codemirror/language"; + +function tokenize(line: string, state: GoModState) { + const stream = new StringStream(line, 4, 1); + const tokens: { text: string; token: string | null }[] = []; + while (!stream.eol()) { + const start = stream.pos; + const kind = goModParser.token(stream, state); + if (stream.pos > start && kind !== null) { + tokens.push({ text: stream.string.slice(start, stream.pos), token: kind }); + } + } + return tokens; +} + +describe("goModParser", () => { + it("highlights module declaration and go version", () => { + const state = initialGoModState(); + + const line1 = tokenize("module onpay-aplikasi-api", state); + expect(line1).toEqual([ + { text: "module", token: "keyword" }, + { text: "onpay-aplikasi-api", token: "string" }, + ]); + + const line2 = tokenize("go 1.27.0", state); + expect(line2).toEqual([ + { text: "go", token: "keyword" }, + { text: "1.27.0", token: "number" }, + ]); + }); + + it("highlights require blocks with module paths and versions", () => { + const state = initialGoModState(); + + const line1 = tokenize("require (", state); + expect(line1).toEqual([ + { text: "require", token: "keyword" }, + { text: "(", token: "paren" }, + ]); + + const line2 = tokenize("\tcloud.google.com/go/auth v0.20.0", state); + expect(line2).toEqual([ + { text: "cloud.google.com/go/auth", token: "string" }, + { text: "v0.20.0", token: "number" }, + ]); + + const line3 = tokenize("\tgithub.com/shopspring/decimal v1.4.0 // indirect", state); + expect(line3).toEqual([ + { text: "github.com/shopspring/decimal", token: "string" }, + { text: "v1.4.0", token: "number" }, + { text: "// indirect", token: "comment" }, + ]); + + const line4 = tokenize(")", state); + expect(line4).toEqual([{ text: ")", token: "paren" }]); + }); + + it("highlights replace directive with operator", () => { + const state = initialGoModState(); + + const line = tokenize("replace example.com/foo => ../bar", state); + expect(line).toEqual([ + { text: "replace", token: "keyword" }, + { text: "example.com/foo", token: "string" }, + { text: "=>", token: "operator" }, + { text: "../bar", token: "string" }, + ]); + }); +}); diff --git a/src/editor/core/cm/gomod.ts b/src/editor/core/cm/gomod.ts new file mode 100644 index 000000000..351028b81 --- /dev/null +++ b/src/editor/core/cm/gomod.ts @@ -0,0 +1,98 @@ +import { StreamLanguage, type StreamParser } from "@codemirror/language"; + +const KEYWORDS = new Set(["module", "go", "toolchain", "require", "replace", "exclude", "retract", "use"]); + +export interface GoModState { + currentKeyword: string | null; + inBlock: boolean; + expectVersion: boolean; +} + +export function initialGoModState(): GoModState { + return { + currentKeyword: null, + inBlock: false, + expectVersion: false, + }; +} + +export const goModParser: StreamParser = { + name: "gomod", + startState: () => initialGoModState(), + token(stream, state) { + if (stream.eatSpace()) return null; + + if (stream.sol()) { + if (!state.inBlock) { + state.currentKeyword = null; + } + state.expectVersion = false; + } + + // Comment + if (stream.match(/^\/\/.*/)) { + return "comment"; + } + + // Punctuation & operators + if (stream.match(/^=>/)) { + state.expectVersion = false; + return "operator"; + } + if (stream.eat("(")) { + state.inBlock = true; + return "paren"; + } + if (stream.eat(")")) { + state.inBlock = false; + state.currentKeyword = null; + return "paren"; + } + + // String literals + if (stream.match(/^"(?:[^"\\]|\\.)*"/)) { + return "string"; + } + if (stream.match(/^`[^`]*`/)) { + return "string"; + } + + // Check words/tokens + const wordMatch = stream.match(/^[^\s()]+/) as RegExpMatchArray | null; + if (!wordMatch) { + stream.next(); + return null; + } + + const token = wordMatch[0] ?? ""; + + // Keyword at start of statement (not expecting a version) + if (!state.expectVersion && KEYWORDS.has(token)) { + state.currentKeyword = token; + return "keyword"; + } + + // Semantic versions: v1.2.3, 1.22, 1.22.0, v0.0.0-2023..., v4.4.0+incompatible + const isVersion = /^v?[0-9]+(\.[0-9]+)*([+-][a-zA-Z0-9_.-]+)*$/.test(token) || /^\[v?[0-9]+.*\]$/.test(token); + + if (isVersion) { + state.expectVersion = false; + return "number"; + } + + // In require/replace/use block or following module/require keyword: + // The first token is the module path/package name (string in GoLand: green) + if (state.currentKeyword) { + state.expectVersion = true; + return "string"; + } + + return null; + }, + languageData: { + commentTokens: { line: "//" }, + indentOnInput: /^\s*\)\s*$/, + }, +}; + +export const goModLanguage = StreamLanguage.define(goModParser); diff --git a/src/editor/core/cm/languages.test.ts b/src/editor/core/cm/languages.test.ts index 4dd8ccc41..c5a7a354b 100644 --- a/src/editor/core/cm/languages.test.ts +++ b/src/editor/core/cm/languages.test.ts @@ -151,4 +151,18 @@ describe("language modes", () => { expect(derefs, mode).toEqual(["?.", ".", "?."]); } }); + + it("recognizes go.mod and go.work files by mode and filename", async () => { + for (const [mode, path] of [ + ["GoMod", "/project/go.mod"], + [null, "/project/go.mod"], + ["GoMod", "/project/go.work"], + [null, "/project/go.work"], + ] as const) { + const desc = findLanguageDescription(mode, path); + expect(desc?.name).toBe("GoMod"); + const support = await loadSupport(desc!); + expect(support?.language).toBeTruthy(); + } + }); }); diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index b0e898622..a323060c1 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -141,6 +141,13 @@ const OWN_MODES = [ ); }, }), + LanguageDescription.of({ + name: "GoMod", + alias: ["gomod", "gowork"], + extensions: ["mod", "work"], + filename: /^(?:go\.mod|go\.work)$/, + load: async () => new LanguageSupport((await import("./gomod")).goModLanguage), + }), ]; const BATCH_KEYWORDS = new Set([ @@ -224,9 +231,9 @@ export function findLanguageDescription(uiMode: string | null, path: string): La const loose = LanguageDescription.matchLanguageName(BUILTIN_MODES, uiMode, false); if (loose) return OWN_MODES.find((l) => l.name === loose.name) ?? loose; } - if (uiMode === null && infos) return null; const own = LanguageDescription.matchFilename(OWN_MODES, path.slice(path.lastIndexOf("/") + 1)); if (own) return own; + if (uiMode === null && infos) return null; const matched = LanguageDescription.matchFilename(BUILTIN_MODES, path.slice(path.lastIndexOf("/") + 1)); return OWN_MODES.find((l) => l.name === matched?.name) ?? matched; }