Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part 17 — Refactoring micro-DSL: rename, extract, inline, semantically safe

The Refactoring micro-DSL declares user-initiated structural transforms: rename a Concept and update every reference, extract an inline value into a separate declaration, inline a referenced value back into its callers. Each refactoring is a function from a target node and a transform parameter (the new name, the extraction location) to a sequence of PatchBus operations. Because the transforms run through the kernel's single mutation channel, semantics are preserved by construction: the workspace remains internally consistent at every step, undo/redo works through the standard channel, the EditLog records the transform atomically.

The boundary against Diagnostics' code actions is scope. Code actions repair one diagnostic at one location. Refactorings transform a structure across one or many files. The user's mental model captures the difference: "this thing is wrong, fix it" versus "I want to restructure".

Concern

The author wants to rename FEATURE-156 to FEATURE-156-REVISED. The rename should: change the id property of the canonical declaration, find every @ReferenceLink('blocks', 'satisfies', ...) pointing at this Feature across the workspace, update each reference, surface a preview UI listing every change before commit, leave the workspace consistent. If a name collision is detected (another Feature already named FEATURE-156-REVISED), the rename should fail with a useful error rather than corrupt the workspace.

The author wants to extract an inline Note (a @ChildLink instance) into a top-level Note declaration that several Features can reference. The extract should: create the new top-level declaration, replace the inline child with a @ReferenceLink to the new declaration, leave the workspace consistent.

Hand-writing these requires implementing textDocument/rename and textDocument/codeAction (refactor kind), walking the workspace, computing the affected nodes, building text edits across files, surfacing the preview. The Refactoring micro-DSL collapses these into @Refactoring decorators per Concept method.

The Surface

import { Refactoring, RefactoringPreview } from '@frenchexdev/ide-dsl-refactoring';
import { Concept, Property, ChildLink, ReferenceLink, PatchBus } from '@frenchexdev/ide-dsl-kernel';

@Concept({ id: 'cmf.req.Feature' })
export class Feature {
  @Property({ type: 'string', constraint: /^FEATURE-\d+(-[A-Z]+)?$/ })
  id!: string;

  @Refactoring({
    kind: 'rename',
    appliesTo: ['id'],
    title: 'Rename Feature',
  })
  static async renameFeature(self: Feature, newId: string, ctx, patch: PatchBus) {
    const collision = await ctx.workspace.findByConcept('cmf.req.Feature', { id: newId });
    if (collision && collision !== self) {
      throw new Error(`Cannot rename: ${newId} already exists at ${collision.location}.`);
    }
    const refs = await ctx.workspace.findReferencesTo(self.nodeId);
    return patch.transaction([
      patch.setProperty(self.nodeId, 'id', newId),
      ...refs.map(ref => patch.setProperty(ref.nodeId, ref.linkName, newId)),
    ]);
  }

  @Refactoring({
    kind: 'extract',
    appliesTo: 'note',
    title: 'Extract Note to Top-Level',
  })
  static async extractNote(self: Feature, noteId: string, targetUri: string, ctx, patch: PatchBus) {
    const note = self.notes.find(n => n.nodeId === noteId);
    if (!note) throw new Error(`Note not found.`);
    return patch.transaction([
      patch.insertChild(targetUri, 'notes', { concept: 'cmf.req.Note', properties: note.properties }),
      patch.removeChild(self.nodeId, 'notes', noteId),
      patch.setReference(self.nodeId, 'noteRefs', note.nodeId),
    ]);
  }
}

@Refactoring declares: a kind ('rename', 'extract', 'inline', plus an 'other' for custom transforms), a target (which property or link the refactoring applies to), a title (shown in the preview UI). The decorated method receives the target node, the user-provided parameter (new name, extract location), the kernel context, and the PatchBus. The method composes a transaction; the kernel applies it atomically.

The transaction method on PatchBus, returning when all operations have been validated and applied (or rejected wholesale on any failure), is what makes the refactoring atomic. There is no partial state where the canonical declaration has been renamed but the references have not been updated yet — either everything moves or nothing does.

Kernel boundary

Reads:

  • The Structure Model — for refactoring registration and target validation.
  • The current document AST — for the targeted node.
  • The workspace index — for cross-file reference enumeration.

Writes:

  • Through PatchBus, only in transactions composed by the refactoring method. The kernel's transaction support is what makes the refactoring atomic.

The micro-DSL is the main consumer of setReference operations in the suite. Every cross-aggregate update (rename target, extract / inline) translates to setReference calls, which the kernel validates against the Structure Model and applies coherently.

Emitted artefacts

// LSP server registration (generated, in server/handlers/refactor.generated.ts)
import { WorkspaceEdit, RenameParams, CodeActionParams } from 'vscode-languageserver';
import { kernel } from '@frenchexdev/ide-dsl-kernel';
import { refactoringRegistry } from './_registry.generated';

export async function handleRename(params: RenameParams): Promise<WorkspaceEdit | null> {
  const ctx = kernel.contextFor(params);
  const refactoring = refactoringRegistry.findRename(ctx);
  if (!refactoring) return null;
  return refactoring.preview(ctx, params.newName);
}

export async function handleCodeAction(params: CodeActionParams): Promise<CodeAction[]> {
  // returns refactor-kind actions (e.g. extract, inline) for the cursor position
}

The preview step computes the WorkspaceEdit without committing — the user sees the proposed changes in VSCode's preview UI, then accepts; on accept, the actual transaction runs. The two-step flow (preview, then commit) is the LSP standard; the Refactoring micro-DSL implements both phases against the same registered method.

Composition with peers

  • Diagnostics (article 11) — orthogonal at request, both go through PatchBus on commit; scope distinguishes them.
  • Symbols (article 16) — provides the workspace index Refactoring queries for impact analysis.
  • CodeLens (article 12) — refactor-kind actions can surface as inline lenses (Rename Feature).
  • LSP host (article 20) — routes textDocument/rename, textDocument/codeAction, textDocument/prepareRename.

MPS aspect referent

MPS Refactorings aspect. MPS authors declare per-Concept refactorings with similar semantics: target node, transform method, atomic application through MPS' typed mutation API. The translation is direct; the only adaptation is that we use VSCode's WorkspaceEdit preview UI instead of MPS' refactoring dialog, and our PatchBus replaces MPS' Editor.execute.

Boundary justification

Why not in Diagnostics' code actions? Code actions are local, single-diagnostic repairs. A code action that deletes one orphaned @ReferenceLink is a code action; a refactoring that renames a Feature and updates seventeen references across nine files is a refactoring. The user's mental model — and the LSP protocol's distinction between codeAction and rename — capture the difference. Forcing both into one shape would either require code actions to surface the WorkspaceEdit preview (overkill for local repairs) or refactorings to skip the preview (unsafe for cross-file transforms).

Why not in Commands? Refactorings are typed transforms over the AST; commands are generic actions that may or may not touch the AST. The PatchBus contract (transactions, atomicity, automatic reference update) is integral to refactorings; commands have no such requirement. Splitting them keeps each surface tight.

Requirements

FEAT-MICRODSL-17 in assets/features.ts:

  • refactorDeclarativeSurfaceShown — the Surface section shows @Refactoring({ kind, appliesTo, title }).
  • semanticPreservationViaPatchBusExplained — the Surface and Kernel boundary sections name the transaction-based atomicity.
  • referenceLinkUpdateStrategyStated — the rename example shows the findReferencesTo walk and the setProperty updates per reference.
  • boundaryAgainstCodeActionsJustified — the Boundary justification section names the local-vs-structural argument.

Article 18 picks up with Projection — the projectional core of the suite, the one bet that distinguishes us from MPS.

⬇ Download