Skip to content
Merged
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/file-language-selectors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Let extensions select syntax highlighting by exact filename or basename/path glob, in addition to file extensions.
8 changes: 6 additions & 2 deletions docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 30 additions & 9 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -324,18 +325,38 @@ 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. Explicit
extension matchers use the same trimming, leading-dot removal, and lowercasing:

```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 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. 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
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)`

Expand Down
7 changes: 7 additions & 0 deletions scripts/check-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
ExtensionChangeset,
ExtensionCommandControls,
ExtensionCommandExecutionOptions,
ExtensionFileLanguageMatcher,
ExtensionFileViewRow,
ExtensionFileViewRowComponentProps,
ExtensionFileViewSourceRange,
Expand Down Expand Up @@ -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}\`);
Expand Down
4 changes: 2 additions & 2 deletions skills/hunk-extensions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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.<id>]` 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.
Expand Down
34 changes: 34 additions & 0 deletions src/app/sessionBootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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([]);
});
});
68 changes: 41 additions & 27 deletions src/app/sessionBootstrap.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<typeof collectSessionCustomThemes>;
sessionVcs: ReturnType<typeof resolveSessionVcsId>;
Expand All @@ -51,35 +58,42 @@ export async function loadConfiguredSessionBootstrap({
loadAppBootstrapImpl = loadAppBootstrap,
baseVcsCatalog = getBundledVcsCatalog(),
}: SessionBootstrapOptions): Promise<SessionBootstrapResult> {
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;
}
}
29 changes: 28 additions & 1 deletion src/core/changeset/diffFile.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 5 additions & 3 deletions src/core/changeset/diffFile.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading