Commit Graph

28 Commits

Author SHA1 Message Date
Levi Neuwirth 19ad5cc8ac fix(json-yaml): checkpoint reviewed LSP configuration fixes
Preserve PR #123's unpushed review fixes on a transfer branch: initial
didChangeConfiguration delivery, explicit JSON validation, the pinned
JSON server provider, corrected YAML configuration sections, and
deterministic plus real-provider acceptance coverage. Record the
observed yaml-language-server 1.24.0 standalone smoke and leave the
real YAML-through-pmacs test, rebase, and full gates explicitly pending
for the destination machine.
2026-07-20 16:49:37 -04:00
Levi Neuwirth 223e26420b feat(edit): auto-pairing (Arc 2)
Typing an opener inserts the closer with the cursor between; typing a
closer over its twin steps over it. Q#AP1: the nine built-in pair
chars leave both optimistic classifiers (shared charset in
pmacs-protocol) and round-trip through dispatch, so the opener and the
hook's closer are adjacent daemon-peer undo units, dispatch CUA
type-over applies, and skip never paints a transient duplicate.

Q#AP9: exact one-shot typed-edit provenance. EditorCore's
apply_active_edit now returns the effective Edit; the dispatch
fallback arms a per-frontend record (codepoint + requested vs
effective ranges + post-cursor + clean verdict) that insert primitives
complete and the daemon's optimistic CRDT arm builds directly. The
record is takeable exactly once via pmacs.editor.take_typed_edit()
during the one after-edit fan-out, then cleared — paste, programmatic
edits, manual hook runs, nested re-runs, rejected edits, and stale
this_command all observe nil, and transformed / relocated /
context-switched source self-inserts fail closed with a status.

pair.lua (loaded BEFORE lsp.lua — ordering contract in editor.rs):
per-language pmacs.pair.sets with a conservative default (no ' or `),
EOL/whitespace/closer insertion predicate, reactive skip-over-close,
rejected/transformed intercept outcomes with context-guarded
translate-and-clamp cursor repair.

Acceptance: 32 dispatch-driven cases (predicate, skip, per-language
sets, non-typed provenance incl. production-shaped paste, type-over,
undo/redo grain, intercept outcomes on both the source and reaction
edits, context-switch probe, record lifecycle, frontend isolation) +
first-didChange ordering against the fake LSP's sighelp mode via a
new PMACS_FAKE_LSP_CHANGE_SINK replay file. Six two-replica CRDT
cases pin dispatch-route convergence with cursor-between, undo/redo
walking the pair on both replicas, both mixed-history undo models as
named substrate limits, and the optimistic custom-char route
(closer-broadcast-before-opener convergence, degraded cross-peer
undo). TestDaemon gains spawn_with_config for init.lua-extended pair
sets.

Framing: docs/auto-pairing-framing.md (revision 3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-11 17:11:56 +01:00
Levi Neuwirth dd6ec68762 fix(lsp): convert rename/prepareRename positions per position encoding
request_rename and request_prepare_rename sent raw byte columns instead
of routing through outbound_position — the same bug class as the
semantic-range and code-action fixes that just merged (#105). On a
UTF-16 server, a rename at a position past non-ASCII text resolves the
wrong character (or an invalid one) and renames the wrong symbol.

Both single-Position builders now convert. The posecho fake validates
request positions on its rename/prepareRename arms in UTF-16 units, and
the new test drives both requests at byte offset 3 of "éx" (UTF-16
character 2) — both stores filling proves both builders converted.

(Fix authored locally by Levi during the round-5 review; recovered from
the working tree after the #105 merge and landed verbatim, plus a
cargo fmt pass.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:16:06 -04:00
Levi Neuwirth 685547f2a7 fix(lsp): convert semantic-range (and code-action) bounds per position encoding
Addresses the round-5 finding: the whole-document range that serves a
RANGE-ONLY semantic-token provider derived its columns from UTF-8 byte
counts and sent them unchanged — unlike the inlay path, it skipped
outbound_position. A UTF-16 server receives an invalid end character
for non-ASCII text ("é" is two bytes, one UTF-16 unit) and may reject
the request; since /range is a range-only provider's ONLY pull path,
that means no semantic styling at all.

Both bounds of request_semantic_tokens_range now go through
outbound_position. request_code_action had the identical bug (byte
columns, no conversion) and is fixed in the same stroke — same class,
same one-line shape, commented as such.

Fixture: `rangeonly16` fake mode = rangeonly + negotiated UTF-16 +
STRICT UTF-16 bounds validation on /range (fail-closed: a missing
didOpen record or absent uri also rejects, so the fixture can never
pass vacuously). An env-gated PMACS_FAKE_RANGE_SINK records the
received range for debugging. Test opens a file whose last line ends
in non-ASCII and asserts tokens arrive; verified it bites — with the
conversion removed the wire carries the byte column (13 vs the valid
11), the fake rejects, and the test fails.

Honest note: an earlier bite-check in this session produced a vacuous
pass because short, non-unique edit patterns hit the WRONG json! block
(temporarily regressing the inlay conversion and accidentally
converting code-action). The final diff is anchored uniquely and
verified: inlay unchanged (whitespace only), semantic + code-action
converted, bite-check red/green confirmed against the exact lines.

Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; m4 99;
killring 30; completion 9; GPU 58; git diff --check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:17:50 -04:00
Levi Neuwirth 625128c139 fix(lsp): range-only providers, completion-accept boundary, exact codepoint classify
Addresses the round-4 findings against the stack (PR #104 portion).

- HIGH range-only semantic-token servers: LSP defines
  semanticTokensProvider.full and .range as optional, INDEPENDENT
  capabilities, but the old any-provider gate sent /full regardless — a
  range-only server rejects it and the swallowed error means no styling,
  ever. Both the auto-pull and the manual command now gate each request
  kind on its own capability: /full (delta under full.delta) when
  negotiated; a range-only provider gets a WHOLE-DOCUMENT /range request.
  New `rangeonly` fake mode (advertises range without full, rejects
  /full) + test proving tokens arrive via the range path.

- MEDIUM completion acceptance left this_command stale: the popup accept
  applies its edit and fires after-edit outside command dispatch, so
  this_command could still read "buffer.self-insert" from the typing that
  raised the popup — a candidate ending in "(" would spuriously
  auto-trigger signature help. Accept now stamps its own boundary
  ("completion.accept"); asserted in the popup acceptance suite.

- MEDIUM GPU shape inference tightened: the 1-4-byte predicate accepted
  a 2-byte "a(" insert (two ASCII codepoints). The classifier now decodes
  the inserted bytes from the post-edit rope and requires the leading
  byte's UTF-8 sequence length to equal inserted_len — exactly one
  codepoint. The daemon unit test now drives an "a(" op and asserts it
  breaks the chain instead of classifying as typing. Exact wire
  provenance on the CRDT op remains the named deferred general fix.

Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; m4 98;
completion 9; killring 30; GPU 58; git diff --check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:48:08 -04:00
Levi Neuwirth 5c2a27aaf9 fix(lsp): negotiate delta before requesting; input-origin signature trigger
Addresses the four post-merge findings against PR #102 (merged as
2d157d8). Stacked on the kill-ring branch (PR #103): the trigger
redesign rides its command-boundary substrate.

- BLOCKING delta without the capability: pull_semantic_tokens_quiet (and
  the pre-existing manual pmacs.lsp.semantic_tokens(), same bug) used
  any stored resultId to request /full/delta while only checking that a
  provider exists. A resultId does not imply delta support --- servers
  may return one from /full regardless --- and a conforming full-only
  server rejects the delta request; the pull path swallows the error, so
  styling stayed silently stale after the first edit. Both sites now
  require semanticTokensProvider.full.delta == true. The fake's default
  mode truthfully advertises { "full": { "delta": true } } (it
  implements delta); a new `fullonly` mode advertises "full": true,
  REJECTS /full/delta, and bumps its resultId per /full response so the
  test can observe WHICH pull refreshed the store. Verified the test
  bites: with the capability check reverted, the post-edit rid stays
  rid-1 (stale) and the test fails.

- HIGH false-positive trigger + cross-frontend misclassification: the
  cursor-delta heuristic ("same buffer, cursor +1") fired on any
  one-byte edit --- including a one-byte paste of "(" once PR #103 made
  paste fire buffer.after-edit --- and its singleton last_typed was
  shared across frontends. Replaced with the input-origin signal from
  the #103 substrate: inside after-edit,
  pmacs.editor.this_command() == "buffer.self-insert" names an edit
  produced by typing, per frontend, with nothing inferred from cursor
  deltas. New ed.this_command() binding; handle_remote_crdt_op now
  classifies a single-codepoint optimistic insert as buffer.self-insert
  (rotation, not just break --- kill-chain semantics identical since
  self-insert is not a kill, and GPU typing now carries the same origin
  signal as TUI typing). Paste/pointer/undo/unbound leave this_command
  as something else and can never trigger.

- MEDIUM first-trigger-ignored: the origin signal needs no prior-edit
  snapshot, so the very first "(" typed in a buffer triggers. The test
  that had encoded the warm-up keystroke as "correct" now types a single
  "(" as the first character.

- MEDIUM non-ASCII trigger characters: char_before read one byte and
  rejected multi-byte strings; LSP trigger characters are strings. Now
  codepoint-aware (read up to 4 bytes back, take the suffix from the
  last non-continuation byte). The sighelp fake declares a two-byte
  trigger ("«") and a test types it.

Tests (m4_acceptance 94 -> 97 after +4/-1 rework):
arc1c_full_only_server_repulls_via_full_not_delta (bites --- verified),
arc1d_signature_help_auto_triggers_on_trigger_char (now first-char),
arc1d_signature_help_triggers_on_non_ascii_trigger_char,
arc1d_signature_help_ignores_non_typed_edits (movement-stamped
programmatic "(" insert + manual after-edit must not trigger --- the
case cursor-delta inference cannot distinguish). Daemon unit test
updated for the insert classification (break-then-classify: `this` =
buffer.self-insert, `last` = None, chain still dead).

Note: completion.lua still uses the Q#C9 cursor-delta heuristic and
inherits its weaknesses; migrating it to this_command is a named
follow-up, out of scope here.

Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; m4 97;
killring 28; completion 9; GPU 58; git diff --check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:40:59 -04:00
Levi Neuwirth 4c4295d0fc feat(lsp): auto-pull semantic tokens; auto-trigger signature help (Arc 1c + 1d)
Closes Arc 1 of docs/roadmap-2026-07.md.

1c --- semantic tokens never appeared (a shipped bug).

Semantic tokens are pull-model: the store only fills from a
`textDocument/semanticTokens/*` response. The ONLY automatic pull was in
reply to a server-initiated `workspace/semanticTokens/refresh`, which
most servers never send. So `LspStyleView` attached to a store nothing
ever filled, and semantic styling silently never appeared unless the user
ran `M-x lsp.semantic-tokens` by hand --- while inlay hints, on the exact
same pull model, were pulled at three points.

`pull_semantic_tokens_quiet` now mirrors `pull_inlay_hints_quiet` at all
three: on `initialized`, on attach, and on edit-flush. The `initialized`
handler is the one that matters --- buffers attach before the server
finishes initializing, so the attach-time pull is a no-op for the first
file (its `server_is_initialized` guard is false). That is precisely why
the file that starts the server never got semantic color. Delta when a
resultId is held, full otherwise, matching the manual command.

1d --- signature help auto-triggers on a trigger character.

A typed character is reconstructed the way `completion.lua` already does
(Q#C9): same buffer, cursor advanced by exactly one byte. Paste, undo,
kill, and remote CRDT edits produce any other delta and never trigger.
The trigger set comes from the server's declared `triggerCharacters` +
`retriggerCharacters`; a provider declaring neither gets `(` and `,`; no
provider means no auto-trigger at all. The request is silent --- an
auto-trigger that announced "no signature help" on every `(` in a comment
would be unusable --- so only a real signature reaches the status line.
It fires after the pending didChange is queued and flushes it first, so
the server sees the character being asked about.

Test helper: `pmacs_fake_lsp` gains a `sighelp` mode that advertises
`signatureHelpProvider`; every other mode omits it, so no existing test
changes behavior.

Tests (m4_acceptance 90 -> 94):
  arc1c_semantic_tokens_auto_pull_on_attach     (default fake: advertises
      the provider, never sends refresh --- exactly the broken case)
  arc1c_semantic_tokens_repull_after_edit_flush (clear store, type, flush)
  arc1d_signature_help_auto_triggers_on_trigger_char
  arc1d_signature_help_does_not_trigger_on_ordinary_typing

Verified the 1c tests bite: both fail with the `initialized`-handler pull
reverted. Named `arc1c_`/`arc1d_` rather than `m4_NN_`, since the m4
numbering maps to spec acceptance bullets and these are not those.

Gates: fmt + workspace clippy clean; lib 1499; m4 94; m9_1 18;
completion 9; listview 6; overlay 2; GPU 58; git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:52:19 -04:00
Levi Neuwirth 71b21dee1e Render inline adornments in pmacs-gpu 2026-05-27 10:24:20 -04:00
Levi Neuwirth 5180a627d1 T M4.5: derive LSP rootUri from the opened file's project (real-server hardening)
The default-bundle auto-attach path (lsp.lua ensure_server) never
forwarded cwd/root_uri to pmacs.lsp.spawn, so build_initialize fell
back to std::env::current_dir() — every auto-attached server received
the *editor's* cwd as rootUri regardless of which project the opened
file belonged to. Module-strict servers (gopls, rust-analyzer) return
nothing unless launched from the project dir; the fake-LSP and clangd
(which finds compile_flags near the file) masked this, gopls exposes
it. Same shape as the #26 transport bugs: lenient fakes hid a gap
strict real servers fall straight into.

Fix: project_root_for(language, path) in lsp.lua —
config[lang].root override -> pmacs.project.detect marker walk (the
canonical detector, honors set_search_boundary) -> the file's own
directory. attach_buffer resolves the path before ensure_server;
spawn now carries cwd/root_uri. Single-root only (fixes which root
the one per-language server uses); one-server-per-root multi-root
scoping stays deferred post-v0.1 (documented: first file of a
language fixes that server's root). New documented
pmacs.lsp.config[lang].root key.

Tests:
- m4_26: deterministic — new fake "rooturi" mode +
  PMACS_FAKE_LSP_ROOT_SINK side-channel; asserts the rootUri sent
  through a real find_or_open auto-attach is the go.mod dir, not the
  cwd, not the file's own dir.
- m4_27: PATH-gated real gopls — documentSymbol + hover round-trip is
  end-to-end proof of the fix against a real strict server.
- m4_28: PATH-gated real clangd — diagnostics arriving is the #26
  deferred-notification-flush + URI-absolutization regression guard;
  also exercises semantic tokens + documentSymbol.

No other latent bugs surfaced; gopls & clangd both clean through the
fixed path. rust-analyzer / basedpyright not installed here, so their
real end-to-end validation is still pending (the fix benefits them
identically — Cargo.toml / pyproject.toml are detect markers).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 15:56:13 -04:00
Levi Neuwirth 1c257305a4 T M4.5: workspace/didChangeWatchedFiles (backlog item 4 — closes backlog)
Dynamic file-watch registration with a full snapshot-diff watcher.

- src/lsp.rs: did_change_watched_files(sid, &changes) notification;
  capability workspace.didChangeWatchedFiles.dynamicRegistration=true
  (mandatory — clangd/rust-analyzer/gopls only register dynamically).
- src/lua_bindings.rs: pmacs.lsp.did_change_watched_files binding.
- builtin/runtime/lsp.lua: client/(un)registerCapability handled in
  the server-request pump (reply null; start/stop watchers). Brace-
  expanding glob → anchored Lua pattern; recursive read_dir/stat
  snapshot-diff poller emitting per-file created/changed/deleted
  filtered by glob + WatchKind, batched into one notification;
  self-cancels when the server dies or unregisters. luajit-safe
  (kind_has() arithmetic, no 5.3 bitwise).
- pmacs_fake_lsp.rs: `filewatch` mode registers a **/*.txt watcher
  and logs received changes to <base>/.received (disk side-channel —
  the protocol stream is drained by the pump).
- tests/m4_acceptance.rs: m4_24 asserts create(1)/change(2)/
  delete(3) for matching .txt only; non-matching .md filtered.

Bug caught in validation: `**/` → `(.*/)?` is not a valid Lua
pattern (no group quantifier) — matched nothing, zero events. Fixed
to `**/`→`.-`, `**`→`.*`; m4_24 surfaced it.

client/unregisterCapability cancels watcher records (code-reviewed);
not asserted in m4_24 — a "no further notifications" negative-timing
check is flaky; the create/change/delete + filter path is the
deterministic proof.

Gates: lib 1301/0, m4 79/0, m8_1 10/0, m8_9 26/0, m8_10 19/0,
m9_1 18/0, m5_8 5/0, m11_5 (--features crdt) 2/0; fmt + clippy
clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 14:44:30 -04:00
Levi Neuwirth 878ef7a80a T M4.5: textDocument/prepareRename (backlog item 3)
Built on #25 + #26 (branched off main after both merged).

- src/prepare_rename.rs: parse the prepareRename union
  null | Range | { range, placeholder } | { defaultBehavior } into
  { allowed, placeholder?, range? }. Store/Key/Shared keyed
  (server, uri). 7 unit tests.
- src/lsp.rs: prepare_rename_store + accessor,
  ResponseRoute::PrepareRename + absorb, request_prepare_rename;
  capability rename.prepareSupport=true +
  prepareSupportDefaultBehavior=1.
- src/lua_bindings.rs: _request_prepare_rename_raw,
  pmacs.prepare_rename.{result,clear}.
- builtin/runtime/lsp.lua: pmacs.lsp.rename() gates on
  caps.renameProvider.prepareProvider. When supported: prepareRename
  round-trip first — null/not-allowed -> "cannot rename here", the
  prompt never opens; otherwise the prompt opens pre-filled with the
  server's placeholder (minibuffer `initial`). Servers without
  prepare keep the unchanged L2 path (on_accept factored into a
  shared local; no behavior change there).
- pmacs_fake_lsp.rs: `prepare`/`preprefuse` modes inject
  renameProvider.prepareProvider + a prepareRename arm
  ({range,placeholder} or null). Default `rename` mode untouched so
  m4_13 still exercises the no-prepare path.
- tests/m4_acceptance.rs: m4_22 (prompt waits for the async
  prepare, placeholder pre-filled, rename applies), m4_23 (refusal:
  prompt never opens, buffer untouched).

Gates: lib 1299/0, m4 78/0, m8_1 10/0, m8_9 26/0, m8_10 19/0,
m9_1 18/0, m5_8 5/0, m11_5 (--features crdt) 2/0; fmt + clippy
clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 14:23:57 -04:00
Levi Neuwirth ea37a8f7ce T M4.5: semantic tokens /range + /full/delta
Backlog item 2 — perf refinement over the v1 full-only request.

- src/semantic_tokens.rs: SemanticTokensResponse retains the raw
  int stream; factored decode(); new apply_delta(prev_raw, v)
  splices a SemanticTokensDelta (edits:[{start,deleteCount,data}])
  over the previous raw — descending-start application so unordered
  server edits stay valid, bounds clamped, spec-allowed
  full-instead-of-delta detected and parsed. +4 unit tests.
- src/lsp.rs: request_semantic_tokens_range (reuses the
  SemanticTokens route/store) and request_semantic_tokens_delta
  (new ResponseRoute::SemanticTokensDelta; absorb splices against
  the store's retained raw). Capability upgraded to
  requests:{ full:{ delta:true }, range:true }.
- src/lua_bindings.rs: _request_semantic_tokens_range_raw,
  _request_semantic_tokens_delta_raw,
  pmacs.semantic_tokens.result_id(sid,uri).
- builtin/runtime/lsp.lua: range/delta wrappers;
  pmacs.lsp.semantic_tokens() auto-prefers delta when a prior
  result id exists (else full), no longer clears the store (delta
  needs the retained raw), tags the modeline "(delta)". The range
  wrapper is exposed without a default command (no viewport source
  in the bundle yet).
- pmacs_fake_lsp.rs: /range and /full/delta arms (delta is an
  edit script over the /full data).
- tests/m4_acceptance.rs: m4_20 (range decode), m4_21 (full seeds
  rid-1; delta against it splices to the updated 3rd token + rid-2).

Gates: lib 1289/0, m4 76/0, m8_1 10/0, m8_9 26/0, m8_10 19/0,
m9_1 18/0, m5_8 5/0, m11_5 (--features crdt) 2/0; fmt + clippy
clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 13:50:05 -04:00
Levi Neuwirth 7ee1db5028 T M4.5: server→client refresh requests (inlayHint + semanticTokens)
Backlog item 1, combined (1a+1b) now that semantic tokens (#23) is
on main. Lets servers tell us cached inlay hints / semantic tokens
are stale and have the client re-pull, instead of the on-demand-
only v1 model.

- src/lsp.rs: advertise workspace.inlayHint.refreshSupport=true and
  workspace.semanticTokens.refreshSupport=true.
- builtin/runtime/lsp.lua: generalize the L3 workspace/applyEdit
  pump into handle_server_requests; add branches for
  workspace/inlayHint/refresh and workspace/semanticTokens/refresh
  — reply null per spec, then repull_for_attachments re-issues the
  matching request (request_inlay_hint / request_semantic_tokens)
  for every attached document on that server. Fire-and-forget; the
  response absorbs via its existing route like the command path.
  Only attachment servers are drained (directly-spawned test
  servers untouched).
- pmacs_fake_lsp.rs: `inlayrefresh` / `semantictokensrefresh`
  modes send the respective server→client refresh request at
  `initialized` (mirrors the wsconfig pattern).
- tests/m4_acceptance.rs: m4_18 / m4_19 attach via config and
  assert the store populates purely from the server-driven refresh
  chain — no explicit inlay_hints()/semantic_tokens() call.

Gates: lib 1285/0, m4 74/0, m8_1 10/0, m8_9 26/0, m8_10 19/0,
m9_1 18/0, m5_8 5/0, m11_5 (--features crdt) 2/0; fmt + clippy
clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 13:36:23 -04:00
Levi Neuwirth cf2947f7f9 T M4.5: semantic tokens (textDocument/semanticTokens/full)
LSP data layer only — independent of the M11 semantic-render
protocol (semantic_render.rs / semantic_client.rs, tree-sitter →
frontend wire families). No InstanceMessage family added; wiring
LSP tokens into styling is a separate rendering milestone. Same
shape as every sibling LSP feature: typed store + async request
+ Lua surface + command + modeline summary.

- src/semantic_tokens.rs: decode the 5-int relative encoding
  (deltaLine, deltaStartChar, length, tokenType, tokenModifiers)
  into absolute SemanticToken{line,start,length,token_type,
  token_modifiers}, with the same-line-vs-new-line deltaStartChar
  rule and defensive truncation of a malformed trailing group.
  SemanticTokensLegend::from_capabilities parses
  semanticTokensProvider.legend and resolves type index / modifier
  bitset to names. Store keyed (server, uri). 6 unit tests.
- src/lsp.rs: store + accessor, ResponseRoute::SemanticTokens +
  absorb, request_semantic_tokens (/full; v1 no range/delta),
  textDocument.semanticTokens client capability (full-only,
  formats=[relative], standard LSP legend).
- src/lua_bindings.rs: _request_semantic_tokens_raw,
  pmacs.semantic_tokens.{tokens, legend, clear} (legend reads the
  per-server initialize capabilities).
- pmacs_fake_lsp.rs: semanticTokensProvider.legend in initialize;
  textDocument/semanticTokens/full arm with relative-encoded data.
- builtin/runtime/lsp.lua: pmacs.lsp.semantic_tokens() requests
  full, stores, modeline summary (first token's type resolved via
  legend); lsp.semantic-tokens command + C-c y.
- tests/m4_acceptance.rs: m4_17 drives the request via the Lua
  surface, asserts decoded absolute tokens (incl. deltaLine!=0 ⇒
  absolute startChar) and legend index→name resolution.

Gates: lib 1285/0, m4 72/0, m8_1 10/0, m8_9 26/0, m8_10 19/0,
m9_1 18/0, m5_8 5/0, m11_5 (--features crdt) 2/0 (confirms no
collision with the M11 render protocol); fmt + clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 13:15:19 -04:00
Levi Neuwirth 1a0ccd0247 T M4.5: inlay hints (textDocument/inlayHint)
Independent LSP feature (not part of the L1-L4 cross-file arc),
shipped in the same shape as every sibling: typed store + async
request + Lua surface + command + modeline summary, with the
inline renderer deferred as its own milestone.

- src/inlay_hint.rs: parse InlayHint[]|null — position, label
  (string OR InlayHintLabelPart[] flattened), kind (type/
  parameter), paddingLeft/Right, tooltip (string|MarkupContent).
  Store keyed (server, uri). 5 unit tests.
- src/lsp.rs: inlay_hint_store + accessor, ResponseRoute::InlayHint
  + absorb, request_inlay_hint (range params), textDocument.
  inlayHint client capability (no resolveSupport/refreshSupport —
  full hints, on-demand re-query is the v1 model).
- src/lua_bindings.rs: _request_inlay_hint_raw,
  pmacs.inlay_hint.{hints,clear}.
- pmacs_fake_lsp.rs: textDocument/inlayHint arm returning a
  string-label type hint and a label-parts parameter hint.
- builtin/runtime/lsp.lua: pmacs.lsp.inlay_hints() requests over
  the whole-buffer range, stores, modeline summary;
  lsp.inlay-hints command + C-c i; scope header notes the inline
  renderer is a later milestone.
- tests/m4_acceptance.rs: m4_16 drives the request via the Lua
  surface, asserts both label shapes / kinds / padding parsed.

Deferred (scoping, not a regression): inline virtual-text
rendering. The VirtualCellOverlay model only overwrites existing
cells; rendering hints inline needs a column-inserting/reflowing
renderer — a rendering milestone, not an LSP task — staged like
the hover panel / references list. pmacs.inlay_hint is the data
surface a future render layer subscribes to.

Gates: lib 1279/0, m4 71/0, m8_1 10/0, m8_9 26/0, m8_10 19/0,
m9_1 18/0, m5_8 5/0, m11_5 (--features crdt) 2/0; fmt + clippy
clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 12:58:36 -04:00
Levi Neuwirth a865dc7a76 T M4.5 L4: WorkspaceEdit resource ops (create/rename/delete file)
Final cross-file layer: filesystem resource operations in
documentChanges, applied in server order alongside text edits.

- src/rename.rs: replace the files/unsupported_ops split with a
  single ordered Vec<WorkspaceOp> (Edit | Create | Rename | Delete
  with options). Order preserved exactly as sent so create-before-
  edit works; the `changes` map still emits URI-sorted edit ops.
  files()/is_empty()/edit_count()/resource_op_count() helpers.
  Tests reworked to the ops model.
- src/code_action.rs: adapt to the ops model (has_edit unchanged).
- src/lua_bindings.rs: workspace_ops_to_lua (ordered, op-tagged) +
  file_edits_to_lua (back-compat); pmacs.rename.ops;
  _parse_workspace_edit -> { ops }; code-action edit is ops; new
  pmacs.buffer.apply_resource_op doing the filesystem op plus
  buffer-registry reconciliation (rename rebinds an open buffer's
  path; delete removes its buffer; create makes parent dirs and
  honours overwrite/ignoreIfExists).
- builtin/runtime/lsp.lua: apply_workspace_edit rewritten to walk
  the ordered ops, preflight-resolve every URI before mutating
  anything, run text edits via apply_text_edits and resource ops
  via apply_resource_op, restore origin best-effort. Returns
  edits, files, resource_ops; status messages updated.
- pmacs_fake_lsp.rs: drop the stray /tmp create from `rename`
  mode; add a `resourceops` mode whose executeCommand->applyEdit
  returns create -> edit-created -> rename -> delete.
- tests/m4_acceptance.rs: m4_15 drives all four ops through the
  applyEdit pump and asserts disk effects + create-before-edit
  ordering.

Gates: lib 1274/0, m4 70/0, m8_1 10/0, m8_9 26/0, m8_10 19/0,
m9_1 18/0, m11_5 (--features crdt) 2/0; fmt + clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 12:31:35 -04:00
Levi Neuwirth a71e4a0ac0 T M4.5 L3: code actions + executeCommand + workspace/applyEdit
Third cross-file layer: code actions and the server→client edit
channel that executeCommand-driven actions depend on.

- src/code_action.rs: normalise (Command | CodeAction)[] into one
  CodeActionItem (bare-Command vs nested-Command disambiguated by
  whether top-level `command` is a string; inline `edit` reuses
  rename::WorkspaceEditResponse). Store keyed (server, uri). 5 tests.
- src/lsp.rs: code_action_store + accessor, ResponseRoute::CodeAction
  + absorb, request_code_action, request_execute_command (awaiter
  only — effect arrives out of band). Capabilities: codeAction
  (+codeActionLiteralSupport), workspace.executeCommand, and
  workspace.applyEdit flipped to true.
- src/lua_bindings.rs: _request_code_action_raw,
  _request_execute_command_raw, _parse_workspace_edit (any raw
  WorkspaceEdit JSON -> applier input shape), pmacs.code_action.*.
- pmacs_fake_lsp.rs: textDocument/codeAction arm (command action
  first, inline-edit action second) + workspace/executeCommand arm
  that emits a server→client workspace/applyEdit before responding.
- builtin/runtime/lsp.lua: pmacs.lsp.code_actions (apply first
  action: inline edit and/or executeCommand); the applyEdit pump
  (chained on pmacs._async.tick, drains only attachment-server
  events, snapshots server ids before applying since find_or_open
  can mutate `attachments`, replies { applied }); lsp.code-actions
  command + C-c a keybind.
- tests/m4_acceptance.rs: m4_14 drives the full
  codeAction→executeCommand→applyEdit chain end to end.

Gates: lib 1273/0, m4 69/0, m8_1 10/0, m8_9 26/0, m8_10 19/0,
m9_1 18/0, m11_5 (--features crdt) 2/0; fmt + clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 11:11:08 -04:00
Levi Neuwirth cbfb7f5e71 T M4.5 L2: WorkspaceEdit applier + textDocument/rename
Second cross-file layer on the L1 foundations: a multi-file
WorkspaceEdit applier and an LSP rename UX driving it.

- src/rename.rs: parse a WorkspaceEdit (both `changes` map and
  `documentChanges`, the latter preferred per spec; AnnotatedTextEdit
  handled; create/rename/delete resource ops counted into
  `unsupported_ops` for L4) into per-file TextEdit lists. RenameStore
  keyed by the request's origin URI. 6 unit tests.
- src/lsp.rs: rename_store + accessor, ResponseRoute::Rename,
  request_rename, and the textDocument.rename client capability
  (prepareSupport=false — L2 renames from the cursor position).
- src/lua_bindings.rs: _request_rename_raw + pmacs.rename.{file_edits,
  unsupported,clear}.
- pmacs_fake_lsp.rs: textDocument/rename arm; `rename` mode returns a
  2-file documentChanges plus a create resource op.
- builtin/runtime/lsp.lua: apply_workspace_edit (preflight rejects
  unresolvable URIs before mutating anything; per-file reverse-sorted
  application; origin buffer restored), pmacs.lsp.rename with a
  minibuffer prompt, lsp.rename command, C-c r keybind.
- tests/m4_acceptance.rs: m4_13 drives rename end-to-end through the
  minibuffer and asserts both files mutated + origin restored.

Gates: lib 1268/0, m4 68/0, m8_1 10/0, m8_9 26/0, m8_10 19/0,
m9_1 18/0, m11_5 (--features crdt) 2/0; fmt + clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 10:42:03 -04:00
Levi Neuwirth f7267cd720 T M4.5 L1: cross-file nav foundations — per-buffer file_path, jump ring, x-file go-to-def
Lays the groundwork for WorkspaceEdit/rename (L2+) by making
navigation cross-file-correct.

- Relocate file_path/file_meta from the EditorCore global onto
  Buffer itself, so each buffer keeps its own filesystem identity
  across cross-file navigation. Accessors + registry/editor/lua/
  semantic_render call sites migrated; zero behavioural change for
  single-file flows.
- uri->path: project_index::uri_to_path made pub; pmacs.lsp.path_for_uri.
- find-or-open: BufferRegistry::find_by_path + pmacs.buffer.find_or_open
  dedups an already-open file instead of spawning a duplicate buffer
  (SP-4 Gap A).
- Bounded jump ring on EditorCore (cap 64, oldest-evict, stale-buffer
  skip): push_jump/jump_back + pmacs.editor.* bindings + lsp.jump-back
  command bound to M-,.
- pmacs.lsp.go_to_definition cross-file branch: decode URI ->
  push_jump -> find_or_open -> reposition, with a failure path that
  unwinds the pushed origin. ensure_server now passes cfg.env through.

Tests: 5 jump-ring unit tests; m4_12_cross_file_go_to_definition_and_
jump_back end-to-end via a new `defenv` fake-LSP mode. All gates green
(lib 1262/0, m4 67/0, m8_1/m8_9/m8_10, m9_1, m11_5 --features crdt 2/0).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 09:53:29 -04:00
Levi Neuwirth 80c0679a5b T M4.5: documentSymbol / workspace symbol / documentHighlight
Completes the read-only LSP feature set (everything except edits).
Same request→store→Lua pattern as the nav batch; these three need
new parsing (new response shapes), so they were deliberately split
from the Location-family PR (#16).

- src/symbol.rs: one flat Symbol type for both symbol requests.
  from_lsp_value handles BOTH LSP shapes — hierarchical
  DocumentSymbol[] (flattened with depth + parent chain) and flat
  SymbolInformation[]/WorkspaceSymbol[] (location.uri, range
  optional for WorkspaceSymbol). Scope-keyed (Document(uri) vs
  Workspace(query)) so an outline and a query don't collide.
- src/document_highlight.rs: range + DocumentHighlightKind (absent
  defaults to Text=1 per spec), (server,uri)-keyed.
- lsp.rs: three ResponseRoute variants + absorb arms + request
  methods. documentSymbol/documentHighlight ranges convert via the
  requested-doc codec; workspace/symbol results are cross-file →
  route uri "" → non-destructive passthrough (same rule as
  cross-file definition).
- Lua: raw bindings + pmacs.document_symbol / .workspace_symbol /
  .document_highlight read surfaces (the new LSP Symbol is aliased
  to avoid the pre-existing project_index::Symbol name clash);
  lsp.lua wrappers + an lsp.document-symbols command on C-c o
  (modeline summary; outline buffer is future UX).
- Tests: 6 parser unit tests (hierarchical depth/parent, flat
  SymbolInformation, range-less WorkspaceSymbol, highlight kind
  default, scope non-collision) + an e2e driving all three through
  the async bridge asserting shape correctness.

Also includes a pre-existing rustfmt normalization of the #15
semantic-frontend files (protocol.rs / semantic_client.rs /
semantic_render.rs) — main was not rustfmt-clean there after the #15
merge; bundled here per operator decision so the fmt gate is green.

Gate: fmt clean; clippy --all-targets -D warnings clean; lib
1257/0; m4_acceptance 66/0; m9_1 18/0; m8_1/m8_9/m8_10 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:15:03 -04:00
Levi Neuwirth 96780b547d T M4.5: nav requests — references/declaration/typeDefinition/implementation
The "cheap batch" subset that is genuine template-fill: these four
return the exact `Location | Location[] | LocationLink[] | null`
shape `textDocument/definition` already parses, so no new parsing —
only a kind discriminator so they don't collide on (server, uri).

- src/locations.rs: a (server, uri, kind)-keyed store whose value
  type is the reused crate::definition::DefinitionResponse. The
  proven definition store + Lua API are untouched.
- lsp.rs: ResponseRoute::Locations { uri, kind }; one absorb arm; a
  DRY request_locations helper + request_references /
  request_declaration / request_type_definition /
  request_implementation. references sends
  context.includeDeclaration. Supersede keys derive from each
  kind's distinct method, so the four don't cancel each other.
- Lua: _request_*_raw bindings + install_locations exposing
  pmacs.references / .declaration / .type_definition /
  .implementation ({ locations, clear }, mirroring pmacs.definition,
  reusing definition_response_to_lua). lsp.lua Handle wrappers + a
  lsp.find-references command bound to M-? (modeline summary;
  references-list buffer is future UX, like the hover panel).
- Tests: locations.rs unit tests (kind labels distinct; keys don't
  collide); e2e driving all four through the async bridge and
  asserting each routes to its own kind slot (fake returns distinct
  lines 11/21/31/41).

Scoped: documentSymbol / workspaceSymbol / documentHighlight return
different shapes (new parsing) — a separate follow-up, not crammed
in here.

Gate: fmt clean; clippy --all-targets -D warnings clean; lib
1232/0; m4_acceptance 65/0; m9_1 18/0; m8_1/m8_9/m8_10 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 21:39:02 -04:00
Levi Neuwirth 195949f73c T M4.5: answer workspace/configuration pull from per-server settings
gopls / pyright / basedpyright / clangd issue server→client
`workspace/configuration` during startup and degrade (or fall back
to defaults) without a reply. pmacs advertised `configuration:false`,
so it never got the chance.

- Advertise `workspace.configuration: true`.
- New `settings` field on the spawn spec, threaded through
  lua_to_lsp_spec → ensure_server (pmacs.lsp.config[lang].settings).
- handle_request intercepts `workspace/configuration` (mirrors the
  publishDiagnostics interception in handle_notification): each
  item's dotted `section` resolves against the server's settings via
  resolve_config_section; one array element per item; unknown
  sections answer `null` (the spec's "not configured" signal,
  distinct from a configured null). All other server→client requests
  still surface as a `Request` event for the consumer.
- The Python default now ships
  `python.analysis.typeCheckingMode = "basic"` (+ basedpyright.*
  alias), so the #12 basedpyright-noise concern is now actually
  fixed rather than only documented; a project pyrightconfig.json /
  [tool.pyright] still wins where present.

Scoped: `scopeUri` ignored (single-root; same settings regardless
of scope) until multi-root, a separate deferred item.

Tests: exhaustive resolve_config_section unit test (dotted paths,
configured-null vs unknown-null, whole-object for absent section);
new `wsconfig` fake mode pulls config at `initialized` and echoes
pmacs's answer back; end-to-end test asserts the configured section
round-trips.

Gate: fmt clean; clippy --all-targets -D warnings clean; lib
1230/0; m4_acceptance 62/0; m9_1 18/0; m8_1/m8_9/m8_10 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 21:07:24 -04:00
Levi Neuwirth b646553fd2 T M4.5 Option B: LSP position-encoding (UTF-16) negotiation
Before this, pmacs sent and consumed LSP `Position.character` as raw
UTF-8 byte offsets while advertising no `positionEncoding`, so per
the LSP spec every non-3.17 server assumed UTF-16. rust-analyzer is
lenient; clangd/gopls/pyright are not — any non-ASCII byte before a
position silently corrupted definition jumps, diagnostic spans,
hover targets, and formatting edits. This is the correctness gate
before Python/C/C++/Go.

- Negotiation: advertise `general.positionEncodings:["utf-8","utf-16"]`;
  honour the server's `capabilities.positionEncoding`; default UTF-16
  (LSP spec default) when absent. Stored per-LspClient.
- Codec: per-line byte<->utf-8/utf-16. UTF-8 is an identity fast
  path; mid-surrogate / mid-codepoint positions clamp to the
  containing char's start (a unit test caught and fixed an overshoot
  in the first cut).
- Document cache: (server,uri)->text mirrored from did_open /
  did_change_full, dropped on did_close and at every teardown site
  (start_generation / on_exit / forget) alongside the existing drain.
- One conversion at the Rust boundary: a recursive JSON Position
  rewriter at the two inbound seams (absorb_routed_response,
  absorb_publish_diagnostics) plus outbound at request-build. Zero
  store / Lua / consumer changes — completion popup, diagnostics
  gutter, and lsp.lua all stay byte-uniform.
- Non-destructive fallback: a Position on a line absent from the
  cached doc (cross-file / not-yet-opened) is left unconverted, not
  collapsed to 0. Correct production behaviour (cross-file encoding
  is v0.2, tied to deferred cross-file nav) and fixes the m4_12
  definition tests.

Tests: 6 lib unit tests (negotiation default, utf-8 identity, utf-16
non-ASCII round-trip, astral surrogate pair, nth_line EOF semantics,
recursive rewrite). pmacs_fake_lsp `posecho` mode (advertises
utf-16, echoes the wire position into the result uri as `pos:N`);
end-to-end m4 test asserts both directions independently and
discriminatingly against a `é=x` fixture.

Gate: fmt clean; clippy --all-targets -D warnings clean; lib
1229/0; m4_acceptance 60/0; m9_1 18/0; m8_1/m8_9/m8_10 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 20:31:58 -04:00
Levi Neuwirth ad530a5beb T M4.5 async bridge: LSP requests settle async-runtime jobs
Replaces the editor-blocking `poll_until` tick-loop in the LSP UX
with the M9.1 external-settle pattern: each `textDocument/*` request
registers a pending entry via `AsyncRuntime::register_external` and
returns the job id; the JSON-RPC response (or a server-teardown /
cancel / timeout) settles it, resuming a `Handle:await()` coroutine.
No worker thread is occupied for the round-trip.

Hybrid result delivery (operator decision): the response is absorbed
into the typed stores *and* carried through the Handle. The
completion popup and diagnostics gutter keep reading the stores
untouched; request/response command code awaits the value directly.

Core (src/lsp.rs):
- `LspManager` gains `runtime: SharedAsyncRuntime` (threaded through
  `make_lsp_manager` / editor.rs, mirroring `make_mcp_manager`) plus
  a `(server, request_id)` -> PendingExternal awaiter table parallel
  to `pending_routes`.
- `request_*` return the async `JobId` (`= u64`, signature
  unchanged; no caller consumed the old JSON-RPC id).
- `handle_response` settles every non-cancelled awaiter ok/failed
  alongside store absorption; null result still wakes await with nil.
- Awaiters drain-cancelled at all three `pending_routes` purge sites
  (restart generation flip / terminal exit / forget) so a coroutine
  cannot park on a server that went away.
- Per-tick sweep: per-awaiter cancellation (Handle:cancel() or
  supersede via a stable `lsp:{method}:{sid}:{uri}` key), with
  `$/cancelRequest` + `cancelled_rids` on abandonment to drop the
  cancel/response race silently. Mirrors mcp.rs.
- Per-request timeout (default 10s, `pmacs.lsp.set_request_timeout_ms`):
  an alive-but-silent server fails the await instead of hanging.

Lua surface:
- `_request_*_raw` job-id bindings (mirror `pmacs.mcp._send_request_raw`).
- builtin/runtime/lsp.lua: Handle wrappers + the four commands
  rewritten to spawn `pmacs.async` coroutines that `:await()`;
  `poll_until` removed. Server-gone / error surface as structured
  await failures in the modeline.

Tests:
- pmacs_fake_lsp: `error` / `silent` modes for deterministic
  failure-path coverage.
- 5 end-to-end await-path tests (success+store, server-error->failed,
  server-stop->cancelled, timeout->failed, supersede->cancelled).

Gate: fmt clean; clippy --all-targets -D warnings clean; lib
1223/0; m4_acceptance 58/0; m9_1_acceptance 18/0 (MCP unaffected).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 19:36:17 -04:00
Levi Neuwirth d3fa63290a Collapse if-let nests into let-chains (MSRV-1.95 collapsible_if sweep)
Root cause of the CI Lint regression: commit 6113c53 bumped
rust-version 1.85 -> 1.95. clippy::collapsible_if is MSRV-gated —
collapsing `if let { if let }` needs let-chains, stabilized in Rust
1.95. At MSRV 1.85 clippy suppressed these; at 1.95 it emits them.
The patterns were pre-existing; the MSRV bump surfaced 47 of them
and turned `Lint (luajit)` / `Lint (lua54)` red at HEAD (was green
through PR #7; red from PR #8 = the release-prep MSRV bump).

Resolution (operator-chosen: autofix into let-chains): applied
`cargo clippy --fix` across the luajit, lua54, and crdt lanes
(--all-targets). The fix only applied with the lint at warn level;
`-- -D warnings` turns it into an error and blocks --fix.

Verified on the pinned 1.95.0, all three lanes:
clippy --all-targets -D warnings clean (luajit / lua54 / crdt);
fmt 0 diffs; lib tests 1223/0.

Note: the prior #6 "quiescent audit, clippy clean" was inaccurate —
clippy was not actually re-run there (build/version/fmt only), so
this MSRV-gated regression went uncaught until the live attach-debug
investigation surfaced it. This commit restores genuine clippy
cleanliness at MSRV 1.95.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:29:36 -04:00
Levi Neuwirth 587a2a15de M9 ship gate
Land the Model Context Protocol (MCP) integration as a transport binding,
not a built-in feature. Six Lua functions plus userdata methods expose
the substance of three MCP feature areas (resources, tools, prompts), a
notification dispatcher, and a non-trivial AI-assistance example package
that meets the architectural ship gate (spec/pmacs-spec.tex:1572): zero
direct calls into the Rust core, zero special-cased MCP handling outside
the public API, source under 2000 lines of Lua.

The M9.5 -> M9.6 -> M9.7 -> M9.8 layered composition validates the claim
"AI is a transport binding, not a feature" -- pmacs-mcp-ai composes with
pmacs-mcp-prompts.render and inherits notification handling transitively
through M9.7's package, demonstrating that the AI domain is a layer
above MCP, not a thread woven through the core.

Subtask shape:
  M9.1 stdio transport + initialize handshake + restart policy
  M9.2 resources with in-flight + settled cache and per-uri invalidation
  M9.3 tools with isError-vs-JSON-RPC-error semantics + cancellation
  M9.4 prompts with required-argument validation
  M9.5 notification dispatcher (on_notification, off_notification)
  M9.6 tools-as-commands fixture package + 12 audit findings disposed
  M9.7 prompts-as-result-buffers fixture package + tree-sitter-md grammar
  M9.8 AI-assistance fixture package (363+ LoC; 17/17 acceptance tests)
  M9.9 formal package audit -- PASS on all three criteria
  M9.10 release: TRANSITION-M9.md + MCP-for-package-authors guide

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:04:23 -04:00
Levi Neuwirth 0b715de505 M7 tail: package system, audit lint, lockfile, resolver 2026-05-07 16:50:37 -04:00
Levi Neuwirth 4da4b09d5d Initial commit: v0.1.0 2026-05-03 19:51:06 -04:00