Skip to content

feat: structural list Tab/Shift-Tab — marker adoption, cross-list nesting, width-aware renumber#207

Merged
mtskf merged 18 commits into
mainfrom
feat/list-structural-transforms
Jul 12, 2026
Merged

feat: structural list Tab/Shift-Tab — marker adoption, cross-list nesting, width-aware renumber#207
mtskf merged 18 commits into
mainfrom
feat/list-structural-transforms

Conversation

@mtskf

@mtskf mtskf commented Jul 12, 2026

Copy link
Copy Markdown
Owner

What & why

Upgrades the list Tab / Shift-Tab commands from pure whitespace shifts to structural moves into a destination sibling run. A CommonMark list run is only valid when every item shares marker type + glyph + delimiter, so marker adoption here is a correctness requirement, not styling.

User-visible changes:

  • Shift-Tab (outdent) adopts the destination run's marker + renumbers. Outdenting a bullet nested under a numbered list now joins that list as the next number (- ddd3. ddd) and renumbers the following items. Previously it stayed a - at the wrong level.
  • Tab (nest) is content-column aware and works across adjacent lists. Nesting a bullet under a numbered item now indents to the parent's content column (so it actually nests instead of breaking the list), and a document already broken by a too-shallow 2-space nest heals on Tab instead of dragging its tail along.
  • Empty continuations adopt the destination shape. Pressing Enter then Shift-Tab on an empty item makes it match the destination: - [ ] aaa → a new - [ ] , a numbered parent → the next number. Checkboxes are always synthesized unchecked; non-empty items are never silently converted, and an existing checkbox is never stripped from a non-empty item.
  • Behaviour change (intended): nesting an ordered item that starts a new sub-list now restarts it at 1. and renumbers the vacated outer run to close the gap (Notion-style), instead of leaving a stale gap. One existing test's expectation was updated accordingly.
  • Fixes a latent Enter-continuation bug: renumbering across a digit-width boundary (9.10.) now re-indents that item's nested children, which previously fell out of the list.

How

New pure planner module src/webview/cm/list/list-transform.ts (marker primitives, line classifier, renumberRun, planIndentItem/planOutdentItem) returns ChangeSpec[]; the keymap Commands are thin shells. list-tree.ts holds the grammar/destination-resolution adapters so node-shape knowledge stays in one place. Every transform is one isolateHistory transaction through the normal edit-sync → host write-lock pipeline — no raw write path, byte-identical round-trip, whole-transform undo. All whitespace shifts funnel through a single per-line net-delta map so ChangeSpecs never overlap; ordered output is capped at 9 digits (fail-closed).

Testing

pnpm compile + pnpm build + pnpm lint green; full pnpm test (unit + browser + e2e) green (e2e 82 passing). New unit tests cover the primary scenarios plus edge cases (delimiter/glyph preservation, task-ness adoption, checked→unchecked, ordered-task combos, forced-children re-homing, multi-digit width crossings, lazy-tail healing, caret placement). Manual GUI smoke (typing round-trip) still recommended before release.

mtskf added 18 commits July 12, 2026 17:55
…width-crossing renumber child re-indent

Relocates the ordered-renumber logic out of Enter-continuation into the
shared list-transform module and makes it marker-width-aware: when a
renumber crosses a digit-width boundary (9. -> 10.), the sibling's nested
children are now re-indented to track the new content column, fixing a
latent bug where a width-crossing renumber dropped a child out of the list.

isEmptyItem and the Enter marker-construction logic also move to
list-transform.ts (continuationMarkerFor), so Enter-continuation now
composes the same primitives a later structural-transform planner will use.
Introduce `orderedShape(number, delim)` + `MAX_LIST_NUMBER` as the sole
cap-enforcement point for ordered ListMark numbers: it returns null when
the number falls outside 1..999_999_999. Route continuationMarkerFor,
renumberRun's per-sibling number, adoptedShapeFrom, adoptedShapeForJoin,
newRunShapeFor, and the forced-child renumber through it.

This closes two bugs at once and removes four scattered naked
`> 999_999_999` guards:

- renumberRun had no lower bound, so a `0.`-based run whose delta drove a
  follower negative emitted `-1.` (rejected by ORDERED_RE = corrupt
  Markdown). It now fails closed (returns []).
- continuationMarkerFor lacked the 9-digit cap every other producer
  enforces, so continuing `999999999.` produced a 10-digit non-marker.

The planner-inline cap re-checks (adopted.number > MAX in both planners)
become dead once `adopted` flows from orderedShape and are removed; the
existing `adopted === null` no-op covers the overflow path.

Tests: (codex) indenting the middle of a `0.`-based run never emits a
negative marker; renumberRun width-SHRINK de-indents a narrowed sibling's
nested child (the negative-deltaWidth removal branch).
Two live data-corruption bugs in planOutdentItem:

fable-1 (empty-item caret): the caret was placed at
`markerLine.from + parentMarkCol + newMarker.length`, adding a COLUMN
count to a byte offset. On a tab-indented line surviving chars differ from
surviving columns, so with a tab indent at EOF the target overshot the
shortened document and view.dispatch threw RangeError — swallowed by
applyShift's catch, dropping the entire outdent. Derive the caret from the
CHARS actually removed ahead of the marker (leadingCharsForColumns) so it
stays in range on tab-mixed indent.

fable-2 (forced-child geometry): promotedContentCol hard-coded a 1-space
marker gap. The non-empty path replaces only the ListMark span, preserving
the item's original marker->content gap, so an aligned run (`1.  a`,
gap 2) computed a content column one short. A forced child at the true
column then failed to nest and the outer run corrupted (1,2,1,3). Use the
item's real gap (contentColumnOf - marker end) for the non-empty path; the
empty path keeps its synthesized single-space gap.

Tests: tab-indent empty outdent at EOF composes without RangeError;
aligned-gap outdent nests the forced child and keeps the outer run intact;
empty item WITH forced children composes with caret after the marker;
tab-indented nested item outdents by whole-tab removal.
Replace the IndentPlan / OutdentPlan `{ changes: ChangeSpec[] }` structs —
where an empty `changes: []` overloaded as an "intentional no-op" sentinel
the caller had to recognise — with a discriminated `ListEditPlan`:

  { kind: "noop" } | { kind: "edit"; changes; selection? }

The `edit` variant couples `changes` with the empty-item-caret `selection`,
so a nonsensical `{ changes: [], selection }` (which applyShift would
early-return on, dropping the selection) is now unrepresentable. applyShift
branches on `plan.kind === "noop"` instead of `changes.length === 0`, and
takes the plan directly. Fixes the stale OutdentPlan doc comment (said the
no-op was "null").

error-handler: distinguish parse-budget exhaustion from a genuine
structural no-op. resolveItemAtEof / renumberRun returned the same no-op
whether ensureSyntaxTree missed budget (retryable on a large doc) or the
caret simply was not in a list item. Add a QUOLL_PERF-gated warnBudgetMiss
at the two ensureSyntaxTree-null sites — dead-coded out of production and
silent in the unit suite, so the Tab/Shift-Tab result is unchanged; it only
surfaces the retryable case to a dev build.

Tests pin the discriminant: noop on a top-level outdent / first-item
indent, edit (with non-empty changes) on a real promotion.
- list-indent-keymap: the dispatch catch logged only `err`, so a planner
  regression (overlapping ChangeSpec = ChangeSet.of throw, or an
  out-of-range selection) gave no clue which transaction failed. Log
  userEvent, docLength, selection, and changeCount.

- Add a disjointness regression test: a width-crossing forced child whose
  renumber + re-indent fold into ONE net per-line change dispatches
  cleanly, proving the catch is an unreachable path.

- Fix three stale comments: contentColumnOf no longer claims a
  list-indent-keymap.ts dupe (that module is a thin shell); enclosingListItem
  narrows its "consolidates dupes" claim to the one surviving copy in
  list-geometry.ts and drops the dangling task-brief pointer; the module doc
  restates present tense (the transforms live in this file) and records the
  file-split as a deferred follow-up TODO.
The ternary built two near-identical edit variants only to omit an
undefined selection; the optional selection field carries it either way
and the dispatch shell drops an undefined selection before view.dispatch.
…er no longer stales at 0

The cycle-1 lower bound >= 1 over-rejected 0., which ORDERED_RE accepts and
CommonMark permits as an ordered-list start. A vacated-run renumber whose
correct target was 0 failed the whole run closed, leaving a stale/split-brain
outer run. Lower the bound to >= 0 so only NEGATIVE markers (the original
cycle-1 bug) fail closed. Update the two doc-comments that mis-stated the
reject condition as < 1. Add a regression test pinning the 0.-follower path.
The weak toBeLessThanOrEqual(doc.length) assertion passed even with the caret
parked before the synthesized marker (in range but wrong) — the exact tab-line
regression condition where surviving chars != surviving columns. Pin the exact
post-transform column with toBe(doc.line(3).to), matching the space-indent tests.
…parent

The empty-item outdent + destination-renumber confluence was only covered with
BULLET parents (renumber never fires). Add a test for an empty ordered child
under an ordered parent with a following top-level sibling, so the destination
renumber fires in the same transaction as the empty-path caret. Pins both the
renumbered run (2. q -> 3. q) and the surviving caret coordinate.
The planner's speculative-no-op contract on a null EOF re-parse (renumberRun ->
[], resolveItemAtEof -> null -> planner noop) was unobserved: every other test
forceParses to EOF. A future rewrite of the fail-close to an early-return could
run the planner over a null tree and corrupt/throw while the suite stayed green.
Isolated file mocks only ensureSyntaxTree (return null), keeping every other
@codemirror/language export real, and pins doc-unchanged + no-throw.
The cycle where orderedShape's lower bound became >= 0 (0. is a valid
CommonMark ordered start) corrected the renumberRun JSDoc but left two
sibling inline comments (renumberRun's fail-close return and
planOutdentItem's adopted-shape note) asserting the old 1..9-digit
range. Align them with the 0..9-digit bound the code now enforces.
@mtskf mtskf merged commit 69ea04d into main Jul 12, 2026
1 check passed
@mtskf mtskf deleted the feat/list-structural-transforms branch July 12, 2026 22:46
mtskf added a commit that referenced this pull request Jul 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant