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
10 changes: 10 additions & 0 deletions packages/core/src/commands/registry.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -287,3 +287,13 @@ test("HostLog.entries returns a snapshot, not the internal records", () => {

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

test("HostLog.append clones the incoming error, isolating later caller mutations", () => {
const log = createHostLog();
const err: HostError = { message: "original" };
log.append("error", err);

err.message = "mutated by caller";

expect(log.entries()[0]?.error.message).toBe("original");
});
4 changes: 3 additions & 1 deletion packages/core/src/host/errors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,9 @@ export function createHostLog(): HostLog {
const records: HostLogEntry[] = [];
return {
append(level, error) {
records.push({ level, error });
// Clone on the way in as well: a caller mutating the error object it
// passed must not rewrite the stored record (append-only contract).
records.push({ level, error: { ...error } });
},
entries() {
// Snapshot: cloning each entry (and its error) keeps the log
Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,20 @@ export {
type CommandRegistry,
type CommandRegistryDeps,
} from "./commands/index";
export { KEYMAP_PLACEHOLDER } from "./keymap/index";
export {
compileWhen,
createContextService,
WhenParseError,
type CompiledWhen,
type ContextService,
type WhenAndNode,
type WhenContextGetter,
type WhenEqNode,
type WhenKeyNode,
type WhenNode,
type WhenNotNode,
type WhenOrNode,
} from "./keymap/index";
export { BUFFER_PLACEHOLDER } from "./buffer/index";
export { UI_PLACEHOLDER } from "./ui/index";
export { CONFIG_PLACEHOLDER } from "./config/index";
Expand Down
119 changes: 119 additions & 0 deletions packages/core/src/keymap/context.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
import { expect, test } from "bun:test";
import { createContextService } from "./context";

test("set then get round-trips the value", () => {
const context = createContextService();

context.set("editorLangId", "ts");

expect(context.get<string>("editorLangId")).toBe("ts");
});

test("get on an unset key returns undefined", () => {
const context = createContextService();

expect(context.get("neverSet")).toBeUndefined();
});

test("set fires onDidChange with the changed key", () => {
const context = createContextService();
const seen: string[] = [];
context.onDidChange((key) => seen.push(key));

context.set("editorFocus", true);

expect(seen).toEqual(["editorFocus"]);
});

test("setting a genuinely different value fires again with the same key", () => {
const context = createContextService();
const seen: string[] = [];
context.onDidChange((key) => seen.push(key));

context.set("editorLangId", "ts");
context.set("editorLangId", "js");

expect(seen).toEqual(["editorLangId", "editorLangId"]);
expect(context.get<string>("editorLangId")).toBe("js");
});

test("setting an identical value does not fire onDidChange", () => {
const context = createContextService();
context.set("editorFocus", true);

const seen: string[] = [];
context.onDidChange((key) => seen.push(key));
context.set("editorFocus", true);

expect(seen).toEqual([]);
});

test("setting the same NaN value twice does not fire (Object.is semantics)", () => {
const context = createContextService();
context.set("metric", NaN);

const seen: string[] = [];
context.onDidChange((key) => seen.push(key));
context.set("metric", NaN);

expect(seen).toEqual([]);
});

test("dispose stops the listener from receiving further changes", () => {
const context = createContextService();
const seen: string[] = [];
const subscription = context.onDidChange((key) => seen.push(key));

context.set("a", 1);
subscription.dispose();
context.set("a", 2);

expect(seen).toEqual(["a"]);
expect(context.get<number>("a")).toBe(2);
});

test("double-dispose is a no-op", () => {
const context = createContextService();
const subscription = context.onDidChange(() => undefined);

subscription.dispose();
expect(() => subscription.dispose()).not.toThrow();
});

test("multiple listeners each receive the change", () => {
const context = createContextService();
const seenA: string[] = [];
const seenB: string[] = [];
context.onDidChange((key) => seenA.push(key));
context.onDidChange((key) => seenB.push(key));

context.set("explorerFocus", true);

expect(seenA).toEqual(["explorerFocus"]);
expect(seenB).toEqual(["explorerFocus"]);
});

test("distinct keys are stored independently", () => {
const context = createContextService();

context.set("editorFocus", true);
context.set("explorerFocus", false);

expect(context.get<boolean>("editorFocus")).toBe(true);
expect(context.get<boolean>("explorerFocus")).toBe(false);
});

test("a throwing listener does not stop other listeners or break set", () => {
const context = createContextService();
const seen: string[] = [];
context.onDidChange(() => {
throw new Error("bad listener");
});
context.onDidChange((key) => {
seen.push(key);
});

expect(() => context.set("editorFocus", true)).not.toThrow();
expect(seen).toEqual(["editorFocus"]);
expect(context.get<boolean>("editorFocus")).toBe(true);
});
71 changes: 71 additions & 0 deletions packages/core/src/keymap/context.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
/**
* The context service (Req 4.6, design.md §6.4): a flat key/value store
* that `when` clauses ({@link WhenContextGetter}) read from. Built as a
* factory function — `createContextService()` — rather than a class, to
* match the rest of core (`createCommandRegistry`, `createHostLog`).
*
* Core sets keys like `editorFocus`, `editorTextFocus`, `editorLangId`,
* and focus-tracking keys as focus moves; extensions set their own (e.g.
* `explorerFocus`) through `tecode.context.set`.
*/

import type { ContextNamespace, Disposable, Event, Listener } from "@tecode/api";

/**
* The context service's internal shape: `set`/`get` are exactly
* `tecode.context` ({@link ContextNamespace}); `onDidChange` is exposed
* only to internal consumers (focus tracking, the palette, the keymap
* service's binding re-evaluation) — it is not part of the public
* `ContextNamespace` surface extensions see. Because `ContextService`
* extends `ContextNamespace`, the public projection is just picking
* `{ set, get }` off of it; no separate wrapper object is needed.
*/
export interface ContextService extends ContextNamespace {
/** Fires with the key that changed whenever `set` actually changes its
* value (design.md §6.4). Setting a key to a value it already holds
* does not fire. */
onDidChange: Event<string>;
}

/** Build a context service (Req 4.6). Backed by a single
* `Map<string, unknown>` — no per-namespace nesting, no schema. */
export function createContextService(): ContextService {
const store = new Map<string, unknown>();
const listeners = new Set<Listener<string>>();

function get<T = unknown>(key: string): T | undefined {
return store.get(key) as T | undefined;
}

function set(key: string, value: unknown): void {
const previous = store.get(key);
// Object.is (not ===) so re-setting NaN to NaN is correctly treated
// as "unchanged" rather than spuriously firing a change event.
if (Object.is(previous, value)) return;
store.set(key, value);
// Snapshot before iterating: a listener that disposes itself (or
// another listener) during the loop must not perturb this dispatch.
for (const listener of Array.from(listeners)) {
try {
listener(key);
} catch {
// Isolate listener failures: one throwing listener must not stop
// the remaining listeners or propagate out of set().
}
}
}

function onDidChange(listener: Listener<string>): Disposable {
listeners.add(listener);
let disposed = false;
return {
dispose() {
if (disposed) return;
disposed = true;
listeners.delete(listener);
},
};
}

return { get, set, onDidChange };
}
19 changes: 16 additions & 3 deletions packages/core/src/keymap/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
// Placeholder for the keymap service (key event pipeline, chord state
// machine, when-clause evaluator).
export const KEYMAP_PLACEHOLDER = true;
// The keymap service (design.md §6): the when-clause evaluator (§6.4) and
// context service land here first; the input pipeline, resolution model
// (§6.2), and chord state machine (§6.3) are later tasks.
export {
compileWhen,
WhenParseError,
type CompiledWhen,
type WhenAndNode,
type WhenContextGetter,
type WhenEqNode,
type WhenKeyNode,
type WhenNode,
type WhenNotNode,
type WhenOrNode,
} from "./when";
export { createContextService, type ContextService } from "./context";
Loading