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>
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>
Two more real languages on the proven async + UTF-16 + config-pull
base. Pure pattern application of the #12 Python shape.
- pmacs.lsp.config.c / .cpp → clangd `--background-index`. One
binary serves both; separate entries only so the didOpen
languageId is accurate. No `settings`: clangd's project model is
compile_commands.json / compile_flags.txt, not
workspace/configuration (documented in-line).
- pmacs.lsp.config.go → gopls (no args = stdio). settings =
{ gopls = {} } so the #13 workspace/configuration pull is answered
"use defaults" (present, not null — gopls prefers that).
- pmacs.lsp.filetypes extended: c/h → c (.h defaults to C,
remappable); cpp cc cxx hpp hh hxx ipp inl cppm → cpp; go → go.
- Two PATH-gated acceptance tests via a shared DRY helper, mirroring
m4_5_basedpyright: reach Initialized + assert the negotiated
positionEncoding is one pmacs can encode. Skip cleanly when the
binary is absent.
clangd is on the dev PATH, so its test ran for real: the full stack
(async bridge + Option B UTF-16 + registry + filetypes) is validated
end-to-end against a real strict-default C/C++ server, closing the
"validate UTF-16 against a real strict server" gap from the Option B
evaluation. gopls test skips here; runs wherever gopls is installed.
Gate: fmt clean; clippy --all-targets -D warnings clean; lib
1230/0; m4_acceptance 64/0 (clangd ran live); m9_1 18/0;
m8_1/m8_9/m8_10 green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
First real language on the now-correct async + UTF-16 substrate.
- pmacs.lsp.config.python → `basedpyright-langserver --stdio`.
basedpyright (MIT fork of pyright) re-enables inlay hints /
semantic tokens in the OSS server that upstream pyright withholds
for Pylance — matches the deferred-feature roadmap. No init_options:
strictness is project config (pyrightconfig.json / [tool.pyright]);
pmacs does not yet advertise workspace/configuration, so an
editor-side typeCheckingMode would not be honoured regardless
(documented in-line, with the upstream-pyright one-field override).
- LSP language detection separated from tree-sitter. pmacs.parse's
extension registry is grammar-gated (rejects "python" — no bundled
grammar). New user-extensible pmacs.lsp.filetypes map (py/pyi →
python); active_buffer_language() tries grammar-backed parse first
(rust/.rs etc. unchanged) then falls back to the map, so a language
with a server but no grammar still auto-attaches.
- PATH-gated acceptance test mirroring m4_5_rust_analyzer_initializes;
unique assertion: a real basedpyright must negotiate a
positionEncoding pmacs can encode — validates Option B against a
real strict server, not just the fake. Skips when absent.
Gate: fmt clean; clippy --all-targets -D warnings clean; lib
1229/0; m4_acceptance 61/0; m9_1 18/0; m8_1/m8_9/m8_10 green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
The async bridge settles awaiters inside `tick_lsp`/`tick_mcp` by
posting to the message bus; `tick_async` drains that bus and resumes
the parked coroutine. With `tick_async` running *first* (historical
accretion from M3.3, predating processes/LSP/MCP), every LSP/MCP
`:await()` resumption was deferred a full frame: the response
absorbed in frame N's `tick_lsp` wasn't observed until frame N+1's
`tick_async` (~33ms structural floor @ 60Hz, plus a render frame).
Reordering both production loops (`editor::run` and the daemon loop)
to `processes → lsp → mcp → async` makes settle→resume happen in the
same frame, halving the floor to one frame. The only documented
ordering invariant — `tick_processes → tick_lsp → tick_mcp` for
same-batch supervisor I/O — is preserved; settle (bus post) and
resume (bus drain) are bus-decoupled, so the move cannot regress
correctness in either direction.
Acceptance tests open-code their own per-test tick orders and never
drive `editor::run`, so none covered production ordering. Added
`m4_5_await_resolves_same_frame_as_response_absorbed`, which drives
the exact production order and asserts the awaited request resolves
in the same frame its response is absorbed (absorbed_cycle ==
done_cycle); it fails if anyone reverts to `tick_async`-first.
Gate: fmt clean; clippy --all-targets -D warnings clean; lib
1223/0; m4_acceptance 59/0; m9_1 18/0; m8_1/m8_9/m8_10 green
(SP-7 outline-aggregate "one async tick" pin unaffected).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
Land the optimistic local-edit-application layer on top of the M10 CRDT
foundation: frontend-side rope replica with local edit application,
daemon-authoritative broadcast, and bidirectional cursor reconciliation.
Keystrokes feel instantaneous because the local replica answers next-render
queries before the daemon round-trip completes, while the daemon remains
the single source of truth for conflict resolution and broadcast to remote
replicas.
Architecture beats:
- BufferMirror (src/buffer_mirror.rs) holds a per-frontend rope replica
with explicit cursor-staleness tracking. Every event that may move the
active cursor or swap the active buffer marks the mirror stale; the
next CursorByte from the daemon clears it.
- CrdtOpOrigin {OptimisticReplica(FrontendId), DaemonKey} routes broadcast.
OptimisticReplica skips re-application on the originating frontend
(already applied locally); DaemonKey broadcasts to all replicas including
source -- covers Lua-driven and generated-buffer edits that bypass the
optimistic path.
- Generated buffers (*help*, *workers*, *pmacs-instance*, *errors*) funnel
apply_edit output through queue_daemon_origin_crdt_op so post-attach
CRDT upgrades don't drop their edits.
- forbid(unsafe_code) preserved throughout; loro 1.12 added as the CRDT
engine.
Audit posture: M10.10 shipped through six post-audit review rounds with
twenty-eight cumulative findings, most categorized as "incomplete
application of a prior round's mechanism." The audit doc records
grep-driven exhaustiveness as the standing countermeasure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>