From e018ec775f4cddf626bcd2d558bb00326976dd76 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 25 Aug 2026 09:46:32 -0400 Subject: [PATCH 1/5] feat(extensions): match file languages by name or glob --- .changeset/file-language-selectors.md | 5 + docs/extension-architecture.md | 8 +- docs/extensions.md | 35 +++-- scripts/check-pack.ts | 7 + skills/hunk-extensions/SKILL.md | 4 +- src/app/sessionBootstrap.test.ts | 34 +++++ src/app/sessionBootstrap.ts | 68 +++++---- src/core/changeset/diffFile.test.ts | 29 +++- src/core/changeset/diffFile.ts | 8 +- src/core/changeset/fileLanguage.test.ts | 144 ++++++++++++++++-- src/core/changeset/fileLanguage.ts | 95 +++++++----- src/core/changeset/fileLanguageLookup.ts | 123 ++++++++++++--- src/extension-api/index.ts | 1 + src/extension-api/types.ts | 16 +- src/extensions/apply.test.ts | 75 ++++++++- src/extensions/apply.ts | 23 +-- src/extensions/host.test.ts | 36 ++++- src/extensions/publicApiRobustness.test.ts | 45 +++++- src/extensions/runExtension.test.ts | 4 +- src/extensions/runExtension.ts | 50 +++++- src/extensions/trust.test.ts | 6 +- src/extensions/types.ts | 5 +- src/ui/AppHost.extensions.test.tsx | 18 ++- src/ui/AppHost.tsx | 3 + .../content/docs/docs/extend/extension-api.md | 16 +- 25 files changed, 702 insertions(+), 156 deletions(-) create mode 100644 .changeset/file-language-selectors.md diff --git a/.changeset/file-language-selectors.md b/.changeset/file-language-selectors.md new file mode 100644 index 000000000..762a54c1a --- /dev/null +++ b/.changeset/file-language-selectors.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Let extensions select syntax highlighting by exact filename or basename/path glob, in addition to file extensions. diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index 9054d0d85..a70d2fcd4 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -47,8 +47,12 @@ Registrations (session behavior, themes, file languages, VCS adapters, changeset transforms, panes, commands, lifecycle/UI events, and inter-extension bus listeners) collect into one `ExtensionRegistry` (`src/extensions/types.ts`) and are resolved/applied -through `src/extensions/apply.ts` on both startup and reload. Staged external-VCS -bootstrap retains the provisional candidate/config snapshot: a final pass that +through `src/extensions/apply.ts` on both startup and reload. File-language registrations stay as +declarative extension, filename, or glob selectors until `fileLanguageLookup.ts` resolves them; +Hunk then pins that answer into Pierre's metadata so rendering cannot re-derive a conflicting +language. A live reload replaces the compiled selector generation while preparing its changeset +and restores the previous generation if any pre-commit step fails. Staged external-VCS bootstrap +retains the provisional candidate/config snapshot: a final pass that only appends repo candidates extends the same registry, while a changed prefix receives bounded `shutdown` before being rebuilt. Live registry replacement uses the same shutdown/startup lifecycle. A factory that throws is rolled back to its diff --git a/docs/extensions.md b/docs/extensions.md index fcdcedf7d..8fdd1b99e 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -280,9 +280,10 @@ new instances and run that shutdown/startup pair around the replacement. ### `hunk.apiVersion` -The API generation this Hunk speaks (currently `8`). Branch on it if you want -one file to support several Hunk versions. Version 8 adds authoritative review -snapshots to command handlers; version 7 added the current source line to +The API generation this Hunk speaks (currently `9`). Branch on it if you want +one file to support several Hunk versions. Version 9 adds exact-filename and glob selectors to +`registerFileLanguage`; version 8 added authoritative review snapshots to command handlers; +version 7 added the current source line to command selection snapshots. Version 6 added session behavior, terminal-command observation, and live navigation/dialogs in event handlers; version 5 added line highlighters and line-granular navigation (`revealLine`); @@ -324,18 +325,34 @@ built-in id. Config-defined themes always win over extension themes for the same id; the loser is reported as a startup notice. Extension themes appear in the selector after config themes, in load order. -### `hunk.registerFileLanguage(extension, language)` +### `hunk.registerFileLanguage(matcher, language)` -Map a file extension to a syntax-highlighting language. The extension may be -written with or without a leading dot and is lowercased. +Map file extensions, exact filenames, or globs to an existing syntax-highlighting language. The +string shorthand registers a case-insensitive extension with or without its leading dot: ```ts hunk.registerFileLanguage(".zig", "zig"); -hunk.registerFileLanguage("bzl", "python"); +hunk.registerFileLanguage({ kind: "extension", value: "bzl" }, "python"); +hunk.registerFileLanguage({ kind: "filename", value: "BUILD" }, "python"); +hunk.registerFileLanguage( + { kind: "glob", value: "generated/**/*.proto", target: "path" }, + "protobuf", +); +hunk.registerFileLanguage({ kind: "glob", value: "*.component", target: "basename" }, "typescript"); ``` -Later registrations win over earlier ones. Hunk's own `.mts` and `.cts` -mappings cannot be overridden; attempts are skipped with a notice. +Filename and glob matching is case-sensitive on every platform. Exact filenames match a basename +at any directory depth. Globs use Bun's shell-style glob syntax and must explicitly target either +the basename or the normalized repo-relative path; Hunk normalizes both `/` and `\\` separators +to `/` before matching path globs. + +Hunk's reserved `.mts` and `.cts` mappings run first and cannot be overridden. Otherwise, exact +filenames take precedence over globs, which take precedence over extensions. The longest matching +extension wins, and later registrations win ties within each category. Direct attempts to register +those two reserved extensions are skipped with a notice. + +This API selects a language already available to Pierre/Shiki. It does not load a new syntax +grammar; an unknown language remains plain text. ### `hunk.registerVcsAdapter(adapter)` diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index 50305343d..0a3613344 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -25,6 +25,7 @@ import type { ExtensionChangeset, ExtensionCommandControls, ExtensionCommandExecutionOptions, + ExtensionFileLanguageMatcher, ExtensionFileViewRow, ExtensionFileViewRowComponentProps, ExtensionFileViewSourceRange, @@ -68,6 +69,12 @@ export default function (hunk: HunkExtensionAPI) { }; hunk.registerTheme(theme); hunk.registerFileLanguage(".zig", "zig"); + const generatedTypeScript: ExtensionFileLanguageMatcher = { + kind: "glob", + value: "generated/**/*.ts", + target: "path", + }; + hunk.registerFileLanguage(generatedTypeScript, "typescript"); const pane = (props: ExtensionPaneProps) => { hunk.log(\`\${props.placement}:\${props.width}x\${props.height}\`); diff --git a/skills/hunk-extensions/SKILL.md b/skills/hunk-extensions/SKILL.md index 2fa75dfeb..e5bee463e 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/skills/hunk-extensions/SKILL.md @@ -97,7 +97,7 @@ bad or duplicate id is skipped with a startup notice. | -------------------------------------------------------- | -------------------------------------------- | | Keep demo/training view settings temporary | `hunk.configureSession(options)` | | Add a selectable color theme | `hunk.registerTheme(theme)` | -| Highlight an unrecognized file extension | `hunk.registerFileLanguage(ext, lang)` | +| Highlight an extension, exact filename, or filename glob | `hunk.registerFileLanguage(matcher, lang)` | | Support another VCS (`git`/`jj`/`sl` are reserved) | `hunk.registerVcsAdapter(adapter)` | | Add a navigation/list/status pane beside the review | `hunk.registerPane(pane)` | | Present a file as something other than a raw diff | `hunk.registerFileView(view)` (experimental) | @@ -109,7 +109,7 @@ bad or duplicate id is skipped with a startup notice. | Coordinate with another loaded extension | `hunk.events.emit` / `hunk.events.on` | | Read user-supplied settings | `hunk.config` (`[extension.]` table) | | Snapshot stable files and every saved review note | `ctx.review.snapshot()` in a command | -| Branch on the API generation (currently `8`) | `hunk.apiVersion` | +| Branch on the API generation (currently `9`) | `hunk.apiVersion` | Registration is only valid while the factory runs — Hunk seals the API object afterwards. diff --git a/src/app/sessionBootstrap.test.ts b/src/app/sessionBootstrap.test.ts index 06035ce98..cad35584f 100644 --- a/src/app/sessionBootstrap.test.ts +++ b/src/app/sessionBootstrap.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { fileLanguageForPath } from "../core/changeset/fileLanguageLookup"; +import { replaceExtensionFileLanguages } from "../core/changeset/fileLanguage"; import type { HunkConfigResolution } from "../core/run/config"; import type { AppBootstrap } from "../core/bootstrap"; import type { CliInput } from "../core/run/commandInputs"; @@ -55,4 +57,36 @@ describe("loadConfiguredSessionBootstrap", () => { expect(result.bootstrap.keybindings).toEqual({ "hunk.review.nextHunk": "]" }); expect(result.bootstrap.viewPreferencesConfigPath).toBe("/tmp/hunk-config.toml"); }); + + test("restores the active file-language selectors when bootstrap loading fails", async () => { + replaceExtensionFileLanguages([ + { + matcher: { kind: "filename", value: "CurrentHunkfile" }, + language: "python", + }, + ]); + const input = createTestInput(); + const extensions = createEmptyExtensionLoadResult(); + extensions.registry.fileLanguages.push({ + extensionId: "replacement", + matcher: { kind: "filename", value: "ReplacementHunkfile" }, + language: "ruby", + }); + + await expect( + loadConfiguredSessionBootstrap({ + configured: createTestConfig(input), + cwd: process.cwd(), + extensions, + loadAppBootstrapImpl: async () => { + expect(fileLanguageForPath("ReplacementHunkfile")).toBe("ruby"); + throw new Error("load failed"); + }, + }), + ).rejects.toThrow("load failed"); + + expect(fileLanguageForPath("CurrentHunkfile")).toBe("python"); + expect(fileLanguageForPath("ReplacementHunkfile")).toBe("text"); + replaceExtensionFileLanguages([]); + }); }); diff --git a/src/app/sessionBootstrap.ts b/src/app/sessionBootstrap.ts index 2c998dbed..eb36b7ed7 100644 --- a/src/app/sessionBootstrap.ts +++ b/src/app/sessionBootstrap.ts @@ -1,3 +1,8 @@ +import { + fileLanguageRegistrationSnapshot, + restoreFileLanguageRegistrations, + type FileLanguageRegistrationSnapshot, +} from "../core/changeset/fileLanguage"; import type { HunkConfigResolution } from "../core/run/config"; import { isVcsReviewInput } from "../core/vcs"; import type { VcsCatalog } from "../core/vcs/types"; @@ -30,6 +35,8 @@ export interface SessionBootstrapOptions { export interface SessionBootstrapResult { applied: AppliedExtensionRegistrations; bootstrap: AppBootstrap; + /** Selector set to restore if a live reload fails before its commit gate. */ + previousFileLanguages: FileLanguageRegistrationSnapshot; input: CliInput; sessionThemes: ReturnType; sessionVcs: ReturnType; @@ -51,35 +58,42 @@ export async function loadConfiguredSessionBootstrap({ loadAppBootstrapImpl = loadAppBootstrap, baseVcsCatalog = getBundledVcsCatalog(), }: SessionBootstrapOptions): Promise { - const sessionThemes = collectSessionCustomThemes( - configured.customThemes, - extensions?.registry.themes, - ); - const applied = applyExtensionRegistrations(extensions, baseVcsCatalog); - const sessionVcs = resolveSessionVcsId(configured.input.options.vcs, cwd, applied.vcsCatalog); - let input = configured.input; + const previousFileLanguages = fileLanguageRegistrationSnapshot(); - if (sessionVcs.vcsId !== input.options.vcs) { - input = { ...input, options: { ...input.options, vcs: sessionVcs.vcsId } }; - } + try { + const sessionThemes = collectSessionCustomThemes( + configured.customThemes, + extensions?.registry.themes, + ); + const applied = applyExtensionRegistrations(extensions, baseVcsCatalog); + const sessionVcs = resolveSessionVcsId(configured.input.options.vcs, cwd, applied.vcsCatalog); + let input = configured.input; - const detectedVcsId = isVcsReviewInput(input) - ? resolveDetectedVcsIdWithExtensions(cwd, applied.vcsCatalog, configured.explicitVcsId) - : undefined; - if (detectedVcsId !== undefined && detectedVcsId !== input.options.vcs) { - input = { ...input, options: { ...input.options, vcs: detectedVcsId } }; - } + if (sessionVcs.vcsId !== input.options.vcs) { + input = { ...input, options: { ...input.options, vcs: sessionVcs.vcsId } }; + } - const bootstrap = (await loadAppBootstrapImpl(input, { - ...(loadAtCwd ? { cwd } : {}), - customThemes: sessionThemes.themes, - vcsCatalog: applied.vcsCatalog, - })) as AppBootstrap; - bootstrap.changeset = await applyExtensionChangesetTransforms(extensions, bootstrap.changeset); - bootstrap.initialThemeMode = initialThemeMode ?? bootstrap.initialThemeMode; - bootstrap.extensions = extensions; - bootstrap.viewPreferencesConfigPath = configured.viewPreferencesConfigPath; - bootstrap.keybindings = configured.keybindings; + const detectedVcsId = isVcsReviewInput(input) + ? resolveDetectedVcsIdWithExtensions(cwd, applied.vcsCatalog, configured.explicitVcsId) + : undefined; + if (detectedVcsId !== undefined && detectedVcsId !== input.options.vcs) { + input = { ...input, options: { ...input.options, vcs: detectedVcsId } }; + } - return { applied, bootstrap, input, sessionThemes, sessionVcs }; + const bootstrap = (await loadAppBootstrapImpl(input, { + ...(loadAtCwd ? { cwd } : {}), + customThemes: sessionThemes.themes, + vcsCatalog: applied.vcsCatalog, + })) as AppBootstrap; + bootstrap.changeset = await applyExtensionChangesetTransforms(extensions, bootstrap.changeset); + bootstrap.initialThemeMode = initialThemeMode ?? bootstrap.initialThemeMode; + bootstrap.extensions = extensions; + bootstrap.viewPreferencesConfigPath = configured.viewPreferencesConfigPath; + bootstrap.keybindings = configured.keybindings; + + return { applied, bootstrap, input, previousFileLanguages, sessionThemes, sessionVcs }; + } catch (error) { + restoreFileLanguageRegistrations(previousFileLanguages); + throw error; + } } diff --git a/src/core/changeset/diffFile.test.ts b/src/core/changeset/diffFile.test.ts index 4312fe4d7..d137fd25e 100644 --- a/src/core/changeset/diffFile.test.ts +++ b/src/core/changeset/diffFile.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { parseDiffFromFile, type FileDiffMetadata } from "@pierre/diffs"; import { buildDiffFile, countDiffStats, createSkippedLargeMetadata } from "./diffFile"; +import { replaceExtensionFileLanguages } from "./fileLanguage"; + +afterEach(() => { + replaceExtensionFileLanguages([]); +}); /** Parse real Pierre metadata for a small before/after pair. */ function metadataFor(before: string, after: string, name = "foo.ts"): FileDiffMetadata { @@ -44,6 +49,28 @@ describe("buildDiffFile", () => { expect(ctsFile.language).toBe("typescript"); }); + test("pins extension-selected languages onto the metadata Pierre renders", () => { + replaceExtensionFileLanguages([ + { + matcher: { kind: "filename", value: "HunkSyntaxFile" }, + language: "python", + }, + ]); + const nested = buildDiffFile( + metadataFor("a\n", "b\n", "pkg/HunkSyntaxFile"), + "PATCH", + 0, + "src", + null, + ); + + expect(nested.language).toBe("python"); + expect(nested.metadata.lang).toBe("python"); + + const plain = buildDiffFile(metadataFor("a\n", "b\n", "notes"), "PATCH", 1, "src", null); + expect(plain.metadata.lang).toBe("text"); + }); + test("infers binary status from the patch when not given explicitly", () => { const binary = buildDiffFile(metadata, "Binary files a/x and b/x differ\n", 0, "src", null); expect(binary.isBinary).toBe(true); diff --git a/src/core/changeset/diffFile.ts b/src/core/changeset/diffFile.ts index cd944c9aa..dfb603df1 100644 --- a/src/core/changeset/diffFile.ts +++ b/src/core/changeset/diffFile.ts @@ -1,4 +1,4 @@ -import { type FileDiffMetadata } from "@pierre/diffs"; +import { setLanguageOverride, type FileDiffMetadata } from "@pierre/diffs"; import { findSidecarFileContext } from "./sidecar"; import { patchLooksBinary } from "./binary"; import { fileLanguageForPath } from "./fileLanguageLookup"; @@ -68,6 +68,7 @@ export function buildDiffFile( ? (previousPath ?? normalizedMetadata.prevName) : (normalizeDiffPath(previousPath) ?? normalizedMetadata.prevName); const resolvedIsBinary = isBinary ?? patchLooksBinary(patch); + const language = fileLanguageForPath(path); const sourceFetcher = sourceFetcherBuilder?.({ path, previousPath: resolvedPreviousPath, @@ -81,9 +82,10 @@ export function buildDiffFile( path, previousPath: resolvedPreviousPath, patch, - language: fileLanguageForPath(path) ?? undefined, + language, stats: stats ?? countDiffStats(normalizedMetadata), - metadata: normalizedMetadata, + // Pierre otherwise re-derives the language from the path and cannot see Hunk-only selectors. + metadata: setLanguageOverride(normalizedMetadata, language), lineMoveKinds, agent: findSidecarFileContext(sidecar, path, resolvedPreviousPath), isUntracked, diff --git a/src/core/changeset/fileLanguage.test.ts b/src/core/changeset/fileLanguage.test.ts index 5105a8841..78c3e1875 100644 --- a/src/core/changeset/fileLanguage.test.ts +++ b/src/core/changeset/fileLanguage.test.ts @@ -1,7 +1,20 @@ -import { describe, expect, test } from "bun:test"; -import { BUILT_IN_FILE_LANGUAGE_EXTENSIONS, registerFileLanguage } from "./fileLanguage"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { + BUILT_IN_FILE_LANGUAGE_EXTENSIONS, + replaceExtensionFileLanguages, + type FileLanguageRegistration, +} from "./fileLanguage"; import { fileLanguageForPath } from "./fileLanguageLookup"; +/** Replace extension selectors with one test-local registration set. */ +function useTestFileLanguages(...registrations: FileLanguageRegistration[]): void { + replaceExtensionFileLanguages(registrations); +} + +beforeEach(() => { + useTestFileLanguages(); +}); + describe("custom file language registration", () => { test("maps TypeScript module/commonjs extensions to typescript", () => { expect(fileLanguageForPath("foo.mts")).toBe("typescript"); @@ -9,11 +22,13 @@ describe("custom file language registration", () => { expect(fileLanguageForPath("src/nested/foo.mts")).toBe("typescript"); }); - test("preserves Pierre's built-in extension detection", () => { + test("preserves Pierre's built-in filename detection at any depth", () => { expect(fileLanguageForPath("foo.ts")).toBe("typescript"); expect(fileLanguageForPath("foo.tsx")).toBe("tsx"); expect(fileLanguageForPath("foo.mjs")).toBe("javascript"); expect(fileLanguageForPath("foo.cjs")).toBe("javascript"); + expect(fileLanguageForPath("docker/Dockerfile")).toBe("dockerfile"); + expect(fileLanguageForPath("build\\tools\\Makefile")).toBe("makefile"); }); test("reports Hunk's own extensions as built in", () => { @@ -21,24 +36,123 @@ describe("custom file language registration", () => { expect(BUILT_IN_FILE_LANGUAGE_EXTENSIONS.has("cts")).toBe(true); expect(BUILT_IN_FILE_LANGUAGE_EXTENSIONS.has("ts")).toBe(false); }); -}); -describe("deferred registration", () => { - test("applies a mapping registered before the first lookup", () => { - registerFileLanguage("hunkdeferred", "python"); - expect(fileLanguageForPath("foo.hunkdeferred")).toBe("python"); + test("matches exact filenames at any path depth with stable casing", () => { + useTestFileLanguages({ + matcher: { kind: "filename", value: "Hunkfile" }, + language: "python", + }); + + expect(fileLanguageForPath("Hunkfile")).toBe("python"); + expect(fileLanguageForPath("tools/Hunkfile")).toBe("python"); + expect(fileLanguageForPath("tools\\nested\\Hunkfile")).toBe("python"); + expect(fileLanguageForPath("tools/hunkfile")).toBe("text"); }); - test("applies a mapping registered after an earlier lookup drained the queue", () => { - // The queue is emptied on drain, so a late registration has to survive on its own. - expect(fileLanguageForPath("foo.ts")).toBe("typescript"); - registerFileLanguage("hunklate", "ruby"); + test("matches globs against either the basename or normalized path", () => { + useTestFileLanguages( + { + matcher: { kind: "glob", value: "*.hunkbasename", target: "basename" }, + language: "ruby", + }, + { + matcher: { kind: "glob", value: "generated/**/*.hunkpath", target: "path" }, + language: "python", + }, + ); + + expect(fileLanguageForPath("nested/example.hunkbasename")).toBe("ruby"); + expect(fileLanguageForPath("generated/example.hunkpath")).toBe("python"); + expect(fileLanguageForPath("generated\\nested\\example.hunkpath")).toBe("python"); + expect(fileLanguageForPath("source/example.hunkpath")).toBe("text"); + }); + + test("prefers filenames, then globs, then the longest extension", () => { + useTestFileLanguages( + { + matcher: { kind: "extension", value: "hunkpriority" }, + language: "ruby", + }, + { + matcher: { kind: "extension", value: "spec.hunkpriority" }, + language: "typescript", + }, + { + matcher: { kind: "glob", value: "*.hunkpriority", target: "basename" }, + language: "javascript", + }, + { + matcher: { kind: "filename", value: "exact.hunkpriority" }, + language: "python", + }, + ); + + expect(fileLanguageForPath("exact.hunkpriority")).toBe("python"); + expect(fileLanguageForPath("other.hunkpriority")).toBe("javascript"); + expect(fileLanguageForPath("other.spec.hunkpriority")).toBe("javascript"); + + useTestFileLanguages( + { + matcher: { kind: "extension", value: "hunklongest" }, + language: "ruby", + }, + { + matcher: { kind: "extension", value: "spec.hunklongest" }, + language: "typescript", + }, + ); + expect(fileLanguageForPath("other.spec.hunklongest")).toBe("typescript"); + }); + + test("does not let broader selectors override Hunk's reserved extensions", () => { + useTestFileLanguages( + { + matcher: { kind: "filename", value: "special.mts" }, + language: "python", + }, + { + matcher: { kind: "glob", value: "*.cts", target: "basename" }, + language: "ruby", + }, + ); + + expect(fileLanguageForPath("special.mts")).toBe("typescript"); + expect(fileLanguageForPath("nested/example.cts")).toBe("typescript"); + }); +}); + +describe("registration replacement", () => { + test("applies a replacement after an earlier lookup", () => { + expect(fileLanguageForPath("foo.hunklate")).toBe("text"); + useTestFileLanguages({ + matcher: { kind: "extension", value: "hunklate" }, + language: "ruby", + }); expect(fileLanguageForPath("foo.hunklate")).toBe("ruby"); }); - test("keeps the last registration for one extension", () => { - registerFileLanguage("hunkrepeat", "python"); - registerFileLanguage("hunkrepeat", "ruby"); + test("removes selectors that disappear on reload", () => { + useTestFileLanguages({ + matcher: { kind: "filename", value: "Reloadfile" }, + language: "python", + }); + expect(fileLanguageForPath("nested/Reloadfile")).toBe("python"); + + useTestFileLanguages(); + expect(fileLanguageForPath("nested/Reloadfile")).toBe("text"); + }); + + test("keeps the last registration for one selector", () => { + useTestFileLanguages( + { + matcher: { kind: "extension", value: "hunkrepeat" }, + language: "python", + }, + { + matcher: { kind: "extension", value: "hunkrepeat" }, + language: "ruby", + }, + ); expect(fileLanguageForPath("foo.hunkrepeat")).toBe("ruby"); }); }); diff --git a/src/core/changeset/fileLanguage.ts b/src/core/changeset/fileLanguage.ts index 6f360defa..3e473af0b 100644 --- a/src/core/changeset/fileLanguage.ts +++ b/src/core/changeset/fileLanguage.ts @@ -1,61 +1,80 @@ import type { SupportedLanguages } from "@pierre/diffs"; +import type { ExtensionFileLanguageMatcher } from "../../extension-api/types"; /** - * Records file-extension → highlight-language mappings without loading the diff engine. + * Records file-language selectors without loading the diff engine. * - * Registration happens during startup, on every invocation, while the mappings are only read - * when a changeset is built. Applying them eagerly would pull the whole diff engine — and its - * syntax grammars — into commands that never render anything, so this module holds them as - * plain data and `fileLanguageLookup` applies them at the first lookup instead. + * Registration happens during startup, on every invocation, while the selectors are only read + * when a changeset is built. Compiling them eagerly would pull the whole diff engine — and its + * syntax grammars — into commands that never render anything, so this module holds them as plain + * data and `fileLanguageLookup` compiles them at the first lookup for each registration version. * - * Keep this module free of runtime imports from `@pierre/diffs`; that is the only thing making - * the deferral worth anything. + * Keep this module free of runtime imports from `@pierre/diffs`; that is the only thing making the + * deferral worth anything. */ -// Pierre omits these TypeScript extensions, so Hunk registers them itself. +export interface FileLanguageRegistration { + matcher: ExtensionFileLanguageMatcher; + language: string; + /** Prevents a broader extension selector from replacing a core syntax guarantee. */ + reserved?: boolean; +} + +export interface FileLanguageRegistrationSnapshot { + version: number; + registrations: readonly FileLanguageRegistration[]; +} + +// Pierre omits these TypeScript extensions, so Hunk registers and reserves them itself. const HUNK_CUSTOM_EXTENSIONS: Record = { mts: "typescript", cts: "typescript", }; -/** - * Extensions Hunk itself registers, in Pierre's dotless lowercase form. - * - * Extension-contributed mappings are skipped rather than allowed to shadow - * these, so a third-party language pack cannot silently break TypeScript - * highlighting for everyone. - */ +/** Extensions Hunk refuses to yield to an extension, in dotless lowercase form. */ export const BUILT_IN_FILE_LANGUAGE_EXTENSIONS: ReadonlySet = new Set( Object.keys(HUNK_CUSTOM_EXTENSIONS), ); -// Hunk's own mappings are seeded here rather than applied on import, so they land in the same -// pass as extension-contributed ones. `apply.ts` refuses extension mappings that collide with -// BUILT_IN_FILE_LANGUAGE_EXTENSIONS, so seeding first cannot lose to a later registration. -const pendingFileLanguages = new Map(Object.entries(HUNK_CUSTOM_EXTENSIONS)); +const builtInFileLanguages: FileLanguageRegistration[] = Object.entries(HUNK_CUSTOM_EXTENSIONS).map( + ([value, language]) => ({ + matcher: { kind: "extension", value }, + language, + reserved: true, + }), +); -/** - * Map one dotless, lowercased file extension to a highlight language. - * - * Pierre's language union is closed, but extensions supply plain strings; an - * unknown language simply fails to match a grammar at render time, which is a - * better failure than refusing the registration outright. - * - * The mapping takes effect at the next lookup rather than immediately. Nothing reads the - * language table except `fileLanguageLookup`, so the delay is not observable. - */ -export function registerFileLanguage(extension: string, language: string) { - pendingFileLanguages.set(extension, language); +let registrationVersion = 0; +let activeFileLanguages: readonly FileLanguageRegistration[] = builtInFileLanguages; + +/** Copy registrations into the active set and invalidate compiled selectors. */ +function setActiveFileLanguages(registrations: readonly FileLanguageRegistration[]): void { + activeFileLanguages = registrations.map((registration) => ({ + matcher: { ...registration.matcher }, + language: registration.language, + reserved: registration.reserved, + })); + registrationVersion += 1; } /** - * Hand over every mapping registered since the last drain, emptying the queue. + * Atomically replace extension-contributed selectors while retaining Hunk's reserved mappings. * - * Draining rather than replaying keeps repeat lookups free once the queue is empty, while a - * registration made after the first lookup still lands on the next one. + * Reloads call this even when no selectors remain, so removed extensions cannot leave stale rules + * or compiled globs active in the process. */ -export function drainPendingFileLanguages(): Array<[string, string]> { - const drained = [...pendingFileLanguages]; - pendingFileLanguages.clear(); - return drained; +export function replaceExtensionFileLanguages( + registrations: readonly FileLanguageRegistration[], +): void { + setActiveFileLanguages([...builtInFileLanguages, ...registrations]); +} + +/** Restore the active set captured before a candidate session bootstrap began. */ +export function restoreFileLanguageRegistrations(snapshot: FileLanguageRegistrationSnapshot): void { + setActiveFileLanguages(snapshot.registrations); +} + +/** Return the current immutable registration set and its compilation version. */ +export function fileLanguageRegistrationSnapshot(): FileLanguageRegistrationSnapshot { + return { version: registrationVersion, registrations: activeFileLanguages }; } diff --git a/src/core/changeset/fileLanguageLookup.ts b/src/core/changeset/fileLanguageLookup.ts index 36e9e440d..fb75ce91e 100644 --- a/src/core/changeset/fileLanguageLookup.ts +++ b/src/core/changeset/fileLanguageLookup.ts @@ -1,28 +1,113 @@ -import { - getFiletypeFromFileName, - setCustomExtension, - type SupportedLanguages, -} from "@pierre/diffs"; -import { drainPendingFileLanguages } from "./fileLanguage"; +import { getFiletypeFromFileName, type SupportedLanguages } from "@pierre/diffs"; +import { fileLanguageRegistrationSnapshot, type FileLanguageRegistration } from "./fileLanguage"; /** - * Resolves a path to a highlight language, applying deferred registrations first. + * Resolves a path to a highlight language, compiling the current registration set on demand. * - * This is the only module that reads or writes Pierre's process-global extension table, which - * is what lets `fileLanguage` defer registrations: a mapping cannot be observed before it is - * applied, because every read goes through here. Importing this module loads the diff engine, - * so call it from changeset construction, never from startup. + * Importing this module loads the diff engine, so call it from changeset construction, never from + * startup. A registration version change atomically replaces compiled selectors, which keeps + * extension reloads from retaining rules that were removed. */ -/** Push every queued mapping into Pierre's extension table. */ -function applyPendingFileLanguages() { - for (const [extension, language] of drainPendingFileLanguages()) { - setCustomExtension(extension, language as SupportedLanguages); +interface AppliedFileLanguageRegistration extends FileLanguageRegistration { + glob?: Bun.Glob; +} + +let appliedRegistrationVersion = -1; +let appliedFileLanguages: AppliedFileLanguageRegistration[] = []; + +/** Compile the current selector set once per registration version. */ +function applyCurrentFileLanguages(): void { + const snapshot = fileLanguageRegistrationSnapshot(); + if (snapshot.version === appliedRegistrationVersion) { + return; + } + + appliedFileLanguages = snapshot.registrations.map((registration) => ({ + ...registration, + glob: + registration.matcher.kind === "glob" ? new Bun.Glob(registration.matcher.value) : undefined, + })); + appliedRegistrationVersion = snapshot.version; +} + +/** Normalize separators without changing the review's displayed path. */ +function normalizeLanguagePath(path: string): string { + return path.replaceAll("\\", "/"); +} + +/** Return the basename of one normalized review path. */ +function basenameForLanguagePath(path: string): string { + return path.slice(path.lastIndexOf("/") + 1); +} + +/** Find the latest exact-filename registration for a basename. */ +function filenameLanguage(basename: string): string | undefined { + for (let index = appliedFileLanguages.length - 1; index >= 0; index -= 1) { + const registration = appliedFileLanguages[index]!; + if (registration.matcher.kind === "filename" && registration.matcher.value === basename) { + return registration.language; + } } + return undefined; } -/** Return the highlight language for one path, or undefined when no grammar matches. */ -export function fileLanguageForPath(path: string) { - applyPendingFileLanguages(); - return getFiletypeFromFileName(path); +/** Find the latest matching glob registration. */ +function globLanguage(path: string, basename: string): string | undefined { + for (let index = appliedFileLanguages.length - 1; index >= 0; index -= 1) { + const registration = appliedFileLanguages[index]!; + if (registration.matcher.kind !== "glob") { + continue; + } + const candidate = registration.matcher.target === "path" ? path : basename; + if (registration.glob?.match(candidate)) { + return registration.language; + } + } + return undefined; +} + +/** Find the longest matching extension, with the latest registration winning ties. */ +function extensionLanguage(basename: string, reservedOnly = false): string | undefined { + const lowerBasename = basename.toLowerCase(); + let best: { length: number; language: string } | undefined; + + for (let index = appliedFileLanguages.length - 1; index >= 0; index -= 1) { + const registration = appliedFileLanguages[index]!; + if ( + registration.matcher.kind !== "extension" || + (reservedOnly && registration.reserved !== true) || + (!reservedOnly && registration.reserved === true) + ) { + continue; + } + const extension = registration.matcher.value; + if ( + lowerBasename.endsWith(`.${extension}`) && + (best === undefined || extension.length > best.length) + ) { + best = { length: extension.length, language: registration.language }; + } + } + + return best?.language; +} + +/** Return the highlight language for one path, or `"text"` when no grammar matches. */ +export function fileLanguageForPath(path: string): SupportedLanguages { + applyCurrentFileLanguages(); + const normalizedPath = normalizeLanguagePath(path); + const basename = basenameForLanguagePath(normalizedPath); + const registeredLanguage = + extensionLanguage(basename, true) ?? + filenameLanguage(basename) ?? + globLanguage(normalizedPath, basename) ?? + extensionLanguage(basename); + + if (registeredLanguage !== undefined) { + return registeredLanguage as SupportedLanguages; + } + + const inferred = getFiletypeFromFileName(path); + return inferred === "text" && basename !== path ? getFiletypeFromFileName(basename) : inferred; } diff --git a/src/extension-api/index.ts b/src/extension-api/index.ts index e7038a7fa..1771ae0fd 100644 --- a/src/extension-api/index.ts +++ b/src/extension-api/index.ts @@ -44,6 +44,7 @@ export type { ExtensionDiffFile, ExtensionDiffHunk, ExtensionFileChangeRange, + ExtensionFileLanguageMatcher, ExtensionFileSide, ExtensionFileView, ExtensionFileViewControls, diff --git a/src/extension-api/types.ts b/src/extension-api/types.ts index c5134664a..0e74713d3 100644 --- a/src/extension-api/types.ts +++ b/src/extension-api/types.ts @@ -21,11 +21,21 @@ * Extensions can branch on `hunk.apiVersion` so a newer Hunk can keep loading * older extensions without guessing at their expectations. */ -export const HUNK_EXTENSION_API_VERSION = 8; +export const HUNK_EXTENSION_API_VERSION = 9; export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION; export type ExtensionNotifyType = "info" | "warning" | "error"; +/** Selects files whose syntax language an extension overrides. */ +export type ExtensionFileLanguageMatcher = + | { readonly kind: "extension"; readonly value: string } + | { readonly kind: "filename"; readonly value: string } + | { + readonly kind: "glob"; + readonly value: string; + readonly target: "basename" | "path"; + }; + /** Capability object handed to every extension event handler and transform. */ export interface ExtensionContext { cwd: string; @@ -1855,8 +1865,8 @@ export interface HunkExtensionAPI { configureSession(options: ExtensionSessionOptions): void; /** Contribute one selectable theme. */ registerTheme(theme: ExtensionThemeConfig): void; - /** Map one file extension (with or without a leading dot) to a highlight language. */ - registerFileLanguage(extension: string, language: string): void; + /** Map a file extension, exact filename, or glob to a syntax-highlighting language. */ + registerFileLanguage(matcher: string | ExtensionFileLanguageMatcher, language: string): void; /** Contribute one additional VCS backend. */ registerVcsAdapter(adapter: ExtensionVcsAdapter): void; /** diff --git a/src/extensions/apply.test.ts b/src/extensions/apply.test.ts index 2380bdf61..bff56e08e 100644 --- a/src/extensions/apply.test.ts +++ b/src/extensions/apply.test.ts @@ -92,8 +92,16 @@ describe("extension file languages", () => { test("registers extension mappings and skips built-in ones", () => { const { result } = createTestLoadResult(); result.registry.fileLanguages.push( - { extensionId: "langs", extension: "zig", language: "zig" }, - { extensionId: "langs", extension: "mts", language: "javascript" }, + { + extensionId: "langs", + matcher: { kind: "extension", value: "zig" }, + language: "zig", + }, + { + extensionId: "langs", + matcher: { kind: "extension", value: "mts" }, + language: "javascript", + }, ); const issues = applyExtensionFileLanguages(result.registry); @@ -110,13 +118,72 @@ describe("extension file languages", () => { const { fileLanguageForPath } = await import("../core/changeset/fileLanguageLookup"); const { result } = createTestLoadResult(); result.registry.fileLanguages.push( - { extensionId: "first", extension: "hunkfixture", language: "python" }, - { extensionId: "second", extension: "hunkfixture", language: "ruby" }, + { + extensionId: "first", + matcher: { kind: "extension", value: "hunkfixture" }, + language: "python", + }, + { + extensionId: "second", + matcher: { kind: "extension", value: "hunkfixture" }, + language: "ruby", + }, ); expect(applyExtensionFileLanguages(result.registry)).toEqual([]); expect(fileLanguageForPath("sample.hunkfixture")).toBe("ruby"); }); + + test("applies exact-filename and glob selectors through the extension registry", async () => { + const { fileLanguageForPath } = await import("../core/changeset/fileLanguageLookup"); + const { result } = createTestLoadResult(); + result.registry.fileLanguages.push( + { + extensionId: "named", + matcher: { kind: "filename", value: "HunkExtensionFile" }, + language: "python", + }, + { + extensionId: "generated", + matcher: { kind: "glob", value: "generated/**/*.hunk", target: "path" }, + language: "ruby", + }, + ); + + expect(applyExtensionFileLanguages(result.registry)).toEqual([]); + expect(fileLanguageForPath("tools/HunkExtensionFile")).toBe("python"); + expect(fileLanguageForPath("generated/nested/example.hunk")).toBe("ruby"); + expect(fileLanguageForPath("source/example.hunk")).toBe("text"); + }); + + test("atomically removes selectors that disappear from a reload", async () => { + const { fileLanguageForPath } = await import("../core/changeset/fileLanguageLookup"); + const { result } = createTestLoadResult(); + result.registry.fileLanguages.push({ + extensionId: "temporary", + matcher: { kind: "filename", value: "TemporaryHunkfile" }, + language: "python", + }); + + applyExtensionFileLanguages(result.registry); + expect(fileLanguageForPath("nested/TemporaryHunkfile")).toBe("python"); + + applyExtensionFileLanguages(createEmptyExtensionLoadResult("/repo").registry); + expect(fileLanguageForPath("nested/TemporaryHunkfile")).toBe("text"); + }); + + test("keeps reserved extensions authoritative over broader selectors", async () => { + const { fileLanguageForPath } = await import("../core/changeset/fileLanguageLookup"); + const { result } = createTestLoadResult(); + result.registry.fileLanguages.push({ + extensionId: "broad", + matcher: { kind: "glob", value: "*.mts", target: "basename" }, + language: "javascript", + }); + + expect(applyExtensionFileLanguages(result.registry)).toEqual([]); + expect(fileLanguageForPath("src/example.mts")).toBe("typescript"); + }); }); describe("extension session options", () => { diff --git a/src/extensions/apply.ts b/src/extensions/apply.ts index 3542ed889..05e79c3e7 100644 --- a/src/extensions/apply.ts +++ b/src/extensions/apply.ts @@ -1,6 +1,7 @@ import { BUILT_IN_FILE_LANGUAGE_EXTENSIONS, - registerFileLanguage, + replaceExtensionFileLanguages, + type FileLanguageRegistration, } from "../core/changeset/fileLanguage"; import type { StartupNotice } from "../core/process/startupNotice"; import type { Changeset } from "../core/changeset/model"; @@ -40,28 +41,29 @@ function describeError(error: unknown) { } /** - * Register every extension-contributed file-extension → language mapping. + * Register every extension-contributed file selector and language. * - * Pierre's mapping table is process-global, so this is applied once per load - * pass. Within extensions the last registration wins, matching how a later - * config layer overrides an earlier one; Hunk's own `.mts`/`.cts` mappings are - * never overridden. + * 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. */ export function applyExtensionFileLanguages(registry: ExtensionRegistry): ExtensionApplyIssue[] { const issues: ExtensionApplyIssue[] = []; + const registrations: FileLanguageRegistration[] = []; - for (const { extensionId, extension, language } of registry.fileLanguages) { - if (BUILT_IN_FILE_LANGUAGE_EXTENSIONS.has(extension)) { + for (const { extensionId, matcher, language } of registry.fileLanguages) { + if (matcher.kind === "extension" && BUILT_IN_FILE_LANGUAGE_EXTENSIONS.has(matcher.value)) { issues.push({ extensionId, - message: `Skipped file language .${extension} from extension ${extensionId} • Hunk defines it`, + message: `Skipped file language .${matcher.value} from extension ${extensionId} • Hunk defines it`, }); continue; } - registerFileLanguage(extension, language); + registrations.push({ matcher, language }); } + replaceExtensionFileLanguages(registrations); return issues; } @@ -352,6 +354,7 @@ export function applyExtensionRegistrations( baseCatalog: VcsCatalog, ): AppliedExtensionRegistrations { if (!result) { + replaceExtensionFileLanguages([]); return { vcsAdapters: [], vcsCatalog: baseCatalog, issues: [] }; } diff --git a/src/extensions/host.test.ts b/src/extensions/host.test.ts index 9eb55bce9..456bcf230 100644 --- a/src/extensions/host.test.ts +++ b/src/extensions/host.test.ts @@ -160,7 +160,11 @@ export default function (hunk: HunkExtensionAPI) { ]); // Extensions may write ".Prisma"; Pierre wants a dotless, lowercased key. expect(result.registry.fileLanguages).toEqual([ - { extensionId: "kitchen-sink", extension: "prisma", language: "graphql" }, + { + extensionId: "kitchen-sink", + matcher: { kind: "extension", value: "prisma" }, + language: "graphql", + }, ]); expect(result.registry.vcsAdapters.map((entry) => entry.adapter.id)).toEqual(["fossil"]); expect(result.registry.eventHandlers.changeset_loaded).toHaveLength(1); @@ -227,7 +231,11 @@ export default function (hunk: { registerFileLanguage: (e: string, l: string) => { id: "folder-ext", sourcePath: candidate.path, origin: "config" }, ]); expect(result.registry.fileLanguages).toEqual([ - { extensionId: "folder-ext", extension: "proof", language: "graphql" }, + { + extensionId: "folder-ext", + matcher: { kind: "extension", value: "proof" }, + language: "graphql", + }, ]); }); @@ -277,7 +285,11 @@ export default function (hunk: { registerFileLanguage: (e: string, l: string) => expect(result.issues).toEqual([]); expect(result.loaded).toEqual([{ id: "dep-ext", sourcePath: entryPath, origin: "flag" }]); expect(result.registry.fileLanguages).toEqual([ - { extensionId: "dep-ext", extension: "dep", language: "graphql" }, + { + extensionId: "dep-ext", + matcher: { kind: "extension", value: "dep" }, + language: "graphql", + }, ]); }); @@ -336,7 +348,11 @@ export default function (hunk: { registerSidebarView: (view: unknown) => void }) expect(result.issues).toEqual([]); expect(result.registry.fileLanguages).toEqual([ - { extensionId: "async-pack", extension: "zig", language: "rust" }, + { + extensionId: "async-pack", + matcher: { kind: "extension", value: "zig" }, + language: "rust", + }, ]); }); @@ -382,7 +398,11 @@ export default function (hunk: { registerSidebarView: (view: unknown) => void }) expect(result.issues[2]?.message).toContain("default-export a function"); // A partially applied extension must not leave registrations behind. expect(result.registry.fileLanguages).toEqual([ - { extensionId: "healthy", extension: "prisma", language: "graphql" }, + { + extensionId: "healthy", + matcher: { kind: "extension", value: "prisma" }, + language: "graphql", + }, ]); }); @@ -410,7 +430,11 @@ export default function (hunk: { registerSidebarView: (view: unknown) => void }) expect(result.issues[0]?.message).toContain(vendor.path); expect(result.issues[1]?.message).toContain("reserved by Hunk"); expect(result.registry.fileLanguages).toEqual([ - { extensionId: "healthy", extension: "prisma", language: "graphql" }, + { + extensionId: "healthy", + matcher: { kind: "extension", value: "prisma" }, + language: "graphql", + }, ]); }); diff --git a/src/extensions/publicApiRobustness.test.ts b/src/extensions/publicApiRobustness.test.ts index 486e634ba..aeaf3b42d 100644 --- a/src/extensions/publicApiRobustness.test.ts +++ b/src/extensions/publicApiRobustness.test.ts @@ -171,7 +171,50 @@ describe("registerFileLanguage with junk", () => { ); expect(issues).toEqual([]); - expect(registry.fileLanguages.map((entry) => entry.extension)).toEqual(["zig", "bzl"]); + expect(registry.fileLanguages.map((entry) => entry.matcher)).toEqual([ + { kind: "extension", value: "zig" }, + { kind: "extension", value: "bzl" }, + ]); + }); + + test("accepts exact filenames and explicit basename or path globs", () => { + const { registry, issues } = loadFactory( + (hunk: { registerFileLanguage: (matcher: unknown, language: string) => void }) => { + hunk.registerFileLanguage({ kind: "filename", value: "Hunkfile" }, "python"); + hunk.registerFileLanguage({ kind: "glob", value: "*.hunk", target: "basename" }, "ruby"); + hunk.registerFileLanguage( + { kind: "glob", value: "generated/**/*.ts", target: "path" }, + "typescript", + ); + }, + ); + + expect(issues).toEqual([]); + expect(registry.fileLanguages.map((entry) => entry.matcher)).toEqual([ + { kind: "filename", value: "Hunkfile" }, + { kind: "glob", value: "*.hunk", target: "basename" }, + { kind: "glob", value: "generated/**/*.ts", target: "path" }, + ]); + }); + + test("refuses malformed matcher objects", () => { + for (const matcher of [ + { kind: "filename", value: "" }, + { kind: "filename", value: "path/Hunkfile" }, + { kind: "glob", value: "*.ts" }, + { kind: "glob", value: "*.ts", target: "somewhere" }, + { kind: "regex", value: ".*" }, + /.*\.ts/, + ]) { + const { registry, issues } = loadFactory( + (hunk: { registerFileLanguage: (matcher: unknown, language: string) => void }) => { + hunk.registerFileLanguage(matcher, "python"); + }, + ); + + expect(issues).toHaveLength(1); + expect(registry.fileLanguages).toEqual([]); + } }); }); diff --git a/src/extensions/runExtension.test.ts b/src/extensions/runExtension.test.ts index f9e4c97a9..1a99a63e6 100644 --- a/src/extensions/runExtension.test.ts +++ b/src/extensions/runExtension.test.ts @@ -34,7 +34,9 @@ describe("runExtensionFactory", () => { expect(apiVersion).toBe(HUNK_EXTENSION_API_VERSION); expect(issues).toEqual([]); expect(registry.extensions.map((extension) => extension.id)).toEqual(["demo"]); - expect(registry.fileLanguages.map((entry) => entry.extension)).toEqual(["demo"]); + expect(registry.fileLanguages.map((entry) => entry.matcher)).toEqual([ + { kind: "extension", value: "demo" }, + ]); }); test("rolls a throwing synchronous factory back before returning", () => { diff --git a/src/extensions/runExtension.ts b/src/extensions/runExtension.ts index 3fdb00b58..5eb5d59a4 100644 --- a/src/extensions/runExtension.ts +++ b/src/extensions/runExtension.ts @@ -4,6 +4,7 @@ import { type ExtensionEventHandler, type ExtensionEventName, type ExtensionFactory, + type ExtensionFileLanguageMatcher, type ExtensionLoadIssue, type ExtensionCommand, type ExtensionCommandHandler, @@ -46,9 +47,13 @@ export function describeError(error: unknown) { return String(error); } -/** Normalize a registered file extension to Pierre's dotless, lowercased form. */ -function normalizeFileExtension(extension: string) { - const normalized = extension.trim().replace(/^\.+/, "").toLowerCase(); +/** Normalize a registered file extension to a dotless, lowercased selector. */ +function normalizeFileExtension(extension: unknown): string { + const value = assertNonEmptyString( + extension, + "registerFileLanguage requires a non-empty file extension.", + ); + const normalized = value.trim().replace(/^\.+/, "").toLowerCase(); if (normalized.length === 0) { throw new Error("registerFileLanguage requires a non-empty file extension."); } @@ -56,6 +61,41 @@ function normalizeFileExtension(extension: string) { return normalized; } +/** Validate and copy one public file-language matcher into its canonical shape. */ +function normalizeFileLanguageMatcher(matcher: unknown): ExtensionFileLanguageMatcher { + if (typeof matcher === "string") { + return { kind: "extension", value: normalizeFileExtension(matcher) }; + } + if (!isPlainObject(matcher)) { + throw new Error("registerFileLanguage requires an extension string or matcher object."); + } + + const value = assertNonEmptyString( + matcher.value, + "registerFileLanguage matcher value must be a non-empty string.", + ).trim(); + + if (matcher.kind === "extension") { + return { kind: "extension", value: normalizeFileExtension(value) }; + } + if (matcher.kind === "filename") { + if (value.includes("/") || value.includes("\\")) { + throw new Error("registerFileLanguage filename matchers cannot contain path separators."); + } + return { kind: "filename", value }; + } + if (matcher.kind === "glob") { + if (matcher.target !== "basename" && matcher.target !== "path") { + throw new Error('registerFileLanguage glob target must be "basename" or "path".'); + } + // Construct once during loading so any runtime rejection still rolls the factory back cleanly. + new Bun.Glob(value); + return { kind: "glob", value, target: matcher.target }; + } + + throw new Error('registerFileLanguage matcher kind must be "extension", "filename", or "glob".'); +} + /** Reject registrations that would leave the registry holding unusable entries. */ function assertNonEmptyString(value: unknown, message: string) { if (typeof value !== "string" || value.trim().length === 0) { @@ -352,12 +392,12 @@ export function createExtensionApi( assertNonEmptyString(theme?.id, "registerTheme requires a theme with a non-empty id."); registry.themes.push({ extensionId: metadata.id, theme }); }, - registerFileLanguage(extension: string, language: string) { + registerFileLanguage(matcher: string | ExtensionFileLanguageMatcher, language: string) { assertOpen("registerFileLanguage"); assertNonEmptyString(language, "registerFileLanguage requires a non-empty language."); registry.fileLanguages.push({ extensionId: metadata.id, - extension: normalizeFileExtension(extension), + matcher: normalizeFileLanguageMatcher(matcher), language, }); }, diff --git a/src/extensions/trust.test.ts b/src/extensions/trust.test.ts index f0a90f330..88f6502fe 100644 --- a/src/extensions/trust.test.ts +++ b/src/extensions/trust.test.ts @@ -184,7 +184,11 @@ describe("extension trust", () => { expect(result.loaded.map((entry) => entry.id)).toEqual(["repo-local"]); expect(result.registry.fileLanguages).toEqual([ - { extensionId: "repo-local", extension: "repo", language: "typescript" }, + { + extensionId: "repo-local", + matcher: { kind: "extension", value: "repo" }, + language: "typescript", + }, ]); expect(result.pendingTrustRepoRoot).toBeUndefined(); }); diff --git a/src/extensions/types.ts b/src/extensions/types.ts index 1d676159d..c712b7309 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -8,6 +8,7 @@ import type { ExtensionCustomEventHandler, ExtensionEventHandler, ExtensionEventName, + ExtensionFileLanguageMatcher, ExtensionFileView, ExtensionKeyboardMode, ExtensionLineHighlighter, @@ -39,6 +40,7 @@ export type { ExtensionEventHandler, ExtensionEventName, ExtensionEventPayloads, + ExtensionFileLanguageMatcher, ExtensionFileSide, ExtensionFileView, ExtensionFileViewControls, @@ -108,8 +110,7 @@ export interface RegisteredTheme { export interface RegisteredFileLanguage { extensionId: string; - /** Normalized extension without a leading dot, lowercased. */ - extension: string; + matcher: ExtensionFileLanguageMatcher; language: string; } diff --git a/src/ui/AppHost.extensions.test.tsx b/src/ui/AppHost.extensions.test.tsx index 124afab5f..b777021af 100644 --- a/src/ui/AppHost.extensions.test.tsx +++ b/src/ui/AppHost.extensions.test.tsx @@ -17,6 +17,7 @@ import { ReviewProducer } from "../app/review/producer"; import type { AppBootstrap } from "../app/types"; import { getBundledVcsCatalog } from "../app/vcsCatalog"; import { loadAppBootstrap as loadCoreAppBootstrap } from "../core/changeset/loaders"; +import { fileLanguageForPath } from "../core/changeset/fileLanguageLookup"; import type { CliInput } from "../core/run/commandInputs"; import type { HunkSessionBrokerClient } from "../session/broker/brokerClient"; @@ -127,12 +128,15 @@ function useTempConfigHome(configToml?: string) { } /** Write an extension that appends every lifecycle event it sees to a log file. */ -function writeProbeExtension(path: string, logPath: string) { +function writeProbeExtension(path: string, logPath: string, languageExtension?: string) { mkdirSync(join(path, ".."), { recursive: true }); writeFileSync( path, `import { appendFileSync } from "node:fs";\n` + `export default function (hunk) {\n` + + (languageExtension + ? ` hunk.registerFileLanguage(${JSON.stringify(languageExtension)}, "python");\n` + : "") + ` appendFileSync(${JSON.stringify(logPath)}, "factory\\n");\n` + ` hunk.on("startup", () => {\n` + ` appendFileSync(${JSON.stringify(logPath)}, "startup\\n");\n` + @@ -496,7 +500,7 @@ describe("reload keeps launch extension authority", () => { const repo = createTestRepo("hunk-apphost-broker-replacement-failure-"); const logPath = join(repo, "probe.log"); const extPath = join(repo, "ext.ts"); - writeProbeExtension(extPath, logPath); + writeProbeExtension(extPath, logPath, "currenthunksyntax"); useTempConfigHome(); const bootstrap = await launchInSubdirectory(repo, { extensionPaths: [extPath] }); @@ -505,6 +509,7 @@ describe("reload keeps launch extension authority", () => { cwd: join(repo, "sub"), cliExtensionPaths: [extPath], }); + applyExtensionRegistrations(bootstrap.extensions, getBundledVcsCatalog()); const broker = createTestBrokerClient({ replaceSessionError: new Error("broker exploded") }); const producer = new ReviewProducer( { @@ -514,6 +519,8 @@ describe("reload keeps launch extension authority", () => { { producerId: "broker-failure" }, ); const initialGeneration = producer.getPublication().generation; + expect(fileLanguageForPath("example.currenthunksyntax")).toBe("python"); + writeProbeExtension(extPath, logPath, "replacementhunksyntax"); await withAppHost( bootstrap, @@ -540,6 +547,8 @@ describe("reload keeps launch extension authority", () => { expect(events.filter((line) => line === "factory")).toHaveLength(2); expect(events.filter((line) => line === "startup")).toHaveLength(1); expect(events.filter((line) => line === "shutdown")).toHaveLength(1); + expect(fileLanguageForPath("example.currenthunksyntax")).toBe("python"); + expect(fileLanguageForPath("example.replacementhunksyntax")).toBe("text"); }, broker.client, { reviewProducer: producer }, @@ -1151,6 +1160,11 @@ describe("startup for extensions loaded mid-session", () => { () => readProbeLog(logPath).includes("shutdown:false"), "retired shutdown controls to resolve without entering replacement UI", ); + await flushUntil( + setup, + () => setup.captureCharFrame().includes("ignored — the review session was reloaded"), + "the retired-control reload notice to render", + ); const frame = setup.captureCharFrame(); expect(frame).not.toContain("RETIRED CONTROL DIALOG"); diff --git a/src/ui/AppHost.tsx b/src/ui/AppHost.tsx index e2359fc8c..00ebc0762 100644 --- a/src/ui/AppHost.tsx +++ b/src/ui/AppHost.tsx @@ -3,6 +3,7 @@ import { resolveConfiguredExtensions } from "../app/extensionBootstrap"; import { ReviewProducer } from "../app/review/producer"; import { loadConfiguredSessionBootstrap } from "../app/sessionBootstrap"; import { getBundledVcsCatalog } from "../app/vcsCatalog"; +import { restoreFileLanguageRegistrations } from "../core/changeset/fileLanguage"; import { resolveConfiguredCliInput } from "../core/run/config"; import { resolveRuntimeCliInput } from "../core/process/terminal"; import type { StartupNotice } from "../core/process/startupNotice"; @@ -315,6 +316,7 @@ export function AppHost({ // registry, broker snapshot, pending lifecycle, and React state all agree. // Quit therefore linearizes either wholly before or wholly after adoption. if (quitRequestedRef.current) { + restoreFileLanguageRegistrations(loaded.previousFileLanguages); await retirePreparedExtensionReplacement(replacementExtensions); throw reloadRefusedDuringShutdown(); } @@ -362,6 +364,7 @@ export function AppHost({ throw error; } } catch (error) { + restoreFileLanguageRegistrations(loaded.previousFileLanguages); await retirePreparedExtensionReplacement(replacementExtensions); throw error; } diff --git a/website/src/content/docs/docs/extend/extension-api.md b/website/src/content/docs/docs/extend/extension-api.md index 882eecf25..ae613bc29 100644 --- a/website/src/content/docs/docs/extend/extension-api.md +++ b/website/src/content/docs/docs/extend/extension-api.md @@ -7,7 +7,7 @@ The extension factory receives one API object. Registration calls are only valid ## `hunk.apiVersion` -The API generation this Hunk speaks (currently `8`). Branch on it if you want one file to support several Hunk versions. Version 8 adds authoritative review snapshots to command handlers; version 7 added the current source line to command selection snapshots. Version 6 added session behavior, terminal-command observation, and live navigation/dialogs in lifecycle and bus handlers; version 5 added line highlighters and line-granular navigation (`revealLine`); version 4 added keyboard modes and docked panes, with API-v3 sidebar names remaining as deprecated aliases. +The API generation this Hunk speaks (currently `9`). Branch on it if you want one file to support several Hunk versions. Version 9 adds exact-filename and glob selectors to `registerFileLanguage`; version 8 added authoritative review snapshots to command handlers; version 7 added the current source line to command selection snapshots. Version 6 added session behavior, terminal-command observation, and live navigation/dialogs in lifecycle and bus handlers; version 5 added line highlighters and line-granular navigation (`revealLine`); version 4 added keyboard modes and docked panes, with API-v3 sidebar names remaining as deprecated aliases. ## `hunk.configureSession(options)` @@ -38,16 +38,22 @@ hunk.registerTheme({ Theme ids are lowercase words separated by `-` or `_` and cannot reuse a built-in id. Config-defined themes win over extension themes for the same id. Extension themes appear in the selector after config themes, in load order. -## `hunk.registerFileLanguage(extension, language)` +## `hunk.registerFileLanguage(matcher, language)` -Map a file extension to a syntax-highlighting language. The extension may be written with or without a leading dot and is lowercased. +Map an extension, exact filename, or glob to an existing syntax-highlighting language. A string remains shorthand for a case-insensitive extension: ```ts hunk.registerFileLanguage(".zig", "zig"); -hunk.registerFileLanguage("bzl", "python"); +hunk.registerFileLanguage({ kind: "filename", value: "BUILD" }, "python"); +hunk.registerFileLanguage( + { kind: "glob", value: "generated/**/*.proto", target: "path" }, + "protobuf", +); ``` -Later registrations win. Hunk's own `.mts` and `.cts` mappings cannot be overridden. +Filename and glob matching is case-sensitive. Filename selectors match at any directory depth; globs explicitly target the basename or normalized repo-relative path. Hunk's reserved `.mts` and `.cts` mappings run first and cannot be overridden. Otherwise, exact filenames take precedence over globs, then extensions. Later registrations win ties. + +This selects a grammar already available to Pierre/Shiki; it does not load a new syntax grammar. ## `hunk.registerVcsAdapter(adapter)` From 021a953fcd8320008e88e15b25d5c025cd6f1c3e Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 25 Aug 2026 13:36:45 -0400 Subject: [PATCH 2/5] fix(extensions): preserve literal path backslashes --- docs/extensions.md | 5 ++-- src/core/changeset/fileLanguage.test.ts | 10 ++++--- src/core/changeset/fileLanguageLookup.ts | 12 +++------ src/core/changeset/loaders.test.ts | 27 +++++++++++++++++++ .../content/docs/docs/extend/extension-api.md | 2 +- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/docs/extensions.md b/docs/extensions.md index 8fdd1b99e..e8b2357f2 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -343,8 +343,9 @@ hunk.registerFileLanguage({ kind: "glob", value: "*.component", target: "basenam Filename and glob matching is case-sensitive on every platform. Exact filenames match a basename at any directory depth. Globs use Bun's shell-style glob syntax and must explicitly target either -the basename or the normalized repo-relative path; Hunk normalizes both `/` and `\\` separators -to `/` before matching path globs. +the basename or the review path exactly as Hunk decoded it. `/` is the review-path separator; +backslashes remain literal filename characters. VCS review paths are normally repo-relative, while +generic patch input may carry an absolute path. Hunk's reserved `.mts` and `.cts` mappings run first and cannot be overridden. Otherwise, exact filenames take precedence over globs, which take precedence over extensions. The longest matching diff --git a/src/core/changeset/fileLanguage.test.ts b/src/core/changeset/fileLanguage.test.ts index 78c3e1875..68ad00cbc 100644 --- a/src/core/changeset/fileLanguage.test.ts +++ b/src/core/changeset/fileLanguage.test.ts @@ -28,7 +28,7 @@ describe("custom file language registration", () => { expect(fileLanguageForPath("foo.mjs")).toBe("javascript"); expect(fileLanguageForPath("foo.cjs")).toBe("javascript"); expect(fileLanguageForPath("docker/Dockerfile")).toBe("dockerfile"); - expect(fileLanguageForPath("build\\tools\\Makefile")).toBe("makefile"); + expect(fileLanguageForPath("build/tools/Makefile")).toBe("makefile"); }); test("reports Hunk's own extensions as built in", () => { @@ -45,11 +45,12 @@ describe("custom file language registration", () => { expect(fileLanguageForPath("Hunkfile")).toBe("python"); expect(fileLanguageForPath("tools/Hunkfile")).toBe("python"); - expect(fileLanguageForPath("tools\\nested\\Hunkfile")).toBe("python"); expect(fileLanguageForPath("tools/hunkfile")).toBe("text"); + // Review paths use `/`; a backslash remains a legal filename character on POSIX. + expect(fileLanguageForPath("tools\\nested\\Hunkfile")).toBe("text"); }); - test("matches globs against either the basename or normalized path", () => { + test("matches globs against either the basename or exact review path", () => { useTestFileLanguages( { matcher: { kind: "glob", value: "*.hunkbasename", target: "basename" }, @@ -63,7 +64,8 @@ describe("custom file language registration", () => { expect(fileLanguageForPath("nested/example.hunkbasename")).toBe("ruby"); expect(fileLanguageForPath("generated/example.hunkpath")).toBe("python"); - expect(fileLanguageForPath("generated\\nested\\example.hunkpath")).toBe("python"); + expect(fileLanguageForPath("generated/nested/example.hunkpath")).toBe("python"); + expect(fileLanguageForPath("generated\\nested\\example.hunkpath")).toBe("text"); expect(fileLanguageForPath("source/example.hunkpath")).toBe("text"); }); diff --git a/src/core/changeset/fileLanguageLookup.ts b/src/core/changeset/fileLanguageLookup.ts index fb75ce91e..a85db715f 100644 --- a/src/core/changeset/fileLanguageLookup.ts +++ b/src/core/changeset/fileLanguageLookup.ts @@ -31,12 +31,7 @@ function applyCurrentFileLanguages(): void { appliedRegistrationVersion = snapshot.version; } -/** Normalize separators without changing the review's displayed path. */ -function normalizeLanguagePath(path: string): string { - return path.replaceAll("\\", "/"); -} - -/** Return the basename of one normalized review path. */ +/** Return the basename of one review path, whose only protocol separator is `/`. */ function basenameForLanguagePath(path: string): string { return path.slice(path.lastIndexOf("/") + 1); } @@ -96,12 +91,11 @@ function extensionLanguage(basename: string, reservedOnly = false): string | und /** Return the highlight language for one path, or `"text"` when no grammar matches. */ export function fileLanguageForPath(path: string): SupportedLanguages { applyCurrentFileLanguages(); - const normalizedPath = normalizeLanguagePath(path); - const basename = basenameForLanguagePath(normalizedPath); + const basename = basenameForLanguagePath(path); const registeredLanguage = extensionLanguage(basename, true) ?? filenameLanguage(basename) ?? - globLanguage(normalizedPath, basename) ?? + globLanguage(path, basename) ?? extensionLanguage(basename); if (registeredLanguage !== undefined) { diff --git a/src/core/changeset/loaders.test.ts b/src/core/changeset/loaders.test.ts index f09a7249d..fb8af3b42 100644 --- a/src/core/changeset/loaders.test.ts +++ b/src/core/changeset/loaders.test.ts @@ -10,6 +10,7 @@ import { } from "node:fs"; import { platform, tmpdir } from "node:os"; import { join } from "node:path"; +import { replaceExtensionFileLanguages } from "./fileLanguage"; import { SourceTextTooLargeError } from "./fileSource"; import { getBundledVcsCatalog } from "../../app/vcsCatalog"; import { createGitVcsAdapter } from "../../extensions/default/vcs/git"; @@ -189,6 +190,7 @@ async function runFromProcessCwd(cwd: string, task: () => Promise) { afterEach(() => { cleanupTempDirs(); + replaceExtensionFileLanguages([]); }); describe("loadAppBootstrap", () => { @@ -1678,6 +1680,31 @@ describe("loadAppBootstrap", () => { }); }); + test("preserves literal backslashes when matching exact Git-quoted paths", async () => { + replaceExtensionFileLanguages([ + { + matcher: { kind: "filename", value: "Hunkfile" }, + language: "python", + }, + ]); + const escapedPath = String.raw`tools\\Hunkfile`; + const bootstrap = await loadAppBootstrap({ + kind: "patch", + text: [ + `diff --git "a/${escapedPath}" "b/${escapedPath}"`, + `--- "a/${escapedPath}"`, + `+++ "b/${escapedPath}"`, + "@@ -1 +1 @@", + "-one", + "+two", + ].join("\n"), + options: { mode: "auto" }, + }); + + expect(bootstrap.changeset.files[0]?.path).toBe("tools\\Hunkfile"); + expect(bootstrap.changeset.files[0]?.language).toBe("text"); + }); + test("preserves trailing control characters in exact Git-quoted paths", async () => { const escapedPath = String.raw`line\n`; const bootstrap = await loadAppBootstrap({ diff --git a/website/src/content/docs/docs/extend/extension-api.md b/website/src/content/docs/docs/extend/extension-api.md index ae613bc29..b5c08f776 100644 --- a/website/src/content/docs/docs/extend/extension-api.md +++ b/website/src/content/docs/docs/extend/extension-api.md @@ -51,7 +51,7 @@ hunk.registerFileLanguage( ); ``` -Filename and glob matching is case-sensitive. Filename selectors match at any directory depth; globs explicitly target the basename or normalized repo-relative path. Hunk's reserved `.mts` and `.cts` mappings run first and cannot be overridden. Otherwise, exact filenames take precedence over globs, then extensions. Later registrations win ties. +Filename and glob matching is case-sensitive. Filename selectors match at any directory depth; globs explicitly target the basename or review path exactly as decoded. `/` is the path separator and backslashes stay literal. VCS review paths are normally repo-relative, while generic patches may carry absolute paths. Hunk's reserved `.mts` and `.cts` mappings run first and cannot be overridden. Otherwise, exact filenames take precedence over globs, then extensions. Later registrations win ties. This selects a grammar already available to Pierre/Shiki; it does not load a new syntax grammar. From e54b5bf2dc091b34742c4626dad918f9ef6b7639 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 25 Aug 2026 14:24:01 -0400 Subject: [PATCH 3/5] test(extensions): define extension selector boundaries --- src/core/changeset/fileLanguage.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/core/changeset/fileLanguage.test.ts b/src/core/changeset/fileLanguage.test.ts index 68ad00cbc..c6ce50abd 100644 --- a/src/core/changeset/fileLanguage.test.ts +++ b/src/core/changeset/fileLanguage.test.ts @@ -37,6 +37,18 @@ describe("custom file language registration", () => { expect(BUILT_IN_FILE_LANGUAGE_EXTENSIONS.has("ts")).toBe(false); }); + test("treats extension selectors only as dotted extensions", () => { + useTestFileLanguages({ + matcher: { kind: "extension", value: "hunklegacy" }, + language: "python", + }); + + expect(fileLanguageForPath("x.hunklegacy")).toBe("python"); + expect(fileLanguageForPath("nested/x.hunklegacy")).toBe("python"); + expect(fileLanguageForPath("hunklegacy")).toBe("text"); + expect(fileLanguageForPath("nested/hunklegacy")).toBe("text"); + }); + test("matches exact filenames at any path depth with stable casing", () => { useTestFileLanguages({ matcher: { kind: "filename", value: "Hunkfile" }, From f661371438ec9097f2fcef4a178eef080ac1a12e Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 25 Aug 2026 20:06:17 -0400 Subject: [PATCH 4/5] fix(extensions): preserve exact language matchers --- docs/extensions.md | 8 +++-- src/core/changeset/fileLanguage.test.ts | 22 ++++++++++--- src/core/changeset/loaders.test.ts | 6 +++- src/extensions/publicApiRobustness.test.ts | 32 ++++++++++++++++--- src/extensions/runExtension.ts | 12 +++---- .../content/docs/docs/extend/extension-api.md | 4 +-- 6 files changed, 62 insertions(+), 22 deletions(-) diff --git a/docs/extensions.md b/docs/extensions.md index e8b2357f2..90d67b83a 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -328,7 +328,8 @@ selector after config themes, in load order. ### `hunk.registerFileLanguage(matcher, language)` Map file extensions, exact filenames, or globs to an existing syntax-highlighting language. The -string shorthand registers a case-insensitive extension with or without its leading dot: +string shorthand registers a case-insensitive extension with or without its leading dot. Explicit +extension matchers use the same trimming, leading-dot removal, and lowercasing: ```ts hunk.registerFileLanguage(".zig", "zig"); @@ -344,8 +345,9 @@ hunk.registerFileLanguage({ kind: "glob", value: "*.component", target: "basenam Filename and glob matching is case-sensitive on every platform. Exact filenames match a basename at any directory depth. Globs use Bun's shell-style glob syntax and must explicitly target either the basename or the review path exactly as Hunk decoded it. `/` is the review-path separator; -backslashes remain literal filename characters. VCS review paths are normally repo-relative, while -generic patch input may carry an absolute path. +backslashes remain literal filename characters. Exact filename and glob values preserve leading and +trailing whitespace. VCS review paths are normally repo-relative, while generic patch input may +carry an absolute path. Hunk's reserved `.mts` and `.cts` mappings run first and cannot be overridden. Otherwise, exact filenames take precedence over globs, which take precedence over extensions. The longest matching diff --git a/src/core/changeset/fileLanguage.test.ts b/src/core/changeset/fileLanguage.test.ts index c6ce50abd..c24011939 100644 --- a/src/core/changeset/fileLanguage.test.ts +++ b/src/core/changeset/fileLanguage.test.ts @@ -49,15 +49,27 @@ describe("custom file language registration", () => { expect(fileLanguageForPath("nested/hunklegacy")).toBe("text"); }); - test("matches exact filenames at any path depth with stable casing", () => { - useTestFileLanguages({ - matcher: { kind: "filename", value: "Hunkfile" }, - language: "python", - }); + test("matches exact filenames at any path depth without altering their characters", () => { + useTestFileLanguages( + { + matcher: { kind: "filename", value: "Hunkfile" }, + language: "python", + }, + { + matcher: { kind: "filename", value: " Tool\\Hunkfile " }, + language: "ruby", + }, + { + matcher: { kind: "filename", value: " " }, + language: "ruby", + }, + ); expect(fileLanguageForPath("Hunkfile")).toBe("python"); expect(fileLanguageForPath("tools/Hunkfile")).toBe("python"); expect(fileLanguageForPath("tools/hunkfile")).toBe("text"); + expect(fileLanguageForPath("nested/ Tool\\Hunkfile ")).toBe("ruby"); + expect(fileLanguageForPath("nested/ ")).toBe("ruby"); // Review paths use `/`; a backslash remains a legal filename character on POSIX. expect(fileLanguageForPath("tools\\nested\\Hunkfile")).toBe("text"); }); diff --git a/src/core/changeset/loaders.test.ts b/src/core/changeset/loaders.test.ts index fb8af3b42..a17bcc4be 100644 --- a/src/core/changeset/loaders.test.ts +++ b/src/core/changeset/loaders.test.ts @@ -1686,6 +1686,10 @@ describe("loadAppBootstrap", () => { matcher: { kind: "filename", value: "Hunkfile" }, language: "python", }, + { + matcher: { kind: "filename", value: "tools\\Hunkfile" }, + language: "ruby", + }, ]); const escapedPath = String.raw`tools\\Hunkfile`; const bootstrap = await loadAppBootstrap({ @@ -1702,7 +1706,7 @@ describe("loadAppBootstrap", () => { }); expect(bootstrap.changeset.files[0]?.path).toBe("tools\\Hunkfile"); - expect(bootstrap.changeset.files[0]?.language).toBe("text"); + expect(bootstrap.changeset.files[0]?.language).toBe("ruby"); }); test("preserves trailing control characters in exact Git-quoted paths", async () => { diff --git a/src/extensions/publicApiRobustness.test.ts b/src/extensions/publicApiRobustness.test.ts index aeaf3b42d..8c32e0b97 100644 --- a/src/extensions/publicApiRobustness.test.ts +++ b/src/extensions/publicApiRobustness.test.ts @@ -6,6 +6,7 @@ import { getBundledVcsCatalog } from "../app/vcsCatalog"; import { createTestDiffFile } from "../../test/helpers/diff-helpers"; import { applyExtensionChangesetTransforms, + applyExtensionFileLanguages, applyExtensionRegistrations, resolveDetectedVcsIdWithExtensions, resolveSessionVcsId, @@ -177,24 +178,45 @@ describe("registerFileLanguage with junk", () => { ]); }); - test("accepts exact filenames and explicit basename or path globs", () => { + test("preserves exact filename and glob values through matching", async () => { + const { fileLanguageForPath } = await import("../core/changeset/fileLanguageLookup"); const { registry, issues } = loadFactory( (hunk: { registerFileLanguage: (matcher: unknown, language: string) => void }) => { - hunk.registerFileLanguage({ kind: "filename", value: "Hunkfile" }, "python"); - hunk.registerFileLanguage({ kind: "glob", value: "*.hunk", target: "basename" }, "ruby"); + hunk.registerFileLanguage({ kind: "filename", value: " Tool\\Hunkfile " }, "python"); + hunk.registerFileLanguage({ kind: "filename", value: " " }, "python"); + hunk.registerFileLanguage({ kind: "glob", value: "*.hunk ", target: "basename" }, "ruby"); + hunk.registerFileLanguage({ kind: "glob", value: " ", target: "basename" }, "ruby"); + hunk.registerFileLanguage( + { kind: "glob", value: " *\\\\name ", target: "basename" }, + "ruby", + ); hunk.registerFileLanguage( { kind: "glob", value: "generated/**/*.ts", target: "path" }, "typescript", ); + hunk.registerFileLanguage({ kind: "extension", value: " .HunkExact " }, "typescript"); }, ); expect(issues).toEqual([]); expect(registry.fileLanguages.map((entry) => entry.matcher)).toEqual([ - { kind: "filename", value: "Hunkfile" }, - { kind: "glob", value: "*.hunk", target: "basename" }, + { kind: "filename", value: " Tool\\Hunkfile " }, + { kind: "filename", value: " " }, + { kind: "glob", value: "*.hunk ", target: "basename" }, + { kind: "glob", value: " ", target: "basename" }, + { kind: "glob", value: " *\\\\name ", target: "basename" }, { kind: "glob", value: "generated/**/*.ts", target: "path" }, + { kind: "extension", value: "hunkexact" }, ]); + + expect(applyExtensionFileLanguages(registry)).toEqual([]); + expect(fileLanguageForPath("nested/ Tool\\Hunkfile ")).toBe("python"); + expect(fileLanguageForPath("nested/ ")).toBe("python"); + expect(fileLanguageForPath("nested/example.hunk ")).toBe("ruby"); + expect(fileLanguageForPath("nested/ ")).toBe("ruby"); + expect(fileLanguageForPath("nested/ x\\name ")).toBe("ruby"); + expect(fileLanguageForPath("generated/nested/example.ts")).toBe("typescript"); + expect(fileLanguageForPath("nested/example.hunkexact")).toBe("typescript"); }); test("refuses malformed matcher objects", () => { diff --git a/src/extensions/runExtension.ts b/src/extensions/runExtension.ts index 5eb5d59a4..e561e8e0b 100644 --- a/src/extensions/runExtension.ts +++ b/src/extensions/runExtension.ts @@ -70,17 +70,17 @@ function normalizeFileLanguageMatcher(matcher: unknown): ExtensionFileLanguageMa throw new Error("registerFileLanguage requires an extension string or matcher object."); } - const value = assertNonEmptyString( - matcher.value, - "registerFileLanguage matcher value must be a non-empty string.", - ).trim(); + if (typeof matcher.value !== "string" || matcher.value.length === 0) { + throw new Error("registerFileLanguage matcher value must be a non-empty string."); + } + const value = matcher.value; if (matcher.kind === "extension") { return { kind: "extension", value: normalizeFileExtension(value) }; } if (matcher.kind === "filename") { - if (value.includes("/") || value.includes("\\")) { - throw new Error("registerFileLanguage filename matchers cannot contain path separators."); + if (value.includes("/")) { + throw new Error("registerFileLanguage filename matchers cannot contain `/`."); } return { kind: "filename", value }; } diff --git a/website/src/content/docs/docs/extend/extension-api.md b/website/src/content/docs/docs/extend/extension-api.md index b5c08f776..b1d93a208 100644 --- a/website/src/content/docs/docs/extend/extension-api.md +++ b/website/src/content/docs/docs/extend/extension-api.md @@ -40,7 +40,7 @@ Theme ids are lowercase words separated by `-` or `_` and cannot reuse a built-i ## `hunk.registerFileLanguage(matcher, language)` -Map an extension, exact filename, or glob to an existing syntax-highlighting language. A string remains shorthand for a case-insensitive extension: +Map an extension, exact filename, or glob to an existing syntax-highlighting language. A string remains shorthand for a case-insensitive extension; object-form extension values receive the same trimming, leading-dot removal, and lowercasing: ```ts hunk.registerFileLanguage(".zig", "zig"); @@ -51,7 +51,7 @@ hunk.registerFileLanguage( ); ``` -Filename and glob matching is case-sensitive. Filename selectors match at any directory depth; globs explicitly target the basename or review path exactly as decoded. `/` is the path separator and backslashes stay literal. VCS review paths are normally repo-relative, while generic patches may carry absolute paths. Hunk's reserved `.mts` and `.cts` mappings run first and cannot be overridden. Otherwise, exact filenames take precedence over globs, then extensions. Later registrations win ties. +Filename and glob matching is case-sensitive. Filename selectors match at any directory depth; globs explicitly target the basename or review path exactly as decoded. `/` is the path separator, backslashes stay literal, and filename/glob whitespace is preserved. VCS review paths are normally repo-relative, while generic patches may carry absolute paths. Hunk's reserved `.mts` and `.cts` mappings run first and cannot be overridden. Otherwise, exact filenames take precedence over globs, then extensions. Later registrations win ties. This selects a grammar already available to Pierre/Shiki; it does not load a new syntax grammar. From a1b5fe1e44633fe3737e87cdef4d4f88973b761c Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 25 Aug 2026 23:19:51 -0400 Subject: [PATCH 5/5] fix(core): keep glob matching platform-neutral --- docs/extensions.md | 5 ++-- src/core/changeset/fileLanguage.test.ts | 28 +++++++++++++++++++ src/core/changeset/fileLanguageLookup.ts | 21 ++++++++++++-- src/extensions/publicApiRobustness.test.ts | 12 ++++---- src/extensions/runExtension.ts | 3 ++ .../content/docs/docs/extend/extension-api.md | 2 +- 6 files changed, 61 insertions(+), 10 deletions(-) diff --git a/docs/extensions.md b/docs/extensions.md index 90d67b83a..78e2ab81d 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -346,8 +346,9 @@ Filename and glob matching is case-sensitive on every platform. Exact filenames at any directory depth. Globs use Bun's shell-style glob syntax and must explicitly target either the basename or the review path exactly as Hunk decoded it. `/` is the review-path separator; backslashes remain literal filename characters. Exact filename and glob values preserve leading and -trailing whitespace. VCS review paths are normally repo-relative, while generic patch input may -carry an absolute path. +trailing whitespace. Globs reject NUL and do not run against NUL-bearing decoded patch paths; exact +filename selectors can still address those paths. VCS review paths are normally repo-relative, +while generic patch input may carry an absolute path. Hunk's reserved `.mts` and `.cts` mappings run first and cannot be overridden. Otherwise, exact filenames take precedence over globs, which take precedence over extensions. The longest matching diff --git a/src/core/changeset/fileLanguage.test.ts b/src/core/changeset/fileLanguage.test.ts index c24011939..956e0f490 100644 --- a/src/core/changeset/fileLanguage.test.ts +++ b/src/core/changeset/fileLanguage.test.ts @@ -63,6 +63,10 @@ describe("custom file language registration", () => { matcher: { kind: "filename", value: " " }, language: "ruby", }, + { + matcher: { kind: "filename", value: "zero\0name" }, + language: "ruby", + }, ); expect(fileLanguageForPath("Hunkfile")).toBe("python"); @@ -70,6 +74,7 @@ describe("custom file language registration", () => { expect(fileLanguageForPath("tools/hunkfile")).toBe("text"); expect(fileLanguageForPath("nested/ Tool\\Hunkfile ")).toBe("ruby"); expect(fileLanguageForPath("nested/ ")).toBe("ruby"); + expect(fileLanguageForPath("nested/zero\0name")).toBe("ruby"); // Review paths use `/`; a backslash remains a legal filename character on POSIX. expect(fileLanguageForPath("tools\\nested\\Hunkfile")).toBe("text"); }); @@ -93,6 +98,29 @@ describe("custom file language registration", () => { expect(fileLanguageForPath("source/example.hunkpath")).toBe("text"); }); + test("treats literal backslash as one glob character and excludes decoded NUL paths", () => { + useTestFileLanguages( + { + matcher: { kind: "glob", value: "foo?bar", target: "basename" }, + language: "python", + }, + { + matcher: { kind: "glob", value: "foo??bar", target: "basename" }, + language: "ruby", + }, + ); + expect(fileLanguageForPath("foo\\bar")).toBe("python"); + expect(fileLanguageForPath("fooXYbar")).toBe("ruby"); + expect(fileLanguageForPath("foo\0bar")).toBe("text"); + + useTestFileLanguages({ + matcher: { kind: "glob", value: "foo[\\]bar", target: "basename" }, + language: "ruby", + }); + expect(fileLanguageForPath("foo\\bar")).toBe("ruby"); + expect(fileLanguageForPath("fooXbar")).toBe("text"); + }); + test("prefers filenames, then globs, then the longest extension", () => { useTestFileLanguages( { diff --git a/src/core/changeset/fileLanguageLookup.ts b/src/core/changeset/fileLanguageLookup.ts index a85db715f..46d04812a 100644 --- a/src/core/changeset/fileLanguageLookup.ts +++ b/src/core/changeset/fileLanguageLookup.ts @@ -16,6 +16,15 @@ interface AppliedFileLanguageRegistration extends FileLanguageRegistration { let appliedRegistrationVersion = -1; let appliedFileLanguages: AppliedFileLanguageRegistration[] = []; +// NUL cannot occur in a filesystem path and counts as one code unit, so it keeps Bun.Glob from +// interpreting a literal backslash as a Windows separator without changing `?` or class width. +const GLOB_LITERAL_BACKSLASH = "\0"; + +/** Encode literal backslashes before handing a review path or pattern to platform-aware Bun.Glob. */ +function encodeGlobBackslashes(value: string): string { + return value.replaceAll("\\", GLOB_LITERAL_BACKSLASH); +} + /** Compile the current selector set once per registration version. */ function applyCurrentFileLanguages(): void { const snapshot = fileLanguageRegistrationSnapshot(); @@ -26,7 +35,9 @@ function applyCurrentFileLanguages(): void { appliedFileLanguages = snapshot.registrations.map((registration) => ({ ...registration, glob: - registration.matcher.kind === "glob" ? new Bun.Glob(registration.matcher.value) : undefined, + registration.matcher.kind === "glob" && !registration.matcher.value.includes("\0") + ? new Bun.Glob(encodeGlobBackslashes(registration.matcher.value)) + : undefined, })); appliedRegistrationVersion = snapshot.version; } @@ -49,13 +60,19 @@ function filenameLanguage(basename: string): string | undefined { /** Find the latest matching glob registration. */ function globLanguage(path: string, basename: string): string | undefined { + // Decoded external patches may contain NUL even though filesystems cannot. Excluding those paths + // keeps the one-code-unit backslash encoding collision-free; exact filename selectors still work. + if (path.includes("\0")) { + return undefined; + } + for (let index = appliedFileLanguages.length - 1; index >= 0; index -= 1) { const registration = appliedFileLanguages[index]!; if (registration.matcher.kind !== "glob") { continue; } const candidate = registration.matcher.target === "path" ? path : basename; - if (registration.glob?.match(candidate)) { + if (registration.glob?.match(encodeGlobBackslashes(candidate))) { return registration.language; } } diff --git a/src/extensions/publicApiRobustness.test.ts b/src/extensions/publicApiRobustness.test.ts index 8c32e0b97..7ff7b09ce 100644 --- a/src/extensions/publicApiRobustness.test.ts +++ b/src/extensions/publicApiRobustness.test.ts @@ -186,10 +186,8 @@ describe("registerFileLanguage with junk", () => { hunk.registerFileLanguage({ kind: "filename", value: " " }, "python"); hunk.registerFileLanguage({ kind: "glob", value: "*.hunk ", target: "basename" }, "ruby"); hunk.registerFileLanguage({ kind: "glob", value: " ", target: "basename" }, "ruby"); - hunk.registerFileLanguage( - { kind: "glob", value: " *\\\\name ", target: "basename" }, - "ruby", - ); + hunk.registerFileLanguage({ kind: "glob", value: " *\\name ", target: "basename" }, "ruby"); + hunk.registerFileLanguage({ kind: "glob", value: "foo?bar", target: "basename" }, "json"); hunk.registerFileLanguage( { kind: "glob", value: "generated/**/*.ts", target: "path" }, "typescript", @@ -204,7 +202,8 @@ describe("registerFileLanguage with junk", () => { { kind: "filename", value: " " }, { kind: "glob", value: "*.hunk ", target: "basename" }, { kind: "glob", value: " ", target: "basename" }, - { kind: "glob", value: " *\\\\name ", target: "basename" }, + { kind: "glob", value: " *\\name ", target: "basename" }, + { kind: "glob", value: "foo?bar", target: "basename" }, { kind: "glob", value: "generated/**/*.ts", target: "path" }, { kind: "extension", value: "hunkexact" }, ]); @@ -215,6 +214,8 @@ describe("registerFileLanguage with junk", () => { expect(fileLanguageForPath("nested/example.hunk ")).toBe("ruby"); expect(fileLanguageForPath("nested/ ")).toBe("ruby"); expect(fileLanguageForPath("nested/ x\\name ")).toBe("ruby"); + expect(fileLanguageForPath("nested/foo\\bar")).toBe("json"); + expect(fileLanguageForPath("nested/foo\0bar")).toBe("text"); expect(fileLanguageForPath("generated/nested/example.ts")).toBe("typescript"); expect(fileLanguageForPath("nested/example.hunkexact")).toBe("typescript"); }); @@ -225,6 +226,7 @@ describe("registerFileLanguage with junk", () => { { kind: "filename", value: "path/Hunkfile" }, { kind: "glob", value: "*.ts" }, { kind: "glob", value: "*.ts", target: "somewhere" }, + { kind: "glob", value: "*\0name", target: "basename" }, { kind: "regex", value: ".*" }, /.*\.ts/, ]) { diff --git a/src/extensions/runExtension.ts b/src/extensions/runExtension.ts index e561e8e0b..66dfc9dca 100644 --- a/src/extensions/runExtension.ts +++ b/src/extensions/runExtension.ts @@ -88,6 +88,9 @@ function normalizeFileLanguageMatcher(matcher: unknown): ExtensionFileLanguageMa if (matcher.target !== "basename" && matcher.target !== "path") { throw new Error('registerFileLanguage glob target must be "basename" or "path".'); } + if (value.includes("\0")) { + throw new Error("registerFileLanguage glob matchers cannot contain NUL."); + } // Construct once during loading so any runtime rejection still rolls the factory back cleanly. new Bun.Glob(value); return { kind: "glob", value, target: matcher.target }; diff --git a/website/src/content/docs/docs/extend/extension-api.md b/website/src/content/docs/docs/extend/extension-api.md index b1d93a208..629e67a02 100644 --- a/website/src/content/docs/docs/extend/extension-api.md +++ b/website/src/content/docs/docs/extend/extension-api.md @@ -51,7 +51,7 @@ hunk.registerFileLanguage( ); ``` -Filename and glob matching is case-sensitive. Filename selectors match at any directory depth; globs explicitly target the basename or review path exactly as decoded. `/` is the path separator, backslashes stay literal, and filename/glob whitespace is preserved. VCS review paths are normally repo-relative, while generic patches may carry absolute paths. Hunk's reserved `.mts` and `.cts` mappings run first and cannot be overridden. Otherwise, exact filenames take precedence over globs, then extensions. Later registrations win ties. +Filename and glob matching is case-sensitive. Filename selectors match at any directory depth; globs explicitly target the basename or review path exactly as decoded. `/` is the path separator, backslashes stay literal, and filename/glob whitespace is preserved. Globs reject NUL and skip NUL-bearing decoded patch paths; exact filenames can still match them. VCS review paths are normally repo-relative, while generic patches may carry absolute paths. Hunk's reserved `.mts` and `.cts` mappings run first and cannot be overridden. Otherwise, exact filenames take precedence over globs, then extensions. Later registrations win ties. This selects a grammar already available to Pierre/Shiki; it does not load a new syntax grammar.