Refactor: extract synthetic-file binding (.prettierrc) out of LanguageRunner into a dedicated helper #82

Closed
opened 2026-05-06 05:16:59 +00:00 by spikerj · 1 comment
Owner

Surface

LanguageRunner (libraries/tools/src/components/language-runner/language-runner.ts) currently carries ~120 lines of .prettierrc-specific machinery for the Gitea #36 binding:

  • prettierStore injection + lastPrettierRcContent field (~10 lines)
  • Two effect() blocks for store↔file binding (~70 lines including comments)
  • isSyntheticFile() predicate (~3 lines + JSDoc)
  • upsertPrettierRcFile() private method (~25 lines + JSDoc)
  • Synthetic-file guards in startRename, deleteFile, setEntryFile (~3 sites × ~5 lines)
  • Dotfile filter in snapshotFiles() (~2 lines)

All of this is dead weight for non-JavaScript playgrounds (C#, Python, future languages) — the inject still runs, the predicate is still in scope, and any future config-file UI (.eslintrc, tsconfig.json, etc.) would balloon LanguageRunner again.

Suggested shape

@Injectable()
export class PrettierRcSyntheticFileBinding implements SyntheticFileBinding {
    readonly path = '.prettierrc';
    constructor(private readonly store: PrettierConfigStore) {}
    install(host: SyntheticFileHost): Disposable { /* effects + upsert */ }
}

export interface SyntheticFileHost {
    readonly mode: Signal<'sandbox' | 'lesson'>;
    readonly effectiveSandboxLevel: Signal<'beginner' | 'advanced'>;
    readonly files: WritableSignal<VirtualFile[]>;
    persistFilesProject(): void;
}

LanguageRunner becomes:

private readonly syntheticBindings = inject(SYNTHETIC_FILE_BINDINGS, { optional: true }) ?? [];

ngOnInit() {
    for (const binding of this.syntheticBindings) {
        binding.install(this).pipe(takeUntilDestroyed()).subscribe();
    }
}

The JS playground config provides [{ provide: SYNTHETIC_FILE_BINDINGS, multi: true, useClass: PrettierRcSyntheticFileBinding }]; other languages provide nothing and pay zero runtime cost.

The isSyntheticFile predicate stays in LanguageRunner (still used by template + guards), but the dotfile convention is now the only contract syntheticbindings need to honor — keeps the predicate generic.

Why now

Three triggers:

  1. Future config files: .eslintrc is the next obvious extension; tsconfig.json for TypeScript playgrounds eventually; learner-editable manifest files for advanced lesson tracks.
  2. Per-language opt-in cleanliness: Today's flag-based injectPrettierRc is a code smell — a feature owned by one language is wired through the universal config interface. DI multi-providers express the relationship more honestly.
  3. Test isolation: The current .prettierrc binding is hard to unit-test in isolation because it's tangled with LanguageRunner's mode/sandbox-level orchestration. A SyntheticFileBinding interface lets us write a prettier-rc-binding.spec.ts that mounts only the binding against a stub host.

Severity

Yellow — refactor, not a bug fix. The current code works correctly (modulo #-canonicalize-on-parse). This is about keeping LanguageRunner from becoming a god-object as more synthetic files arrive.

Acceptance

  • LanguageRunner has no .prettierrc-specific code (the injectPrettierRc flag also goes away).
  • A SyntheticFileBinding interface + DI multi-provider seam exists.
  • The JS playground provides PrettierRcSyntheticFileBinding; existing tests in language-runner-prettierrc.spec.ts continue to pass against the new shape.
  • A new unit test mounts the binding against a stub host without booting LanguageRunner.
## Surface `LanguageRunner` (`libraries/tools/src/components/language-runner/language-runner.ts`) currently carries ~120 lines of `.prettierrc`-specific machinery for the Gitea #36 binding: - `prettierStore` injection + `lastPrettierRcContent` field (~10 lines) - Two `effect()` blocks for store↔file binding (~70 lines including comments) - `isSyntheticFile()` predicate (~3 lines + JSDoc) - `upsertPrettierRcFile()` private method (~25 lines + JSDoc) - Synthetic-file guards in `startRename`, `deleteFile`, `setEntryFile` (~3 sites × ~5 lines) - Dotfile filter in `snapshotFiles()` (~2 lines) All of this is dead weight for non-JavaScript playgrounds (C#, Python, future languages) — the inject still runs, the predicate is still in scope, and any future config-file UI (`.eslintrc`, `tsconfig.json`, etc.) would balloon `LanguageRunner` again. ## Suggested shape ```ts @Injectable() export class PrettierRcSyntheticFileBinding implements SyntheticFileBinding { readonly path = '.prettierrc'; constructor(private readonly store: PrettierConfigStore) {} install(host: SyntheticFileHost): Disposable { /* effects + upsert */ } } export interface SyntheticFileHost { readonly mode: Signal<'sandbox' | 'lesson'>; readonly effectiveSandboxLevel: Signal<'beginner' | 'advanced'>; readonly files: WritableSignal<VirtualFile[]>; persistFilesProject(): void; } ``` `LanguageRunner` becomes: ```ts private readonly syntheticBindings = inject(SYNTHETIC_FILE_BINDINGS, { optional: true }) ?? []; ngOnInit() { for (const binding of this.syntheticBindings) { binding.install(this).pipe(takeUntilDestroyed()).subscribe(); } } ``` The JS playground config provides `[{ provide: SYNTHETIC_FILE_BINDINGS, multi: true, useClass: PrettierRcSyntheticFileBinding }]`; other languages provide nothing and pay zero runtime cost. The `isSyntheticFile` predicate stays in `LanguageRunner` (still used by template + guards), but the dotfile convention is now the only contract syntheticbindings need to honor — keeps the predicate generic. ## Why now Three triggers: 1. **Future config files**: `.eslintrc` is the next obvious extension; `tsconfig.json` for TypeScript playgrounds eventually; learner-editable manifest files for advanced lesson tracks. 2. **Per-language opt-in cleanliness**: Today's flag-based `injectPrettierRc` is a code smell — a feature owned by one language is wired through the universal config interface. DI multi-providers express the relationship more honestly. 3. **Test isolation**: The current `.prettierrc` binding is hard to unit-test in isolation because it's tangled with `LanguageRunner`'s mode/sandbox-level orchestration. A `SyntheticFileBinding` interface lets us write a `prettier-rc-binding.spec.ts` that mounts only the binding against a stub host. ## Severity Yellow — refactor, not a bug fix. The current code works correctly (modulo #-canonicalize-on-parse). This is about keeping `LanguageRunner` from becoming a god-object as more synthetic files arrive. ## Acceptance - `LanguageRunner` has no `.prettierrc`-specific code (the `injectPrettierRc` flag also goes away). - A `SyntheticFileBinding` interface + DI multi-provider seam exists. - The JS playground provides `PrettierRcSyntheticFileBinding`; existing tests in `language-runner-prettierrc.spec.ts` continue to pass against the new shape. - A new unit test mounts the binding against a stub host without booting `LanguageRunner`.
Author
Owner

Done — shipped in spikersoft-angular PR #59 (merged to master as beaba4b).

All acceptance criteria met:

  • LanguageRunner now carries zero .prettierrc-specific code; the injectPrettierRc config flag is removed.
  • New SyntheticFileBinding / SyntheticFileHost interfaces + SYNTHETIC_FILE_BINDINGS multi-provider token in platform/language-runner.
  • PrettierRcSyntheticFileBinding holds the bidirectional store↔file binding (including the #80 dynamic-dependency note and the #81 canonicalize-under-cursor fix); the JS shell opts in via { provide: SYNTHETIC_FILE_BINDINGS, useClass: PrettierRcSyntheticFileBinding, multi: true }. Other playgrounds provide nothing.
  • The generic dotfile isSyntheticFile predicate, the startRename/deleteFile/setEntryFile guards, and the snapshotFiles() filter stay in LanguageRunner.
  • language-runner-prettierrc.spec.ts updated to the new shape and stays green; a new prettier-rc-synthetic-file-binding.spec.ts mounts the binding in isolation against a stub host.

Deviation from the sketch: install(host) returns a { destroy() } disposer rather than an Observable/takeUntilDestroyed() flow — teardown maps directly onto Angular EffectRefs without an rxjs round-trip.

Verified: platform-language-runner 153 tests passing (incl. the #81 cases ported into the binding), feature-dev-tools-javascript-runner green, full workspace lint green.

Done — shipped in spikersoft-angular PR #59 (merged to `master` as `beaba4b`). **All acceptance criteria met:** - `LanguageRunner` now carries zero `.prettierrc`-specific code; the `injectPrettierRc` config flag is removed. - New `SyntheticFileBinding` / `SyntheticFileHost` interfaces + `SYNTHETIC_FILE_BINDINGS` multi-provider token in `platform/language-runner`. - `PrettierRcSyntheticFileBinding` holds the bidirectional store↔file binding (including the #80 dynamic-dependency note and the #81 canonicalize-under-cursor fix); the JS shell opts in via `{ provide: SYNTHETIC_FILE_BINDINGS, useClass: PrettierRcSyntheticFileBinding, multi: true }`. Other playgrounds provide nothing. - The generic dotfile `isSyntheticFile` predicate, the `startRename`/`deleteFile`/`setEntryFile` guards, and the `snapshotFiles()` filter stay in `LanguageRunner`. - `language-runner-prettierrc.spec.ts` updated to the new shape and stays green; a new `prettier-rc-synthetic-file-binding.spec.ts` mounts the binding in isolation against a stub host. **Deviation from the sketch:** `install(host)` returns a `{ destroy() }` disposer rather than an `Observable`/`takeUntilDestroyed()` flow — teardown maps directly onto Angular `EffectRef`s without an rxjs round-trip. Verified: `platform-language-runner` 153 tests passing (incl. the #81 cases ported into the binding), `feature-dev-tools-javascript-runner` green, full workspace lint green.
Sign in to join this conversation.