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
1 change: 1 addition & 0 deletions packages/core/src/commands/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,4 +4,5 @@ export {
isValidCommandId,
type CommandRegistry,
type CommandRegistryDeps,
type RegisterLazyOptions,
} from "./registry";
85 changes: 85 additions & 0 deletions packages/core/src/commands/registry.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -297,3 +297,88 @@ test("HostLog.append clones the incoming error, isolating later caller mutations

expect(log.entries()[0]?.error.message).toBe("original");
});

// --- registerLazy / lazy commands (design.md §4.1, §5) ---------------------

test("registerLazy adds the command to list() with its meta, but no handler runs it yet", async () => {
const log = createHostLog();
const { sink } = createRecordingSink();
const registry = createCommandRegistry({ log, sink });

registry.registerLazy("demo.run", {
extensionId: "demo.ext",
meta: { title: "Run Demo", category: "Demo" },
});

expect(registry.list()).toEqual([
{ id: "demo.run", title: "Run Demo", category: "Demo", when: undefined },
]);
});

test("executing a lazy, not-yet-activated command reports a HostError and does not throw", async () => {
const log = createHostLog();
const { errors, sink } = createRecordingSink();
const registry = createCommandRegistry({ log, sink });

registry.registerLazy("demo.run", { extensionId: "demo.ext" });

const result = await registry.execute("demo.run", "arg");

expect(result).toBeUndefined();
expect(errors).toHaveLength(1);
expect(errors[0]?.message).toContain("demo.ext");
expect(errors[0]?.message.toLowerCase()).toContain("not activated yet");
expect(errors[0]?.extensionId).toBe("demo.ext");

const logged = log.entries();
expect(logged).toHaveLength(1);
expect(logged[0]?.level).toBe("warning");
});

test("register() over a lazy entry replaces it with a real handler that execute() then runs", async () => {
const log = createHostLog();
const { sink } = createRecordingSink();
const registry = createCommandRegistry({ log, sink });

registry.registerLazy("demo.run", { extensionId: "demo.ext" });
registry.register("demo.run", () => "activated!");

expect(await registry.execute("demo.run")).toBe("activated!");
});

test("registerLazy rejects command IDs that are not namespace.verb form", () => {
const log = createHostLog();
const { sink } = createRecordingSink();
const registry = createCommandRegistry({ log, sink });

expect(() => registry.registerLazy("save", { extensionId: "demo.ext" })).toThrow(TypeError);
});

test("registerLazy's Disposable removes the command, matching register()'s dispose semantics", async () => {
const log = createHostLog();
const { errors, sink } = createRecordingSink();
const registry = createCommandRegistry({ log, sink });

const registration = registry.registerLazy("demo.run", { extensionId: "demo.ext" });
registration.dispose();

const result = await registry.execute("demo.run");
expect(result).toBeUndefined();
expect(errors.at(-1)?.message).toBe("Command not found: demo.run");
});

test("registerLazy twice for the same ID logs a re-registration warning (last-wins)", async () => {
const log = createHostLog();
const { sink } = createRecordingSink();
const registry = createCommandRegistry({ log, sink });

registry.registerLazy("demo.run", { extensionId: "first.ext" });
registry.registerLazy("demo.run", { extensionId: "second.ext" });

const result = await registry.execute("demo.run");
expect(result).toBeUndefined();

const warnings = log.entries().filter((e) => e.level === "warning");
// One for the re-registration, one for the not-activated-yet report.
expect(warnings.some((w) => w.error.message.includes("re-registered"))).toBe(true);
});
101 changes: 83 additions & 18 deletions packages/core/src/commands/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,14 @@
* bindings, the palette, UI callbacks, extension-to-extension calls — goes
* through `execute` rather than a direct function call (Req 1.5).
*
* Lazy (manifest-declared, not-yet-activated) commands are out of scope
* here — they arrive with the extension host task (design.md §4.1's
* "lazy commands"); `CommandEntry` therefore carries no `lazy` flag.
* Lazy (manifest-declared, not-yet-activated) commands (design.md §4.1's
* "lazy commands", §5's `CommandEntry = { handler?, meta, extensionId?,
* lazy }`) are registered via {@link CommandRegistry.registerLazy} by the
* extension host (`host/registration.ts`) with no `handler` yet — real
* activation (calling the owning extension's `activate(ctx)` on first
* execution) is Task 1.12; until then, executing a lazy command reports a
* "not activated yet" `HostError` through `log`/`sink` rather than
* throwing or silently no-op'ing.
*/

import type {
Expand All@@ -17,10 +22,23 @@ import type {
} from "@tecode/api";
import type { HostError, HostLog, StatusSink } from "../host/errors";

/** Internal registry state for one registered command. */
/** Internal registry state for one registered command (design.md §5).
* `handler` is absent for a lazy (manifest-declared, not-yet-activated)
* command; `extensionId` is set only for lazy entries — a plain
* `register()` call has no extension attribution. */
interface CommandEntry {
handler: CommandHandler;
handler?: CommandHandler;
meta: CommandMeta;
extensionId?: string;
lazy: boolean;
}

/** Options for {@link CommandRegistry.registerLazy}. */
export interface RegisterLazyOptions {
/** The extension whose `index.ts` owns this command, activated on first
* `execute()` once Task 1.12 wires real activation. */
extensionId: string;
meta?: CommandMeta;
}

/** Dependencies a {@link createCommandRegistry} instance reports through
Expand All@@ -34,9 +52,21 @@ export interface CommandRegistryDeps {
}

/** The public shape of the command registry — the implementation behind
* `tecode.commands` (Req 10.1). */
* `tecode.commands` (Req 10.1), plus `registerLazy` (design.md §4.1),
* which is host-internal (extensions never call it directly; the `tecode`
* API surface handed to extensions exposes only `register`). */
export interface CommandRegistry {
register(id: string, handler: CommandHandler, meta?: CommandMeta): Disposable;
/**
* Register a command declared in a manifest's `contributes.commands`
* without a handler yet (design.md §4.1, §5): the command appears in
* {@link list} and can be looked up by keybindings/the palette
* immediately, but `execute`-ing it before the owning extension has
* activated reports a "not activated yet" error rather than running
* anything. Same last-wins/duplicate-warning/`Disposable` semantics as
* {@link register}.
*/
registerLazy(id: string, options: RegisterLazyOptions): Disposable;
execute(id: string, ...args: unknown[]): Promise<unknown>;
list(): CommandDescriptor[];
}
Expand DownExpand Up@@ -91,22 +121,18 @@ export function createCommandRegistry(deps: CommandRegistryDeps): CommandRegistr
}
}

function register(
id: string,
handler: CommandHandler,
meta: CommandMeta = {},
): Disposable {
if (!isValidCommandId(id)) {
throw new TypeError(
`Invalid command ID "${id}": expected namespace.verb form (Req 3.2)`,
);
}
/** Shared last-wins storage behind both {@link register} and
* {@link registerLazy}: warns on an existing entry under `id`, stores
* `entry`, and returns the identity-checked `Disposable` common to both
* (mirrors the entry-identity comparison design.md §5 relies on so a
* stale handle from a superseded registration never removes a newer
* one). */
function storeEntry(id: string, entry: CommandEntry): Disposable {
if (commands.has(id)) {
logSafely("warning", {
message: `Command re-registered, replacing previous handler: ${id}`,
});
}
const entry: CommandEntry = { handler, meta };
commands.set(id, entry);

let disposed = false;
Expand All@@ -124,13 +150,52 @@ export function createCommandRegistry(deps: CommandRegistryDeps): CommandRegistr
};
}

function register(
id: string,
handler: CommandHandler,
meta: CommandMeta = {},
): Disposable {
if (!isValidCommandId(id)) {
throw new TypeError(
`Invalid command ID "${id}": expected namespace.verb form (Req 3.2)`,
);
}
return storeEntry(id, { handler, meta, lazy: false });
}

function registerLazy(id: string, options: RegisterLazyOptions): Disposable {
if (!isValidCommandId(id)) {
throw new TypeError(
`Invalid command ID "${id}": expected namespace.verb form (Req 3.2)`,
);
}
return storeEntry(id, {
meta: options.meta ?? {},
extensionId: options.extensionId,
lazy: true,
});
}

async function execute(id: string, ...args: unknown[]): Promise<unknown> {
const entry = commands.get(id);
if (!entry) {
const err: HostError = { message: `Command not found: ${id}` };
notifySafely(err);
return undefined;
}
if (!entry.handler) {
// Lazy, not-yet-activated command (design.md §4.1) — real activation
// is Task 1.12; for now report and stop, never throw.
const err: HostError = {
message:
`Command "${id}" belongs to extension "${entry.extensionId ?? "unknown"}", ` +
`which has not activated yet`,
extensionId: entry.extensionId,
};
logSafely("warning", err);
notifySafely(err);
return undefined;
}
try {
return await entry.handler(...args);
} catch (cause: unknown) {
Expand All@@ -152,5 +217,5 @@ export function createCommandRegistry(deps: CommandRegistryDeps): CommandRegistr
}));
}

return { register, execute, list };
return { register, registerLazy, execute, list };
}
Loading