Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/starlark-highlighting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Add syntax highlighting for Bazel/Starlark.
4 changes: 3 additions & 1 deletion docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions src/extensions/apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
41 changes: 25 additions & 16 deletions src/extensions/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions src/extensions/default/languages/index.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
21 changes: 21 additions & 0 deletions src/extensions/default/languages/index.ts
Original file line number Diff line number Diff line change
@@ -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,
}));
}
64 changes: 64 additions & 0 deletions src/extensions/default/languages/starlark.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
35 changes: 35 additions & 0 deletions src/extensions/default/languages/starlark.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
12 changes: 7 additions & 5 deletions src/extensions/default/vcs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading