diff --git a/.changeset/starlark-highlighting.md b/.changeset/starlark-highlighting.md new file mode 100644 index 000000000..990198df1 --- /dev/null +++ b/.changeset/starlark-highlighting.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add syntax highlighting for Bazel/Starlark. diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index e386a2ff4..242d7115f 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -21,7 +21,9 @@ object and registry collection (`src/extensions/runExtension.ts`): before config resolution, so backends exist without making core import the extension host. `default/ui/index.ts` is deliberately not part of that list: it synchronously loads the bundled files pane through `runExtensionFactory` - only where the app resolves UI panes. + only where the app resolves UI panes. `default/languages/` ships file-language + selectors as plain data that `applyExtensionFileLanguages` prepends ahead of + user extensions, so apply stays off the diff-engine import graph. Git and the built-in file navigation use the public `registerVcsAdapter` and `registerPane` paths. The external [Hunk Lens](https://github.com/modem-dev/hunk-lens) diff --git a/src/extensions/apply.test.ts b/src/extensions/apply.test.ts index bff56e08e..eac422a71 100644 --- a/src/extensions/apply.test.ts +++ b/src/extensions/apply.test.ts @@ -170,6 +170,21 @@ describe("extension file languages", () => { applyExtensionFileLanguages(createEmptyExtensionLoadResult("/repo").registry); expect(fileLanguageForPath("nested/TemporaryHunkfile")).toBe("text"); + // Bundled Starlark selectors survive a user-extension-only reload wipe. + expect(fileLanguageForPath("pkg/BUILD")).toBe("python"); + }); + + test("lets a user extension replace a bundled Starlark selector", async () => { + const { fileLanguageForPath } = await import("../core/changeset/fileLanguageLookup"); + const { result } = createTestLoadResult(); + result.registry.fileLanguages.push({ + extensionId: "override", + matcher: { kind: "filename", value: "BUILD" }, + language: "ruby", + }); + + expect(applyExtensionFileLanguages(result.registry)).toEqual([]); + expect(fileLanguageForPath("pkg/BUILD")).toBe("ruby"); }); test("keeps reserved extensions authoritative over broader selectors", async () => { diff --git a/src/extensions/apply.ts b/src/extensions/apply.ts index 05e79c3e7..c2ba64573 100644 --- a/src/extensions/apply.ts +++ b/src/extensions/apply.ts @@ -8,16 +8,18 @@ import type { Changeset } from "../core/changeset/model"; import { detectVcs, extendVcsCatalog, getDefaultVcsAdapter } from "../core/vcs"; import type { VcsAdapter, VcsCatalog } from "../core/vcs/types"; import { sanitizeTerminalLine } from "../lib/terminalText"; -import type { - ExtensionContext, - ExtensionLoadResult, - ExtensionRegistry, - RegisteredCommand, - RegisteredFileView, - RegisteredKeyboardMode, - RegisteredLineHighlighter, - RegisteredPane, +import { + createEmptyExtensionRegistry, + type ExtensionContext, + type ExtensionLoadResult, + type ExtensionRegistry, + type RegisteredCommand, + type RegisteredFileView, + type RegisteredKeyboardMode, + type RegisteredLineHighlighter, + type RegisteredPane, } from "./types"; +import { getBundledFileLanguages } from "./default/languages"; /** * One registration Hunk refused to apply. @@ -41,17 +43,20 @@ function describeError(error: unknown) { } /** - * Register every extension-contributed file selector and language. + * Register every bundled and user-extension file selector and language. * - * Selectors are applied once per load pass. Within one selector category the last registration - * wins, matching how a later config layer overrides an earlier one; Hunk's own `.mts`/`.cts` - * extension mappings are never overridden. + * Bundled selectors load first so `--no-extensions` still gets shipped defaults; user + * registrations follow and win ties within a selector category. Hunk's reserved `.mts`/`.cts` + * extension mappings remain non-overridable. */ export function applyExtensionFileLanguages(registry: ExtensionRegistry): ExtensionApplyIssue[] { const issues: ExtensionApplyIssue[] = []; const registrations: FileLanguageRegistration[] = []; - for (const { extensionId, matcher, language } of registry.fileLanguages) { + for (const { extensionId, matcher, language } of [ + ...getBundledFileLanguages(), + ...registry.fileLanguages, + ]) { if (matcher.kind === "extension" && BUILT_IN_FILE_LANGUAGE_EXTENSIONS.has(matcher.value)) { issues.push({ extensionId, @@ -354,8 +359,12 @@ export function applyExtensionRegistrations( baseCatalog: VcsCatalog, ): AppliedExtensionRegistrations { if (!result) { - replaceExtensionFileLanguages([]); - return { vcsAdapters: [], vcsCatalog: baseCatalog, issues: [] }; + // Still apply bundled file languages when user extensions are absent or disabled. + return { + vcsAdapters: [], + vcsCatalog: baseCatalog, + issues: applyExtensionFileLanguages(createEmptyExtensionRegistry()), + }; } const languageIssues = applyExtensionFileLanguages(result.registry); diff --git a/src/extensions/default/languages/index.test.ts b/src/extensions/default/languages/index.test.ts new file mode 100644 index 000000000..6ec028f75 --- /dev/null +++ b/src/extensions/default/languages/index.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from "bun:test"; +import { HUNK_VENDOR_EXTENSION_ID } from "../../extensionIds"; +import { getBundledFileLanguages } from "."; + +describe("bundled file languages", () => { + test("exposes Starlark selectors under the vendor extension id", () => { + const languages = getBundledFileLanguages(); + expect(languages.length).toBeGreaterThan(0); + expect(languages.every((entry) => entry.extensionId === HUNK_VENDOR_EXTENSION_ID)).toBe(true); + }); +}); diff --git a/src/extensions/default/languages/index.ts b/src/extensions/default/languages/index.ts new file mode 100644 index 000000000..89a0d9671 --- /dev/null +++ b/src/extensions/default/languages/index.ts @@ -0,0 +1,21 @@ +import { HUNK_VENDOR_EXTENSION_ID } from "../../extensionIds"; +import type { RegisteredFileLanguage } from "../../types"; +import { STARLARK_FILE_LANGUAGE_SELECTORS } from "./starlark"; + +/** + * Bundled file-language selectors. + * + * `applyExtensionFileLanguages` prepends these ahead of user extensions so shipped + * defaults stay active under `--no-extensions` and remain replaceable by a later + * registration. This module stays free of `runExtension` so the startup graph can + * reach apply without loading the diff engine. + */ + +/** Return the shipped file-language selectors for the apply path. */ +export function getBundledFileLanguages(): readonly RegisteredFileLanguage[] { + return STARLARK_FILE_LANGUAGE_SELECTORS.map((entry) => ({ + extensionId: HUNK_VENDOR_EXTENSION_ID, + matcher: entry.matcher, + language: entry.language, + })); +} diff --git a/src/extensions/default/languages/starlark.test.ts b/src/extensions/default/languages/starlark.test.ts new file mode 100644 index 000000000..52524702c --- /dev/null +++ b/src/extensions/default/languages/starlark.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { applyExtensionFileLanguages } from "../../apply"; +import { HUNK_VENDOR_EXTENSION_ID } from "../../extensionIds"; +import { runExtensionFactory } from "../../runExtension"; +import { createEmptyExtensionRegistry, type ExtensionLoadIssue } from "../../types"; +import { getBundledFileLanguages } from "."; +import registerStarlarkFileLanguages, { STARLARK_FILE_LANGUAGE_SELECTORS } from "./starlark"; + +describe("bundled Starlark file languages", () => { + test("lists the Linguist Starlark selectors", () => { + const matchers = getBundledFileLanguages().map((entry) => entry.matcher); + + expect(matchers).toContainEqual({ kind: "extension", value: "bzl" }); + expect(matchers).toContainEqual({ kind: "extension", value: "star" }); + expect(matchers).toContainEqual({ kind: "extension", value: "bazel" }); + expect(matchers).toContainEqual({ kind: "extension", value: "bzlmod" }); + expect(matchers).toContainEqual({ kind: "extension", value: "sky" }); + expect(matchers).toContainEqual({ kind: "filename", value: "BUILD" }); + expect(matchers).toContainEqual({ kind: "filename", value: "WORKSPACE" }); + expect(matchers).toContainEqual({ kind: "filename", value: "BUCK" }); + expect(matchers).toContainEqual({ kind: "filename", value: "Tiltfile" }); + expect(getBundledFileLanguages().every((entry) => entry.language === "python")).toBe(true); + }); + + test("registers the same selectors through the public factory API", () => { + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + runExtensionFactory({ + metadata: { + id: HUNK_VENDOR_EXTENSION_ID, + sourcePath: "hunk:bundled/languages/starlark", + origin: "bundled", + }, + registry, + issues, + factory: registerStarlarkFileLanguages, + }); + + expect(issues).toEqual([]); + expect(registry.fileLanguages.map((entry) => entry.matcher)).toEqual( + STARLARK_FILE_LANGUAGE_SELECTORS.map((entry) => entry.matcher), + ); + }); + + test("highlights Bazel and Starlark paths as Python after apply", async () => { + const { fileLanguageForPath } = await import("../../../core/changeset/fileLanguageLookup"); + expect(applyExtensionFileLanguages(createEmptyExtensionRegistry())).toEqual([]); + + expect(fileLanguageForPath("defs.bzl")).toBe("python"); + expect(fileLanguageForPath("tools/defs.bzl")).toBe("python"); + expect(fileLanguageForPath("rules.star")).toBe("python"); + expect(fileLanguageForPath("copy.bara.sky")).toBe("python"); + expect(fileLanguageForPath("BUILD.bazel")).toBe("python"); + expect(fileLanguageForPath("pkg/nested/MODULE.bazel")).toBe("python"); + expect(fileLanguageForPath("WORKSPACE.bzlmod")).toBe("python"); + expect(fileLanguageForPath("BUILD")).toBe("python"); + expect(fileLanguageForPath("pkg/BUILD")).toBe("python"); + expect(fileLanguageForPath("a/b/c/WORKSPACE")).toBe("python"); + expect(fileLanguageForPath("third_party/BUCK")).toBe("python"); + expect(fileLanguageForPath("Tiltfile")).toBe("python"); + // `.bazelrc` is a flag file rather than Starlark. + expect(fileLanguageForPath(".bazelrc")).toBe("text"); + }); +}); diff --git a/src/extensions/default/languages/starlark.ts b/src/extensions/default/languages/starlark.ts new file mode 100644 index 000000000..641be49ba --- /dev/null +++ b/src/extensions/default/languages/starlark.ts @@ -0,0 +1,35 @@ +import type { ExtensionFileLanguageMatcher, HunkExtensionAPI } from "hunkdiff/extension"; + +/** + * Bazel/Starlark path → Python highlight selectors. + * + * Starlark has no grammar of its own. GitHub Linguist classifies it with + * `tm_scope: source.python`, so Python is the intended rendering rather than a + * stand-in. Extension and filename selectors follow Linguist's key list; the + * `bazel` and `bzlmod` extensions also cover `BUILD.bazel`, `MODULE.bazel`, + * `REPO.bazel`, `VENDOR.bazel`, and `WORKSPACE.bzlmod` without naming each one. + * + * Kept as plain data so apply can prepend these without loading the extension + * host or the diff engine. The factory below is the same public API a user + * extension would call. + */ +export const STARLARK_FILE_LANGUAGE_SELECTORS: readonly { + matcher: ExtensionFileLanguageMatcher; + language: string; +}[] = [ + ...(["bzl", "star", "bazel", "bzlmod", "sky"] as const).map((value) => ({ + matcher: { kind: "extension" as const, value }, + language: "python", + })), + ...(["BUILD", "WORKSPACE", "BUCK", "Tiltfile"] as const).map((value) => ({ + matcher: { kind: "filename" as const, value }, + language: "python", + })), +]; + +/** Register the bundled Starlark selectors through the public extension API. */ +export default function registerStarlarkFileLanguages(hunk: HunkExtensionAPI): void { + for (const { matcher, language } of STARLARK_FILE_LANGUAGE_SELECTORS) { + hunk.registerFileLanguage(matcher, language); + } +} diff --git a/src/extensions/default/vcs/index.ts b/src/extensions/default/vcs/index.ts index 7cdc221ce..01b8bca7c 100644 --- a/src/extensions/default/vcs/index.ts +++ b/src/extensions/default/vcs/index.ts @@ -35,11 +35,13 @@ import { * rather than a crash — even though these factories are Hunk's own and that * path should be unreachable. * - * VCS backends are the only registration kind this tier uses today. The app - * composition root reads `getBundledVcsAdapters` and builds the core catalog. - * A bundled extension that registered a theme or a changeset transform would - * also have to be threaded through `applyExtensionRegistrations`, which today - * only sees the user-extension load result. + * File-language selectors live in `default/languages/` as plain registration + * data that `applyExtensionFileLanguages` prepends; that path must stay free of + * `runExtension` so startup does not pull the diff engine. The app composition + * root reads `getBundledVcsAdapters` and builds the core catalog. A bundled + * extension that registered a theme or a changeset transform would also have to + * be threaded through `applyExtensionRegistrations`, which today only sees the + * user-extension load result for those kinds. */ interface BundledExtensionDefinition {