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 <[email protected]>
Цей коміт міститься в:
Kamal ElFadlтаelfadl authored and GitHub committed 2026-11-07 11:12:41 +08:00
1 джерело 1af1744290
коміт 14a31fe8a1
7 files changed
+196 -1

No files matched your search

+73
Переглянути файл
@@ -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" },
]);
});
});
+98
Переглянути файл
@@ -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<GoModState> = {
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);
+14
Переглянути файл
@@ -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();
}
});
});
+8 -1
Переглянути файл
@@ -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;
}