Merge canonical main after Vterm Stage 1

Synchronize the documentation continuity lane with #126, preserve the parked
work inventory, and record the landed terminal-core state and next sequential
TUI/GPU stages.
This commit is contained in:
Levi Neuwirth 2026-07-21 16:59:57 -04:00
commit 4e93596e8a
34 changed files with 12150 additions and 537 deletions

23
Cargo.lock generated
View File

@ -2558,6 +2558,7 @@ dependencies = [
"tree-sitter-cuda",
"tree-sitter-go",
"tree-sitter-javascript",
"tree-sitter-json",
"tree-sitter-lua",
"tree-sitter-make",
"tree-sitter-md",
@ -2565,7 +2566,9 @@ dependencies = [
"tree-sitter-rust",
"tree-sitter-toml-ng",
"tree-sitter-typescript",
"tree-sitter-yaml",
"tree-sitter-zig",
"unicode-segmentation",
"unicode-width",
]
@ -3807,6 +3810,16 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-json"
version = "0.24.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-language"
version = "0.1.7"
@ -3883,6 +3896,16 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-yaml"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-zig"
version = "1.1.2"

View File

@ -86,6 +86,7 @@ crdt = ["dep:loro", "pmacs-protocol/crdt"]
crossterm = "0.28"
thiserror = { workspace = true }
unicode-width = "0.2"
unicode-segmentation = "1"
# Regex engine for in-buffer regex search (Q#RX1). `regex::bytes::Regex`
# matches over rope-snapshot bytes and yields byte offsets directly.
# Already in the lockfile transitively; promoted to a direct dependency.
@ -196,6 +197,13 @@ tree-sitter-javascript = "0.25"
tree-sitter-typescript = "0.23"
tree-sitter-toml-ng = "0.7"
tree-sitter-zig = "1.1"
# JSON + YAML — config formats + the honest gate on the Jupyter path
# (JSON). Both are ABI-current (`LANGUAGE: LanguageFn` via
# `tree-sitter-language`), NOT a `tree-sitter ^0.20` fork. Registering
# yaml also lights up markdown `---` frontmatter through the #122
# injection engine (the block injection query already sets yaml for it).
tree-sitter-json = "0.24"
tree-sitter-yaml = "0.7"
# T M9.7: markdown grammar so prompt result buffers with
# `_meta.format = "markdown"` get structured highlighting through the
# same M4 path as rust/lua — no special-case painter in Lua.

View File

@ -200,6 +200,62 @@ pmacs.lsp.config.zig = pmacs.lsp.config.zig or {
args = {},
}
-- JSON via the VS Code JSON server, binary `vscode-json-language-server`.
-- It is a PUSH-model server: it reads config from
-- `workspace/didChangeConfiguration` (the daemon now sends one after
-- `initialized`) and does NOT issue `workspace/configuration` pulls, so
-- without that push these settings would be inert. `json.validate.enable`
-- is set explicitly true — the server treats a MISSING value as false, so
-- an empty `json = {}` would silently disable validation. Schema
-- retrieval performs NETWORK ACCESS for remote `$schema` URLs (left
-- enabled; `handledSchemaProtocols = {"file"}` would disable it but break
-- remote schemas without a `vscode/content` impl). Note: the server does
-- NOT auto-associate `package.json`/`tsconfig.json` — it starts with empty
-- contributions; explicit `$schema` refs or configured `json.schemas` /
-- a `json/schemaAssociations` push (not implemented) are required.
-- Provider: pin `@t1ckbase/vscode-langservers-extracted@2.0.2`
-- (`npm install -g @t1ckbase/vscode-langservers-extracted@2.0.2`).
-- Its published payload bundles the JSON server from VS Code 1.129.0,
-- preserves this command name, and was live-smoked through initialize →
-- config push → invalid-JSON diagnostic → shutdown. The older unscoped
-- package is stale and the current `@zed-industries` payload has a broken
-- JSON launcher; neither is the recommended provider.
pmacs.lsp.config.json = pmacs.lsp.config.json or {
command = "vscode-json-language-server",
args = { "--stdio" },
settings = {
json = { validate = { enable = true } },
http = {},
},
}
-- YAML via Red Hat `yaml-language-server`. On
-- `workspace/didChangeConfiguration` (now pushed after `initialized`) it
-- reads the `yaml`, `http`, `[yaml]`, `editor`, and `files` sections — all
-- ship present-not-null (empty ⇒ server defaults). SchemaStore / remote
-- schema retrieval performs NETWORK ACCESS by default. The standalone
-- server does not upload telemetry itself — it emits `telemetry/event`
-- notifications to its client, and pmacs has no telemetry uploader, so a
-- `redhat.telemetry` setting would be inert and is not shipped. Sections
-- live-observed with Red Hat `yaml-language-server@1.24.0`: its initial
-- pull requests exactly those five sections, and opening a YAML document
-- requests a second scoped `[yaml]` section. The standalone smoke reached
-- a real syntax diagnostic and clean shutdown. The PATH-gated pmacs
-- acceptance also proves auto-attach, initialization, config pulls, a
-- syntax diagnostic, and continued server liveness with both catalogs
-- disabled for network-free determinism.
pmacs.lsp.config.yaml = pmacs.lsp.config.yaml or {
command = "yaml-language-server",
args = { "--stdio" },
settings = {
yaml = {},
http = {},
["[yaml]"] = {},
editor = {},
files = {},
},
}
-- LSP-side extension → language map, deliberately independent of the
-- tree-sitter detection in `pmacs.parse`. Consulted only when
-- `pmacs.parse.language_for_path` finds nothing (an extension with a
@ -267,6 +323,11 @@ pmacs.lsp.filetypes.toml = pmacs.lsp.filetypes.toml or "toml"
-- Zig (zls). `.zon` is Zig Object Notation, handled by the same server.
pmacs.lsp.filetypes.zig = pmacs.lsp.filetypes.zig or "zig"
pmacs.lsp.filetypes.zon = pmacs.lsp.filetypes.zon or "zig"
-- JSON / YAML. Both ship grammars, so `language_for_path` already resolves
-- these and the map is the stable-id fallback (same role as `lua`/`cuda`).
pmacs.lsp.filetypes.json = pmacs.lsp.filetypes.json or "json"
pmacs.lsp.filetypes.yaml = pmacs.lsp.filetypes.yaml or "yaml"
pmacs.lsp.filetypes.yml = pmacs.lsp.filetypes.yml or "yaml"
-- Per-buffer attachment record: { language, server, uri, version }.
-- Keyed by `tostring(BufferIdLua)` because BufferIdLua hands out fresh
@ -730,6 +791,22 @@ function pmacs.lsp.active_attachment()
return attachments[tostring(buf)]
end
-- Arc 4 stage 3: pure modeline projection. This reads the private
-- per-buffer attachment map directly so passive split windows report their
-- own buffer instead of the focused window. It never attaches, flushes
-- didChange, or issues a request.
pmacs.statusline.register {
name = "lsp",
side = "right",
priority = 0,
face = "ui.modeline.lsp",
fn = function(ctx)
local rec = attachments[tostring(ctx.buffer)]
if not rec then return nil end
return "LSP:" .. pmacs.lsp.modeline_label(rec.server)
end,
}
-- Flushing variant for request-issuing callers outside this file
-- (Q#C8): when the active buffer already has a server attached,
-- flush any debounced didChange first and return the record, so the

View File

@ -114,64 +114,85 @@ commands, read `docs/active-work.md` immediately after this file.
`pmacs.editor.take_typed_edit()` (buffer-revision postcondition,
Q#AP9). Substrate: `buf:path()`, `pmacs.lsp.buffer_language(buf)`,
`PMACS_FAKE_LSP_CHANGE_SINK`, `TestDaemon::spawn_with_config`.
- **Themes (Arc 4) stage 1 LANDED — #120 merged after 5 review
rounds** (`docs/theme-faces-framing.md` rev 9 is the full record):
named UI faces as reserved `ui`/`ui.*` theme entries (12-face
inventory, owns-surface-within-mask, masks identical on both
frontends); `Theme::face()` walk (`None` when unset); transactional
mutators with split syntax/face epochs (fixed the pre-existing
mid-session `theme.set` span staleness); `ThemeFacts` channel (v16,
one authoritative send per attachment; v15 peers excluded incl. the
`FileStyleSummary` face-leak side channel). Review rounds hardened
substrate beyond faces: the **snapshot/baseline reset contract**
(`on_buffer_snapshot_sent` daemon-side + the GPU arm's symmetric
search/menu/status clears; minibuffer, gutter mode, `ThemeFacts`
survive both sides) and the **store-sourced diag-count freeze**
(per-URI severity totals in `DiagnosticStore`, O(1), survive
`mark_stale`).
- **Themes (Arc 4) stage 2 LANDED — #124 merged after the complete
behavioral review** (`docs/gpu-set-font-framing.md` rev 5):
`pmacs.gpu.set_font { family?, size? }` is a live global preference;
authoritative bufferless `FontFacts` is gated at protocol v17.
GPU family resolution is frontend-local and fail-closed to a
sanitized monospace default across normal/bold/italic queries;
metrics for all seven glyphon buffers derive atomically from one
logical-pixel size. The visual-run caret substrate normalizes
source bytes through adornments and shaped clusters (including
combining sequences/ligatures), preserves deliberately scrolled-away
viewports, and reflows on font, gutter, minimap, text, CRDT, resize,
and snapshot geometry changes. Test fixtures enter fontdb before
`FontSystem` construction; alternate advances are measured across
complete shaped runs, not sampled glyphs.
- **Themes (Arc 4) stage 3 LANDED — #125; ARC 4 COMPLETE**
(`docs/statusline-segments-framing.md` rev 3). Strict composable
`pmacs.statusline` providers evaluate per frontend/window through a
borrow-released three-phase transaction with per-context failure
latches. TUI composition is grapheme/display-width correct; the pure
built-in LSP provider proves passive-window context. Protocol v18
carries complete `StatuslineSegments` replacements and dynamic
modeline faces; snapshot baselines reset symmetrically. GPU validates
untrusted payloads atomically, shapes rich runs without wrapping, and
right-pins the protected suffix under narrow clipping. Review
hardening pins fixed-face sortedness, retains flattened provider
tracebacks, and names unavailable layout contexts accurately.
- **Vterm Stage 1 terminal core LANDED — #126**
(`docs/vterm-framing.md` rev 5). `AnsiParserProfile::{LineOriented,
FullScreen}` preserves compile/REPL behavior while terminal PTYs emit the
full cursor/mode/device operation set. `src/terminal/{screen,input,session}.rs`
owns a bounded terminal screen, encoders, and the transactional lifecycle
registry; one pathless read-only identity buffer anchors each private
process/screen. Review hardening added IND/NEL/RI, `TERM=xterm-256color`,
portable shutdown liveness, custom-tab-stop preservation, control-free
cells, and button-preserving SGR mouse release. Final gates: 1,661 default +
1,837 CRDT library tests; 9 default + 10 CRDT Vterm acceptance; 114 M4; 109
required GPU; workspace 2,769 across 79 suites; CI green. This is headless:
Vterm Stage 2 TUI/Lua and Stage 3 protocol/GPU start as separate PRs from
post-#126 `main`.
- **Themes (Arc 4) stages 13 LANDED; Arc 4 COMPLETE ON `main`.**
- Stage 1 (#120, `docs/theme-faces-framing.md` rev 9): named UI faces
as reserved `ui`/`ui.*` theme entries; transactional split
syntax/face epochs; protocol-v16 `ThemeFacts`; snapshot/baseline
symmetry; store-sourced diagnostic-count freeze.
- Stage 2 (#124, `docs/gpu-set-font-framing.md` rev 5):
`pmacs.gpu.set_font` and authoritative protocol-v17 `FontFacts`;
frontend-local family resolution, live font reload/reflow, and
visual-run caret geometry.
- Stage 3 (#125, `statusline-segments`,
`docs/statusline-segments-framing.md` rev 3): composable strict
`pmacs.statusline` providers; borrow-released per-window evaluation
with failure latches; legacy-preserving TUI composition; a pure
built-in LSP provider; dynamic modeline faces; protocol-v18
`StatuslineSegments`; authoritative-empty/snapshot symmetry; and
atomic GPU validation, face resolution, shaping, clipping, and
cache invalidation. Acceptance 1-27 is implemented. Final gates:
Clippy clean; 1,619 default + 1,793 CRDT library tests; 7 default +
8 CRDT feature acceptance; 114 M4; 109 required GPU; one-invocation
workspace sweep 2,718 passed across 78 suites (19 ignored,
`basedpyright` filtered); `git diff --check` clean. Stage 3 landed
as #125 and completed Arc 4 on `main`.
- **Vterm Stage 1 terminal core LANDED ON `main`#126**
(`docs/vterm-framing.md` rev 5; merge `643d1e1`).
- Implementation commits: `bbc1f33` (Stage 1), `962944b` (Darwin signal
normalization), first-review fixes `f0a235f`, `28f2e6c`, `bf972a7`, and
second-review hardening `9797ada`; reviewed feature head `fc4e0ce` merged
through PR #126, <https://github.com/levineuwirth/pmacs/pull/126>.
- `AnsiParserProfile::{LineOriented, FullScreen}` preserves compile/REPL
behavior while terminal PTYs emit the full cursor/mode/device operation
set. `src/terminal/{screen,input,session}.rs` owns the state machine,
encoders, and lifecycle registry.
- Public session seam: owned strict `TerminalSpec`; owned
`TerminalSnapshot`; `TerminalProcessState`; and
`SharedTerminalManager = Rc<RefCell<TerminalManager>>` with
`open/is_terminal/process_id/snapshot/tick/send/resize/terminate/prune/
shutdown`. Stage 1 snapshots are context-free; Stage 2 adds per-view
state without a second screen.
- `EditorState` tick order is supervisor → terminal-owned PID drain/prune →
`process.after-tick`. Terminal IDs are not exposed through
`pmacs.process`; ordinary Lua/LSP/MCP ownership is unchanged. Terminal
identity buffers are pathless, clean, empty, round-trip, and guarded
read-only at every rope/CRDT/history mutation boundary.
- Acceptance 114 is mapped in the framing. The real PTY bite splits
ESC/CSI writes, observes alternate-screen cursor addressing, blocks and
resumes through raw `send`, restores the main screen, and pins final
output before exact PID/outcome annotation. One-row annotation visibility,
TERM-ignoring shutdown, spawn rollback, buffer-kill prune, and immutable
empty CRDT bootstrap are pinned.
- Review round 1 added typed IND/NEL/RI with margin-correct screen behavior,
defaults absent `TERM` to `xterm-256color`, makes shutdown liveness
acceptance portable with `kill(pid, 0)`, and preserves custom tab stops on
resize. Review round 2 rejects C0/C1 controls before they enter screen
cells, preserves the released button code in SGR mouse reports, removes
dead screen paths, and clears stale round-trip state during prune. Stage 2
must uniquify default terminal buffer names.
- Exact CUU/CUD and out-of-range DECSTBM clamping, combining across controls,
xterm alternate-screen details, legacy non-SGR mouse, printable ASCII and
CSI-dispatch allocation fast paths, and scrollback-cap naming are explicit
post-arc deferrals in the framing.
- Final from-start rerun after review round 2: Clippy clean; 1,661 default +
1,837 CRDT library tests (3 ignored each); 9 default + 10 CRDT vterm
acceptance; M4 114 passed (3 ignored, 1 filtered); required GPU 109;
workspace 2,769 passed across 79 suites (19 ignored, 1 filtered); diff
check clean. `scripts/bite HEAD^ src/terminal/screen.rs --test
vterm_stage1_acceptance terminal_cells_reject_child_control_characters`
is a clean behavioral bite. The parser dispatch has its independent clean
behavioral bite; the original `main`/crate-root bite remains explicitly
weaker compile-time API evidence.
- Stage 2 reviews require a durable focus/input resize owner, owning
`FrontendId` for the global `C-c` continuation, and local clipboard/BEL
signal drainage. Stage 3 additionally owns `pmacs-gpu/src/attach.rs`,
authenticated source routing, protocol-owned wire types/limits, and a
deliberate complete-frame limit decision: 16 MiB is insufficient; use a
measured legal-worst cap or aggregate bound, never silent chunking.
- **PARKED: kill-ring browser + persistence.** Revision 2 framing is
preserved on branch `kill-ring-browser`, but its `0efb5cd` scout is
stale and must be repeated before implementation. No PR or
implementation is active.
preserved on branch `kill-ring-browser`, but its `0efb5cd` scout is stale
and must be repeated before implementation. No PR or implementation is
active.
- Roadmap: `docs/roadmap-2026-07.md` (ranked arcs). Position:
- **Arc 1 (LSP utility surface) COMPLETE** — completion popup
(#92/#93), panels/references/outline/hover (#94#96), plus

220
docs/json-yaml-framing.md Normal file
View File

@ -0,0 +1,220 @@
# JSON + YAML grammars — framing (side quest, highlight family)
**Revision 4 — 2026-07-21. Status: PR #123 open and awaiting review;
the public and checkpoint branches are at fully gated `5c202c5`, rebased
onto `main` `f8096ff`.**
The JSON provider and Red Hat YAML 1.24.0 have each passed their
PATH-gated pmacs acceptance, in addition to the deterministic fake-server
config-push proof and the YAML standalone protocol smoke.
**Intent.** Add `tree-sitter-json` and `tree-sitter-yaml` grammars (plus
their language servers) to the bundle. Two config formats that pmacs
currently renders as plain text, and — the reason this is the natural
next side quest — the **honest gate on the Jupyter `.ipynb` path** (JSON)
and an **immediate payoff from the injection engine just shipped (#122)**:
the markdown block grammar's `injections.scm` already sets
`injection.language "yaml"` for `---` frontmatter and `"toml"` for `+++`
frontmatter, so registering YAML lights up YAML frontmatter highlighting
with zero extra wiring (TOML frontmatter already works — `toml` landed in
#118 and injections in #122). This is a mostly-additive grammar-gap-style
change, following the #118 pattern, with the frontmatter/fence synergy as
the demonstrable headline.
---
## Ground truth (as of `main` @ `56eb67e`, #121)
- **Adding a grammar** is a one-line `LanguageEntry` in
`crate::syntax::BUILTIN_LANGUAGES` (`name`, `extensions`, `loader`,
`highlights_query`, `injections_query`) + a `tree-sitter-foo` dep. The
Lua `buffer.after-load` path picks it up automatically; detection is
extension → LSP filetype → filename → shebang
(`resolve_active_language`).
- **Grammar name MUST equal the `pmacs.lsp.config.<name>` key** — grammar
detection wins over the filetype map, so the name it resolves is the id
the LSP client keys off (the #118 invariant; there's an acceptance test
that pins every grammar-gap language to its config key).
- **LSP configs** are `pmacs.lsp.config.<name> = … or { command, args,
[settings|init_options] }` (`builtin/runtime/lsp.lua`); no json/yaml
config today. `pmacs.lsp.filetypes` is the LSP-only extension fallback
(consulted only when `language_for_path` misses).
- **Injection synergy (#122).** The bundled `tree_sitter_md::
INJECTION_QUERY_BLOCK` contains:
- `((minus_metadata) @injection.content (#set! injection.language
"yaml"))` — `---`-fenced frontmatter,
- `((plus_metadata) @injection.content (#set! injection.language
"toml"))` — `+++`-fenced frontmatter,
- fenced code blocks via the dynamic info-string.
So a registered `yaml` grammar is injected into markdown frontmatter
automatically, and ` ```json `/` ```yaml `/` ```yml ` fences resolve
(`yml`→yaml is already in `default_injection_aliases`; `json` is the
bundled name).
**Confirmed crate facts** (probed against the registry + a build under
tree-sitter 0.26):
1. `tree-sitter-json` **0.24.8**`pub const LANGUAGE: LanguageFn` (via
`tree-sitter-language`, the modern shared ABI) + `HIGHLIGHTS_QUERY`.
Compiles and links under our tree-sitter 0.26. No `INJECTIONS_QUERY`
(JSON embeds nothing).
2. `tree-sitter-yaml` **0.7.2** — same shape (`LANGUAGE: LanguageFn`,
`HIGHLIGHTS_QUERY`, `tree-sitter-language` dep). Compiles under 0.26.
No `INJECTIONS_QUERY`.
3. Both are the ABI-current crates — **not** a `tree-sitter ^0.20` fork
(the dockerfile trap from #118). A single build confirmed link +
compile; runtime `set_language` is pinned by the ABI acceptance test.
---
## Decisions
### Q#JY1 — Two `LanguageEntry`s, self-contained highlights, no injections
Add `json` and `yaml` to `BUILTIN_LANGUAGES`, each
`highlights_query: &[…::HIGHLIGHTS_QUERY]` (self-contained, no
`; inherits:` delta), `injections_query: &[]`. Extensions:
- **json:** `.json`. (`.jsonc`/`.json5` — comment/trailing-comma variants
the plain JSON grammar rejects — are **deferred**; a `.jsonc` grammar
or a lenient mode is a separate call.)
- **yaml:** `.yaml`, `.yml`.
Root kinds (pinned by the ABI test): json `document`, yaml `stream`.
### Q#JY2 — LSP configs: `vscode-json-language-server` + `yaml-language-server`
- **json:** binary `vscode-json-language-server --stdio` (the VS Code
JSON server). It is **push-model**: it reads config from
`workspace/didChangeConfiguration` and does **not** issue
`workspace/configuration` pulls — so pmacs, which previously only
*answered* pulls, must now also **push** a `didChangeConfiguration`
after `initialized` (a general LSP-client fix in `src/lsp.rs`; pull
servers ignore it). `json.validate.enable` is set **explicitly true**
— a missing value reads as false and silently disables validation, so
an empty `json = {}` is wrong. The server does **not** auto-associate
`package.json`/`tsconfig.json` (it starts with empty contributions);
explicit `$schema` refs or configured `json.schemas` / a
`json/schemaAssociations` push (not implemented) are required. Schema
retrieval performs **network access** for remote `$schema` URLs, left
enabled (`handledSchemaProtocols = {"file"}` would disable it but break
remote schemas without a `vscode/content` impl). **Provider:** pin
`@t1ckbase/vscode-langservers-extracted@2.0.2`
(`npm install -g @t1ckbase/vscode-langservers-extracted@2.0.2`). Its
published payload bundles the JSON server from VS Code 1.129.0,
preserves the `vscode-json-language-server` command, and was
live-smoked through initialize → config push → invalid-JSON diagnostic
→ shutdown. The unscoped package is stale; the current
`@zed-industries` payload has a broken JSON launcher, so neither is the
recommended provider.
- **yaml:** `yaml-language-server --stdio` (Red Hat). Its settings handler
reads the sections **`yaml`, `http`, `[yaml]`, `editor`, `files`** (via
`didChangeConfiguration` / pulls) — all ship present-not-null. It does
**not** upload telemetry itself (it emits `telemetry/event` to the
client; pmacs has no uploader), so a `redhat.telemetry` setting is inert
and is not shipped. SchemaStore / remote schema retrieval performs
**network access** by default.
Both servers stay **external** (installed by the user), adding **no
licensing payload** to pmacs; if either is ever bundled, retain its MIT +
dependency notices. The exact sections are **pinned in the config + a
test** (not merely "some non-nil table exists"). The pinned JSON
provider was installed into an isolated temporary prefix and
live-smoked through pmacs. Red Hat `yaml-language-server@1.24.0` was
also installed in an isolated prefix and live-smoked over stdio: its
initial configuration pull was exactly `yaml`, `http`, `[yaml]`,
`editor`, `files`; opening the document caused a second scoped
`[yaml]` pull; invalid YAML produced a parser diagnostic; shutdown was
clean. Both providers have also passed their PATH-gated pmacs acceptance.
Config-push delivery is proven deterministically through the fake server's
config sink. Servers activate only if installed; the grammar is the
always-on value.
### Q#JY3 — Filetype fallback + alias entries
Add `pmacs.lsp.filetypes` entries (`json`→json, `yaml`/`yml`→yaml) as the
stable-id fallback (grammar detection wins in practice, same role as the
`cuda`/`lua` entries). `default_injection_aliases` already has `yml`→yaml;
`json`/`yaml` are bundled names needing no alias. Special *filenames*
(`.prettierrc`, `docker-compose.yml` is already `.yml`, extensionless
CI/config yaml) are **deferred** to the filename map as a follow-up.
### Q#JY4 — Frontmatter/fence highlighting is the headline, and it's free
No new injection wiring: registering `yaml` makes the existing markdown
`minus_metadata`→yaml injection resolve, and ` ```json `/` ```yaml `
fences resolve through the #122 engine. Acceptance proves both end to end
(this is the demonstrable payoff and the tie-back to injections).
---
## Bets
1. The two crates are ABI-current and drop in like the #118 grammar-gap
languages — verified by a build; the ABI test is the runtime pin.
2. The frontmatter/fence synergy needs zero engine changes — it falls out
of #122 + the markdown injection query.
3. The two configuration models are now observed: JSON consumes the
pushed full settings object; YAML 1.24.0 pulls the five documented
sections plus a document-scoped `[yaml]` request. The remaining bet
was that pmacs answers the real YAML server correctly end to end;
the PATH-gated acceptance now proves that against version 1.24.0.
## Deferred (named)
- `.jsonc` / `.json5` (comments / trailing commas) — needs a lenient
grammar or variant entry.
- Special-filename detection for extensionless config files (`.prettierrc`,
CI yaml) via the filename map.
- JSON **schema** wiring (custom `json.schemas` / `yaml.schemas` settings)
beyond the servers' built-in schema stores.
- The Jupyter `.ipynb` arc itself (JSON is its prerequisite, not its
delivery).
## Acceptance
1. `builtin_languages_include_json_and_yaml` — entries present, claim
their extensions, ship non-empty highlights.
2. `json_grammar_loads_and_parses` — ABI: `set_language` + parse a JSON
object; root `document`, no error (the runtime ABI pin).
3. `yaml_grammar_loads_and_parses` — ABI: parse a YAML mapping; root
`stream`, no error.
4. `json_yaml_highlights_compile` — both highlights queries compile and
resolve several capture classes.
5. `language_for_path_resolves_json_yaml``.json`→json, `.yaml`/`.yml`→
yaml.
6. `json_yaml_align_with_lsp_configs` — grammar name == the
`pmacs.lsp.config.<name>` key (the #118 invariant).
7. **`yaml_frontmatter_injects_in_markdown`** — a markdown doc with a
`---\nkey: val\n---` frontmatter yields a `yaml` child layer that
highlights; **the headline synergy with #122**.
8. `json_fence_injects_in_markdown` — a ` ```json ` fence yields a `json`
child layer.
9. `m4_json_yaml_lsp_configs_pin_command_and_sections` — the configs
pin the binary + `json.validate.enable = true` + the exact section
sets (json: `json`,`http`; yaml: `yaml`,`http`,`[yaml]`,`editor`,
`files`; no inert `redhat.telemetry`) — pinned, not merely non-nil.
10. `m4_5_initial_config_pushed_via_did_change_configuration` — the
daemon PUSHES `workspace/didChangeConfiguration` after `initialized`
(the push-model delivery path), verified through the fake server's
config sink. Without it, push-only servers' settings are inert.
11. `m4_real_json_provider_receives_config_and_reports_diagnostics`
PATH-gated live smoke for the pinned provider: initialize through
pmacs, receive the pushed default config, open invalid JSON, and
publish a syntax diagnostic. Skips when the binary is absent.
12. `m4_real_yaml_provider_pulls_config_and_reports_diagnostics`
PATH-gated live smoke for Red Hat `yaml-language-server@1.24.0`:
auto-attach through pmacs, disable SchemaStore and Kubernetes CRD
catalog network access for determinism, reach initialized, open
invalid YAML, publish a diagnostic, and remain alive.
## Risks / interactions
- **LSP configuration** (Q#JY2) — JSON push, YAML standalone pulls, and
the real YAML-through-pmacs path are observed. Both live provider
tests remain PATH-gated, so release verification must put the pinned
binaries on PATH rather than accepting their skip paths.
- **Themes / injections** — untouched. This is pure grammar+detection
addition; it consumes the #122 engine, doesn't change it. No protocol
bump.
- **`.yml` vs `.yaml`** — both map to `yaml`; no collision with any
existing entry.

View File

@ -106,8 +106,8 @@ Package entry chunks run during package load, including audit and
headless load paths. Keep top-level code limited to registration and
state setup. Surfaces installed by the base Lua host are available
there: `pmacs.buffer`, `pmacs.command`, `pmacs.keymap`,
`pmacs.hook`, `pmacs.describe`, `pmacs.help`, `pmacs.attach`,
`pmacs.now_ms`, and the standard Lua libraries.
`pmacs.hook`, `pmacs.statusline`, `pmacs.describe`, `pmacs.help`,
`pmacs.attach`, `pmacs.now_ms`, and the standard Lua libraries.
Editor-state surfaces are available once the editor bridge is
installed: command bodies invoked by pmacs, main-thread hooks fired by
@ -414,6 +414,55 @@ to work whenever `define` works (parity), and packages need to
call it from `on_unload` hooks that fire on post-init `reload(name)`
calls.
### Statusline providers: register for every window, unregister on unload
`pmacs.statusline.register` installs a live provider and returns an
opaque handle. Registration accepts a strict table with only `name`,
`side`, `priority`, `face`, and `fn`: `name` is a non-empty display
label, `side` is `"left"` or `"right"`, `priority` defaults to `0`,
`face` defaults to `"ui.modeline"` and otherwise must be a
`ui.modeline.*` face, and `fn` is the callback.
Providers are evaluated once for each rendered window context, not once
for the editor's active buffer. Always read the callback's `ctx.buffer`
handle; a split's passive window can display a different buffer:
```lua
local segment = pmacs.statusline.register {
name = "mypkg-buffer",
side = "left",
priority = 20,
face = "ui.modeline.mypkg",
fn = function(ctx)
-- ctx.frontend and ctx.window are integer identities.
-- ctx.buffer is this window's Buffer handle, even when passive.
local marker = ctx.active and "*" or ""
return marker .. ctx.buffer:name()
end,
}
pmacs.packages.on_unload(function()
pmacs.statusline.unregister(segment) -- idempotent; false if already gone
end)
```
The callback returns a string, `nil`, or `""`; the latter two mean no
segment. Output is one line (the first newline ends it), control
characters become spaces, and an over-limit result is omitted as a
provider failure. Failures are reported once per provider/window
context until that context succeeds or the provider is disabled and
re-enabled.
Ordering is deterministic: left providers use priority descending,
then registration order; right providers use priority ascending, then
registration order. `pmacs.statusline.providers()` returns fresh
metadata tables. `set_priority(handle, integer)` and
`set_enabled(handle, boolean)` return `false` for a stale handle and
change live output immediately. `unregister(handle)` is idempotent and
returns whether it removed a live provider. Registering in package
top-level code without the matching `on_unload` cleanup leaks the old
provider across `reload(name)`.
### `pmacs.fs.*` — worker-dispatched filesystem primitives
The four async fs operations packages need without reaching for

View File

@ -21,6 +21,12 @@ against this design:
- **M11.5** — the headless `SemanticClient` glue + reconstruction-
equivalence and end-to-end tests.
- **Themes Arc 4 stage 3 (protocol v18)** — composable Lua statusline
providers project complete ordered left/right text+face runs through
`StatuslineSegments`. The daemon evaluates one callback per matching
window context; the frontend owns shaping, separators, clipping, and
all pixel placement.
Post-M11 producer arc (the LSP feature arc landed the missing data
sources, so the "wire in when those features land" promise came due):
@ -97,36 +103,36 @@ prohibited by this contract, not merely discouraged.
## Composition with v1.0 primitives
The semantic projection ships **no text**. A `semantic_render`
The semantic projection ships **no document text**. A `semantic_render`
session is required to also be a text replica — it holds the rope
locally via the existing `crdt_replica` machinery
(`BufferSnapshot` to bootstrap, `CrdtOp` to stay live). The
semantic frame is purely the *interpretation layer* over a buffer
the frontend already has: styling and decoration keyed by byte
range. This mirrors how v1.0 already coupled `multi_frontend`
and `crdt_replica`, and it keeps the new wire tiny — single-digit
KB for a screenful, diffable at span granularity.
locally via the existing `crdt_replica` machinery (`BufferSnapshot` to
bootstrap, `CrdtOp` to stay live). Styling and decorations are purely
interpretation over bytes the frontend already holds. Protocol v18's
one deliberate text-bearing exception is `StatuslineSegments`: bounded
one-line chrome text that is not document content. This preserves the
semantics-down model while letting daemon-owned Lua state contribute to
frontend-local modeline layout.
Consequently the new surface is small. Cursor reuses the existing
`InstanceMessage::CursorByte` (authoritative cursor as a buffer
offset — added for CRDT optimistic-apply, exactly what a
layout-local frontend consumes). Peer cursors reuse the existing
`PresenceUpdate`. Edits and local cursor travel the existing
`FrontendEvent::CrdtOp` / presence path. The genuinely new wire
is: one capability bit, ~five instance→frontend interpretation
variants, and one frontend→instance `Viewport` variant.
`FrontendEvent::CrdtOp` / presence path. Later interpretation and
chrome families append under explicit protocol-version gates; v18 adds
only `StatuslineSegments` to the v17 shape.
**`BufferSnapshot` resets buffer-scoped interpretation state.** A
frontend receiving a snapshot drops everything it holds for the
named buffer — spans, decorations, adornments, minimap summary,
completion popup, search and menu prompts (which also gate the
frontend's key/pointer interception), and status facts — and
rebuilds from the frames that follow; the instance mirrors this by
invalidating its per-buffer emission baselines whenever it writes a
snapshot, so the frontend's post-snapshot viewport declaration
receives authoritative re-sends even when nothing changed
daemon-side (the unchanged-generation A → B → A revisit). Bufferless
facts (`ThemeFacts`, `FontFacts`, the minibuffer prompt) and
frontend's key/pointer interception), status facts, and statusline
segments — and rebuilds from the frames that follow; the instance
mirrors this by invalidating its per-buffer emission baselines whenever
it writes a snapshot. The frontend's post-snapshot viewport declaration
therefore receives authoritative re-sends even when nothing changed
daemon-side (the unchanged-generation A → B → A revisit).
Bufferless facts (`ThemeFacts`, `FontFacts`, the minibuffer prompt) and
per-frontend state (the gutter mode) survive snapshots on both sides
(frontend-locally the normalized code scroll — a caret-follow view
residual — is buffer-scoped and resets, while the resolved font and
@ -238,13 +244,19 @@ ResourceOffer {
/// declaration; cached-compare suppressed thereafter, so an
/// unthemed session pays one small message and nothing more.
/// Resolution (the `ui.*` dotted-prefix inheritance walk) happens
/// daemon-side over the stage-1 face inventory — frontends do
/// exact-name lookup only, and apply each face within its
/// stage-1 component mask (docs/theme-faces-framing.md Q#TH3/Q#TH5:
/// a set face owns its surface; `Default` components mean the
/// frontend's plain rendering; out-of-mask components are never
/// read). Daemon-gated `>= 16`; appended as the FINAL variant —
/// postcard discriminants are ordinal.
/// daemon-side; frontends do exact-name lookup only and apply each face
/// within its stage-1 component mask
/// (`docs/theme-faces-framing.md` Q#TH3/Q#TH5: a set face owns its
/// surface; `Default` components mean the frontend's plain rendering;
/// out-of-mask components are never read).
///
/// At protocol v18 the resolved inventory also includes every enabled
/// statusline provider's exact `ui.modeline.*` face name. Registration,
/// unregister, and enable changes invalidate that inventory; priority
/// changes do not. v16/v17 peers retain only the fixed stage-1 set and
/// never execute statusline providers. Daemon-gated `>= 16`; its
/// postcard placement remains before `FontFacts` and
/// `StatuslineSegments`.
ThemeFacts {
faces: Vec<ThemeFace>, // { name: String, style: Style }, sorted by name
},
@ -263,19 +275,59 @@ ThemeFacts {
/// owns every metric consequence; sizes travel as integer
/// hundredths of a logical pixel (1600 = 16.0, validated to
/// 600..=7200 on BOTH sides — the receiver fails closed on
/// out-of-range wire values). Daemon-gated `>= 17`; appended as
/// the FINAL variant — postcard discriminants are ordinal, and the
/// ThemeFacts byte pin above guards this placement.
/// out-of-range wire values). Daemon-gated `>= 17`; v18's
/// `StatuslineSegments` is appended after it because postcard
/// discriminants are ordinal.
FontFacts {
family: Option<String>, // None = the frontend's default family
size_centi_px: Option<u32>, // None = the frontend's default size
},
/// One daemon-evaluated statusline run. `text` is non-empty,
/// control-free UTF-8; `face` is `ui.modeline` or a valid
/// `ui.modeline.*` name resolved through `ThemeFacts`.
StatuslineSegment {
text: String,
face: String,
},
/// Themes Arc 4 stage 3 (protocol v18). A complete replacement for one
/// buffer's custom modeline runs, never a patch. The left vector is in
/// display order (priority descending, registration id ascending);
/// right is in display order from the center toward the protected
/// suffix (priority ascending, registration id ascending).
StatuslineSegments {
buffer_id: BufferId,
left: Vec<StatuslineSegment>,
right: Vec<StatuslineSegment>,
},
```
Each family member diffs against the previous frame the same way
`CellDelta` does today — the instance ships changed spans, not
full re-sends, scoped to the viewport range the frontend last
declared.
`StyleSpans` retains its dirty-segment diffing. `StatuslineSegments`
uses a complete-payload baseline instead: first sight of a buffer sends
one authoritative replacement, including `left=[]`, `right=[]`; a
byte-identical later evaluation is silent. Authoritative empty is data,
not "no message": it clears a prior payload after unregister, disable,
provider failure, or an evaluation invalidated by callback mutation.
Nil and empty-string provider returns are simply absent runs.
The v18 producer evaluates only after a matching viewport declaration
for the semantic session's active daemon window. Provider execution is
version-gated before evaluation, so a v17 peer incurs no callbacks and
receives neither this variant nor provider-only dynamic `ThemeFacts`
entries. The receiver validates a whole message atomically using the
shared protocol limits (64 runs, 1024 bytes per run, 64 KiB aggregate,
256-byte valid face names); malformed input leaves the prior payload
unchanged.
`BufferSnapshot` clears the named buffer's frontend mirror immediately
and drops the producer baseline. The unchanged-generation A → B → A
return therefore remains empty until the authoritative re-send arrives,
then restores the exact prior runs. The instance owns callback order,
sanitation, face names, and replacement semantics. The frontend owns
separators (using the adjacent run's face), grapheme shaping, clipping,
and the protected diagnostic/cursor/scroll suffix; none of those pixel
decisions return to the daemon.
## Frontend → instance: `Viewport`

File diff suppressed because it is too large Load Diff

1004
docs/vterm-framing.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -50,9 +50,11 @@ pub use message::{
CompletionPopupRow, CursorState, Decoration, DecorationKind, DecorationSegment,
FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InlineAdornment,
InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, KeyEvent,
LineNumberMode, MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind,
NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody,
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan, ThemeFace,
is_builtin_pair_char, is_supported_protocol_version, negotiate_capabilities,
LineNumberMode, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES,
MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES,
MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities,
PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot,
StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char,
is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, negotiate_capabilities,
};
pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message};

View File

@ -104,6 +104,32 @@ pub const BUILTIN_PAIR_CHARS: [char; 9] = ['(', ')', '[', ']', '{', '}', '"', '\
pub fn is_builtin_pair_char(c: char) -> bool {
BUILTIN_PAIR_CHARS.contains(&c)
}
/// Maximum number of live statusline providers and wire segments.
pub const MAX_STATUSLINE_PROVIDERS: usize = 64;
/// Maximum UTF-8 byte length of a statusline provider's display name.
pub const MAX_STATUSLINE_PROVIDER_NAME_BYTES: usize = 256;
/// Maximum UTF-8 byte length of a statusline segment face name.
pub const MAX_STATUSLINE_FACE_BYTES: usize = 256;
/// Maximum UTF-8 byte length of one statusline segment's text.
pub const MAX_STATUSLINE_SEGMENT_BYTES: usize = 1024;
/// Maximum aggregate UTF-8 text bytes in one statusline payload.
pub const MAX_STATUSLINE_TOTAL_TEXT_BYTES: usize = 64 * 1024;
/// True when `name` belongs to the reserved UI-face namespace.
#[must_use]
pub fn is_ui_face_name(name: &str) -> bool {
name == "ui" || name.starts_with("ui.")
}
/// True when `name` is the modeline face or one of its children.
#[must_use]
pub fn is_modeline_face_name(name: &str) -> bool {
name == "ui.modeline" || name.starts_with("ui.modeline.")
}
/// Modifier-key set. Bit-flag encoding for compact wire shape.
///
@ -1026,6 +1052,21 @@ pub enum InstanceMessage {
/// closed (deserialized protocol input is untrusted).
size_centi_px: Option<u32>,
},
/// Statusline segments (Q#SL7, protocol v18). Custom provider output
/// for the semantic frontend's current buffer. This is a complete
/// replacement: empty vectors authoritatively mean no custom segments.
/// Daemon-gated `>= 18`.
///
/// Appended after [`Self::FontFacts`], the final v17 variant, so no
/// existing postcard discriminant moves.
StatuslineSegments {
/// Buffer whose modeline the segments describe.
buffer_id: crate::BufferId,
/// Left-side custom segments in display order.
left: Vec<StatuslineSegment>,
/// Right-side custom segments in display order.
right: Vec<StatuslineSegment>,
},
}
/// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full
@ -1041,6 +1082,19 @@ pub struct ThemeFace {
pub style: crate::cell::Style,
}
/// One daemon-produced custom modeline segment.
///
/// `text` has already been sanitized to one line. `face` is
/// `ui.modeline` or one of its child names; a missing exact entry in
/// [`InstanceMessage::ThemeFacts`] means the base modeline text color.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StatuslineSegment {
/// Non-empty, single-line segment text.
pub text: String,
/// Static modeline face name selected at provider registration.
pub face: String,
}
/// Line-number gutter mode for a window (UX gutter arc). Shared across the
/// wire, the daemon, and both frontends so the *number rule* — what value
/// each line shows — is identical everywhere (Q#UX7). `pmacs` re-exports
@ -1411,7 +1465,13 @@ pub enum ResourceBody {
/// `< 17`; a v16 peer negotiates v16 and simply keeps its built-in
/// font. Appended after `ThemeFacts` — the final v16 variant —
/// same ordinal-discriminant reasoning as every additive bump.
pub const PROTOCOL_VERSION: u32 = 17;
///
/// Statusline segments (Q#SL7): bumped 17 → 18 for
/// [`InstanceMessage::StatuslineSegments`] — a new additive variant
/// carrying custom modeline provider output. Daemon-gated `< 18`; a
/// v17 peer keeps the built-in status band. Appended after `FontFacts`
/// so the final v17 discriminant remains stable.
pub const PROTOCOL_VERSION: u32 = 18;
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
@ -1477,7 +1537,10 @@ pub const PROTOCOL_VERSION: u32 = 17;
///
/// Q#F4: extended to `[6, ..., 17]`. `InstanceMessage::FontFacts`
/// is additive and daemon-gated per session, so the ladder resumes.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17];
///
/// Q#SL7: extended to `[6, ..., 18]`.
/// [`InstanceMessage::StatuslineSegments`] is additive and daemon-gated.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18];
/// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].

View File

@ -43,6 +43,80 @@
use crate::cell::{Color, Style, UnderlineStyle};
/// Parser compatibility profile.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AnsiParserProfile {
/// Preserve the compile/REPL byte-stream contract.
#[default]
LineOriented,
/// Emit terminal operations for a stateful full-screen consumer.
FullScreen,
}
#[allow(missing_docs)]
/// Erase direction for display and line operations.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EraseMode {
ToEnd,
ToStart,
All,
Saved,
}
#[allow(missing_docs)]
/// DEC alternate-screen selector.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AlternateScreenMode {
Mode47,
Mode1047,
Mode1049,
}
#[allow(missing_docs)]
/// Terminal modes understood by the screen/input core.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TerminalMode {
Insert,
Origin,
AutoWrap,
ApplicationCursor,
ApplicationKeypad,
CursorVisible,
BracketedPaste,
FocusReporting,
SynchronizedOutput,
MouseX10,
MouseButton,
MouseAny,
MouseSgr,
}
#[allow(missing_docs)]
/// G0/G1 designation target and supported character set.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CharacterSetSlot {
G0,
G1,
}
/// Character set designated into a DEC G0/G1 slot.
#[allow(missing_docs)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CharacterSet {
Ascii,
DecSpecialGraphics,
}
#[allow(missing_docs)]
/// Typed terminal query. Only these requests may generate PTY input.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeviceRequest {
PrimaryAttributes,
SecondaryAttributes,
OperatingStatus,
CursorPosition,
}
// ---------------------------------------------------------------------------
// Public output
// ---------------------------------------------------------------------------
@ -53,6 +127,7 @@ use crate::cell::{Color, Style, UnderlineStyle};
/// at the moment the parser has enough context to commit to it
/// (e.g., `Text` is emitted at every transition out of Ground, not
/// per byte).
#[allow(missing_docs)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AnsiEvent {
/// Append literal text to the consumer's rope. Text never
@ -98,6 +173,61 @@ pub enum AnsiEvent {
/// `CSI ? 1049 l`: alternate-screen exited. `Text` and
/// `SetStyle` resume.
AlternateScreenExit,
/// Full-screen-only terminal operations.
Bell,
LineFeed,
/// `ESC D`: advance one row, scrolling at the bottom margin.
Index,
/// `ESC E`: return to column zero and advance one row.
NextLine,
/// `ESC M`: move up one row, scrolling down at the top margin.
ReverseIndex,
HorizontalTab,
SetTabStop,
ClearTabStop,
ClearAllTabStops,
CursorUp(u32),
CursorDown(u32),
CursorForward(u32),
CursorBackward(u32),
CursorNextLine(u32),
CursorPreviousLine(u32),
CursorHorizontalAbsolute(u32),
CursorVerticalAbsolute(u32),
CursorPosition {
row: u32,
col: u32,
},
EraseDisplay(EraseMode),
EraseLineMode(EraseMode),
EraseCharacters(u32),
InsertCharacters(u32),
DeleteCharacters(u32),
InsertLines(u32),
DeleteLines(u32),
ScrollUp(u32),
ScrollDown(u32),
SetScrollingRegion {
top: u32,
bottom: Option<u32>,
},
SaveCursor,
RestoreCursor,
AlternateScreen {
mode: AlternateScreenMode,
enabled: bool,
},
SetMode {
mode: TerminalMode,
enabled: bool,
},
DesignateCharacterSet {
slot: CharacterSetSlot,
charset: CharacterSet,
},
ShiftOut,
ShiftIn,
DeviceRequest(DeviceRequest),
}
/// Tunable knobs for [`AnsiParser`].
@ -155,6 +285,7 @@ enum State {
Ground,
Escape,
EscapeIntermediate,
EscapeIgnore,
CsiEntry,
CsiParam,
CsiIntermediate,
@ -326,6 +457,7 @@ pub struct AnsiParser {
osc_body: Vec<u8>,
/// Intermediate bytes for plain ESC sequences (`ESC` + 0x20..=0x2F).
escape_intermediates: Vec<u8>,
profile: AnsiParserProfile,
config: AnsiParserConfig,
}
@ -339,12 +471,24 @@ impl AnsiParser {
/// Construct a parser with default configuration.
#[must_use]
pub fn new() -> Self {
Self::with_config(AnsiParserConfig::default())
Self::with_profile(AnsiParserProfile::LineOriented)
}
/// Construct a parser with custom configuration.
/// Construct a parser using the selected compatibility profile.
#[must_use]
pub fn with_profile(profile: AnsiParserProfile) -> Self {
Self::with_profile_and_config(profile, AnsiParserConfig::default())
}
/// Construct a line-oriented parser with custom configuration.
#[must_use]
pub fn with_config(config: AnsiParserConfig) -> Self {
Self::with_profile_and_config(AnsiParserProfile::LineOriented, config)
}
/// Construct a parser with both an explicit profile and configuration.
#[must_use]
pub fn with_profile_and_config(profile: AnsiParserProfile, config: AnsiParserConfig) -> Self {
Self {
state: State::Ground,
current_style: Style::default(),
@ -356,6 +500,7 @@ impl AnsiParser {
csi: CsiCollector::default(),
osc_body: Vec::new(),
escape_intermediates: Vec::new(),
profile,
config,
}
}
@ -396,11 +541,11 @@ impl AnsiParser {
// transition path (flush_text_run) does emit U+FFFD for
// pending bytes because a non-text byte genuinely
// interrupts the sequence; feed-boundary doesn't.
if !self.text_run.is_empty() && !self.alt_screen_active {
if !self.text_run.is_empty() {
let run = std::mem::take(&mut self.text_run);
events.push(AnsiEvent::Text(run));
} else {
self.text_run.clear();
if !self.suppress_visible() {
events.push(AnsiEvent::Text(run));
}
}
events
}
@ -431,23 +576,22 @@ impl AnsiParser {
pub fn finish(&mut self) -> Vec<AnsiEvent> {
let mut events = Vec::new();
self.flush_pending_utf8_as_replacement();
if !self.text_run.is_empty() && !self.alt_screen_active {
if !self.text_run.is_empty() {
let run = std::mem::take(&mut self.text_run);
events.push(AnsiEvent::Text(run));
} else {
self.text_run.clear();
if !self.suppress_visible() {
events.push(AnsiEvent::Text(run));
}
}
// Balancing state events, in unwind order. `reset` alone
// deliberately preserves alt-screen suppression (a
// mid-stream reset must not unhide alt-screen contents); a
// stream END does end it, observably.
if self.alt_screen_active {
self.alt_screen_active = false;
events.push(AnsiEvent::AlternateScreenExit);
}
if self.emitted_style != Style::default() {
events.push(AnsiEvent::SetStyle(Style::default()));
if self.profile == AnsiParserProfile::LineOriented {
if self.alt_screen_active {
self.alt_screen_active = false;
events.push(AnsiEvent::AlternateScreenExit);
}
if self.emitted_style != Style::default() {
events.push(AnsiEvent::SetStyle(Style::default()));
}
}
self.alt_screen_active = false;
self.current_style = Style::default();
self.emitted_style = Style::default();
self.reset();
@ -472,28 +616,53 @@ impl AnsiParser {
return;
}
// Per-state byte cap. The counter increments for every byte
// consumed in any non-Ground state, and is reset to zero at
// every transition into a fresh sequence (ESC-anywhere) or
// back to Ground (normal dispatch / force-recover). At the
// limit, the parser drops the in-flight sequence and
// returns to Ground; the *current* byte is dropped on the
// floor, but subsequent bytes are processed normally as
// ordinary text. Spec §sec:ansi-scope: "drops back to ground
// state at the next ESC or after a bounded number of bytes
// (1 KiB), whichever comes first."
if self.state != State::Ground {
// Bound retained control-string payload without ever exposing its
// overflow as printable text. Once capped, remain in a zero-storage
// ignore state until BEL/ST or a fresh ESC sequence provides a safe
// recovery boundary.
if self.state != State::Ground
&& !matches!(
self.state,
State::EscapeIgnore | State::CsiIgnore | State::OscIgnore | State::DcsIgnore
)
{
self.ignore_byte_count = self.ignore_byte_count.saturating_add(1);
if self.ignore_byte_count > self.config.unknown_sequence_byte_limit {
self.recover_to_ground();
match self.state {
State::OscString | State::OscEscPending => {
self.osc_body.clear();
self.state = State::OscIgnore;
self.ignore_byte_count = 0;
}
State::DcsEntry
| State::DcsParam
| State::DcsIntermediate
| State::DcsPassthrough
| State::SosPmApcString => {
self.state = State::DcsIgnore;
self.ignore_byte_count = 0;
}
State::Escape | State::EscapeIntermediate => {
self.escape_intermediates.clear();
self.state = State::EscapeIgnore;
self.ignore_byte_count = 0;
}
State::CsiEntry | State::CsiParam | State::CsiIntermediate => {
self.csi.reset();
self.state = State::CsiIgnore;
self.ignore_byte_count = 0;
}
_ => self.recover_to_ground(),
}
return;
}
}
match self.state {
State::Ground => self.feed_ground(b, events),
State::Escape => self.feed_escape(b),
State::EscapeIntermediate => self.feed_escape_intermediate(b),
State::Escape => self.feed_escape(b, events),
State::EscapeIntermediate => self.feed_escape_intermediate(b, events),
State::EscapeIgnore => self.feed_escape_ignore(b),
State::CsiEntry => self.feed_csi_entry(b, events),
State::CsiParam => self.feed_csi_param(b, events),
State::CsiIntermediate => self.feed_csi_intermediate(b, events),
@ -530,13 +699,13 @@ impl AnsiParser {
return;
}
let run = std::mem::take(&mut self.text_run);
if !self.alt_screen_active {
if !self.suppress_visible() {
events.push(AnsiEvent::Text(run));
}
}
fn emit_set_style(&mut self, events: &mut Vec<AnsiEvent>) {
if self.alt_screen_active {
if self.suppress_visible() {
return;
}
self.emitted_style = self.current_style;
@ -548,11 +717,15 @@ impl AnsiParser {
/// paste / `SetTitle`). The alt-screen markers themselves
/// bypass this.
fn push_visible(&self, ev: AnsiEvent, events: &mut Vec<AnsiEvent>) {
if !self.alt_screen_active {
if !self.suppress_visible() {
events.push(ev);
}
}
fn suppress_visible(&self) -> bool {
self.profile == AnsiParserProfile::LineOriented && self.alt_screen_active
}
/// Begin a fresh escape sequence (called from ESC-anywhere).
/// Resets the byte budget and all in-flight sequence state.
fn start_new_sequence(&mut self) {
@ -579,44 +752,53 @@ impl AnsiParser {
// -----------------------------------------------------------------------
fn feed_ground(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
match b {
// CR: flush text, emit CarriageReturn.
0x0D => {
self.flush_text_run(events);
if !self.alt_screen_active {
events.push(AnsiEvent::CarriageReturn);
if self.profile == AnsiParserProfile::LineOriented {
match b {
0x0D => {
self.flush_text_run(events);
self.push_visible(AnsiEvent::CarriageReturn, events);
}
0x08 => {
self.flush_text_run(events);
self.push_visible(AnsiEvent::Backspace, events);
}
0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E | 0x80..=0xFF => {
self.push_text_byte(b);
}
0x00..=0x1F | 0x7F => {}
}
return;
}
match b {
0x07 => {
self.flush_text_run(events);
events.push(AnsiEvent::Bell);
}
// BS: flush text, emit Backspace.
0x08 => {
self.flush_text_run(events);
if !self.alt_screen_active {
events.push(AnsiEvent::Backspace);
}
events.push(AnsiEvent::Backspace);
}
// BEL (0x07), VT (0x0B), FF (0x0C), HT (0x09), LF
// (0x0A): pass through to text alongside printable
// ASCII (0x20..=0x7E). The REPL view treats LF as a
// line break in the rope; HT as a literal tab. Other
// C0 controls (0x00..=0x06, 0x0E..=0x1F) and DEL
// (0x7F) are dropped silently.
//
// 0x80..=0xFF: UTF-8 lead or continuation byte. Goes
// through `push_text_byte`'s stateful decoder so
// multi-byte sequences across feeds are buffered until
// complete.
//
// All text bytes route through `push_text_byte` (not
// just non-ASCII): an ASCII byte arriving while a
// partial UTF-8 sequence is pending invalidates that
// sequence (the partial prefix's expected continuation
// didn't arrive), and `push_text_byte` is the only
// place that knows to flush the partial as `U+FFFD`.
// The fast path inside `push_text_byte` keeps the
// pure-ASCII case allocation-free.
0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E | 0x80..=0xFF => {
self.push_text_byte(b);
0x09 => {
self.flush_text_run(events);
events.push(AnsiEvent::HorizontalTab);
}
0x0A..=0x0C => {
self.flush_text_run(events);
events.push(AnsiEvent::LineFeed);
}
0x0D => {
self.flush_text_run(events);
events.push(AnsiEvent::CarriageReturn);
}
0x0E => {
self.flush_text_run(events);
events.push(AnsiEvent::ShiftOut);
}
0x0F => {
self.flush_text_run(events);
events.push(AnsiEvent::ShiftIn);
}
0x20..=0x7E | 0x80..=0xFF => self.push_text_byte(b),
0x00..=0x1F | 0x7F => {}
}
}
@ -726,7 +908,7 @@ impl AnsiParser {
// Escape
// -----------------------------------------------------------------------
fn feed_escape(&mut self, b: u8) {
fn feed_escape(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
match b {
0x20..=0x2F => {
self.escape_intermediates.push(b);
@ -740,42 +922,66 @@ impl AnsiParser {
self.osc_body.clear();
self.state = State::OscString;
}
// DCS / SOS / PM / APC introducers --- parse and discard.
b'P' => {
self.state = State::DcsEntry;
}
b'X' | b'^' | b'_' => {
self.state = State::SosPmApcString;
}
// ESC \ in Escape state is a stray ST; final byte for
// a bare ESC sequence (0x30..=0x7E) lands here too. We
// don't dispatch any single-byte ESC commands in v0.1
// (cursor save/restore `ESC 7`/`ESC 8` are deliberately
// unsupported per spec); both cases consume and return
// to Ground.
b'\\' | 0x30..=0x7E => {
b'P' => self.state = State::DcsEntry,
b'X' | b'^' | b'_' => self.state = State::SosPmApcString,
b'7' | b'8' | b'D' | b'E' | b'H' | b'M' | b'=' | b'>'
if self.profile == AnsiParserProfile::FullScreen =>
{
let event = match b {
b'7' => AnsiEvent::SaveCursor,
b'8' => AnsiEvent::RestoreCursor,
b'D' => AnsiEvent::Index,
b'E' => AnsiEvent::NextLine,
b'H' => AnsiEvent::SetTabStop,
b'M' => AnsiEvent::ReverseIndex,
b'=' => AnsiEvent::SetMode {
mode: TerminalMode::ApplicationKeypad,
enabled: true,
},
_ => AnsiEvent::SetMode {
mode: TerminalMode::ApplicationKeypad,
enabled: false,
},
};
events.push(event);
self.recover_to_ground();
}
// C0 controls inside Escape: drop, stay in Escape.
b'\\' | 0x30..=0x7E => self.recover_to_ground(),
_ => {}
}
}
fn feed_escape_intermediate(&mut self, b: u8) {
fn feed_escape_intermediate(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
match b {
0x20..=0x2F => {
self.escape_intermediates.push(b);
}
// Final byte: drop the sequence (no ESC + intermediate
// dispatches in v0.1 --- charsets are deliberately
// unsupported per spec) and return to Ground.
0x20..=0x2F => self.escape_intermediates.push(b),
0x30..=0x7E => {
if self.profile == AnsiParserProfile::FullScreen {
let slot = match self.escape_intermediates.as_slice() {
[b'('] => Some(CharacterSetSlot::G0),
[b')'] => Some(CharacterSetSlot::G1),
_ => None,
};
let charset = match b {
b'0' => Some(CharacterSet::DecSpecialGraphics),
b'B' => Some(CharacterSet::Ascii),
_ => None,
};
if let (Some(slot), Some(charset)) = (slot, charset) {
events.push(AnsiEvent::DesignateCharacterSet { slot, charset });
}
}
self.recover_to_ground();
}
_ => {}
}
}
fn feed_escape_ignore(&mut self, b: u8) {
if matches!(b, 0x30..=0x7E) {
self.recover_to_ground();
}
}
// -----------------------------------------------------------------------
// CSI
// -----------------------------------------------------------------------
@ -850,70 +1056,129 @@ impl AnsiParser {
/// Dispatch a fully-collected CSI sequence. `final_byte` is the
/// terminating byte (`0x40..=0x7E`). The collected parameters
/// are taken from `self.csi`.
#[allow(clippy::too_many_lines)]
fn dispatch_csi(&mut self, final_byte: u8, events: &mut Vec<AnsiEvent>) {
let private_marker = self.csi.private_marker;
let private = self.csi.private_marker;
let intermediates = self.csi.intermediates.clone();
let params = self.csi.finalize();
match (private_marker, final_byte) {
// SGR.
(None, b'm') => self.dispatch_sgr(&params, events),
// Erase in line: `CSI [n] K`. n=0 (default) →
// EraseToEol; n=2 → EraseLine; n=1 (start to cursor)
// and others: parsed and ignored.
(None, b'K') => {
let n = params.first().map_or(0, |p| p.main);
match n {
if private.is_none() && final_byte == b'm' {
self.dispatch_sgr(&params, events);
self.csi.reset();
return;
}
if self.profile == AnsiParserProfile::LineOriented {
match (private, final_byte) {
(None, b'K') => match param(&params, 0, 0) {
0 => self.push_visible(AnsiEvent::EraseToEol, events),
2 => self.push_visible(AnsiEvent::EraseLine, events),
_ => {}
}
}
// Bracketed paste markers: `CSI 200 ~` / `CSI 201 ~`.
(None, b'~') => {
let n = params.first().map_or(0, |p| p.main);
match n {
},
(None, b'~') => match param(&params, 0, 0) {
200 => self.push_visible(AnsiEvent::BracketedPasteBegin, events),
201 => self.push_visible(AnsiEvent::BracketedPasteEnd, events),
_ => {}
}
}
// DEC private mode set / reset: `CSI ? <num> h` / `l`.
// Of these, only ?1049 (alternate screen) produces an
// event; mouse modes (?1000, ?1006), bracketed-paste
// mode (?2004), and the long tail are parsed and
// discarded per spec §sec:ansi-scope.
(Some(b'?'), b'h' | b'l') => {
let set = final_byte == b'h';
for p in &params {
if p.main == 1049 {
if set && !self.alt_screen_active {
self.alt_screen_active = true;
events.push(AnsiEvent::AlternateScreenEnter);
} else if !set && self.alt_screen_active {
self.alt_screen_active = false;
events.push(AnsiEvent::AlternateScreenExit);
// SGR changes inside the alternate
// screen advanced `current_style` while
// their events were suppressed; the
// consumer still holds the pre-enter
// style. Resynchronize the effective
// style on exit (round-4 finding 2).
if self.current_style != self.emitted_style {
self.emit_set_style(events);
},
(Some(b'?'), b'h' | b'l') => {
let set = final_byte == b'h';
for p in &params {
if p.main == 1049 {
if set && !self.alt_screen_active {
self.alt_screen_active = true;
events.push(AnsiEvent::AlternateScreenEnter);
} else if !set && self.alt_screen_active {
self.alt_screen_active = false;
events.push(AnsiEvent::AlternateScreenExit);
if self.current_style != self.emitted_style {
self.emit_set_style(events);
}
}
}
}
}
_ => {}
}
// Cursor motions (A/B/C/D/E/F/G/H/J/f) and other CSI
// commands: parsed and discarded for M6.3. The M6.4
// view layer handles intra-line motion (CR / BS) at
// its own level; cross-region motion via CSI is in the
// "parsed but ignored when it would cross region
// boundaries" bucket from §sec:ansi-scope.
_ => {}
self.csi.reset();
return;
}
let count = || param(&params, 0, 1).max(1);
let event = match (private, intermediates.as_slice(), final_byte) {
(None, [], b'A') => Some(AnsiEvent::CursorUp(count())),
(None, [], b'B') => Some(AnsiEvent::CursorDown(count())),
(None, [], b'C' | b'a') => Some(AnsiEvent::CursorForward(count())),
(None, [], b'D') => Some(AnsiEvent::CursorBackward(count())),
(None, [], b'E') => Some(AnsiEvent::CursorNextLine(count())),
(None, [], b'F') => Some(AnsiEvent::CursorPreviousLine(count())),
(None, [], b'G' | b'`') => Some(AnsiEvent::CursorHorizontalAbsolute(
param(&params, 0, 1).max(1),
)),
(None, [], b'd') => Some(AnsiEvent::CursorVerticalAbsolute(
param(&params, 0, 1).max(1),
)),
(None, [], b'H' | b'f') => Some(AnsiEvent::CursorPosition {
row: param(&params, 0, 1).max(1),
col: param(&params, 1, 1).max(1),
}),
(None, [], b'J') => erase_mode(param(&params, 0, 0)).map(AnsiEvent::EraseDisplay),
(None, [], b'K') => erase_mode(param(&params, 0, 0)).map(AnsiEvent::EraseLineMode),
(None, [], b'X') => Some(AnsiEvent::EraseCharacters(count())),
(None, [], b'@') => Some(AnsiEvent::InsertCharacters(count())),
(None, [], b'P') => Some(AnsiEvent::DeleteCharacters(count())),
(None, [], b'L') => Some(AnsiEvent::InsertLines(count())),
(None, [], b'M') => Some(AnsiEvent::DeleteLines(count())),
(None, [], b'S') => Some(AnsiEvent::ScrollUp(count())),
(None, [], b'T') => Some(AnsiEvent::ScrollDown(count())),
(None, [], b'r') => Some(AnsiEvent::SetScrollingRegion {
top: param(&params, 0, 1).max(1),
bottom: params.get(1).map(|p| p.main).filter(|&n| n != 0),
}),
(None, [], b's') => Some(AnsiEvent::SaveCursor),
(None, [], b'u') => Some(AnsiEvent::RestoreCursor),
(None, [], b'g') => match param(&params, 0, 0) {
0 => Some(AnsiEvent::ClearTabStop),
3 => Some(AnsiEvent::ClearAllTabStops),
_ => None,
},
(None, [], b'~') => match param(&params, 0, 0) {
200 => Some(AnsiEvent::BracketedPasteBegin),
201 => Some(AnsiEvent::BracketedPasteEnd),
_ => None,
},
(None, [], b'h' | b'l') => {
let enabled = final_byte == b'h';
for p in &params {
if p.main == 4 {
events.push(AnsiEvent::SetMode {
mode: TerminalMode::Insert,
enabled,
});
}
}
None
}
(Some(b'?'), [], b'h' | b'l') => {
let enabled = final_byte == b'h';
for p in &params {
if let Some(ev) = private_mode_event(p.main, enabled) {
events.push(ev);
}
}
None
}
(None, [], b'c') => Some(AnsiEvent::DeviceRequest(DeviceRequest::PrimaryAttributes)),
(Some(b'>'), [], b'c') => {
Some(AnsiEvent::DeviceRequest(DeviceRequest::SecondaryAttributes))
}
(None, [], b'n') => match param(&params, 0, 0) {
5 => Some(AnsiEvent::DeviceRequest(DeviceRequest::OperatingStatus)),
6 => Some(AnsiEvent::DeviceRequest(DeviceRequest::CursorPosition)),
_ => None,
},
_ => None,
};
if let Some(event) = event {
events.push(event);
}
self.csi.reset();
}
@ -959,9 +1224,13 @@ impl AnsiParser {
_ => UnderlineStyle::Single,
};
}
// 5/6 (blink, rapid blink): mapped to bold per spec
// §sec:ansi-scope ("blink-as-bold").
5 | 6 => self.current_style.bold = true,
// Line-oriented compile/REPL consumers historically render
// blink as bold. Full-screen preserves the shared Style
// contract: blink is unsupported and leaves it unchanged.
5 | 6 if self.profile == AnsiParserProfile::LineOriented => {
self.current_style.bold = true;
}
5 | 6 => {}
7 => self.current_style.reverse = true,
// 8 (concealed/invisible): no-op. Out of scope.
8 => {}
@ -975,9 +1244,8 @@ impl AnsiParser {
22 => self.current_style.bold = false,
23 => self.current_style.italic = false,
24 => self.current_style.underline = UnderlineStyle::None,
// 25 (blink off): no-op. Symmetry with 5/6 → bold:
// we do not unset bold here, since that would also
// unset bold acquired via SGR 1.
// 25 (blink off): unsupported. It must not unset bold
// acquired through SGR 1.
25 => {}
27 => self.current_style.reverse = false,
28 => {}
@ -1039,11 +1307,12 @@ impl AnsiParser {
}
// ESC: begin ST-terminator check (ESC \).
0x1B => self.state = State::OscEscPending,
// 0x20..=0x7F: body bytes. The per-state byte cap
// (enforced at the top of `feed_byte`) bounds how many
// bytes we'll accept before force-recovering.
0x20..=0x7F => self.osc_body.push(b),
// Other C0/C1 controls: drop silently, stay in OSC.
// OSC payload is UTF-8 bytes, not ASCII. Retain printable ASCII,
// DEL (compatibility), and all high bytes; lossy UTF-8 decoding at
// dispatch replaces malformed sequences. The per-state cap bounds
// retained storage.
0x20..=0xFF => self.osc_body.push(b),
// Other C0 controls: drop silently, stay in OSC.
_ => {}
}
}
@ -1083,7 +1352,7 @@ impl AnsiParser {
let num: Option<u32> = std::str::from_utf8(num_part)
.ok()
.and_then(|s| s.parse().ok());
if matches!(num, Some(133)) && !self.alt_screen_active {
if matches!(num, Some(133)) && !self.suppress_visible() {
match text_part.first().copied() {
Some(b'A') => events.push(AnsiEvent::PromptStart),
Some(b'B') => events.push(AnsiEvent::PromptEnd),
@ -1099,13 +1368,66 @@ impl AnsiParser {
// above produce events. Other OSC numbers are parsed and
// discarded per spec §sec:ansi-scope, with the critical
// guarantee that state alignment is preserved.
if matches!(num, Some(0 | 2)) && !self.alt_screen_active {
if matches!(num, Some(0 | 2)) && !self.suppress_visible() {
let title = String::from_utf8_lossy(text_part).into_owned();
events.push(AnsiEvent::SetTitle(title));
}
}
}
fn param(params: &CsiParams, index: usize, default: u32) -> u32 {
params
.get(index)
.map_or(default, |p| if p.main == 0 { default } else { p.main })
}
fn erase_mode(value: u32) -> Option<EraseMode> {
match value {
0 => Some(EraseMode::ToEnd),
1 => Some(EraseMode::ToStart),
2 => Some(EraseMode::All),
3 => Some(EraseMode::Saved),
_ => None,
}
}
fn private_mode_event(value: u32, enabled: bool) -> Option<AnsiEvent> {
let mode = match value {
47 => {
return Some(AnsiEvent::AlternateScreen {
mode: AlternateScreenMode::Mode47,
enabled,
});
}
1047 => {
return Some(AnsiEvent::AlternateScreen {
mode: AlternateScreenMode::Mode1047,
enabled,
});
}
1049 => {
return Some(AnsiEvent::AlternateScreen {
mode: AlternateScreenMode::Mode1049,
enabled,
});
}
1 => TerminalMode::ApplicationCursor,
6 => TerminalMode::Origin,
7 => TerminalMode::AutoWrap,
25 => TerminalMode::CursorVisible,
66 => TerminalMode::ApplicationKeypad,
1000 => TerminalMode::MouseX10,
1002 => TerminalMode::MouseButton,
1003 => TerminalMode::MouseAny,
1004 => TerminalMode::FocusReporting,
1006 => TerminalMode::MouseSgr,
2004 => TerminalMode::BracketedPaste,
2026 => TerminalMode::SynchronizedOutput,
_ => return None,
};
Some(AnsiEvent::SetMode { mode, enabled })
}
/// Parse a CSI 38/48 extended-color suffix into a `Color` plus
/// the number of *additional* params consumed (legacy form only;
/// the modern subparam form keeps everything inside `p.sub` so
@ -1995,4 +2317,177 @@ mod tests {
even though the internal style is already default"
);
}
#[test]
fn full_screen_emits_typed_operation_set_across_every_split() {
let bytes = b"\x07\t\n\x1bD\x1bE\x1bM\x1bH\x1b[2A\x1b[3B\x1b[4C\x1b[5D\
\x1b[2E\x1b[2F\x1b[7G\x1b[8d\x1b[2;3H\x1b[J\x1b[1K\x1b[2X\
\x1b[3@\x1b[4P\x1b[2L\x1b[2M\x1b[3S\x1b[2T\x1b[2;20r\x1b[s\
\x1b[u\x1b[3g\x1b[?1;6;7;25;1000;1002;1003;1004;1006;2004;2026h\
\x1b[?47h\x1b[?1047h\x1b[?1049h\x1b[c\x1b[>c\x1b[5n\x1b[6n";
let mut whole = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
let expected = whole.feed(bytes);
assert!(expected.contains(&AnsiEvent::Bell));
assert!(expected.contains(&AnsiEvent::Index));
assert!(expected.contains(&AnsiEvent::NextLine));
assert!(expected.contains(&AnsiEvent::ReverseIndex));
assert!(expected.contains(&AnsiEvent::CursorPosition { row: 2, col: 3 }));
assert!(expected.contains(&AnsiEvent::SetScrollingRegion {
top: 2,
bottom: Some(20)
}));
assert!(expected.contains(&AnsiEvent::SetMode {
mode: TerminalMode::SynchronizedOutput,
enabled: true
}));
assert!(expected.contains(&AnsiEvent::AlternateScreen {
mode: AlternateScreenMode::Mode1049,
enabled: true
}));
assert!(expected.contains(&AnsiEvent::DeviceRequest(DeviceRequest::CursorPosition)));
for split in 0..=bytes.len() {
let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
let mut actual = parser.feed(&bytes[..split]);
actual.extend(parser.feed(&bytes[split..]));
assert_eq!(actual, expected, "split {split}");
}
}
#[test]
fn full_screen_finish_flushes_without_synthetic_balancing() {
let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
let events = parser.feed(b"\x1b[?1049h\x1b[31mred");
assert!(events.contains(&AnsiEvent::AlternateScreen {
mode: AlternateScreenMode::Mode1049,
enabled: true,
}));
assert!(
events
.iter()
.any(|event| matches!(event, AnsiEvent::SetStyle(_)))
);
assert!(parser.finish().is_empty());
assert!(!parser.alt_screen_active);
assert!(parser.finish().is_empty());
assert_eq!(parser.feed(b"x"), vec![AnsiEvent::Text("x".into())]);
}
#[test]
fn full_screen_charset_designation_and_shift_are_typed() {
let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
assert_eq!(
parser.feed(b"\x1b(0\x1b)B\x0e\x0f"),
vec![
AnsiEvent::DesignateCharacterSet {
slot: CharacterSetSlot::G0,
charset: CharacterSet::DecSpecialGraphics,
},
AnsiEvent::DesignateCharacterSet {
slot: CharacterSetSlot::G1,
charset: CharacterSet::Ascii,
},
AnsiEvent::ShiftOut,
AnsiEvent::ShiftIn,
]
);
}
#[test]
fn full_screen_capped_control_strings_recover_invisibly() {
let config = AnsiParserConfig {
unknown_sequence_byte_limit: 8,
};
let mut parser = AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config);
let events = parser.feed(b"\x1b]52;AAAAAAAABsecret\x1b\\ok\x1bPAAAAAAAAAAAA\x1b\\done");
let visible: String = events
.iter()
.filter_map(|event| match event {
AnsiEvent::Text(text) => Some(text.as_str()),
_ => None,
})
.collect();
assert!(!visible.contains("secret"));
assert!(!visible.contains("AAAA"));
assert!(visible.ends_with("done"));
assert!(!visible.contains('\x1b'));
}
#[test]
fn capped_csi_and_escape_intermediate_never_leak_payload() {
let config = AnsiParserConfig {
unknown_sequence_byte_limit: 8,
};
for input in [
b"\x1b[12345678901234567890mOK".as_slice(),
b"\x1b[?999999999999999999hOK".as_slice(),
b"\x1b 0OK".as_slice(),
] {
let mut parser =
AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config);
let visible: String = parser
.feed(input)
.into_iter()
.filter_map(|event| {
if let AnsiEvent::Text(text) = event {
Some(text)
} else {
None
}
})
.collect();
assert_eq!(visible, "OK", "input {input:?}");
}
}
#[test]
fn full_screen_unsupported_sgr_attributes_leave_style_unchanged() {
let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
parser.feed(b"\x1b[1;3;31m");
let before = parser.current_style;
parser.feed(b"\x1b[2;5;6;8;9;25;28;29m");
assert_eq!(parser.current_style, before);
assert!(before.bold);
let mut line = AnsiParser::new();
line.feed(b"\x1b[5m");
assert!(
line.current_style.bold,
"line-oriented blink-as-bold compatibility"
);
}
#[test]
fn unicode_osc_titles_survive_every_feed_split() {
let bytes = "\u{1b}]2;héllo 世界\u{7}".as_bytes();
let mut whole = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
let expected = whole.feed(bytes);
assert_eq!(expected, vec![AnsiEvent::SetTitle("héllo 世界".into())]);
for split in 0..=bytes.len() {
let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
let mut actual = parser.feed(&bytes[..split]);
actual.extend(parser.feed(&bytes[split..]));
assert_eq!(actual, expected, "split {split}");
}
}
#[test]
fn malformed_utf8_osc_title_is_replaced_and_bounded() {
let config = AnsiParserConfig {
unknown_sequence_byte_limit: 32,
};
let mut parser = AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config);
let events = parser.feed(b"\x1b]0;bad\xfftitle\x07");
let title = events
.into_iter()
.find_map(|event| {
if let AnsiEvent::SetTitle(title) = event {
Some(title)
} else {
None
}
})
.expect("title event");
assert_eq!(title, "bad\u{fffd}title");
assert!(title.len() <= config.unknown_sequence_byte_limit);
}
}

View File

@ -414,6 +414,26 @@ fn main() {
});
write_frame(&mut stdout, &resp);
}
("workspace/didChangeConfiguration", _) => {
// Record the pushed `settings` so a test can assert the
// daemon delivered configuration after `initialized` (the
// push-model config-delivery path push-only servers like the
// VS Code JSON server rely on). One JSON line per push.
if let Ok(sink) = std::env::var("PMACS_FAKE_LSP_CONFIG_SINK") {
use std::io::Write as _;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&sink)
{
let settings = params
.get("settings")
.cloned()
.unwrap_or(serde_json::Value::Null);
let _ = writeln!(f, "{settings}");
}
}
}
("textDocument/didOpen" | "textDocument/didChange", _) => {
let uri = params
.get("textDocument")

View File

@ -160,6 +160,9 @@ pub struct Buffer {
rope: Rope,
name: String,
is_modified: bool,
/// When set, every content mutation is rejected before touching the
/// rope, CRDT, history, revision, modified bit, marks, or views.
read_only: bool,
/// Monotonic counter bumped by every successful forward edit, undo,
/// and redo. Used by the editor to detect "did this command modify
/// the buffer?" without reaching into the rope. LSP `did_change`
@ -243,6 +246,7 @@ impl Buffer {
rope,
name: name.into(),
is_modified: false,
read_only: false,
revision: 0,
views: Vec::new(),
next_view_id: 0,
@ -468,6 +472,32 @@ impl Buffer {
self.is_modified = false;
}
/// Whether content mutation is disabled for this buffer.
#[must_use]
pub fn is_read_only(&self) -> bool {
self.read_only
}
/// Enable or disable the buffer-owned content-mutation guard.
///
/// This is deliberately independent of edit intercepts: terminal identity
/// buffers use it to reject host-side edits, undo/redo, and remote CRDT
/// imports as well as ordinary interactive edits.
pub fn set_read_only(&mut self, read_only: bool) {
self.read_only = read_only;
}
fn ensure_writable(&self) -> Result<(), BufferError> {
if self.read_only {
Err(BufferError::ReadOnly {
id: self.id,
name: self.name.clone(),
})
} else {
Ok(())
}
}
/// Total length of the buffer in bytes.
#[must_use]
pub fn len(&self) -> Position {
@ -614,6 +644,7 @@ impl Buffer {
/// `apply_edit_skip_intercepts` surfaces a typed error rather
/// than silently corrupting state.
pub fn begin_edit(&mut self) -> Result<(), BufferError> {
self.ensure_writable()?;
if self.editing_in_progress {
return Err(BufferError::ConcurrentEdit {
id: self.id,
@ -661,6 +692,7 @@ impl Buffer {
///
/// Threading: main thread only.
pub fn apply_edit(&mut self, op: EditOp<'_>) -> Result<Edit, BufferError> {
self.ensure_writable()?;
if self.editing_in_progress {
return Err(BufferError::ConcurrentEdit {
id: self.id,
@ -732,6 +764,7 @@ impl Buffer {
/// ops in a CRDT-redundant edge case).
#[cfg(feature = "crdt")]
pub fn apply_remote_crdt_op(&mut self, op_bytes: &[u8]) -> Result<Option<Edit>, BufferError> {
self.ensure_writable()?;
if self.editing_in_progress {
return Err(BufferError::ConcurrentEdit {
id: self.id,
@ -942,6 +975,7 @@ impl Buffer {
reason = "by-value mirrors apply_edit's signature; the Lua bindings build a fresh EditOp per call"
)]
pub fn apply_edit_skip_intercepts(&mut self, op: EditOp<'_>) -> Result<Edit, BufferError> {
self.ensure_writable()?;
let mut views = std::mem::take(&mut self.views);
let result = self.run_rope_edit_and_broadcast(&mut views, &op);
self.views = views;
@ -1187,6 +1221,7 @@ impl Buffer {
///
/// Threading: main thread only.
pub fn undo(&mut self) -> Result<Edit, BufferError> {
self.ensure_writable()?;
// T M10.4: in CRDT mode, route through loro's UndoManager via
// the materialize-and-replace path (Day 1 morning audit
// decision — path (a)). Inverse ops are produced as proper
@ -1294,6 +1329,7 @@ impl Buffer {
///
/// Threading: main thread only.
pub fn redo(&mut self) -> Result<Edit, BufferError> {
self.ensure_writable()?;
// T M10.4: in CRDT mode, route through loro's UndoManager.
#[cfg(feature = "crdt")]
if self.crdt.is_some() {
@ -1675,6 +1711,15 @@ pub enum BufferError {
/// The underlying rope rejected the operation.
#[error("rope error: {0}")]
Rope(#[from] RopeError),
/// A content mutation was attempted on a buffer whose owner marked it
/// read-only. The check runs before all rope, CRDT, and history changes.
#[error("buffer `{name}` (id {id:?}) is read-only")]
ReadOnly {
/// The protected buffer.
id: BufferId,
/// Buffer name for user-facing diagnostics.
name: String,
},
/// `undo` was called with an empty undo stack.
#[error("nothing to undo")]
NothingToUndo,
@ -1824,6 +1869,99 @@ mod tests {
out
}
dual_mode_test!(
read_only_rejects_direct_skip_history_mutations,
|make, make_bytes| {
let mut buf = make_bytes("*read-only*", b"abc");
buf.apply_edit(EditOp::Insert {
pos: 3,
bytes: b"d",
})
.expect("seed undo history");
buf.undo().expect("seed redo history");
buf.set_read_only(true);
let before = (
collect(&buf),
buf.revision(),
buf.is_modified(),
buf.undo.len(),
buf.redo.len(),
);
assert!(matches!(
buf.begin_edit(),
Err(BufferError::ReadOnly { .. })
));
assert!(!buf.editing_in_progress());
let attempts = [
buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"x",
}),
buf.apply_edit_skip_intercepts(EditOp::Replace {
range: Range::new(0, 1),
bytes: b"y",
}),
buf.undo(),
buf.redo(),
];
assert!(
attempts
.iter()
.all(|result| matches!(result, Err(BufferError::ReadOnly { .. })))
);
assert_eq!(
before,
(
collect(&buf),
buf.revision(),
buf.is_modified(),
buf.undo.len(),
buf.redo.len(),
)
);
// Keep the generated dual-mode factory used in both configurations.
drop(make("*unused*"));
}
);
#[cfg(feature = "crdt")]
#[test]
fn read_only_rejects_remote_crdt_before_import_and_allows_empty_bootstrap() {
let mut protected = Buffer::new(BufferId::next(), "*terminal*");
protected.set_read_only(true);
protected
.upgrade_to_crdt(1)
.expect("immutable empty CRDT bootstrap remains valid");
let before_snapshot = protected
.crdt_state()
.expect("CRDT attached")
.export_snapshot()
.expect("snapshot");
let donor = crate::crdt::CrdtState::new(2).expect("donor");
let version = donor.version();
donor.insert(0, "forged").expect("donor edit");
let op = donor.export_updates_since(&version).expect("remote op");
assert!(matches!(
protected.apply_remote_crdt_op(&op),
Err(BufferError::ReadOnly { .. })
));
assert!(protected.is_empty());
assert_eq!(protected.revision(), 0);
assert!(!protected.is_modified());
assert_eq!(
protected
.crdt_state()
.expect("CRDT attached")
.export_snapshot()
.expect("snapshot"),
before_snapshot
);
}
// A view that records every callback for assertions.
#[derive(Default)]
struct RecorderView {

View File

@ -791,6 +791,14 @@ fn per_attach_thread(
let _ = dispatcher_tx.send(DispatcherEvent::SessionDetached { frontend_id });
}
/// Belt-and-braces write-loop gate for the additive protocol-v18
/// statusline variant. The producer has its own callback/evaluation gate;
/// this filter independently prevents an unknown discriminant reaching an
/// older peer even if a message is injected into the frame vector.
fn peer_accepts_statusline_message(protocol_version: u32, message: &InstanceMessage) -> bool {
protocol_version >= 18 || !matches!(message, InstanceMessage::StatuslineSegments { .. })
}
/// T M10.8 — dispatcher loop. The single thread that owns the editor.
///
/// All attached frontends' inputs arrive via the `dispatcher_rx`
@ -1144,6 +1152,12 @@ fn dispatcher_loop(
let peer_knows_font_facts = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 17);
// Q#SL7 — independently gate the v18 statusline variant
// even though the semantic producer also skips callbacks
// and message construction for older peers.
let negotiated_protocol_version = session_registry
.session_state(*fid)
.map_or(0, |s| s.negotiated_protocol_version);
for msg in &messages {
if !peer_knows_status_facts
&& matches!(msg, InstanceMessage::StatusFacts { .. })
@ -1186,6 +1200,9 @@ fn dispatcher_loop(
if !peer_knows_font_facts && matches!(msg, InstanceMessage::FontFacts { .. }) {
continue;
}
if !peer_accepts_statusline_message(negotiated_protocol_version, msg) {
continue;
}
// T M10.10 Day 4 / M10.11 F2 — the criterion-1
// jitter site: render-write latency.
//
@ -1267,6 +1284,10 @@ fn dispatcher_loop(
last_dispatch_idle_sent.remove(fid);
last_active_buffer_sent.remove(fid);
session_registry.unregister_session(*fid);
editor
.statusline_registry
.borrow_mut()
.detach_frontend(*fid);
editor.core.borrow_mut().unregister_frontend_view(*fid);
}
}
@ -1639,6 +1660,10 @@ fn handle_dispatcher_event(
last_dispatch_idle_sent.remove(&frontend_id);
last_active_buffer_sent.remove(&frontend_id);
session_registry.unregister_session(frontend_id);
editor
.statusline_registry
.borrow_mut()
.detach_frontend(frontend_id);
{
let mut core = editor.core.borrow_mut();
core.unregister_frontend_view(frontend_id);
@ -2555,6 +2580,24 @@ mod tests {
assert_eq!(b, 3);
}
#[test]
fn statusline_segments_write_gate_rejects_v17_independently() {
let segments = InstanceMessage::StatuslineSegments {
buffer_id: crate::buffer::BufferId::from_raw(1),
left: Vec::new(),
right: Vec::new(),
};
assert!(!peer_accepts_statusline_message(17, &segments));
assert!(peer_accepts_statusline_message(18, &segments));
assert!(peer_accepts_statusline_message(
17,
&InstanceMessage::FontFacts {
family: None,
size_centi_px: None,
}
));
}
#[test]
fn build_identity_includes_version_and_uptime() {
let s = DaemonState::new(Some("research".into()));

View File

@ -13,12 +13,15 @@
//! until the user quits.
use std::cell::RefCell;
use std::collections::HashMap;
use std::io;
use std::path::PathBuf;
use std::rc::Rc;
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyModifiers};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::async_runtime::SharedAsyncRuntime;
use crate::cell::CellCoord;
@ -61,6 +64,9 @@ pub struct EditorState {
/// Drop-time `shutdown` enforces SIGTERM-then-SIGKILL so editor
/// exit cannot leave zombies.
pub process_supervisor: crate::lua_bindings::SharedProcessSupervisor,
/// Terminal session registry. Shared with future terminal Lua bindings;
/// snapshots are owned so no screen borrow crosses editor/Lua/render work.
pub terminal_manager: crate::terminal::session::SharedTerminalManager,
/// LSP manager (T M4.5). Holds one [`crate::lsp::LspClient`] per
/// language server; rides on top of [`Self::process_supervisor`]
/// for spawn / I/O / restart. Constructed empty; user code
@ -101,6 +107,8 @@ pub struct EditorState {
/// Snippet store (T M4.11). Co-owned with the snippet
/// provider closure inside [`Self::completion_registry`].
pub snippets: crate::completion_framework::SharedSnippetRegistry,
/// Lua statusline providers shared by grid and semantic renderers.
pub statusline_registry: crate::statusline::SharedStatuslineRegistry,
/// Last left-button down event, used to synthesize terminal double
/// clicks from crossterm's plain Down/Up mouse event stream.
mouse_click: Option<MouseClickState>,
@ -128,6 +136,11 @@ impl Drop for EditorState {
/// stuck mid-handoff stays alive (bounded by its job), which is
/// still a ~15x improvement over leaking every pool whole.
fn drop(&mut self) {
{
let mut supervisor = self.process_supervisor.borrow_mut();
self.terminal_manager.borrow_mut().shutdown(&mut supervisor);
supervisor.shutdown();
}
self.async_runtime.shutdown_workers();
}
}
@ -167,6 +180,8 @@ impl EditorState {
lua_host
.attach_editor(&core)
.expect("editor bindings + builtin chunks");
let statusline_registry = crate::lua_bindings::statusline_registry(lua_host.lua())
.expect("statusline registry installed by editor bindings");
// The on-disk state dirs (minibuffer history + pmacs.state) are
// deliberately NOT configured here — see `install_state_dirs`,
// called by the real entry points (`run` / `run_daemon`) only.
@ -232,6 +247,7 @@ impl EditorState {
// shutdown enforces no-zombie cleanup at editor exit.
let process_supervisor = crate::lua_bindings::make_process_supervisor(lua_host.lua())
.expect("install pmacs.process");
let terminal_manager = Rc::new(RefCell::new(crate::terminal::TerminalManager::new()));
// T M4.5 LSP manager. Wires onto the same supervisor so its
// spawn/restart/I/O machinery is shared with `pmacs.process.*`.
// The manager itself is reachable from Lua as `pmacs.lsp.*`.
@ -474,6 +490,7 @@ impl EditorState {
async_runtime,
syntax_registry,
process_supervisor,
terminal_manager,
lsp_manager,
font_pref,
mcp_manager,
@ -481,21 +498,40 @@ impl EditorState {
project_indexer,
completion_registry,
snippets,
statusline_registry,
mouse_click: None,
}
}
/// One pass of the process supervisor: drain pending I/O / exit
/// events and apply restart policies. Mirrors
/// [`Self::tick_async`]; the run loop calls both per iteration.
/// Transactionally open an internal Stage-1 terminal session.
///
/// Fires the `process.after-tick` hook (T M6.5) after the supervisor
/// tick releases its borrow. Lua subscribers typically own a
/// `{[process_id] = handle}` registry and drain events via
/// `pmacs.process.events_take(id)`; the REPL package
/// (`builtin/packages/repl/init.lua`) is the first such consumer.
/// No interactive Lua command is registered until a frontend can render
/// terminal snapshots. This Rust seam is used by headless acceptance and
/// future bindings.
pub fn open_terminal(
&mut self,
spec: crate::terminal::TerminalSpec,
) -> Result<crate::buffer::BufferId, crate::terminal::TerminalError> {
let mut manager = self.terminal_manager.borrow_mut();
let mut core = self.core.borrow_mut();
let mut supervisor = self.process_supervisor.borrow_mut();
manager.open(spec, &mut core, &mut supervisor)
}
/// One pass of the process supervisor and terminal-owned event drain.
///
/// Ordering is supervisor tick → terminal drain/prune →
/// `process.after-tick`. `TerminalManager` calls `take_events` only for its
/// own `ProcessId`s; existing Lua/LSP/MCP ownership remains unchanged.
pub fn tick_processes(&mut self) {
self.process_supervisor.borrow_mut().tick();
{
let mut supervisor = self.process_supervisor.borrow_mut();
supervisor.tick();
let mut manager = self.terminal_manager.borrow_mut();
manager.tick(&mut supervisor);
let mut core = self.core.borrow_mut();
manager.prune(&mut core, &mut supervisor);
}
self.lua_host
.run_hook("process.after-tick", mlua::MultiValue::new());
}
@ -2108,6 +2144,25 @@ pub fn paint_frame(
return None;
}
let text_rows = term_size.rows - 1;
// Statusline callbacks may call arbitrary editor APIs. Evaluate the
// complete visible-window fan-out before the long mutable core borrow
// below, then paint only the transactionally validated owned results.
let frontend_id = state.core.borrow().active_frontend;
let statusline_evaluation = crate::statusline::evaluate_statusline(
state.lua_host.lua(),
&state.core,
&state.statusline_registry,
crate::statusline::StatuslineEvaluationTarget::Grid { frontend_id },
);
let statusline_by_window: HashMap<WindowId, crate::statusline::StatuslineWindowSegments> =
match statusline_evaluation.outcome {
crate::statusline::StatuslineEvaluationOutcome::Ready(windows) => windows
.into_iter()
.map(|segments| (segments.context.window_id, segments))
.collect(),
crate::statusline::StatuslineEvaluationOutcome::Invalidated { .. }
| crate::statusline::StatuslineEvaluationOutcome::NoMessage(_) => HashMap::new(),
};
// Themes Q#TH9: one theme clone per frame for the chrome faces —
// the same single-lock discipline as `SyntaxHighlightView::render`.
@ -2226,6 +2281,7 @@ pub fn paint_frame(
let guard = diag_store.lock().expect("diag store mutex poisoned");
diag_mode_line_summary(&guard, buf)
};
let custom = statusline_by_window.get(id);
paint_mode_line(
grid,
&rect,
@ -2237,6 +2293,9 @@ pub fn paint_frame(
&scroll,
&diags,
mode_line_style(&theme),
custom.map_or(&[], |segments| segments.left.as_slice()),
custom.map_or(&[], |segments| segments.right.as_slice()),
&theme,
);
}
drop(reg);
@ -2549,9 +2608,131 @@ fn diag_mode_line_summary(
}
}
#[derive(Copy, Clone)]
struct ModeLineRun<'a> {
text: &'a str,
style: crate::cell::Style,
}
struct ModeLineGrapheme {
glyph: crate::cell::Glyph,
width: u32,
style: crate::cell::Style,
}
fn prepare_mode_line_runs(runs: &[ModeLineRun<'_>]) -> Vec<ModeLineGrapheme> {
let mut graphemes = Vec::new();
for run in runs {
let sanitized = run.text.chars().any(char::is_control).then(|| {
run.text
.chars()
.map(|ch| if ch.is_control() { ' ' } else { ch })
.collect::<String>()
});
let text = sanitized.as_deref().unwrap_or(run.text);
for grapheme in text.graphemes(true) {
let width = UnicodeWidthStr::width(grapheme) as u32;
if width == 0 {
continue;
}
let mut chars = grapheme.chars();
let first = chars
.next()
.expect("unicode segmentation never yields an empty grapheme");
let glyph = if chars.next().is_none() {
crate::cell::Glyph::Char(first)
} else {
crate::cell::Glyph::Cluster(grapheme.as_bytes().into())
};
graphemes.push(ModeLineGrapheme {
glyph,
width,
style: run.style,
});
}
}
graphemes
}
fn mode_line_grapheme_width(graphemes: &[ModeLineGrapheme]) -> u32 {
graphemes.iter().map(|grapheme| grapheme.width).sum()
}
/// Paint complete graphemes at a logical signed origin. A grapheme that
/// straddles either clip edge is omitted wholesale, so a wide glyph can never
/// leave a dangling half-cell at a window or left/right collision boundary.
fn paint_mode_line_graphemes(
grid: &mut crate::cell::CellGrid<'_>,
rect: &crate::window::Rect,
row: u32,
origin: i64,
clip_start: u32,
clip_end: u32,
graphemes: &[ModeLineGrapheme],
) {
let mut logical_col = origin;
for grapheme in graphemes {
let next_col = logical_col + i64::from(grapheme.width);
if logical_col >= i64::from(clip_start) && next_col <= i64::from(clip_end) {
let local_col =
u32::try_from(logical_col).expect("non-negative clipped modeline column");
let cell = grid.at(CellCoord::new(row, rect.origin.col + local_col));
cell.glyph = grapheme.glyph.clone();
cell.style = grapheme.style;
for continuation in 1..grapheme.width {
let cell = grid.at(CellCoord::new(
row,
rect.origin.col + local_col + continuation,
));
cell.glyph = crate::cell::Glyph::Continuation;
cell.style = grapheme.style;
}
}
logical_col = next_col;
}
}
fn statusline_segment_style(
theme: &crate::highlight::Theme,
face: &str,
base: crate::cell::Style,
) -> crate::cell::Style {
let Some(override_style) = theme.modeline_segment_face(face) else {
return base;
};
let mut style = base;
if style.reverse {
style.bg = override_style.fg;
} else {
style.fg = override_style.fg;
}
style
}
fn custom_mode_line_runs<'a>(
segments: &'a [crate::statusline::EvaluatedStatuslineSegment],
theme: &crate::highlight::Theme,
base: crate::cell::Style,
) -> Vec<ModeLineRun<'a>> {
let mut runs = Vec::with_capacity(segments.len().saturating_mul(2));
for (index, segment) in segments.iter().enumerate() {
if index > 0 {
runs.push(ModeLineRun {
text: " ",
style: base,
});
}
runs.push(ModeLineRun {
text: &segment.text,
style: statusline_segment_style(theme, &segment.face, base),
});
}
runs
}
#[allow(
clippy::too_many_arguments,
reason = "the mode line packs nine unrelated facts; bundling them into a struct just adds ceremony"
reason = "the modeline packs built-in facts plus two already-evaluated custom sides"
)]
fn paint_mode_line(
grid: &mut crate::cell::CellGrid<'_>,
@ -2563,10 +2744,10 @@ fn paint_mode_line(
cursor_col: u32,
scroll: &str,
diags: &str,
// The resolved row style ([`mode_line_style`]) — this fn is a
// pure formatter, so the `ui.modeline` face resolution stays with
// the caller (themes arc Q#TH9).
mode_style: crate::cell::Style,
custom_left: &[crate::statusline::EvaluatedStatuslineSegment],
custom_right: &[crate::statusline::EvaluatedStatuslineSegment],
theme: &crate::highlight::Theme,
) {
if rect.size.rows == 0 || rect.size.cols == 0 {
return;
@ -2574,46 +2755,78 @@ fn paint_mode_line(
let row = rect.origin.row + rect.size.rows - 1;
let marker = if modified { '*' } else { ' ' };
let active_marker = if is_active { '+' } else { '-' };
let left = format!(" {active_marker}{marker} {name} ");
let right = if diags.is_empty() {
let protected_left = format!(" {active_marker}{marker} {name} ");
let protected_right = if diags.is_empty() {
format!(" L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1)
} else {
format!(" {diags} L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1)
};
// Fill the row with the mode-line style.
for c in 0..rect.size.cols {
let cell = grid.at(CellCoord::new(row, rect.origin.col + c));
// Fill exactly this window's row once with the base modeline surface.
for col in 0..rect.size.cols {
let cell = grid.at(CellCoord::new(row, rect.origin.col + col));
cell.glyph = crate::cell::Glyph::Char(' ');
cell.style = mode_style;
}
// Right-align the cursor / scroll readout. If the window is too
// narrow to fit both halves, drop the right side rather than
// overlap the buffer name.
let right_chars: Vec<char> = right.chars().collect();
let right_len = right_chars.len() as u32;
let right_start_col = if right_len < rect.size.cols {
Some(rect.size.cols - right_len)
} else {
None
};
if let Some(start_col) = right_start_col {
for (i, ch) in right_chars.iter().enumerate() {
let col = rect.origin.col + start_col + i as u32;
grid.at(CellCoord::new(row, col)).glyph = crate::cell::Glyph::Char(*ch);
}
let mut left_runs = Vec::with_capacity(custom_left.len().saturating_mul(2) + 2);
left_runs.push(ModeLineRun {
text: &protected_left,
style: mode_style,
});
if !custom_left.is_empty() {
left_runs.push(ModeLineRun {
text: " ",
style: mode_style,
});
left_runs.extend(custom_mode_line_runs(custom_left, theme, mode_style));
}
let left_graphemes = prepare_mode_line_runs(&left_runs);
// Paint the left side, stopping before the right side begins.
let stop_col = right_start_col.unwrap_or(rect.size.cols);
for (i, ch) in left.chars().enumerate() {
let i = i as u32;
if i >= stop_col {
break;
let protected_right_graphemes = prepare_mode_line_runs(&[ModeLineRun {
text: &protected_right,
style: mode_style,
}]);
let protected_right_width = mode_line_grapheme_width(&protected_right_graphemes);
// Preserve the legacy strict boundary: a suffix as wide as the entire
// window is dropped wholesale. Custom text can never cause that drop when
// the protected suffix itself still satisfies the legacy fit test.
if protected_right_width < rect.size.cols {
let mut right_prefix_runs = custom_mode_line_runs(custom_right, theme, mode_style);
if !custom_right.is_empty() {
right_prefix_runs.push(ModeLineRun {
text: " ",
style: mode_style,
});
}
let col = rect.origin.col + i;
grid.at(CellCoord::new(row, col)).glyph = crate::cell::Glyph::Char(ch);
let right_prefix_graphemes = prepare_mode_line_runs(&right_prefix_runs);
let right_prefix_width = mode_line_grapheme_width(&right_prefix_graphemes);
let suffix_start = rect.size.cols - protected_right_width;
let right_origin = i64::from(suffix_start) - i64::from(right_prefix_width);
let left_clip_end = u32::try_from(right_origin).unwrap_or(0);
paint_mode_line_graphemes(grid, rect, row, 0, 0, left_clip_end, &left_graphemes);
paint_mode_line_graphemes(
grid,
rect,
row,
right_origin,
0,
suffix_start,
&right_prefix_graphemes,
);
paint_mode_line_graphemes(
grid,
rect,
row,
i64::from(suffix_start),
suffix_start,
rect.size.cols,
&protected_right_graphemes,
);
} else {
paint_mode_line_graphemes(grid, rect, row, 0, 0, rect.size.cols, &left_graphemes);
}
}
@ -6921,6 +7134,403 @@ mod tests {
}
}
#[test]
fn statusline_no_visible_provider_preserves_ascii_modeline_cells() {
let s = fresh_with(b"hello");
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let actual = (0..80)
.map(|col| glyph_at(&cells, stride, 22, col))
.collect::<String>();
let left = " + test ";
let right = " L1:C1 All ";
let expected = format!("{left}{}{right}", " ".repeat(80 - left.len() - right.len()));
assert_eq!(actual, expected);
}
#[test]
fn statusline_real_frame_orders_runs_styles_separators_and_keeps_echo_independent() {
let s = fresh_with(b"hello");
s.core.borrow_mut().status = "echo-only".to_owned();
s.lua_host
.lua()
.load(
r#"
pmacs.theme.merge {
["ui.modeline.red"] = { fg = 1 },
["ui.modeline.blue"] = { fg = 2 },
}
_G.statusline_handles = {
pmacs.statusline.register {
name = "left-zero", side = "left", priority = 0,
face = "ui.modeline.blue", fn = function() return "L0" end,
},
pmacs.statusline.register {
name = "left-high", side = "left", priority = 10,
face = "ui.modeline.red", fn = function() return "LH" end,
},
pmacs.statusline.register {
name = "left-nil", side = "left", priority = 100,
fn = function() return nil end,
},
pmacs.statusline.register {
name = "left-empty", side = "left", priority = 100,
fn = function() return "" end,
},
pmacs.statusline.register {
name = "left-zero-late", side = "left", priority = 0,
face = "ui.modeline.blue", fn = function() return "L1" end,
},
pmacs.statusline.register {
name = "right-zero", side = "right", priority = 0,
face = "ui.modeline.blue", fn = function() return "R0" end,
},
pmacs.statusline.register {
name = "right-high", side = "right", priority = 10,
face = "ui.modeline.red", fn = function() return "RH" end,
},
pmacs.statusline.register {
name = "right-zero-late", side = "right", priority = 0,
face = "ui.modeline.blue", fn = function() return "R1" end,
},
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&s, 24, 100);
let mode = row_text(&cells, stride, 22, 100);
assert!(
mode.starts_with(" + test LH L0 L1"),
"wrong left composition: {mode:?}"
);
assert!(
mode.ends_with("R0 R1 RH L1:C1 All"),
"wrong right composition: {mode:?}"
);
assert!(!mode.contains("left-nil") && !mode.contains("left-empty"));
assert_eq!(row_text(&cells, stride, 23, 100), "echo-only");
let lh_col = mode.find("LH").unwrap() as u32;
let l0_col = mode.find("L0").unwrap() as u32;
let rh_col = mode.find("RH").unwrap() as u32;
let base = cells[(22 * stride) as usize].style;
for col in [lh_col, lh_col + 1, rh_col, rh_col + 1] {
let style = cells[(22 * stride + col) as usize].style;
assert!(style.reverse);
assert_eq!(style.bg, crate::cell::Color::Indexed(1));
}
for col in [l0_col, l0_col + 1] {
let style = cells[(22 * stride + col) as usize].style;
assert!(style.reverse);
assert_eq!(style.bg, crate::cell::Color::Indexed(2));
}
assert_eq!(
cells[(22 * stride + lh_col + 2) as usize].style,
base,
"custom/custom separator must retain ui.modeline"
);
let protected_right_col = mode.find(" L1:C1 All").unwrap() as u32;
assert_eq!(
cells[(22 * stride + protected_right_col - 1) as usize].style,
base,
"custom/built-in separator must retain ui.modeline"
);
}
#[test]
fn statusline_real_frame_evaluates_distinct_split_contexts_and_focus() {
let s = fresh_with(b"left");
s.lua_host
.lua()
.load(
r#"
_G.other_statusline_buffer = pmacs.buffer.create("other")
pmacs.window.split_vertical()
pmacs.window.switch_buffer(_G.other_statusline_buffer)
_G.statusline_seen = {}
_G.statusline_context_handle = pmacs.statusline.register {
name = "contexts", side = "left",
fn = function(ctx)
table.insert(_G.statusline_seen, {
frontend = ctx.frontend,
window = ctx.window,
buffer = tostring(ctx.buffer),
active = ctx.active,
})
return ctx.active and "ACTIVE" or "PASSIVE"
end,
}
_G.statusline_split_clip_handle = pmacs.statusline.register {
name = "split-clipping", side = "right",
fn = function(ctx)
return string.rep(ctx.active and "X" or "Y", 20)
end,
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&s, 24, 120);
let seen: mlua::Table = s.lua_host.lua().globals().get("statusline_seen").unwrap();
assert_eq!(seen.raw_len(), 2);
let first: mlua::Table = seen.raw_get(1).unwrap();
let second: mlua::Table = seen.raw_get(2).unwrap();
let first_window: u64 = first.get("window").unwrap();
let second_window: u64 = second.get("window").unwrap();
let first_buffer: String = first.get("buffer").unwrap();
let second_buffer: String = second.get("buffer").unwrap();
let first_frontend: u64 = first.get("frontend").unwrap();
let second_frontend: u64 = second.get("frontend").unwrap();
let first_active: bool = first.get("active").unwrap();
let second_active: bool = second.get("active").unwrap();
assert_ne!(first_window, second_window);
assert_ne!(first_buffer, second_buffer);
assert_eq!(first_frontend, FrontendId::LOCAL.0);
assert_eq!(second_frontend, FrontendId::LOCAL.0);
assert_ne!(first_active, second_active);
let left_mode = (0..60)
.map(|col| glyph_at(&cells, stride, 22, col))
.collect::<String>();
let right_mode = (60..120)
.map(|col| glyph_at(&cells, stride, 22, col))
.collect::<String>();
assert!(
(left_mode.contains("ACTIVE") && right_mode.contains("PASSIVE"))
|| (left_mode.contains("PASSIVE") && right_mode.contains("ACTIVE"))
);
s.lua_host
.lua()
.load("_G.statusline_seen = {}; pmacs.window.focus_next()")
.exec()
.unwrap();
let _ = render_to_grid(&s, 24, 120);
let seen: mlua::Table = s.lua_host.lua().globals().get("statusline_seen").unwrap();
assert_eq!(seen.raw_len(), 2);
let now_first: mlua::Table = seen.raw_get(1).unwrap();
let now_second: mlua::Table = seen.raw_get(2).unwrap();
let active_by_window = |table: &mlua::Table| {
(
table.get::<u64>("window").unwrap(),
table.get::<bool>("active").unwrap(),
)
};
let flipped = [active_by_window(&now_first), active_by_window(&now_second)];
assert!(flipped.contains(&(first_window, !first_active)));
assert!(flipped.contains(&(second_window, !second_active)));
let (narrow_cells, narrow_stride, _) = render_to_grid(&s, 24, 30);
let narrow_left = (0..15)
.map(|col| glyph_at(&narrow_cells, narrow_stride, 22, col))
.collect::<String>();
let narrow_right = (15..30)
.map(|col| glyph_at(&narrow_cells, narrow_stride, 22, col))
.collect::<String>();
assert!(
(narrow_left.contains('X')
&& !narrow_left.contains('Y')
&& narrow_right.contains('Y')
&& !narrow_right.contains('X'))
|| (narrow_left.contains('Y')
&& !narrow_left.contains('X')
&& narrow_right.contains('X')
&& !narrow_right.contains('Y')),
"custom runs crossed a split boundary: left={narrow_left:?} right={narrow_right:?}"
);
}
#[test]
fn statusline_real_frame_discards_context_mutated_during_callback() {
let s = fresh_with(b"old");
s.lua_host
.lua()
.load(
r#"
_G.statusline_switch_target = pmacs.buffer.create("switched")
_G.statusline_switch_once = true
_G.statusline_switch_handle = pmacs.statusline.register {
name = "context-mutator", side = "left",
fn = function()
if _G.statusline_switch_once then
_G.statusline_switch_once = false
pmacs.window.switch_buffer(_G.statusline_switch_target)
return "STALE"
end
return "FRESH"
end,
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let first = row_text(&cells, stride, 22, 80);
assert!(
first.contains("switched"),
"callback buffer switch did not land"
);
assert!(
!first.contains("STALE"),
"invalidated old-context output reached the new buffer: {first:?}"
);
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let second = row_text(&cells, stride, 22, 80);
assert!(
second.contains("FRESH"),
"next valid frame did not evaluate the surviving context: {second:?}"
);
}
#[test]
fn statusline_real_frame_paints_unicode_clusters_and_sanitizes_all_runs() {
let s = fresh_with(b"hello");
{
let core = s.core.borrow();
let registry = core.registry.clone();
registry
.borrow_mut()
.get_mut(core.active_buffer_id())
.unwrap()
.set_name("na\r\n\u{1b}me");
}
s.lua_host
.lua()
.load(
r#"
_G.statusline_unicode_handle = pmacs.statusline.register {
name = "unicode", side = "left",
fn = function() return "\204\129界e\204\129\27Z" end,
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let row = &cells[(22 * stride) as usize..(23 * stride) as usize];
let wide_col = row
.iter()
.position(|cell| cell.glyph == crate::cell::Glyph::Char('界'))
.expect("CJK grapheme should be present");
assert_eq!(row[wide_col + 1].glyph, crate::cell::Glyph::Continuation);
assert_eq!(
row[wide_col + 2].glyph,
crate::cell::Glyph::Cluster("e\u{301}".as_bytes().into())
);
assert_eq!(row[wide_col + 3].glyph, crate::cell::Glyph::Char(' '));
assert_eq!(row[wide_col + 4].glyph, crate::cell::Glyph::Char('Z'));
for cell in row {
match &cell.glyph {
crate::cell::Glyph::Char(ch) => assert!(!ch.is_control()),
crate::cell::Glyph::Cluster(bytes) => {
let text = std::str::from_utf8(bytes).unwrap();
assert!(!text.chars().any(char::is_control));
assert_ne!(text, "\u{301}", "standalone zero-width grapheme leaked");
}
crate::cell::Glyph::Continuation => {}
}
}
let ascii_projection = row
.iter()
.map(|cell| match cell.glyph {
crate::cell::Glyph::Char(ch) => ch,
_ => '?',
})
.collect::<String>();
assert!(
ascii_projection.contains("na me"),
"buffer-name controls were not replaced independently: {ascii_projection:?}"
);
}
#[test]
fn statusline_real_frame_clips_custom_edges_but_preserves_protected_suffix() {
let s = fresh_with(b"hello");
s.lua_host
.lua()
.load(
r#"
_G.statusline_clip_handles = {
pmacs.statusline.register {
name = "left-high", side = "left", priority = 10,
fn = function() return "HIGH" end,
},
pmacs.statusline.register {
name = "left-low", side = "left", priority = 0,
fn = function() return "界LOW" end,
},
pmacs.statusline.register {
name = "right-low", side = "right", priority = 0,
fn = function() return "LOW" end,
},
pmacs.statusline.register {
name = "right-high", side = "right", priority = 10,
fn = function() return "HIGH" end,
},
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&s, 6, 17);
let mode = row_text(&cells, stride, 4, 17);
assert!(
mode.contains("HIGH"),
"high-priority right edge lost: {mode:?}"
);
assert!(
!mode.contains("LOW"),
"low-priority right edge survived: {mode:?}"
);
assert!(
mode.ends_with(" L1:C1 All"),
"protected suffix was not preserved in full: {mode:?}"
);
assert_ne!(
cells[(4 * stride) as usize].glyph,
crate::cell::Glyph::Continuation,
"a clipped wide grapheme left a continuation at the window edge"
);
let left_only = fresh_with(b"hello");
left_only
.lua_host
.lua()
.load(
r#"
_G.statusline_left_clip_handles = {
pmacs.statusline.register {
name = "left-high", side = "left", priority = 10,
fn = function() return "HIGH" end,
},
pmacs.statusline.register {
name = "left-low", side = "left", priority = 0,
fn = function() return "界LOW" end,
},
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&left_only, 6, 26);
let mode = row_text(&cells, stride, 4, 26);
assert!(mode.starts_with(" + test HIGH"));
assert!(!mode.contains("LOW"));
assert!(mode.ends_with(" L1:C1 All"));
let (cells, stride, _) = render_to_grid(&s, 6, 11);
let mode = row_text(&cells, stride, 4, 11);
assert!(
!mode.contains("L1:C1") && !mode.contains("HIGH") && !mode.contains("LOW"),
"a non-fitting protected suffix must drop the whole right group: {mode:?}"
);
}
/// Give the active buffer a file path and return its `file://`
/// URI, so diag-store entries can be keyed to it.
fn set_active_buffer_path(s: &EditorState, path: &str) -> String {

View File

@ -422,6 +422,10 @@ impl Frontend {
// preference; terminal fonts belong to the terminal, so
// the cell-grid TUI drops this silently too.
| InstanceMessage::FontFacts { .. }
// Q#SL7 — custom statusline segments are semantic-only;
// the grid TUI paints provider output directly from the
// registry and silently drops an unexpected wire copy.
| InstanceMessage::StatuslineSegments { .. }
| InstanceMessage::ResourceOffer { .. }
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
// optimistic-apply gate; if any reaches this render path
@ -817,6 +821,28 @@ mod tests {
.expect("the grid frontend must drop FontFacts silently");
}
#[test]
fn statusline_segments_drop_silently_on_the_grid_frontend() {
let mut fe = Frontend {
out: BufWriter::new(io::stdout()),
size: CellSize::new(24, 80),
raw_mode: false,
alt_screen: false,
bracketed_paste: false,
mouse: false,
keyboard_enhancement: false,
};
fe.apply_message(&InstanceMessage::StatuslineSegments {
buffer_id: crate::buffer::BufferId::from_raw(7),
left: vec![pmacs_protocol::StatuslineSegment {
text: "project".into(),
face: "ui.modeline.project".into(),
}],
right: Vec::new(),
})
.expect("the grid frontend must drop StatuslineSegments silently");
}
#[test]
fn emit_span_writes_cursor_move_then_chars() {
let span = DiffSpan {

View File

@ -90,7 +90,7 @@ pub struct Theme {
/// and the `ThemeFacts` producer's key filter.
#[must_use]
pub fn is_face_name(name: &str) -> bool {
name == "ui" || name.starts_with("ui.")
pmacs_protocol::is_ui_face_name(name)
}
impl Theme {
@ -226,6 +226,37 @@ impl Theme {
}
}
/// Resolve a custom modeline segment face relative to the already
/// resolved `ui.modeline` surface (statusline framing Q#SL6).
///
/// Only a concrete foreground from the exact child or an intermediate
/// child is returned. The walk stops before `ui.modeline`: reaching the
/// base means the segment keeps the base modeline's effective text
/// color. An explicitly default foreground also stops inheritance and
/// returns to that base. Out-of-mask style components are discarded.
#[must_use]
pub fn modeline_segment_face(&self, name: &str) -> Option<Style> {
debug_assert!(
pmacs_protocol::is_modeline_face_name(name),
"modeline_segment_face() takes ui.modeline/ui.modeline.* names"
);
let mut name = name;
while name != "ui.modeline" {
if let Some(style) = self.by_capture.get(name) {
return match style.fg {
Color::Default => None,
fg => Some(Style {
fg,
..Style::default()
}),
};
}
let index = name.rfind('.')?;
name = &name[..index];
}
None
}
/// Set the style for one capture name, replacing any prior entry.
pub fn insert(&mut self, capture_name: impl Into<String>, style: Style) {
self.by_capture.insert(capture_name.into(), style);
@ -842,6 +873,47 @@ mod tests {
assert!(!is_face_name("gui.modeline"));
}
#[test]
fn modeline_segment_face_is_base_relative_and_fg_only() {
let mut t = Theme::empty();
t.insert(
"ui.modeline",
Style {
fg: Color::Indexed(1),
bg: Color::Indexed(2),
reverse: true,
..Style::default()
},
);
assert_eq!(t.modeline_segment_face("ui.modeline"), None);
assert_eq!(t.modeline_segment_face("ui.modeline.unset"), None);
t.insert(
"ui.modeline.project",
Style {
fg: Color::Indexed(6),
bg: Color::Indexed(5),
reverse: true,
bold: true,
..Style::default()
},
);
assert_eq!(
t.modeline_segment_face("ui.modeline.project.branch"),
Some(Style {
fg: Color::Indexed(6),
..Style::default()
})
);
t.insert("ui.modeline.project.branch", Style::default());
assert_eq!(
t.modeline_segment_face("ui.modeline.project.branch"),
None,
"an exact default foreground blocks the colored intermediate parent"
);
}
#[test]
fn line_offsets_basic() {
let src = b"a\nbb\nccc";

View File

@ -128,8 +128,10 @@ pub mod semantic_tokens;
pub mod signature;
pub mod socket_path;
pub mod state;
pub mod statusline;
pub mod symbol;
pub mod syntax;
pub mod terminal;
pub mod text_view;
pub mod transport;
pub mod view;

View File

@ -1233,6 +1233,10 @@ impl LspManager {
let id = LspServerId::next();
let mut client = LspClient::new(spec);
self.start_generation(id, &mut client)?;
// The statusline may render before the supervisor's next `Started`
// event is drained. Seed the documented initializing state now so an
// attached live server is never mislabeled as forgotten (`?`).
self.status_tracker.ensure(id, Instant::now());
self.clients.insert(id, client);
Ok(id)
}
@ -2497,6 +2501,25 @@ impl LspManager {
}
}
/// Push the client's configured `settings` via
/// `workspace/didChangeConfiguration` immediately after `initialized`.
/// Push-model servers — notably the VS Code JSON server, which listens
/// for this notification and does NOT issue `workspace/configuration`
/// pulls — only learn their config this way; pull-model servers
/// (pyright, clangd, gopls) ignore it and pull instead, so it is safe
/// to send unconditionally. No-op when no `settings` are configured.
fn push_initial_configuration(&self, sid: LspServerId) {
if let Some(client) = self.clients.get(&sid)
&& let Some(settings) = client.spec.settings.clone()
{
let cfg = make_notification(
"workspace/didChangeConfiguration",
json!({ "settings": settings }),
);
let _ = send_frame_to(&self.supervisor, client, &cfg);
}
}
fn handle_response(
&mut self,
sid: LspServerId,
@ -2552,6 +2575,9 @@ impl LspManager {
if let Some(client) = self.clients.get(&sid) {
let _ = send_frame_to(&self.supervisor, client, &body);
}
// Push the configured settings right after `initialized`
// (before any deferred `didOpen`).
self.push_initial_configuration(sid);
// T M4.5 Option B: honour the server's negotiated
// `general.positionEncoding`. Absent ⇒ LSP spec default
// (UTF-16). We advertised `["utf-8","utf-16"]`, so a

View File

@ -66,6 +66,10 @@ use crate::packages::{
};
use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity};
use crate::rope::Range;
use crate::statusline::{
SharedStatuslineRegistry, StatuslineProviderFailure, StatuslineProviderId, StatuslineRegistry,
StatuslineSide,
};
use crate::syntax::{self, ParseTreeBundle, ParseView, ParseViewHandle, SharedSyntaxRegistry};
use crate::workers_buffer;
@ -2011,6 +2015,230 @@ impl UserData for MarkHandleLua {
}
}
/// Opaque Lua handle for a statusline provider registration.
#[derive(Copy, Clone)]
pub struct StatuslineProviderIdLua(pub StatuslineProviderId);
impl FromLua for StatuslineProviderIdLua {
fn from_lua(value: Value, _: &Lua) -> mlua::Result<Self> {
match value {
Value::UserData(data) => Ok(*data.borrow::<Self>()?),
other => Err(mlua::Error::FromLuaConversionError {
from: other.type_name(),
to: "StatuslineProviderIdLua".to_owned(),
message: Some("expected a statusline provider handle".to_owned()),
}),
}
}
}
impl UserData for StatuslineProviderIdLua {
fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
methods.add_method("raw", |_, this, ()| Ok(this.0.raw()));
methods.add_meta_method(mlua::MetaMethod::ToString, |_, this, ()| {
Ok(this.0.to_string())
});
methods.add_meta_method(
mlua::MetaMethod::Eq,
|_, this, other: StatuslineProviderIdLua| Ok(this.0 == other.0),
);
}
}
/// Retrieve the statusline registry installed with the base `pmacs` table.
///
/// `EditorState` and semantic/TUI constructors use this exact shared handle;
/// bare Lua hosts always receive an empty registry rather than an absent API.
pub fn statusline_registry(lua: &Lua) -> mlua::Result<SharedStatuslineRegistry> {
lua.app_data_ref::<SharedStatuslineRegistry>()
.map(|registry| registry.clone())
.ok_or_else(|| mlua::Error::external("statusline registry is not installed"))
}
/// Install the strict `pmacs.statusline` registration/lifecycle surface.
#[allow(
clippy::too_many_lines,
reason = "one strict table parser followed by four small lifecycle bindings; splitting obscures the all-fields-before-mutation contract"
)]
pub fn install_statusline_module(
lua: &Lua,
registry: &SharedStatuslineRegistry,
) -> mlua::Result<Table> {
let module = lua.create_table()?;
{
let registry = registry.clone();
module.set(
"register",
lua.create_function(move |lua, spec: Table| {
let mut unknown = None;
spec.clone().for_each(|key: Value, _: Value| {
let name = match &key {
Value::String(value) => value.to_str().map_or_else(
|_| "<invalid UTF-8>".to_owned(),
|value| value.to_owned(),
),
other => format!("{other:?}"),
};
if !matches!(name.as_str(), "name" | "side" | "priority" | "face" | "fn")
&& unknown.is_none()
{
unknown = Some(name);
}
Ok(())
})?;
if let Some(key) = unknown {
return Err(mlua::Error::external(format!(
"pmacs.statusline.register: unknown field `{key}`"
)));
}
let name =
strict_statusline_string(spec.raw_get("name")?, "name", false)?
.expect("required statusline name");
let side_value =
strict_statusline_string(spec.raw_get("side")?, "side", false)?
.expect("required statusline side");
let side = match side_value.as_str() {
"left" => StatuslineSide::Left,
"right" => StatuslineSide::Right,
other => {
return Err(mlua::Error::external(format!(
"pmacs.statusline.register: `side` must be \"left\" or \"right\", got {other:?}"
)));
}
};
let priority = strict_statusline_priority(spec.raw_get("priority")?)?;
let face = strict_statusline_string(spec.raw_get("face")?, "face", true)?
.unwrap_or_else(|| "ui.modeline".to_owned());
let callback = match spec.raw_get::<Value>("fn")? {
Value::Function(function) => function,
other => {
return Err(mlua::Error::external(format!(
"pmacs.statusline.register: `fn` must be a function, got {}",
other.type_name()
)));
}
};
// Every raw field is now parsed and typed; only this final call
// mutates the registry.
let id = registry.borrow_mut().register(
name,
side,
priority,
face,
callback,
caller_source(lua, 2),
)
.map_err(mlua::Error::external)?;
Ok(StatuslineProviderIdLua(id))
})?,
)?;
}
{
let registry = registry.clone();
module.set(
"unregister",
lua.create_function(move |_, id: StatuslineProviderIdLua| {
Ok(registry.borrow_mut().unregister(id.0))
})?,
)?;
}
{
let registry = registry.clone();
module.set(
"set_priority",
lua.create_function(move |_, (id, value): (StatuslineProviderIdLua, Value)| {
let priority = strict_statusline_priority(value)?;
Ok(registry.borrow_mut().set_priority(id.0, priority))
})?,
)?;
}
{
let registry = registry.clone();
module.set(
"set_enabled",
lua.create_function(move |_, (id, value): (StatuslineProviderIdLua, Value)| {
let Value::Boolean(enabled) = value else {
return Err(mlua::Error::external(
"pmacs.statusline.set_enabled: `enabled` must be a boolean",
));
};
Ok(registry.borrow_mut().set_enabled(id.0, enabled))
})?,
)?;
}
{
let registry = registry.clone();
module.set(
"providers",
lua.create_function(move |lua, ()| {
let providers = registry.borrow().providers();
let output = lua.create_table_with_capacity(providers.len(), 0)?;
for (index, provider) in providers.iter().enumerate() {
let metadata = lua.create_table_with_capacity(0, 6)?;
metadata.raw_set("handle", StatuslineProviderIdLua(provider.id))?;
metadata.raw_set("name", provider.name.as_str())?;
metadata.raw_set("side", provider.side.as_str())?;
metadata.raw_set("priority", provider.priority)?;
metadata.raw_set("face", provider.face.as_str())?;
metadata.raw_set("enabled", provider.enabled)?;
output.raw_set(index + 1, metadata)?;
}
Ok(output)
})?,
)?;
}
Ok(module)
}
fn strict_statusline_string(
value: Value,
field: &'static str,
optional: bool,
) -> mlua::Result<Option<String>> {
match value {
Value::Nil if optional => Ok(None),
Value::String(value) => value
.to_str()
.map(|value| Some(value.to_owned()))
.map_err(|_| {
mlua::Error::external(format!(
"pmacs.statusline.register: `{field}` must be valid UTF-8"
))
}),
other => Err(mlua::Error::external(format!(
"pmacs.statusline.register: `{field}` must be a string, got {}",
other.type_name()
))),
}
}
fn strict_statusline_priority(value: Value) -> mlua::Result<i32> {
match value {
Value::Nil => Ok(0),
Value::Integer(value) => i32::try_from(value).map_err(|_| {
mlua::Error::external(
"pmacs.statusline priority must be an integer in the signed 32-bit range",
)
}),
Value::Number(value)
if value.is_finite()
&& value.fract() == 0.0
&& value >= f64::from(i32::MIN)
&& value <= f64::from(i32::MAX) =>
{
Ok(value as i32)
}
Value::Number(_) => Err(mlua::Error::external(
"pmacs.statusline priority must be an integer in the signed 32-bit range",
)),
other => Err(mlua::Error::external(format!(
"pmacs.statusline priority must be a number, got {}",
other.type_name()
))),
}
}
// ---------------------------------------------------------------------------
// Module install
// ---------------------------------------------------------------------------
@ -2047,6 +2275,8 @@ pub fn install(
lua.set_app_data(PackageUnloadHooks::new());
lua.set_app_data(CurrentlyLoadingPackage::new());
lua.set_app_data(BufferRemoveCallbacks::new());
let statusline = Rc::new(RefCell::new(StatuslineRegistry::new()));
lua.set_app_data(statusline.clone());
let pmacs = lua.create_table()?;
pmacs.set("buffer", install_buffer_module(lua, registry)?)?;
@ -2054,6 +2284,7 @@ pub fn install(
pmacs.set("keymap", install_keymap_module(lua, keymaps)?)?;
pmacs.set("menu", install_menu_module(lua, menus)?)?;
pmacs.set("hook", install_hook_module(lua, hooks)?)?;
pmacs.set("statusline", install_statusline_module(lua, &statusline)?)?;
// Wall-clock millis (since UNIX epoch). Used by builtin runtime
// chunks for timeout loops; `os.clock()` only counts CPU time and
// is a poor fit for "wait until something arrives over I/O".
@ -4681,6 +4912,10 @@ fn installed_package_to_lua(lua: &Lua, pkg: &InstalledPackage) -> mlua::Result<T
/// `bracketed_paste_begin` / `bracketed_paste_end` /
/// `alt_screen_enter` / `alt_screen_exit`: `{ kind=<name> }` only
/// - `set_title`: `{ kind="set_title", title=<string> }`
#[allow(
clippy::too_many_lines,
reason = "exhaustive wire-to-Lua conversion keeps every ANSI variant and field visible in one audited match"
)]
fn event_to_lua_table(lua: &Lua, ev: &crate::ansi::AnsiEvent) -> mlua::Result<Table> {
use crate::ansi::AnsiEvent;
let t = lua.create_table()?;
@ -4733,6 +4968,151 @@ fn event_to_lua_table(lua: &Lua, ev: &crate::ansi::AnsiEvent) -> mlua::Result<Ta
AnsiEvent::AlternateScreenExit => {
t.set("kind", "alt_screen_exit")?;
}
AnsiEvent::Bell => t.set("kind", "bell")?,
AnsiEvent::LineFeed => t.set("kind", "line_feed")?,
AnsiEvent::Index => t.set("kind", "index")?,
AnsiEvent::NextLine => t.set("kind", "next_line")?,
AnsiEvent::ReverseIndex => t.set("kind", "reverse_index")?,
AnsiEvent::HorizontalTab => t.set("kind", "horizontal_tab")?,
AnsiEvent::SetTabStop => t.set("kind", "set_tab_stop")?,
AnsiEvent::ClearTabStop => t.set("kind", "clear_tab_stop")?,
AnsiEvent::ClearAllTabStops => t.set("kind", "clear_all_tab_stops")?,
AnsiEvent::CursorUp(count)
| AnsiEvent::CursorDown(count)
| AnsiEvent::CursorForward(count)
| AnsiEvent::CursorBackward(count)
| AnsiEvent::CursorNextLine(count)
| AnsiEvent::CursorPreviousLine(count)
| AnsiEvent::EraseCharacters(count)
| AnsiEvent::InsertCharacters(count)
| AnsiEvent::DeleteCharacters(count)
| AnsiEvent::InsertLines(count)
| AnsiEvent::DeleteLines(count)
| AnsiEvent::ScrollUp(count)
| AnsiEvent::ScrollDown(count) => {
let kind = match ev {
AnsiEvent::CursorUp(_) => "cursor_up",
AnsiEvent::CursorDown(_) => "cursor_down",
AnsiEvent::CursorForward(_) => "cursor_forward",
AnsiEvent::CursorBackward(_) => "cursor_backward",
AnsiEvent::CursorNextLine(_) => "cursor_next_line",
AnsiEvent::CursorPreviousLine(_) => "cursor_previous_line",
AnsiEvent::EraseCharacters(_) => "erase_characters",
AnsiEvent::InsertCharacters(_) => "insert_characters",
AnsiEvent::DeleteCharacters(_) => "delete_characters",
AnsiEvent::InsertLines(_) => "insert_lines",
AnsiEvent::DeleteLines(_) => "delete_lines",
AnsiEvent::ScrollUp(_) => "scroll_up",
AnsiEvent::ScrollDown(_) => "scroll_down",
_ => unreachable!("outer match restricts the event"),
};
t.set("kind", kind)?;
t.set("count", *count)?;
}
AnsiEvent::CursorHorizontalAbsolute(col) => {
t.set("kind", "cursor_horizontal_absolute")?;
t.set("col", *col)?;
}
AnsiEvent::CursorVerticalAbsolute(row) => {
t.set("kind", "cursor_vertical_absolute")?;
t.set("row", *row)?;
}
AnsiEvent::CursorPosition { row, col } => {
t.set("kind", "cursor_position")?;
t.set("row", *row)?;
t.set("col", *col)?;
}
AnsiEvent::EraseDisplay(mode) | AnsiEvent::EraseLineMode(mode) => {
t.set(
"kind",
if matches!(ev, AnsiEvent::EraseDisplay(_)) {
"erase_display"
} else {
"erase_line_mode"
},
)?;
t.set(
"mode",
match mode {
crate::ansi::EraseMode::ToEnd => "to_end",
crate::ansi::EraseMode::ToStart => "to_start",
crate::ansi::EraseMode::All => "all",
crate::ansi::EraseMode::Saved => "saved",
},
)?;
}
AnsiEvent::SetScrollingRegion { top, bottom } => {
t.set("kind", "set_scrolling_region")?;
t.set("top", *top)?;
t.set("bottom", *bottom)?;
}
AnsiEvent::SaveCursor => t.set("kind", "save_cursor")?,
AnsiEvent::RestoreCursor => t.set("kind", "restore_cursor")?,
AnsiEvent::AlternateScreen { mode, enabled } => {
t.set("kind", "alternate_screen")?;
t.set(
"mode",
match mode {
crate::ansi::AlternateScreenMode::Mode47 => 47,
crate::ansi::AlternateScreenMode::Mode1047 => 1047,
crate::ansi::AlternateScreenMode::Mode1049 => 1049,
},
)?;
t.set("enabled", *enabled)?;
}
AnsiEvent::SetMode { mode, enabled } => {
t.set("kind", "set_mode")?;
t.set(
"mode",
match mode {
crate::ansi::TerminalMode::Insert => "insert",
crate::ansi::TerminalMode::Origin => "origin",
crate::ansi::TerminalMode::AutoWrap => "auto_wrap",
crate::ansi::TerminalMode::ApplicationCursor => "application_cursor",
crate::ansi::TerminalMode::ApplicationKeypad => "application_keypad",
crate::ansi::TerminalMode::CursorVisible => "cursor_visible",
crate::ansi::TerminalMode::BracketedPaste => "bracketed_paste",
crate::ansi::TerminalMode::FocusReporting => "focus_reporting",
crate::ansi::TerminalMode::SynchronizedOutput => "synchronized_output",
crate::ansi::TerminalMode::MouseX10 => "mouse_x10",
crate::ansi::TerminalMode::MouseButton => "mouse_button",
crate::ansi::TerminalMode::MouseAny => "mouse_any",
crate::ansi::TerminalMode::MouseSgr => "mouse_sgr",
},
)?;
t.set("enabled", *enabled)?;
}
AnsiEvent::DesignateCharacterSet { slot, charset } => {
t.set("kind", "designate_character_set")?;
t.set(
"slot",
match slot {
crate::ansi::CharacterSetSlot::G0 => "g0",
crate::ansi::CharacterSetSlot::G1 => "g1",
},
)?;
t.set(
"charset",
match charset {
crate::ansi::CharacterSet::Ascii => "ascii",
crate::ansi::CharacterSet::DecSpecialGraphics => "dec_special_graphics",
},
)?;
}
AnsiEvent::ShiftOut => t.set("kind", "shift_out")?,
AnsiEvent::ShiftIn => t.set("kind", "shift_in")?,
AnsiEvent::DeviceRequest(request) => {
t.set("kind", "device_request")?;
t.set(
"request",
match request {
crate::ansi::DeviceRequest::PrimaryAttributes => "primary_attributes",
crate::ansi::DeviceRequest::SecondaryAttributes => "secondary_attributes",
crate::ansi::DeviceRequest::OperatingStatus => "operating_status",
crate::ansi::DeviceRequest::CursorPosition => "cursor_position",
},
)?;
}
}
Ok(t)
}
@ -5364,6 +5744,48 @@ fn log_hook_error(lua: &Lua, hook_name: &str, err: &crate::hook::HookCallbackErr
notify_buffer_edit_to_windows(lua, id, &edit);
}
}
/// Append a first-in-run statusline provider failure to `*errors*`.
///
/// The latch decision lives in [`crate::statusline::StatuslineRegistry`];
/// this function owns only the repository-standard durable sink and window
/// invalidation.
pub(crate) fn log_statusline_provider_error(lua: &Lua, failure: &StatuslineProviderFailure) {
let message = crate::statusline::sanitize_provider_error_text(&failure.message);
let line = format!(
"[statusline:{}] provider registered at {} failed for {:?}/{:?}/{:?}/active={}: {}\n",
failure.provider_name,
failure.source.render(),
failure.context.frontend_id,
failure.context.window_id,
failure.context.buffer_id,
failure.context.active,
message,
);
let result = {
let Some(app) = lua.app_data_ref::<SharedRegistry>() else {
return;
};
let mut registry = app.borrow_mut();
let id = match registry.find_by_name(crate::lua::ERRORS_BUFFER_NAME) {
Some(id) => id,
None => registry.create(crate::lua::ERRORS_BUFFER_NAME),
};
let Ok(buffer) = registry.get_mut(id) else {
return;
};
let position = buffer.len();
let edit = buffer
.apply_edit(EditOp::Insert {
pos: position,
bytes: line.as_bytes(),
})
.ok();
edit.map(|edit| (id, edit))
};
if let Some((id, edit)) = result {
notify_buffer_edit_to_windows(lua, id, &edit);
}
}
/// T M7.8: append a `[package <name>]` entry to `*errors*`.
///
@ -7309,6 +7731,7 @@ fn lua_to_spec(table: &Table) -> mlua::Result<ProcessSpec> {
mode,
restart,
ansi_events,
ansi_profile: crate::ansi::AnsiParserProfile::LineOriented,
stdin,
group,
})
@ -7514,7 +7937,14 @@ pub fn install_process(lua: &Lua, supervisor: &SharedProcessSupervisor) -> mlua:
"list",
lua.create_function(move |lua, ()| {
let sup = s.borrow();
let ids: Vec<ProcessId> = sup.ids().collect();
let ids: Vec<ProcessId> = sup
.ids()
.filter(|id| {
sup.spec(*id).is_none_or(|spec| {
spec.ansi_profile == crate::ansi::AnsiParserProfile::LineOriented
})
})
.collect();
let out = lua.create_table_with_capacity(ids.len(), 0)?;
for (i, id) in ids.iter().enumerate() {
let row = lua.create_table_with_capacity(0, 3)?;

View File

@ -60,7 +60,7 @@ use crossbeam::channel::{self, Receiver, Sender};
use nix::sys::signal::Signal;
use nix::unistd::Pid;
use crate::ansi::{AnsiEvent, AnsiParser};
use crate::ansi::{AnsiEvent, AnsiParser, AnsiParserProfile};
// ---------------------------------------------------------------------------
// Identity and configuration
@ -216,6 +216,9 @@ pub struct ProcessSpec {
/// instead of raw stdout bytes. Opt-in so LSP and other byte-stream
/// consumers keep their existing stdout/stderr contract.
pub ansi_events: bool,
/// Compatibility profile for structured ANSI parsing. Ignored unless
/// `ansi_events` is true; ordinary process/Lua callers remain line-oriented.
pub ansi_profile: AnsiParserProfile,
/// Stdin disposition (pipe-mode only; rejected under PTY).
pub stdin: StdinMode,
/// Compile-mode group lifecycle (Q#CM3; pipe-mode only, rejected
@ -245,6 +248,7 @@ impl ProcessSpec {
mode: ProcessMode::Pipes,
restart: RestartPolicy::Never,
ansi_events: false,
ansi_profile: AnsiParserProfile::LineOriented,
stdin: StdinMode::Piped,
group: false,
}
@ -749,29 +753,36 @@ impl TermStatus {
}
/// Map `libc::strsignal` description strings (as surfaced by
/// `portable-pty`) to symbolic SIGFOO names. Unknown descriptions pass
/// through unchanged — better to surface an unfamiliar string than to
/// fabricate a wrong name. Covers every signal in
/// `portable-pty`) to symbolic SIGFOO names. Darwin appends the signal
/// number (for example, `"Terminated: 15"`), while glibc returns only
/// the description. Unknown descriptions pass through unchanged —
/// better to surface an unfamiliar string than to fabricate a wrong
/// name. Covers every signal in
/// [`super::lua_bindings::parse_signal`]'s accept-list plus the common
/// fault signals that surface during process crashes.
fn canonicalize_pty_signal_name(desc: &str) -> String {
match desc {
"Interrupt" => "SIGINT".to_owned(),
"Terminated" => "SIGTERM".to_owned(),
"Killed" => "SIGKILL".to_owned(),
"Hangup" => "SIGHUP".to_owned(),
"Quit" => "SIGQUIT".to_owned(),
"User defined signal 1" => "SIGUSR1".to_owned(),
"User defined signal 2" => "SIGUSR2".to_owned(),
"Aborted" => "SIGABRT".to_owned(),
"Segmentation fault" => "SIGSEGV".to_owned(),
"Floating point exception" => "SIGFPE".to_owned(),
"Illegal instruction" => "SIGILL".to_owned(),
"Broken pipe" => "SIGPIPE".to_owned(),
"Alarm clock" => "SIGALRM".to_owned(),
"Bus error" => "SIGBUS".to_owned(),
other => other.to_owned(),
let base = desc
.rsplit_once(": ")
.filter(|(_, number)| number.parse::<u32>().is_ok())
.map_or(desc, |(description, _)| description);
match base {
"Interrupt" => "SIGINT",
"Terminated" => "SIGTERM",
"Killed" => "SIGKILL",
"Hangup" => "SIGHUP",
"Quit" => "SIGQUIT",
"User defined signal 1" => "SIGUSR1",
"User defined signal 2" => "SIGUSR2",
"Aborted" => "SIGABRT",
"Segmentation fault" => "SIGSEGV",
"Floating point exception" => "SIGFPE",
"Illegal instruction" => "SIGILL",
"Broken pipe" => "SIGPIPE",
"Alarm clock" => "SIGALRM",
"Bus error" => "SIGBUS",
_ => desc,
}
.to_owned()
}
impl Default for ProcessSupervisor {
@ -823,6 +834,23 @@ impl ProcessSupervisor {
/// crashes *after* spawn shows up as a [`Termination::Crashed`]
/// in the event stream, not as a return error.
pub fn spawn(&mut self, spec: ProcessSpec) -> Result<ProcessId, String> {
self.spawn_inner(spec, true)
}
/// Spawn an unpublished terminal-owned process.
///
/// Unlike the public Lua/process path, synchronous failure does not emit an
/// event for an ID no caller can own. `TerminalManager` rolls back its
/// temporary identity buffer and returns the error directly.
pub(crate) fn spawn_terminal(&mut self, spec: ProcessSpec) -> Result<ProcessId, String> {
self.spawn_inner(spec, false)
}
fn spawn_inner(
&mut self,
spec: ProcessSpec,
publish_synchronous_failure: bool,
) -> Result<ProcessId, String> {
if self.shut_down {
return Err("supervisor is shut down".to_owned());
}
@ -834,15 +862,20 @@ impl ProcessSupervisor {
attempt_count: 0,
next_restart_at: None,
};
self.start_generation(id, &mut managed)?;
self.start_generation(id, &mut managed, publish_synchronous_failure)?;
self.processes.insert(id, managed);
Ok(id)
}
/// Start a fresh generation for `managed`. Mutates `managed`
/// in place; on failure the state is left as
/// `Terminated(Crashed{...})` and an event is emitted.
fn start_generation(&self, id: ProcessId, managed: &mut ManagedProcess) -> Result<(), String> {
/// Start a fresh generation for `managed`. Mutates `managed` in place; on
/// failure its state is `Terminated(Crashed{...})`, and the event is emitted
/// only when `publish_failure` is true.
fn start_generation(
&self,
id: ProcessId,
managed: &mut ManagedProcess,
publish_failure: bool,
) -> Result<(), String> {
managed.attempt_count += 1;
managed.next_restart_at = None;
match build_runtime(&managed.spec, id) {
@ -865,11 +898,13 @@ impl ProcessSupervisor {
ended: now,
});
managed.runtime = None;
let _ = self.events_tx.send(ProcessEvent {
id,
kind: ProcessEventKind::Crashed { error: e.clone() },
at: now,
});
if publish_failure {
let _ = self.events_tx.send(ProcessEvent {
id,
kind: ProcessEventKind::Crashed { error: e.clone() },
at: now,
});
}
Err(e)
}
}
@ -1207,7 +1242,7 @@ impl ProcessSupervisor {
kind: ProcessEventKind::Restarting { attempt },
at: now,
});
let _ = self.start_generation(id, &mut managed);
let _ = self.start_generation(id, &mut managed, true);
self.processes.insert(id, managed);
} else {
// Schedule a restart attempt for `restart_backoff` from
@ -1547,7 +1582,12 @@ fn build_pty_runtime(
)];
let output_rx = if spec.ansi_events {
let (ansi_tx, ansi_rx) = channel::bounded::<AnsiBatch>(ANSI_EVENT_CHANNEL_CAP);
readers.push(spawn_ansi_parser(byte_rx, ansi_tx, Arc::clone(&cancel)));
readers.push(spawn_ansi_parser(
byte_rx,
ansi_tx,
Arc::clone(&cancel),
spec.ansi_profile,
));
RuntimeOutputRx::Ansi(ansi_rx)
} else {
RuntimeOutputRx::Bytes(byte_rx)
@ -1954,9 +1994,10 @@ fn spawn_ansi_parser(
byte_rx: Receiver<ByteChunk>,
ansi_tx: Sender<AnsiBatch>,
cancel: Arc<AtomicBool>,
profile: AnsiParserProfile,
) -> JoinHandle<()> {
std::thread::spawn(move || {
let mut parser = AnsiParser::new();
let mut parser = AnsiParser::with_profile(profile);
loop {
if cancel.load(Ordering::Relaxed) {
return;
@ -1964,31 +2005,44 @@ fn spawn_ansi_parser(
let (kind, bytes) = match byte_rx.recv_timeout(READER_SEND_POLL_INTERVAL) {
Ok(chunk) => chunk,
Err(crossbeam::channel::RecvTimeoutError::Timeout) => continue,
Err(crossbeam::channel::RecvTimeoutError::Disconnected) => return,
Err(crossbeam::channel::RecvTimeoutError::Disconnected) => {
let events = parser.finish();
if !events.is_empty() {
let _ = send_ansi_batch(&ansi_tx, &cancel, events);
}
return;
}
};
if !matches!(kind, ReaderKind::Stdout) {
continue;
}
let mut events = parser.feed(&bytes);
if events.is_empty() {
continue;
}
loop {
match ansi_tx.send_timeout(events, READER_SEND_POLL_INTERVAL) {
Ok(()) => break,
Err(crossbeam::channel::SendTimeoutError::Timeout(rejected)) => {
if cancel.load(Ordering::Relaxed) {
return;
}
events = rejected;
}
Err(crossbeam::channel::SendTimeoutError::Disconnected(_)) => return,
}
let events = parser.feed(&bytes);
if !events.is_empty() && !send_ansi_batch(&ansi_tx, &cancel, events) {
return;
}
}
})
}
fn send_ansi_batch(
ansi_tx: &Sender<AnsiBatch>,
cancel: &AtomicBool,
mut events: AnsiBatch,
) -> bool {
loop {
match ansi_tx.send_timeout(events, READER_SEND_POLL_INTERVAL) {
Ok(()) => return true,
Err(crossbeam::channel::SendTimeoutError::Timeout(rejected)) => {
if cancel.load(Ordering::Relaxed) {
return false;
}
events = rejected;
}
Err(crossbeam::channel::SendTimeoutError::Disconnected(_)) => return false,
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@ -2026,6 +2080,30 @@ mod tests {
})
}
#[test]
fn pty_signal_names_are_canonical_across_libc_variants() {
assert_eq!(canonicalize_pty_signal_name("Terminated"), "SIGTERM");
assert_eq!(canonicalize_pty_signal_name("Terminated: 15"), "SIGTERM");
assert_eq!(canonicalize_pty_signal_name("Killed: 9"), "SIGKILL");
assert_eq!(
canonicalize_pty_signal_name("Unknown signal: 99"),
"Unknown signal: 99"
);
}
#[test]
fn terminal_transactional_spawn_failure_has_no_event_or_process_residue() {
let mut supervisor = ProcessSupervisor::new();
let spec = ProcessSpec::new(
"unpublished-terminal",
"/definitely/not/a/real/pmacs-terminal-program",
);
assert!(supervisor.spawn_terminal(spec).is_err());
supervisor.tick();
assert_eq!(supervisor.ids().count(), 0);
assert!(supervisor.take_all_events().is_empty());
}
#[test]
fn spawn_pipes_lifecycle_started_then_exited() {
let mut sup = ProcessSupervisor::new();
@ -2618,7 +2696,12 @@ mod tests {
let (byte_tx, byte_rx) = channel::bounded::<ByteChunk>(1);
let (ansi_tx, _ansi_rx) = channel::bounded::<AnsiBatch>(1);
let cancel = Arc::new(AtomicBool::new(false));
let handle = spawn_ansi_parser(byte_rx, ansi_tx, Arc::clone(&cancel));
let handle = spawn_ansi_parser(
byte_rx,
ansi_tx,
Arc::clone(&cancel),
AnsiParserProfile::LineOriented,
);
drop(byte_tx);
let deadline = Instant::now() + Duration::from_millis(500);

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[test]
fn protocol_version_is_seventeen_for_font_facts() {
fn protocol_version_is_eighteen_for_statusline_segments() {
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
@ -1712,7 +1712,10 @@ mod tests {
// Themes stage 2 Q#F4 bumped 16→17 (`InstanceMessage::
// FontFacts`, additive + daemon-gated, appended as the final
// variant — see the ThemeFacts placement pin).
assert_eq!(PROTOCOL_VERSION, 17);
// Statusline segments Q#SL7 bumped 17→18 (`InstanceMessage::
// StatuslineSegments`, additive + daemon-gated, appended after
// FontFacts — see the v17 placement pin).
assert_eq!(PROTOCOL_VERSION, 18);
}
#[test]
@ -1786,18 +1789,18 @@ mod tests {
// (`TripleDown`), v8 (`StatusFacts`), v9 + v10 (`SearchPrompt` +
// regex/invalid), v11 (the context menu), v12 (the GUI
// minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`)
// all interoperate, so v6 through v17 talk.
for accepted in 6..=17 {
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`),
// and v18 (`StatuslineSegments`) all interoperate.
for accepted in 6..=18 {
assert!(
is_supported_protocol_version(accepted),
"v{accepted} must be accepted"
);
}
for rejected in [0, 1, 2, 3, 4, 5, 18, u32::MAX] {
for rejected in [0, 1, 2, 3, 4, 5, 19, u32::MAX] {
assert!(
!is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v17 binary"
"v{rejected} must be rejected by a v18 binary"
);
}
}
@ -1855,6 +1858,77 @@ mod tests {
}
}
#[test]
fn statusline_segments_round_trip_through_postcard() {
let bid = crate::buffer::BufferId::next();
for msg in [
InstanceMessage::StatuslineSegments {
buffer_id: bid,
left: Vec::new(),
right: Vec::new(),
},
InstanceMessage::StatuslineSegments {
buffer_id: bid,
left: vec![StatuslineSegment {
text: "project".into(),
face: "ui.modeline.project".into(),
}],
right: vec![
StatuslineSegment {
text: "LSP:ready".into(),
face: "ui.modeline.lsp".into(),
},
StatuslineSegment {
text: "main".into(),
face: "ui.modeline".into(),
},
],
},
] {
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(msg, decoded);
}
}
#[test]
fn font_facts_encoding_is_unchanged_by_the_v18_build() {
let msg = InstanceMessage::FontFacts {
family: Some("Iosevka".into()),
size_centi_px: Some(1850),
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
assert_eq!(
bytes,
[
24, 1, 7, b'I', b'o', b's', b'e', b'v', b'k', b'a', 1, 186, 14
],
"FontFacts' v17 wire bytes changed — append new InstanceMessage variants at the end"
);
}
#[test]
fn shared_face_namespace_predicates_are_exact() {
for (name, ui, modeline) in [
("ui", true, false),
("ui.", true, false),
("ui.modeline", true, true),
("ui.modeline.", true, true),
("ui.modeline.lsp", true, true),
("ui.statusline", true, false),
("uix", false, false),
("gui.modeline", false, false),
("", false, false),
] {
assert_eq!(is_ui_face_name(name), ui, "UI predicate for {name:?}");
assert_eq!(
is_modeline_face_name(name),
modeline,
"modeline predicate for {name:?}"
);
}
}
#[test]
fn theme_facts_encoding_is_unchanged_by_the_v17_build() {
// Q#F4 placement pin: `FontFacts` must be APPENDED after

View File

@ -37,7 +37,12 @@ use crate::cell::Style;
use crate::editor::EditorState;
use crate::protocol::{
AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment,
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, StyleSegment, StyleSpan,
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, StatuslineSegment, StyleSegment,
StyleSpan,
};
use crate::statusline::{
StatuslineEvaluation, StatuslineEvaluationOutcome, StatuslineEvaluationTarget,
evaluate_statusline,
};
/// The viewport a `semantic_render` frontend last declared.
@ -227,6 +232,11 @@ pub struct SemanticRenderState {
/// viewport declaration. A frontend retaining face state across
/// attachments is therefore corrected even by an unthemed daemon.
last_theme_faces: Option<Vec<crate::protocol::ThemeFace>>,
/// For v18 peers, the enabled provider-face set epoch inspected by
/// `theme_facts_msg`. Kept separate from `last_face_epoch` so
/// priority-only provider changes do not rebuild the face table.
/// v16/v17 peers never read the registry and leave this `None`.
last_statusline_face_set_epoch: Option<u64>,
/// Whether the peer negotiated protocol >= 16 (PR #120 round 1
/// finding 3). Faces reach a semantic frontend through TWO
/// channels: `ThemeFacts` (daemon write-loop gated) and the
@ -251,6 +261,14 @@ pub struct SemanticRenderState {
/// font state, so this gate has no summary-style companion
/// filter.
peer_knows_font_facts: bool,
/// Whether the peer negotiated protocol >= 18 (Q#SL7). This gates
/// callback evaluation in the producer, independently of the daemon's
/// write-loop gate.
peer_knows_statusline_segments: bool,
/// Complete replacement baseline per buffer. `None` means the peer has
/// never received an authoritative payload, so the first empty result
/// must still be emitted.
last_statusline: HashMap<BufferId, (Vec<StatuslineSegment>, Vec<StatuslineSegment>)>,
/// Cached byte↔line table for the diagnostics projection, keyed
/// by buffer revision. Building it costs an O(buffer) rope copy
/// plus a full scan; before this cache, that ran on *every tick*
@ -352,11 +370,12 @@ impl SemanticRenderState {
let mut s = Self::new(frontend_id);
s.peer_knows_theme_facts = negotiated_protocol_version >= 16;
s.peer_knows_font_facts = negotiated_protocol_version >= 17;
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
s
}
/// Fresh session state for frontend `frontend_id`: no viewport
/// declared, nothing sent. Assumes a current-build peer (>= 16);
/// declared, nothing sent. Assumes a current-build peer (>= 18);
/// daemon sessions with a real negotiated version use
/// [`Self::for_peer`].
#[must_use]
@ -384,6 +403,7 @@ impl SemanticRenderState {
// (empty included), and the epoch gate cannot short-circuit
// an epoch-0 daemon before that send.
last_face_epoch: None,
last_statusline_face_set_epoch: None,
last_theme_faces: None,
peer_knows_theme_facts: true,
// Q#F5: both seeded None — the first frame after viewport
@ -393,6 +413,8 @@ impl SemanticRenderState {
last_font_epoch: None,
last_font_facts: None,
peer_knows_font_facts: true,
peer_knows_statusline_segments: true,
last_statusline: HashMap::new(),
diag_line_cache: HashMap::new(),
}
}
@ -413,10 +435,11 @@ impl SemanticRenderState {
///
/// A `BufferSnapshot` resets the receiving frontend's
/// buffer-scoped render state wholesale — spans, decorations,
/// adornments, minimap summary, completion popup (see the GPU's
/// `BufferSnapshot` arm) — so every buffer-scoped emission
/// baseline this producer holds for that buffer must die with the
/// send. Otherwise an unchanged-key revisit (the A → B → A round
/// adornments, minimap summary, completion popup, status facts, and
/// statusline segments (see the GPU's `BufferSnapshot` arm) — so
/// every buffer-scoped emission baseline this producer holds for
/// that buffer must die with the send.
/// Otherwise an unchanged-key revisit (the A → B → A round
/// trip at one CRDT generation) suppresses every re-send and the
/// frontend never regains the state until an edit, diagnostic
/// republish, or theme mutation happens to move the key.
@ -426,7 +449,8 @@ impl SemanticRenderState {
/// harmless — the failure mode is one redundant re-send, never
/// staleness.
///
/// Deliberately NOT reset: `last_face_epoch` / `last_theme_faces`
/// Deliberately NOT reset: `last_face_epoch`,
/// `last_statusline_face_set_epoch`, and `last_theme_faces`
/// (`ThemeFacts` is bufferless — the frontend keeps its face
/// table across snapshots), `last_minibuffer` (one global core
/// instance, not buffer-scoped), `last_line_numbers`
@ -449,6 +473,7 @@ impl SemanticRenderState {
self.last_search_prompt.remove(&buffer_id);
self.last_menu_prompt.remove(&buffer_id);
self.last_completion_popup.remove(&buffer_id);
self.last_statusline.remove(&buffer_id);
}
/// Project one frame.
@ -476,6 +501,23 @@ impl SemanticRenderState {
return Vec::new();
};
// Evaluate callbacks before any long-lived core borrow and before
// ThemeFacts is computed. A callback may change the registry; the
// post-evaluation face inventory must then precede the authoritative
// segment replacement in this same frame. Unsupported peers skip the
// evaluator entirely and therefore pay no Lua callback/dynamic-face cost.
let statusline_evaluation = self.peer_knows_statusline_segments.then(|| {
evaluate_statusline(
state.lua_host.lua(),
&state.core,
&state.statusline_registry,
StatuslineEvaluationTarget::Semantic {
frontend_id: self.frontend_id,
declared_buffer: vp.buffer_id,
},
)
});
let generation = buffer_generation(state, vp.buffer_id);
let mut out = Vec::new();
@ -645,9 +687,85 @@ impl SemanticRenderState {
// --- ThemeFacts (UI faces; themes arc Q#TH7, protocol v16) ---
out.extend(self.theme_facts_msg(state));
out.extend(self.font_facts_msg(state));
// Q#SL6/Q#SL8: face inventory must precede segment text.
if let Some(evaluation) = statusline_evaluation {
self.emit_statusline_segments(evaluation, &mut out);
}
out
}
/// Apply the lead evaluator's publication outcome to the v18 wire
/// baseline. Invalidated evaluations discard all callback text and
/// publish authoritative empty replacements for the captured old
/// contexts; phase-1 stale outcomes publish nothing.
fn emit_statusline_segments(
&mut self,
evaluation: StatuslineEvaluation,
out: &mut Vec<InstanceMessage>,
) {
let to_wire = |segments: Vec<crate::statusline::EvaluatedStatuslineSegment>| {
segments
.into_iter()
.map(|segment| StatuslineSegment {
text: segment.text,
face: segment.face,
})
.collect()
};
let frontend_id = self.frontend_id;
match evaluation.outcome {
StatuslineEvaluationOutcome::Ready(windows) => {
if let Some(window) = windows
.into_iter()
.find(|window| window.context.frontend_id == frontend_id)
{
self.emit_statusline_payload(
window.context.buffer_id,
to_wire(window.left),
to_wire(window.right),
out,
);
}
}
StatuslineEvaluationOutcome::Invalidated {
authoritative_empty,
} => {
for context in authoritative_empty
.into_iter()
.filter(|context| context.frontend_id == frontend_id)
{
self.emit_statusline_payload(context.buffer_id, Vec::new(), Vec::new(), out);
}
}
StatuslineEvaluationOutcome::NoMessage(_) => {}
}
}
fn emit_statusline_payload(
&mut self,
buffer_id: BufferId,
left: Vec<StatuslineSegment>,
right: Vec<StatuslineSegment>,
out: &mut Vec<InstanceMessage>,
) {
if self
.last_statusline
.get(&buffer_id)
.is_some_and(|(old_left, old_right)| old_left == &left && old_right == &right)
{
return;
}
let baseline = (left.clone(), right.clone());
out.push(InstanceMessage::StatuslineSegments {
buffer_id,
left,
right,
});
// Advance only after the complete replacement has entered the
// frame output, including the authoritative-empty invalidation path.
self.last_statusline.insert(buffer_id, baseline);
}
/// The `CompletionPopup` message for this frame, or `None` when the
/// popup state for `buffer_id` is unchanged (Arc 1a Q#C5). Only the
/// active buffer carries a live popup, and — the multi-frontend
@ -1134,41 +1252,53 @@ impl SemanticRenderState {
})
}
/// The `ThemeFacts` message for this frame, or `None` when the
/// face table is unchanged (themes arc Q#TH7, protocol v16).
/// Resolves the [`UI_FACES`] inventory through
/// [`crate::highlight::Theme::face`] under one lock — resolution
/// is daemon-side; frontends do exact-name lookup, no walk. The
/// `last_face_epoch` gate keeps unchanged ticks to one u64
/// compare; `last_theme_faces` (the frontend's believed table)
/// decides emission. Both advance on computation, and both seed
/// `None`, so every attachment ships exactly one authoritative
/// table — the empty table included — on its first frame.
/// Build the authoritative `ThemeFacts` table. v16/v17 peers retain the
/// fixed stage-1 inventory and never inspect the statusline registry.
/// v18 peers union in enabled provider faces and key recomputation on
/// `(theme.face_epoch, registry.face_set_epoch)`.
fn theme_facts_msg(&mut self, state: &EditorState) -> Option<InstanceMessage> {
// PR #120 round 1 finding 3: never even produced for a peer
// below v16 (the daemon write-loop gate remains as the
// belt-and-braces filter).
if !self.peer_knows_theme_facts {
return None;
}
let theme = state.syntax_registry.theme();
let (faces, face_epoch) = {
let th = theme.lock().expect("theme mutex poisoned");
let th = theme.lock().expect("theme mutex poisoned");
let (face_set_epoch, dynamic_faces) = if self.peer_knows_statusline_segments {
let registry = state.statusline_registry.borrow();
let face_set_epoch = registry.face_set_epoch();
if self.last_face_epoch == Some(th.face_epoch)
&& self.last_statusline_face_set_epoch == Some(face_set_epoch)
{
return None;
}
(Some(face_set_epoch), registry.enabled_face_names())
} else {
if self.last_face_epoch == Some(th.face_epoch) {
return None;
}
let faces: Vec<crate::protocol::ThemeFace> = UI_FACES
.iter()
.filter_map(|name| {
th.face(name).map(|style| crate::protocol::ThemeFace {
name: (*name).to_owned(),
style,
})
})
.collect();
(faces, th.face_epoch)
(None, Vec::new())
};
let mut names: Vec<String> = UI_FACES.iter().map(|name| (*name).to_owned()).collect();
names.extend(dynamic_faces);
names.sort_unstable();
names.dedup();
let faces = names
.into_iter()
.filter_map(|name| {
let style = if UI_FACES.binary_search(&name.as_str()).is_ok() {
th.face(&name)
} else {
th.modeline_segment_face(&name)
};
style.map(|style| crate::protocol::ThemeFace { name, style })
})
.collect::<Vec<_>>();
let face_epoch = th.face_epoch;
drop(th);
self.last_face_epoch = Some(face_epoch);
self.last_statusline_face_set_epoch = face_set_epoch;
let unchanged = self.last_theme_faces.as_ref() == Some(&faces);
self.last_theme_faces = Some(faces.clone());
if unchanged {
@ -2251,6 +2381,14 @@ mod tests {
state.core.borrow().active_window().buffer_id
}
#[test]
fn fixed_ui_face_inventory_is_strictly_sorted() {
assert!(
UI_FACES.windows(2).all(|pair| pair[0] < pair[1]),
"theme_facts_msg uses binary_search; duplicates or unsorted insertions misclassify faces"
);
}
#[test]
fn line_numbers_emitted_on_toggle_then_suppressed() {
// UX gutter (protocol v13): the daemon ships the per-window gutter
@ -2303,6 +2441,19 @@ mod tests {
})
}
fn statusline_of(
msgs: &[InstanceMessage],
) -> Option<(BufferId, Vec<StatuslineSegment>, Vec<StatuslineSegment>)> {
msgs.iter().find_map(|message| match message {
InstanceMessage::StatuslineSegments {
buffer_id,
left,
right,
} => Some((*buffer_id, left.clone(), right.clone())),
_ => None,
})
}
/// Simulate a committed face mutation: what `pmacs.theme.merge`
/// does after its transactional parse (insert + face-epoch bump).
fn merge_face(state: &EditorState, name: &str, style: Style) {
@ -2312,6 +2463,222 @@ mod tests {
th.face_epoch += 1;
}
#[test]
fn statusline_first_empty_is_authoritative_then_silent_and_snapshot_resends() {
let state = empty_state();
let buffer_id = active_buffer(&state);
let lua = state.lua_host.lua();
let callback = lua
.load("return function(_) return __baseline_text end")
.eval()
.expect("callback");
state
.statusline_registry
.borrow_mut()
.register(
"baseline".into(),
crate::statusline::StatuslineSide::Left,
0,
"ui.modeline".into(),
callback,
crate::command::SourceLocation::default(),
)
.expect("register");
let mut semantic = SemanticRenderState::for_peer(FrontendId::LOCAL, 18);
semantic.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
assert_eq!(
statusline_of(&semantic.render_frame(&state)),
Some((buffer_id, Vec::new(), Vec::new()))
);
assert_eq!(statusline_of(&semantic.render_frame(&state)), None);
lua.globals()
.set("__baseline_text", "changed")
.expect("set");
let changed = vec![StatuslineSegment {
text: "changed".into(),
face: "ui.modeline".into(),
}];
assert_eq!(
statusline_of(&semantic.render_frame(&state)),
Some((buffer_id, changed.clone(), Vec::new()))
);
assert_eq!(
statusline_of(&semantic.render_frame(&state)),
None,
"byte-identical callback output is silent"
);
semantic.on_buffer_snapshot_sent(buffer_id);
assert_eq!(
statusline_of(&semantic.render_frame(&state)),
Some((buffer_id, changed, Vec::new())),
"snapshot reset makes an unchanged revisit authoritative again"
);
}
#[test]
fn v17_skips_callbacks_and_dynamic_faces_while_v18_orders_theme_first() {
let state = empty_state();
let buffer_id = active_buffer(&state);
merge_face(
&state,
"ui.modeline.project",
Style {
fg: crate::cell::Color::Indexed(6),
bg: crate::cell::Color::Indexed(2),
bold: true,
reverse: true,
..Style::default()
},
);
let lua = state.lua_host.lua();
lua.globals().set("__statusline_calls", 0).expect("set");
let callback = lua
.load(
"return function(_) \
__statusline_calls = __statusline_calls + 1; return 'project' end",
)
.eval()
.expect("callback");
let provider_id = state
.statusline_registry
.borrow_mut()
.register(
"project".into(),
crate::statusline::StatuslineSide::Left,
0,
"ui.modeline.project".into(),
callback,
crate::command::SourceLocation::default(),
)
.expect("register");
let mut v17 = SemanticRenderState::for_peer(FrontendId::LOCAL, 17);
v17.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let old_frame = v17.render_frame(&state);
assert_eq!(lua.globals().get::<i64>("__statusline_calls").unwrap(), 0);
assert_eq!(statusline_of(&old_frame), None);
assert!(
theme_facts_of(&old_frame)
.expect("v17 still receives fixed ThemeFacts")
.iter()
.all(|face| face.name != "ui.modeline.project")
);
let mut v18 = SemanticRenderState::for_peer(FrontendId::LOCAL, 18);
v18.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let frame = v18.render_frame(&state);
assert_eq!(lua.globals().get::<i64>("__statusline_calls").unwrap(), 1);
let theme_index = frame
.iter()
.position(|message| matches!(message, InstanceMessage::ThemeFacts { .. }))
.expect("dynamic ThemeFacts");
let segments_index = frame
.iter()
.position(|message| matches!(message, InstanceMessage::StatuslineSegments { .. }))
.expect("segments");
assert!(theme_index < segments_index);
let project_face = theme_facts_of(&frame)
.unwrap()
.into_iter()
.find(|face| face.name == "ui.modeline.project")
.expect("exact dynamic face");
assert_eq!(
project_face.style,
Style {
fg: crate::cell::Color::Indexed(6),
..Style::default()
}
);
let face_epoch = v18.last_statusline_face_set_epoch;
assert!(
state
.statusline_registry
.borrow_mut()
.set_priority(provider_id, 10)
);
assert_eq!(theme_facts_of(&v18.render_frame(&state)), None);
assert_eq!(v18.last_statusline_face_set_epoch, face_epoch);
assert!(
state
.statusline_registry
.borrow_mut()
.set_enabled(provider_id, false)
);
let disabled = v18.render_frame(&state);
assert!(
theme_facts_of(&disabled)
.expect("face-set shrink emits")
.iter()
.all(|face| face.name != "ui.modeline.project")
);
assert_eq!(
statusline_of(&disabled),
Some((buffer_id, Vec::new(), Vec::new()))
);
}
#[test]
fn invalidated_statusline_publishes_one_empty_baseline() {
let buffer_id = BufferId::from_raw(77);
let mut semantic = local();
let mut initial = Vec::new();
semantic.emit_statusline_payload(
buffer_id,
vec![StatuslineSegment {
text: "old".into(),
face: "ui.modeline".into(),
}],
Vec::new(),
&mut initial,
);
assert_eq!(initial.len(), 1);
let mut stale = Vec::new();
semantic.emit_statusline_segments(
StatuslineEvaluation {
outcome: StatuslineEvaluationOutcome::NoMessage(
crate::statusline::StatuslineNoMessageReason::DeclaredBufferMismatch,
),
new_failures: Vec::new(),
},
&mut stale,
);
assert!(stale.is_empty(), "phase-1 stale evaluation emits nothing");
assert_eq!(
semantic.last_statusline[&buffer_id].0[0].text, "old",
"stale evaluation retains the prior baseline until snapshot reset"
);
let invalidated = || StatuslineEvaluation {
outcome: StatuslineEvaluationOutcome::Invalidated {
authoritative_empty: vec![crate::statusline::StatuslineContext {
frontend_id: FrontendId::LOCAL,
window_id: crate::window::WindowId::next(),
buffer_id,
active: true,
}],
},
new_failures: Vec::new(),
};
let mut replacement = Vec::new();
semantic.emit_statusline_segments(invalidated(), &mut replacement);
assert_eq!(
statusline_of(&replacement),
Some((buffer_id, Vec::new(), Vec::new()))
);
let mut unchanged = Vec::new();
semantic.emit_statusline_segments(invalidated(), &mut unchanged);
assert!(
unchanged.is_empty(),
"the empty invalidation became baseline"
);
}
#[test]
fn theme_facts_authoritative_empty_then_silent_then_face_change_emits() {
// Q#TH7: the first frame after viewport declaration ships the
@ -2647,9 +3014,10 @@ mod tests {
/// All `InstanceMessage` variants the semantic projection may
/// emit are `StyleSpans`, `Decorations`, `InlineAdornments`,
/// `FileStyleSummary`, `StatusFacts` (Q#S1), `SearchPrompt`
/// (Q#SR5), `LineNumbers`, or `ThemeFacts` (Q#TH7) — never
/// `CellDelta`, grid `Cursor`, or the still-unwired
/// `BlockAdornments` / `FoldState` families.
/// (Q#SR5), `LineNumbers`, `ThemeFacts` (Q#TH7), `FontFacts`
/// (Q#F5), or `StatuslineSegments` (Q#SL7) — never `CellDelta`,
/// grid `Cursor`, or the still-unwired `BlockAdornments` /
/// `FoldState` families.
fn assert_semantic_only(msgs: &[InstanceMessage]) {
for m in msgs {
assert!(
@ -2664,6 +3032,7 @@ mod tests {
| InstanceMessage::LineNumbers { .. }
| InstanceMessage::ThemeFacts { .. }
| InstanceMessage::FontFacts { .. }
| InstanceMessage::StatuslineSegments { .. }
),
"semantic projection emitted an unexpected variant: {m:?}"
);
@ -2835,13 +3204,14 @@ mod tests {
// (post-M11 minimap producer, generation-keyed), as does
// StatusFacts (Q#S1, cached-compare), the authoritative
// ThemeFacts table (Q#TH7 — empty for an unthemed daemon), and
// the authoritative FontFacts preference (Q#F5 — all-default).
// the authoritative FontFacts preference (Q#F5 — all-default), and
// authoritative empty statusline segments (Q#SL8).
let first = s.render_frame(&state);
assert_eq!(
first.len(),
6,
7,
"first frame ships StyleSpans + Decorations + FileStyleSummary \
+ StatusFacts + ThemeFacts + FontFacts"
+ StatusFacts + ThemeFacts + FontFacts + StatuslineSegments"
);
assert_semantic_only(&first);
let (style_full, _) = style_segments(&first).expect("StyleSpans present");

1114
src/statusline.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1027,6 +1027,27 @@ pub const BUILTIN_LANGUAGES: &[LanguageEntry] = &[
highlights_query: &[tree_sitter_zig::HIGHLIGHTS_QUERY],
injections_query: &[],
},
// JSON + YAML — config formats, both self-contained highlights and no
// injections of their own. Registering `yaml` also lights up markdown
// `---` frontmatter via the #122 injection engine (the markdown block
// injection query sets `injection.language "yaml"` for `minus_metadata`;
// `+++` TOML frontmatter already works). Root kinds: json `document`,
// yaml `stream`. `.jsonc`/`.json5` (comments / trailing commas) are a
// deferred variant — the plain JSON grammar rejects them.
LanguageEntry {
name: "json",
extensions: &["json"],
loader: || tree_sitter_json::LANGUAGE.into(),
highlights_query: &[tree_sitter_json::HIGHLIGHTS_QUERY],
injections_query: &[],
},
LanguageEntry {
name: "yaml",
extensions: &["yaml", "yml"],
loader: || tree_sitter_yaml::LANGUAGE.into(),
highlights_query: &[tree_sitter_yaml::HIGHLIGHTS_QUERY],
injections_query: &[],
},
];
/// Registry that the Lua surface ([`crate::lua_bindings::install_parse`])
@ -2062,6 +2083,167 @@ mod tests {
}
}
#[test]
fn builtin_languages_include_json_and_yaml() {
// Framing acceptance #1: both entries present, claim their
// extensions, ship non-empty highlights.
let json = BUILTIN_LANGUAGES
.iter()
.find(|l| l.name == "json")
.expect("`json` entry present");
assert!(json.extensions.contains(&"json"), "`json` claims `.json`");
assert!(!json.highlights_query.is_empty(), "`json` ships highlights");
let yaml = BUILTIN_LANGUAGES
.iter()
.find(|l| l.name == "yaml")
.expect("`yaml` entry present");
assert!(yaml.extensions.contains(&"yaml"), "`yaml` claims `.yaml`");
assert!(yaml.extensions.contains(&"yml"), "`yaml` claims `.yml`");
assert!(!yaml.highlights_query.is_empty(), "`yaml` ships highlights");
}
#[test]
fn json_grammar_loads_and_parses() {
// Framing acceptance #2 / ABI pin: `tree-sitter-json` 0.24 is
// accepted by our tree-sitter 0.26 core; a JSON object parses to a
// `document` root without error.
let reg = SyntaxRegistry::new();
let language = reg.language("json").expect("`json` loads");
let mut buf = fresh_buffer("data.json");
buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"{\n \"name\": \"pmacs\",\n \"nums\": [1, 2, 3],\n \"ok\": true\n}\n",
})
.unwrap();
let view = ParseView::new(&buf, language, "json".to_owned());
let handle = view.handle();
let _vid = buf.attach_view(Box::new(view));
let bundle = parse_synchronously(&handle);
assert_eq!(
bundle.root_tree().root_node().kind(),
"document",
"json grammar roots at `document`"
);
assert!(
!bundle.root_tree().root_node().has_error(),
"json grammar parses an object without error"
);
}
#[test]
fn yaml_grammar_loads_and_parses() {
// Framing acceptance #3 / ABI pin: `tree-sitter-yaml` 0.7 loads and
// a YAML mapping parses to a `stream` root without error.
let reg = SyntaxRegistry::new();
let language = reg.language("yaml").expect("`yaml` loads");
let mut buf = fresh_buffer("config.yaml");
buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"name: pmacs\nversion: 1\ntags:\n - a\n - b\n",
})
.unwrap();
let view = ParseView::new(&buf, language, "yaml".to_owned());
let handle = view.handle();
let _vid = buf.attach_view(Box::new(view));
let bundle = parse_synchronously(&handle);
assert_eq!(
bundle.root_tree().root_node().kind(),
"stream",
"yaml grammar roots at `stream`"
);
assert!(
!bundle.root_tree().root_node().has_error(),
"yaml grammar parses a mapping without error"
);
}
#[test]
fn json_yaml_highlights_compile() {
// Framing acceptance #4: both highlights queries compile against
// their grammars and resolve capture classes.
let reg = SyntaxRegistry::new();
let json = reg
.highlights_query("json")
.expect("json highlights compile");
assert!(
json.capture_names().len() >= 3,
"json highlights resolve capture classes; got {}",
json.capture_names().len()
);
let yaml = reg
.highlights_query("yaml")
.expect("yaml highlights compile");
assert!(
yaml.capture_names().len() >= 3,
"yaml highlights resolve capture classes; got {}",
yaml.capture_names().len()
);
}
#[test]
fn language_for_path_resolves_json_yaml() {
// Framing acceptance #5.
let reg = SyntaxRegistry::new();
assert_eq!(
reg.language_name_for_path("tsconfig.json").as_deref(),
Some("json")
);
assert_eq!(
reg.language_name_for_path("config.yaml").as_deref(),
Some("yaml")
);
assert_eq!(
reg.language_name_for_path("ci.yml").as_deref(),
Some("yaml")
);
}
#[test]
fn yaml_frontmatter_injects_in_markdown() {
// Framing acceptance #7 — THE headline synergy with #122: a markdown
// `---` frontmatter block (a `minus_metadata` node) is injected as
// yaml by the bundled markdown injection query, so registering the
// yaml grammar lights it up with no extra wiring.
let reg = SyntaxRegistry::new();
let src = b"---\ntitle: Hello\ntags: [a, b]\n---\n\n# Body\n";
let bundle = parse_layered(&reg, "markdown", src);
let yaml = bundle
.layers
.iter()
.find(|l| l.language_name == "yaml")
.expect("`---` frontmatter yields a yaml child layer");
assert_eq!(
yaml.tree.root_node().kind(),
"stream",
"yaml layer roots at stream"
);
let query = yaml
.highlight_query
.as_ref()
.expect("yaml highlights resolved");
let spans = compute_highlight_spans_for(query, &yaml.tree, &bundle.source, None);
assert!(!spans.is_empty(), "the yaml frontmatter layer highlights");
}
#[test]
fn json_fence_injects_in_markdown() {
// Framing acceptance #8: a ```json fence yields a json child layer
// through the #122 engine.
let reg = SyntaxRegistry::new();
let src = b"# Doc\n\n```json\n{\"a\": 1, \"b\": [2, 3]}\n```\n";
let bundle = parse_layered(&reg, "markdown", src);
let json = bundle
.layers
.iter()
.find(|l| l.language_name == "json")
.expect("a ```json fence yields a json child layer");
assert_eq!(
json.tree.root_node().kind(),
"document",
"json layer roots at document"
);
}
#[test]
fn builtin_languages_include_dockerfile_make_cmake() {
for (name, exts) in [

366
src/terminal/input.rs Normal file
View File

@ -0,0 +1,366 @@
use crate::cell::CellCoord;
use crate::protocol::{Key, Modifiers, MouseButton, MouseKind};
use super::screen::{MouseTrackingMode, TerminalModes};
/// Encode one normalized key press for the child terminal.
///
/// Lock/media/unknown keys return `None`. Application-keypad mode is
/// intentionally not applied to `Key::Char` digits because the normalized
/// protocol cannot distinguish number-row and keypad input.
#[must_use]
pub fn encode_key(key: Key, mods: Modifiers, modes: TerminalModes) -> Option<Vec<u8>> {
if mods.contains(Modifiers::META) || mods.contains(Modifiers::HYPER) {
return None;
}
let alt = mods.contains(Modifiers::ALT);
let ctrl = mods.contains(Modifiers::CTRL);
let mut out = match key {
Key::Char(ch) => {
let mut bytes = Vec::with_capacity(4);
if ctrl {
bytes.push(control_byte(ch)?);
} else {
let mut encoded = [0; 4];
bytes.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes());
}
bytes
}
Key::Enter => vec![b'\r'],
Key::Tab => vec![b'\t'],
Key::Backspace => vec![0x7f],
Key::Escape => vec![0x1b],
Key::BackTab if mods == Modifiers::NONE || mods == Modifiers::SHIFT => b"\x1b[Z".to_vec(),
Key::BackTab => modified_csi(b'Z', mods, None),
Key::Up => navigation(b'A', mods, modes.application_cursor),
Key::Down => navigation(b'B', mods, modes.application_cursor),
Key::Right => navigation(b'C', mods, modes.application_cursor),
Key::Left => navigation(b'D', mods, modes.application_cursor),
Key::Home => navigation(b'H', mods, modes.application_cursor),
Key::End => navigation(b'F', mods, modes.application_cursor),
Key::Insert => tilde_key(2, mods),
Key::Delete => tilde_key(3, mods),
Key::PageUp => tilde_key(5, mods),
Key::PageDown => tilde_key(6, mods),
Key::F(n @ 1..=4) => function_1_to_4(n, mods),
Key::F(n @ 5..=12) => {
let code = [15, 17, 18, 19, 20, 21, 23, 24][usize::from(n - 5)];
tilde_key(code, mods)
}
Key::Null if ctrl => vec![0],
Key::F(_)
| Key::CapsLock
| Key::ScrollLock
| Key::NumLock
| Key::PrintScreen
| Key::Pause
| Key::Menu
| Key::KeypadBegin
| Key::Null
| Key::Unknown(_) => return None,
};
// Character/control/basic keys use the traditional ESC prefix for Alt.
// Named CSI keys encode Alt in their xterm modifier parameter already.
if alt
&& matches!(
key,
Key::Char(_) | Key::Enter | Key::Tab | Key::Backspace | Key::Escape
)
{
out.insert(0, 0x1b);
}
Some(out)
}
/// Encode pasted bytes, optionally framing them with bracketed-paste markers.
#[must_use]
pub fn encode_paste(bytes: &[u8], bracketed_paste: bool) -> Vec<u8> {
if !bracketed_paste {
return bytes.to_vec();
}
let mut out = Vec::with_capacity(bytes.len() + 12);
out.extend_from_slice(b"\x1b[200~");
out.extend_from_slice(bytes);
out.extend_from_slice(b"\x1b[201~");
out
}
/// Encode a focus transition when focus reporting is enabled.
#[must_use]
pub fn encode_focus(focused: bool, focus_reporting: bool) -> Option<Vec<u8>> {
focus_reporting.then(|| {
if focused {
b"\x1b[I".to_vec()
} else {
b"\x1b[O".to_vec()
}
})
}
/// Encode an xterm SGR mouse report using zero-based terminal coordinates.
#[must_use]
pub fn encode_mouse(
kind: MouseKind,
coord: CellCoord,
mods: Modifiers,
modes: TerminalModes,
) -> Option<Vec<u8>> {
if mods.contains(Modifiers::META) || mods.contains(Modifiers::HYPER) {
return None;
}
if !modes.mouse_sgr || modes.mouse_tracking == MouseTrackingMode::Off {
return None;
}
let allowed = match modes.mouse_tracking {
MouseTrackingMode::Off => false,
MouseTrackingMode::X10 => matches!(kind, MouseKind::Down(_)),
MouseTrackingMode::Button => !matches!(kind, MouseKind::Move),
MouseTrackingMode::Any => true,
};
if !allowed {
return None;
}
let (mut code, release) = match kind {
MouseKind::Down(button) => (button_code(button), false),
MouseKind::Up(button) => (button_code(button), true),
MouseKind::Drag(button) => (button_code(button) + 32, false),
MouseKind::Move => (35, false),
MouseKind::ScrollUp => (64, false),
MouseKind::ScrollDown => (65, false),
MouseKind::ScrollLeft => (66, false),
MouseKind::ScrollRight => (67, false),
};
if mods.contains(Modifiers::SHIFT) {
code += 4;
}
if mods.contains(Modifiers::ALT) {
code += 8;
}
if mods.contains(Modifiers::CTRL) {
code += 16;
}
let final_byte = if release { 'm' } else { 'M' };
Some(
format!(
"\x1b[<{code};{};{}{final_byte}",
coord.col.saturating_add(1),
coord.row.saturating_add(1)
)
.into_bytes(),
)
}
fn control_byte(ch: char) -> Option<u8> {
match ch {
'@' | ' ' | '`' => Some(0),
'a'..='z' => Some(ch as u8 - b'a' + 1),
'A'..='Z' => Some(ch as u8 - b'A' + 1),
'[' | '{' => Some(0x1b),
'\\' | '|' => Some(0x1c),
']' | '}' => Some(0x1d),
'^' | '~' => Some(0x1e),
'_' => Some(0x1f),
'?' => Some(0x7f),
_ => None,
}
}
fn navigation(final_byte: u8, mods: Modifiers, application: bool) -> Vec<u8> {
let parameter = modifier_parameter(mods);
if parameter == 1 {
vec![0x1b, if application { b'O' } else { b'[' }, final_byte]
} else {
modified_csi(final_byte, mods, None)
}
}
fn function_1_to_4(n: u8, mods: Modifiers) -> Vec<u8> {
let final_byte = b'P' + n - 1;
if modifier_parameter(mods) == 1 {
vec![0x1b, b'O', final_byte]
} else {
modified_csi(final_byte, mods, None)
}
}
fn tilde_key(code: u8, mods: Modifiers) -> Vec<u8> {
let modifier = modifier_parameter(mods);
if modifier == 1 {
format!("\x1b[{code}~").into_bytes()
} else {
format!("\x1b[{code};{modifier}~").into_bytes()
}
}
fn modified_csi(final_byte: u8, mods: Modifiers, first: Option<u8>) -> Vec<u8> {
let modifier = modifier_parameter(mods);
if modifier == 1 && first.is_none() {
return vec![0x1b, b'[', final_byte];
}
let first = first.unwrap_or(1);
format!("\x1b[{first};{modifier}{}", final_byte as char).into_bytes()
}
fn modifier_parameter(mods: Modifiers) -> u8 {
1 + u8::from(mods.contains(Modifiers::SHIFT))
+ 2 * u8::from(mods.contains(Modifiers::ALT))
+ 4 * u8::from(mods.contains(Modifiers::CTRL))
}
fn button_code(button: MouseButton) -> u8 {
match button {
MouseButton::Left => 0,
MouseButton::Middle => 1,
MouseButton::Right => 2,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn modes() -> TerminalModes {
TerminalModes::default()
}
#[test]
fn utf8_ctrl_and_alt_boundaries() {
assert_eq!(
encode_key(Key::Char('é'), Modifiers::NONE, modes()),
Some("é".as_bytes().to_vec())
);
assert_eq!(
encode_key(Key::Char('c'), Modifiers::CTRL, modes()),
Some(vec![3])
);
assert_eq!(
encode_key(Key::Char('?'), Modifiers::CTRL | Modifiers::ALT, modes()),
Some(vec![0x1b, 0x7f])
);
assert_eq!(encode_key(Key::Char('é'), Modifiers::CTRL, modes()), None);
}
#[test]
fn application_cursor_and_xterm_modifiers() {
let mut app = modes();
app.application_cursor = true;
assert_eq!(
encode_key(Key::Up, Modifiers::NONE, app),
Some(b"\x1bOA".to_vec())
);
assert_eq!(
encode_key(Key::Up, Modifiers::CTRL | Modifiers::SHIFT, app),
Some(b"\x1b[1;6A".to_vec())
);
assert_eq!(
encode_key(Key::Delete, Modifiers::ALT, modes()),
Some(b"\x1b[3;3~".to_vec())
);
assert_eq!(
encode_key(Key::F(1), Modifiers::NONE, modes()),
Some(b"\x1bOP".to_vec())
);
assert_eq!(
encode_key(Key::F(12), Modifiers::CTRL, modes()),
Some(b"\x1b[24;5~".to_vec())
);
assert_eq!(
encode_key(Key::BackTab, Modifiers::SHIFT, modes()),
Some(b"\x1b[Z".to_vec())
);
}
#[test]
fn ambiguous_digits_ignore_application_keypad() {
let mut app = modes();
app.application_keypad = true;
assert_eq!(
encode_key(Key::Char('7'), Modifiers::NONE, app),
Some(b"7".to_vec())
);
}
#[test]
fn paste_and_focus_are_exact() {
assert_eq!(encode_paste(b"a\0b", false), b"a\0b".to_vec());
assert_eq!(
encode_paste(b"a\0b", true),
b"\x1b[200~a\0b\x1b[201~".to_vec()
);
assert_eq!(encode_focus(true, true), Some(b"\x1b[I".to_vec()));
assert_eq!(encode_focus(false, true), Some(b"\x1b[O".to_vec()));
assert_eq!(encode_focus(true, false), None);
}
#[test]
fn sgr_mouse_modes_modifiers_and_coordinates() {
let mut m = modes();
m.mouse_sgr = true;
m.mouse_tracking = MouseTrackingMode::Any;
assert_eq!(
encode_mouse(
MouseKind::Down(MouseButton::Left),
CellCoord::new(0, 0),
Modifiers::NONE,
m
),
Some(b"\x1b[<0;1;1M".to_vec())
);
assert_eq!(
encode_mouse(
MouseKind::Drag(MouseButton::Right),
CellCoord::new(511, 511),
Modifiers::CTRL | Modifiers::ALT,
m
),
Some(b"\x1b[<58;512;512M".to_vec())
);
assert_eq!(
encode_mouse(
MouseKind::Up(MouseButton::Right),
CellCoord::new(4, 9),
Modifiers::NONE,
m
),
Some(b"\x1b[<2;10;5m".to_vec())
);
assert_eq!(
encode_mouse(
MouseKind::ScrollDown,
CellCoord::new(1, 2),
Modifiers::SHIFT,
m
),
Some(b"\x1b[<69;3;2M".to_vec())
);
}
#[test]
fn unsupported_keys_are_invisible() {
assert_eq!(encode_key(Key::Unknown(7), Modifiers::NONE, modes()), None);
assert_eq!(encode_key(Key::F(13), Modifiers::NONE, modes()), None);
assert_eq!(encode_key(Key::Char('c'), Modifiers::META, modes()), None);
assert_eq!(encode_key(Key::Up, Modifiers::HYPER, modes()), None);
let mut mouse_modes = modes();
mouse_modes.mouse_sgr = true;
mouse_modes.mouse_tracking = MouseTrackingMode::Any;
assert_eq!(
encode_mouse(
MouseKind::Down(MouseButton::Left),
CellCoord::new(0, 0),
Modifiers::META,
mouse_modes,
),
None,
);
assert_eq!(
encode_mouse(
MouseKind::Move,
CellCoord::new(4, 9),
Modifiers::HYPER,
mouse_modes,
),
None,
);
}
}

30
src/terminal/mod.rs Normal file
View File

@ -0,0 +1,30 @@
//! Stateful terminal core and process-session ownership.
//!
//! Terminal buffers are identity/lifecycle anchors. Visible contents live in
//! [`screen::TerminalScreen`] and are exposed as owned session snapshots.
/// Terminal input byte encoders.
pub mod input;
/// Stateful terminal screen model.
pub mod screen;
pub mod session;
pub use session::{
SharedTerminalManager, TerminalError, TerminalManager, TerminalProcessState,
TerminalSelectionSpan, TerminalSnapshot, TerminalSpec,
};
/// Maximum terminal rows accepted at creation or resize.
pub const MAX_TERMINAL_ROWS: u16 = 512;
/// Maximum terminal columns accepted at creation or resize.
pub const MAX_TERMINAL_COLS: u16 = 512;
/// Maximum visible terminal cells accepted at creation or resize.
pub const MAX_TERMINAL_VISIBLE_CELLS: usize = 262_144;
/// Maximum UTF-8 bytes retained in one terminal grapheme cluster.
pub const MAX_TERMINAL_GRAPHEME_BYTES: usize = 256;
/// Default retained main-screen scrollback rows.
pub const DEFAULT_TERMINAL_SCROLLBACK_ROWS: usize = 10_000;
/// Maximum retained main-screen history cells.
pub const MAX_TERMINAL_HISTORY_CELLS: usize = 4_000_000;
/// Shared cap for terminal title and process-outcome metadata.
pub const MAX_TERMINAL_METADATA_BYTES: usize = 1_024;

2019
src/terminal/screen.rs Normal file

File diff suppressed because it is too large Load Diff

606
src/terminal/session.rs Normal file
View File

@ -0,0 +1,606 @@
//! Terminal process/session registry.
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::time::Instant;
use thiserror::Error;
use crate::ansi::AnsiParserProfile;
use crate::buffer::{Buffer, BufferId};
use crate::cell::{Cell, CellCoord, CellSize};
use crate::editor_core::EditorCore;
use crate::process::{
ProcessEventKind, ProcessId, ProcessMode, ProcessSpec, ProcessState, ProcessSupervisor,
RestartPolicy, StdinMode, TerminalMode,
};
use crate::terminal::screen::TerminalScreen;
use crate::terminal::{
MAX_TERMINAL_COLS, MAX_TERMINAL_HISTORY_CELLS, MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS,
MAX_TERMINAL_VISIBLE_CELLS,
};
/// Shared single-owner terminal registry used by editor and future Lua bindings.
pub type SharedTerminalManager = Rc<RefCell<TerminalManager>>;
/// Complete owned description of a terminal child and its initial screen.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TerminalSpec {
/// Executable path or name resolved through `PATH`.
pub command: String,
/// Child arguments, excluding argv[0].
pub args: Vec<String>,
/// Working directory, or the editor process directory when absent.
pub cwd: Option<PathBuf>,
/// Environment overrides inherited by the child. `TERM` defaults to
/// `xterm-256color` when the caller does not provide it.
pub env: Vec<(String, String)>,
/// Identity-buffer name. Defaults to `*terminal:<command>*`.
pub name: Option<String>,
/// Initial terminal rows.
pub rows: u16,
/// Initial terminal columns.
pub cols: u16,
/// Retained main-screen scrollback row cap.
pub scrollback_rows: usize,
}
impl TerminalSpec {
/// Construct a conventional 24x80 terminal specification.
#[must_use]
pub fn new(command: impl Into<String>) -> Self {
Self {
command: command.into(),
args: Vec::new(),
cwd: None,
env: Vec::new(),
name: None,
rows: 24,
cols: 80,
scrollback_rows: crate::terminal::DEFAULT_TERMINAL_SCROLLBACK_ROWS,
}
}
/// Validate every raw field before any buffer or process is created.
pub fn validate(&self) -> Result<(), TerminalError> {
if self.command.is_empty() {
return Err(TerminalError::InvalidSpec(
"command must not be empty".into(),
));
}
reject_nul("command", self.command.as_bytes())?;
for arg in &self.args {
reject_nul("argument", arg.as_bytes())?;
}
if let Some(cwd) = &self.cwd {
if cwd.as_os_str().is_empty() {
return Err(TerminalError::InvalidSpec(
"cwd must not be an empty path".into(),
));
}
reject_nul("cwd", cwd.as_os_str().as_encoded_bytes())?;
}
let mut env_names = HashSet::with_capacity(self.env.len());
for (name, value) in &self.env {
if name.is_empty() || name.contains('=') {
return Err(TerminalError::InvalidSpec(format!(
"environment name {name:?} must be non-empty and contain no '='"
)));
}
reject_nul("environment name", name.as_bytes())?;
reject_nul("environment value", value.as_bytes())?;
if !env_names.insert(name) {
return Err(TerminalError::InvalidSpec(format!(
"duplicate environment name {name:?}"
)));
}
}
if let Some(name) = &self.name {
if name.is_empty() {
return Err(TerminalError::InvalidSpec(
"buffer name must not be empty".into(),
));
}
reject_nul("buffer name", name.as_bytes())?;
if name.contains(['\r', '\n']) {
return Err(TerminalError::InvalidSpec(
"buffer name must fit on one line".into(),
));
}
}
validate_size(self.rows, self.cols)?;
if self.scrollback_rows > MAX_TERMINAL_HISTORY_CELLS {
return Err(TerminalError::InvalidSpec(format!(
"scrollback row cap {} exceeds terminal history cell budget {}",
self.scrollback_rows, MAX_TERMINAL_HISTORY_CELLS
)));
}
Ok(())
}
fn buffer_name(&self) -> String {
self.name.clone().unwrap_or_else(|| {
let command = Path::new(&self.command)
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or(self.command.as_str());
format!("*terminal:{command}*")
})
}
}
/// Process outcome published with an owned terminal snapshot.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TerminalProcessState {
/// Child is running or termination has only been requested.
Running,
/// Child exited with a status code.
Exited(i32),
/// Child was terminated by a sanitized symbolic signal.
Signaled(String),
/// Supervision failed after the session was published.
Crashed(String),
}
/// One selected terminal-row span. Stage 1 snapshots leave selection empty.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TerminalSelectionSpan {
/// Visible row.
pub row: u32,
/// Inclusive starting column.
pub start_col: u32,
/// Exclusive ending column.
pub end_col: u32,
}
/// Owned, renderer-safe terminal state captured after a manager tick.
#[derive(Clone, Debug, PartialEq)]
pub struct TerminalSnapshot {
/// Identity buffer backing this terminal.
pub buffer_id: BufferId,
/// Visible grid dimensions.
pub size: CellSize,
/// Row-major visible cells.
pub cells: Vec<Cell>,
/// Visible child cursor, if enabled.
pub cursor: Option<CellCoord>,
/// Sanitized child title.
pub title: Option<String>,
/// Published screen generation.
pub screen_generation: u64,
/// Context selection. Empty in context-free Stage 1 snapshots.
pub selection: Vec<TerminalSelectionSpan>,
/// Context scrollback offset. Zero in Stage 1 snapshots.
pub scroll_offset: u32,
/// Whether this context follows the bottom. Always true in Stage 1.
pub at_bottom: bool,
/// Exact operating-system process id for this session generation.
pub pid: u32,
/// Latest observed process state.
pub process: TerminalProcessState,
}
/// Terminal session/registry failures.
#[derive(Debug, Error)]
pub enum TerminalError {
/// Specification validation failed before creation began.
#[error("invalid terminal specification: {0}")]
InvalidSpec(String),
/// The synchronous PTY spawn failed; no session is published.
#[error("terminal spawn failed: {0}")]
Spawn(String),
/// Buffer registry work failed during transactional creation.
#[error("terminal buffer operation failed: {0}")]
Buffer(String),
/// Screen construction or resize failed.
#[error("terminal screen operation failed: {0}")]
Screen(String),
/// No session owns the requested identity buffer.
#[error("buffer {0:?} is not a terminal")]
NotTerminal(BufferId),
/// Process I/O, resize, signal, or cleanup failed.
#[error("terminal process operation failed: {0}")]
Process(String),
}
struct TerminalSession {
process_id: ProcessId,
pid: u32,
screen: TerminalScreen,
process: TerminalProcessState,
annotated: bool,
}
/// Owns the one-buffer/one-process/one-screen terminal registry.
#[derive(Default)]
pub struct TerminalManager {
sessions: HashMap<BufferId, TerminalSession>,
process_to_buffer: HashMap<ProcessId, BufferId>,
/// Removed buffers whose children are still being reaped. Their events
/// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch.
closing: HashSet<ProcessId>,
}
impl TerminalManager {
/// Construct an empty manager.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Number of published terminal sessions.
#[must_use]
pub fn len(&self) -> usize {
self.sessions.len()
}
/// Whether no terminal session is currently published.
#[must_use]
pub fn is_empty(&self) -> bool {
self.sessions.is_empty()
}
/// Transactionally create an internal terminal identity, PTY, and screen.
pub fn open(
&mut self,
spec: TerminalSpec,
core: &mut EditorCore,
supervisor: &mut ProcessSupervisor,
) -> Result<BufferId, TerminalError> {
spec.validate()?;
let size = CellSize::new(u32::from(spec.rows), u32::from(spec.cols));
let screen = TerminalScreen::new(size, spec.scrollback_rows)
.map_err(|error| TerminalError::Screen(error.to_string()))?;
let buffer_name = spec.buffer_name();
let buffer_id = BufferId::next();
let mut buffer = Buffer::new(buffer_id, buffer_name.clone());
buffer.set_read_only(true);
core.registry.borrow_mut().insert(buffer);
let mut process_spec = ProcessSpec::new(buffer_name, spec.command);
process_spec.args = spec.args;
process_spec.cwd = spec.cwd;
process_spec.env = spec.env;
if !process_spec.env.iter().any(|(name, _)| name == "TERM") {
process_spec
.env
.push(("TERM".into(), "xterm-256color".into()));
}
process_spec.mode = ProcessMode::Pty {
rows: spec.rows,
cols: spec.cols,
mode: TerminalMode::Raw,
};
process_spec.restart = RestartPolicy::Never;
process_spec.ansi_events = true;
process_spec.ansi_profile = AnsiParserProfile::FullScreen;
process_spec.stdin = StdinMode::Piped;
process_spec.group = false;
let process_id = match supervisor.spawn_terminal(process_spec) {
Ok(id) => id,
Err(error) => {
core.registry
.borrow_mut()
.remove(buffer_id)
.map_err(|rollback| {
TerminalError::Buffer(format!(
"spawn failed ({error}); buffer rollback failed: {rollback}"
))
})?;
return Err(TerminalError::Spawn(error));
}
};
let pid =
if let Some(ProcessState::Running { pid, .. } | ProcessState::Exiting { pid, .. }) =
supervisor.state(process_id)
{
*pid
} else {
let _ = supervisor.terminate(process_id);
let _ = core.registry.borrow_mut().remove(buffer_id);
return Err(TerminalError::Spawn(
"supervisor published a PTY without a running pid".into(),
));
};
let previous = self.sessions.insert(
buffer_id,
TerminalSession {
process_id,
pid,
screen,
process: TerminalProcessState::Running,
annotated: false,
},
);
debug_assert!(previous.is_none(), "fresh BufferId collided");
self.process_to_buffer.insert(process_id, buffer_id);
core.set_round_trip_input(buffer_id, true);
Ok(buffer_id)
}
/// Whether `buffer_id` identifies a published terminal session.
#[must_use]
pub fn is_terminal(&self, buffer_id: BufferId) -> bool {
self.sessions.contains_key(&buffer_id)
}
/// Owned process id for a terminal buffer. The OS pid stays in snapshots.
#[must_use]
pub fn process_id(&self, buffer_id: BufferId) -> Option<ProcessId> {
self.sessions
.get(&buffer_id)
.map(|session| session.process_id)
}
/// Capture context-free owned visible state after the latest tick.
#[must_use]
pub fn snapshot(&self, buffer_id: BufferId) -> Option<TerminalSnapshot> {
let session = self.sessions.get(&buffer_id)?;
let screen = session.screen.snapshot();
Some(TerminalSnapshot {
buffer_id,
size: screen.size,
cells: screen.cells,
cursor: screen.cursor,
title: screen.title.map(|title| sanitize_metadata(&title)),
screen_generation: screen.generation,
selection: Vec::new(),
scroll_offset: 0,
at_bottom: true,
pid: session.pid,
process: session.process.clone(),
})
}
/// Drain only terminal-owned process IDs after the supervisor tick.
pub fn tick(&mut self, supervisor: &mut ProcessSupervisor) {
let process_ids: Vec<ProcessId> = self.process_to_buffer.keys().copied().collect();
for process_id in process_ids {
let Some(buffer_id) = self.process_to_buffer.get(&process_id).copied() else {
continue;
};
let events = supervisor.take_events(process_id);
let Some(session) = self.sessions.get_mut(&buffer_id) else {
continue;
};
let mut outcome = None;
for event in events {
match event.kind {
ProcessEventKind::Started { pid } => session.pid = pid,
ProcessEventKind::Ansi(events) => {
for event in events {
if let Some(response) = session.screen.apply_event(event) {
let _ = supervisor.write_stdin(process_id, &response);
}
}
}
ProcessEventKind::Exited { code } => {
outcome = Some(TerminalProcessState::Exited(code));
}
ProcessEventKind::Signaled { signal } => {
outcome = Some(TerminalProcessState::Signaled(sanitize_metadata(&signal)));
}
ProcessEventKind::Crashed { error } => {
outcome = Some(TerminalProcessState::Crashed(sanitize_metadata(&error)));
}
ProcessEventKind::Stdout(_)
| ProcessEventKind::Stderr(_)
| ProcessEventKind::Restarting { .. } => {}
}
}
let _ = session.screen.synchronized_watchdog_expired(Instant::now());
if let Some(outcome) = outcome {
finish_session(session, outcome);
}
}
let closing: Vec<ProcessId> = self.closing.iter().copied().collect();
for process_id in closing {
// Continue owning and discarding every final batch until reaped.
let _ = supervisor.take_events(process_id);
if matches!(
supervisor.state(process_id),
Some(ProcessState::Terminated(_)) | None
) {
let _ = supervisor.forget(process_id);
self.closing.remove(&process_id);
}
}
}
/// Queue raw terminal input for a running child.
pub fn send(
&self,
buffer_id: BufferId,
bytes: &[u8],
supervisor: &mut ProcessSupervisor,
) -> Result<(), TerminalError> {
let session = self
.sessions
.get(&buffer_id)
.ok_or(TerminalError::NotTerminal(buffer_id))?;
supervisor
.write_stdin(session.process_id, bytes)
.map_err(TerminalError::Process)
}
/// Resize a terminal screen and its PTY after validating shared limits.
pub fn resize(
&mut self,
buffer_id: BufferId,
rows: u16,
cols: u16,
supervisor: &mut ProcessSupervisor,
) -> Result<(), TerminalError> {
validate_size(rows, cols)?;
let session = self
.sessions
.get_mut(&buffer_id)
.ok_or(TerminalError::NotTerminal(buffer_id))?;
if matches!(session.process, TerminalProcessState::Running) {
supervisor
.resize_pty(session.process_id, rows, cols)
.map_err(TerminalError::Process)?;
}
session
.screen
.resize(CellSize::new(u32::from(rows), u32::from(cols)))
.map_err(|error| TerminalError::Screen(error.to_string()))
}
/// Request SIGTERM. Snapshot state stays `Running` until the outcome event.
pub fn terminate(
&mut self,
buffer_id: BufferId,
supervisor: &mut ProcessSupervisor,
) -> Result<(), TerminalError> {
let session = self
.sessions
.get(&buffer_id)
.ok_or(TerminalError::NotTerminal(buffer_id))?;
if matches!(session.process, TerminalProcessState::Running) {
supervisor
.terminate(session.process_id)
.map_err(TerminalError::Process)?;
}
Ok(())
}
/// Tear down sessions whose identity buffers were removed by any path.
pub fn prune(&mut self, core: &mut EditorCore, supervisor: &mut ProcessSupervisor) {
let removed: Vec<BufferId> = {
let registry = core.registry.borrow();
self.sessions
.keys()
.copied()
.filter(|buffer_id| !registry.contains(*buffer_id))
.collect()
};
for buffer_id in removed {
core.set_round_trip_input(buffer_id, false);
let Some(session) = self.sessions.remove(&buffer_id) else {
continue;
};
self.process_to_buffer.remove(&session.process_id);
match supervisor.state(session.process_id) {
Some(
ProcessState::Starting
| ProcessState::Running { .. }
| ProcessState::Exiting { .. },
) => {
let _ = supervisor.terminate(session.process_id);
self.closing.insert(session.process_id);
}
Some(ProcessState::Terminated(_)) => {
let _ = supervisor.take_events(session.process_id);
let _ = supervisor.forget(session.process_id);
}
None => {}
}
}
}
/// Terminate every terminal child and unpublish all sessions.
///
/// The editor follows this with the supervisor's bounded global shutdown,
/// which performs final TERM/KILL escalation for terminal and non-terminal
/// processes alike.
pub fn shutdown(&mut self, supervisor: &mut ProcessSupervisor) {
let process_ids: Vec<ProcessId> = self.process_to_buffer.keys().copied().collect();
for process_id in process_ids {
if matches!(
supervisor.state(process_id),
Some(
ProcessState::Running { .. }
| ProcessState::Exiting { .. }
| ProcessState::Starting
)
) {
let _ = supervisor.terminate(process_id);
}
self.closing.insert(process_id);
}
self.sessions.clear();
self.process_to_buffer.clear();
}
}
fn finish_session(session: &mut TerminalSession, outcome: TerminalProcessState) {
if session.annotated {
session.process = outcome;
return;
}
session.screen.finish_output();
let annotation = match &outcome {
TerminalProcessState::Running => return,
TerminalProcessState::Exited(0) => {
format!("Process {} exited normally with code 0", session.pid)
}
TerminalProcessState::Exited(code) => {
format!("Process {} exited abnormally with code {code}", session.pid)
}
TerminalProcessState::Signaled(signal) => format!(
"Process {} exited abnormally with signal {signal}",
session.pid
),
TerminalProcessState::Crashed(error) => {
format!("Process {} crashed: {error}", session.pid)
}
};
session.screen.append_process_annotation(&annotation);
session.annotated = true;
// Publish exit metadata only after final bytes and annotation are applied.
session.process = outcome;
}
fn validate_size(rows: u16, cols: u16) -> Result<(), TerminalError> {
let cells = usize::from(rows) * usize::from(cols);
if rows == 0 || rows > MAX_TERMINAL_ROWS {
return Err(TerminalError::InvalidSpec(format!(
"rows must be in 1..={MAX_TERMINAL_ROWS}; got {rows}"
)));
}
if cols == 0 || cols > MAX_TERMINAL_COLS {
return Err(TerminalError::InvalidSpec(format!(
"cols must be in 1..={MAX_TERMINAL_COLS}; got {cols}"
)));
}
if cells > MAX_TERMINAL_VISIBLE_CELLS {
return Err(TerminalError::InvalidSpec(format!(
"visible cell count {cells} exceeds {MAX_TERMINAL_VISIBLE_CELLS}"
)));
}
Ok(())
}
fn reject_nul(field: &str, bytes: &[u8]) -> Result<(), TerminalError> {
if bytes.contains(&0) {
Err(TerminalError::InvalidSpec(format!(
"{field} must not contain NUL"
)))
} else {
Ok(())
}
}
fn sanitize_metadata(value: &str) -> String {
let mut clean = String::with_capacity(value.len().min(MAX_TERMINAL_METADATA_BYTES));
for ch in value.chars() {
let ch = if ch == '\r' || ch == '\n' || ch.is_control() {
' '
} else {
ch
};
if clean.len() + ch.len_utf8() > MAX_TERMINAL_METADATA_BYTES {
break;
}
clean.push(ch);
}
clean
}

View File

@ -6265,6 +6265,9 @@ fn m4_gap_grammars_align_with_lsp_configs() {
("A.tsx", "typescriptreact"),
("Cargo.toml", "toml"),
("build.zig", "zig"),
("tsconfig.json", "json"),
("config.yaml", "yaml"),
("ci.yml", "yaml"),
] {
let (grammar, has_cfg): (Option<String>, bool) = s
.lua_host
@ -6287,6 +6290,298 @@ fn m4_gap_grammars_align_with_lsp_configs() {
}
}
/// JSON/YAML LSP configs pin the server commands and the settings shape
/// each consumes (framing Q#JY2): JSON receives a pushed full object;
/// YAML pulls five named sections. The pinned JSON and YAML providers each
/// have a separate PATH-gated live smoke below. The point is to pin the
/// contract, not merely assert that some settings table exists.
#[test]
fn m4_json_yaml_lsp_configs_pin_command_and_sections() {
use pmacs::editor::EditorState;
let s = EditorState::new();
let lua = s.lua_host.lua();
// json: the `@t1ckbase/vscode-langservers-extracted@2.0.2` binary
// (NOT the stale standalone `vscode-json-languageserver`), `--stdio`,
// and the `json` + `http` workspace-config sections present. The
// provider preserves this stable command name; its exact pin and live
// handshake evidence are documented beside the default config.
let json_command: String = lua
.load("return pmacs.lsp.config.json.command")
.eval()
.unwrap();
assert_eq!(
json_command, "vscode-json-language-server",
"json uses the pinned T1ckbase provider's stable command name"
);
// The JSON server is push-model (reads didChangeConfiguration, no
// pulls), so `json.validate.enable` must be EXPLICITLY true — a missing
// value reads as false and disables validation. Remote schemas left on.
let json_ok: bool = lua
.load(
"local c = pmacs.lsp.config.json
return c.args[1] == '--stdio'
and c.settings.json.validate.enable == true
and c.settings.http ~= nil
and c.settings.handledSchemaProtocols == nil",
)
.eval()
.unwrap();
assert!(
json_ok,
"json config: --stdio, json.validate.enable=true, http present, remote schemas on"
);
// yaml: `yaml-language-server --stdio`. Its settings handler reads the
// `yaml`, `http`, `[yaml]`, `editor`, and `files` sections — pin all
// five, and confirm the inert `redhat.telemetry` is NOT shipped (the
// standalone server emits telemetry events to the client; it does not
// upload, and pmacs has no uploader).
let yaml_command: String = lua
.load("return pmacs.lsp.config.yaml.command")
.eval()
.unwrap();
assert_eq!(
yaml_command, "yaml-language-server",
"yaml uses the Red Hat yaml-language-server"
);
let yaml_ok: bool = lua
.load(
"local c = pmacs.lsp.config.yaml
return c.args[1] == '--stdio'
and c.settings.yaml ~= nil
and c.settings.http ~= nil
and c.settings['[yaml]'] ~= nil
and c.settings.editor ~= nil
and c.settings.files ~= nil
and c.settings.redhat == nil",
)
.eval()
.unwrap();
assert!(
yaml_ok,
"yaml config: --stdio, the five pulled sections present, no inert redhat.telemetry"
);
}
/// Round-1 finding (P1): the daemon must PUSH configuration via
/// `workspace/didChangeConfiguration` after `initialized`. Push-model
/// servers — notably the VS Code JSON server — never issue
/// `workspace/configuration` pulls, so without the push their `settings`
/// (including `json.validate.enable`) are inert. Verified through the fake
/// server's config sink: the settings the daemon sends are recorded and
/// inspected, proving delivery end to end.
#[test]
fn m4_5_initial_config_pushed_via_did_change_configuration() {
use pmacs::editor::EditorState;
let mut state = EditorState::new();
let fake = fake_lsp_path();
let dir = tempfile::tempdir().expect("tempdir");
let sink = dir.path().join("config.jsonl");
let file = dir.path().join("probe.rs");
std::fs::write(&file, "fn main() {}\n").expect("write");
let sink_disp = sink.display().to_string();
let file_disp = file.display().to_string();
// Point rust at the fake server WITH a settings table and route the
// config sink into the spawned process, then open the file (auto-attach
// → initialize → initialized → the config push).
state
.lua_host
.lua()
.load(format!(
"pmacs.lsp.config.rust = {{
command = '{fake}',
env = {{ PMACS_FAKE_LSP_CONFIG_SINK = '{sink_disp}' }},
settings = {{ rust = {{ probe = true }} }},
}}
pmacs.buffer.find_or_open('{file_disp}')"
))
.exec()
.expect("configure + open");
assert!(
pump_lua_flag(
&mut state,
"(function() for _,r in ipairs(pmacs.lsp.list()) do \
if r.state and r.state.kind=='initialized' then return true end \
end return false end)()",
5,
),
"fake never initialized"
);
// A few more ticks for the push + the server's sink write to land.
let sink_probe = sink.clone();
pump_async(&mut state, move |_| {
std::fs::read_to_string(&sink_probe).is_ok_and(|s| s.contains("probe"))
});
let recorded = std::fs::read_to_string(&sink).unwrap_or_else(|e| {
panic!("config sink not written ({e}); the didChangeConfiguration push did not arrive")
});
assert!(
recorded.contains("\"probe\":true"),
"the daemon pushed the configured settings after initialized: {recorded}"
);
}
/// PATH-gated provider smoke: drive a real `vscode-json-language-server`
/// through pmacs's default JSON config, including the post-initialize
/// `didChangeConfiguration` push, and require a syntax diagnostic for an
/// invalid document. The reviewed provider is
/// `@t1ckbase/vscode-langservers-extracted@2.0.2`; CI skips cleanly when
/// no compatible binary is installed.
#[test]
fn m4_real_json_provider_receives_config_and_reports_diagnostics() {
use pmacs::editor::EditorState;
let Ok(command) = which_binary("vscode-json-language-server") else {
eprintln!("vscode-json-language-server not on PATH; skipping");
return;
};
let command = command.display().to_string();
let dir = tempfile::tempdir().expect("tempdir");
let file = std::fs::canonicalize(dir.path())
.expect("canonicalize")
.join("invalid.json");
std::fs::write(&file, b"{\"broken\": }\n").expect("write invalid json");
let file_disp = file.display().to_string();
let uri = format!("file://{file_disp}");
let mut state = EditorState::new();
state
.lua_host
.lua()
.load(format!(
"pmacs.lsp.config.json.command = '{command}'
pmacs.buffer.find_or_open('{file_disp}')"
))
.exec()
.expect("configure real JSON server + open file");
assert!(
pump_lua_flag(
&mut state,
"(function() for _,r in ipairs(pmacs.lsp.list()) do \
if r.state and r.state.kind=='initialized' then return true end \
end return false end)()",
30,
),
"real JSON server never reached initialized"
);
let deadline = Instant::now() + Duration::from_secs(10);
let mut got_diagnostic = false;
while Instant::now() < deadline {
state.tick_processes();
state.tick_lsp();
state.tick_async();
got_diagnostic = state
.lua_host
.lua()
.load(format!("return pmacs.diag.count('{uri}') > 0"))
.eval()
.unwrap_or(false);
if got_diagnostic {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(
got_diagnostic,
"real JSON server produced no diagnostic; config delivery or validation is broken"
);
assert_no_lsp_crash(&mut state, "real JSON server");
}
/// PATH-gated provider smoke: drive Red Hat
/// `yaml-language-server@1.24.0` through pmacs's default YAML config and
/// require a syntax diagnostic for an invalid document. `SchemaStore` and
/// the Kubernetes CRD catalog are disabled in this test so the result is
/// deterministic and does not depend on network access. CI skips cleanly
/// when no compatible binary is installed.
#[test]
fn m4_real_yaml_provider_pulls_config_and_reports_diagnostics() {
use pmacs::editor::EditorState;
let Ok(command) = which_binary("yaml-language-server") else {
eprintln!("yaml-language-server not on PATH; skipping");
return;
};
let command = command.display().to_string();
let dir = tempfile::tempdir().expect("tempdir");
let file = std::fs::canonicalize(dir.path())
.expect("canonicalize")
.join("invalid.yaml");
std::fs::write(&file, b"root:\n broken: [one,\n").expect("write invalid yaml");
let file_disp = file.display().to_string();
let uri = format!("file://{file_disp}");
let mut state = EditorState::new();
state
.lua_host
.lua()
.load(format!(
"local c = pmacs.lsp.config.yaml
c.command = '{command}'
c.settings.yaml.schemaStore = {{ enable = false }}
c.settings.yaml.kubernetesCRDStore = {{ enable = false }}
pmacs.buffer.find_or_open('{file_disp}')"
))
.exec()
.expect("configure real YAML server + open file");
assert!(
pump_lua_flag(
&mut state,
"(function() for _,r in ipairs(pmacs.lsp.list()) do \
if r.language_id=='yaml' and r.state \
and r.state.kind=='initialized' then return true end \
end return false end)()",
30,
),
"auto-attached real YAML server never reached initialized"
);
let deadline = Instant::now() + Duration::from_secs(10);
let mut got_diagnostic = false;
while Instant::now() < deadline {
state.tick_processes();
state.tick_lsp();
state.tick_async();
got_diagnostic = state
.lua_host
.lua()
.load(format!("return pmacs.diag.count('{uri}') > 0"))
.eval()
.unwrap_or(false);
if got_diagnostic {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(
got_diagnostic,
"real YAML server produced no diagnostic; config pulls or validation are broken"
);
assert_no_lsp_crash(&mut state, "real YAML server");
let still_initialized: bool = state
.lua_host
.lua()
.load(
"for _,r in ipairs(pmacs.lsp.list()) do \
if r.language_id=='yaml' and r.state \
and r.state.kind=='initialized' then return true end \
end return false",
)
.eval()
.expect("inspect YAML server state");
assert!(
still_initialized,
"real YAML server did not remain alive after publishing diagnostics"
);
}
/// Typing-perf: the default bundle coalesces full-document
/// `didChange` notifications instead of sending one per keystroke
/// (each send copies the whole buffer several times and writes

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,436 @@
//! Shared Stage 1 terminal registry, lifecycle, and read-only acceptance.
use std::time::{Duration, Instant};
use pmacs::ansi::AnsiEvent;
use pmacs::buffer::{Buffer, BufferError, BufferId, EditOp};
use pmacs::cell::{CellSize, Glyph};
use pmacs::editor::EditorState;
use pmacs::process::ProcessState;
use pmacs::rope::Range;
use pmacs::terminal::screen::TerminalScreen;
use pmacs::terminal::{TerminalProcessState, TerminalSpec};
fn rope_bytes(buffer: &Buffer) -> Vec<u8> {
let mut bytes = vec![0; buffer.len() as usize];
if !bytes.is_empty() {
buffer.snapshot_rope().slice(0, buffer.len(), &mut bytes);
}
bytes
}
fn screen_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String {
let mut text = String::new();
for (index, cell) in snapshot.cells.iter().enumerate() {
if index > 0 && index % snapshot.size.cols as usize == 0 {
text.push('\n');
}
match &cell.glyph {
Glyph::Char(ch) => text.push(*ch),
Glyph::Cluster(bytes) => text.push_str(&String::from_utf8_lossy(bytes)),
Glyph::Continuation => {}
}
}
text
}
fn tick_until(
state: &mut EditorState,
timeout: Duration,
mut done: impl FnMut(&EditorState) -> bool,
) {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
state.tick_processes();
if done(state) {
return;
}
std::thread::sleep(Duration::from_millis(5));
}
panic!("terminal condition did not settle before {timeout:?}");
}
#[test]
fn terminal_cells_reject_child_control_characters() {
let mut screen = TerminalScreen::new(CellSize::new(2, 4), 0).expect("valid screen");
let before = screen.snapshot();
screen.apply_event(AnsiEvent::Text("\u{9b}\n\0".into()));
assert_eq!(screen.snapshot(), before);
}
#[test]
fn spawn_failure_is_transactional() {
let mut state = EditorState::new();
let buffers_before = state.core.borrow().registry.borrow().len();
let processes_before = state.process_supervisor.borrow().ids().count();
state.process_supervisor.borrow_mut().shutdown();
let result = state.open_terminal(TerminalSpec::new("/bin/sh"));
assert!(result.is_err());
assert_eq!(state.core.borrow().registry.borrow().len(), buffers_before);
assert_eq!(state.terminal_manager.borrow().len(), 0);
assert_eq!(
state.process_supervisor.borrow().ids().count(),
processes_before
);
}
#[test]
fn strict_owned_spec_rejects_before_spawn_and_is_mutation_independent() {
let mut state = EditorState::new();
let buffers_before = state.core.borrow().registry.borrow().len();
let mut invalid = TerminalSpec::new("/bin/sh");
invalid.rows = 0;
assert!(state.open_terminal(invalid).is_err());
assert_eq!(state.core.borrow().registry.borrow().len(), buffers_before);
assert!(state.terminal_manager.borrow().is_empty());
assert_eq!(state.process_supervisor.borrow().ids().count(), 0);
let mut spec = TerminalSpec::new("/bin/sh");
spec.args = vec!["-c".into(), "sleep 30".into()];
spec.env = vec![("PMACS_VTERM_OWNED".into(), "original".into())];
let mut caller_copy = spec.clone();
let buffer_id = state.open_terminal(spec).expect("valid owned spec");
caller_copy.command.clear();
caller_copy.args.clear();
caller_copy.env[0].1 = "mutated".into();
let lua_processes: usize = state
.lua_host
.lua()
.load("return #pmacs.process.list()")
.eval()
.expect("process list");
assert_eq!(
lua_processes, 0,
"terminal-owned ProcessId must not be exposed through pmacs.process"
);
let terminal_module_absent: bool = state
.lua_host
.lua()
.load("return pmacs.terminal == nil")
.eval()
.expect("terminal module absence");
assert!(
terminal_module_absent,
"Stage 1 must not publish an unrenderable interactive Lua terminal API"
);
let process_id = state
.terminal_manager
.borrow()
.process_id(buffer_id)
.expect("terminal process");
let supervisor = state.process_supervisor.borrow();
let process_spec = supervisor.spec(process_id).expect("owned process spec");
assert_eq!(process_spec.command, "/bin/sh");
assert_eq!(
process_spec.args,
[String::from("-c"), String::from("sleep 30")]
);
assert_eq!(
process_spec.env,
[
(String::from("PMACS_VTERM_OWNED"), String::from("original")),
(String::from("TERM"), String::from("xterm-256color")),
]
);
}
#[test]
fn read_only_guard_covers_direct_skip_undo_and_redo_without_state_change() {
let mut buffer = Buffer::from_bytes(BufferId::next(), "*protected*", b"abc");
buffer
.apply_edit(EditOp::Insert {
pos: 3,
bytes: b"d",
})
.expect("seed undo");
buffer.undo().expect("seed redo");
buffer.set_read_only(true);
let before = (rope_bytes(&buffer), buffer.revision(), buffer.is_modified());
assert!(matches!(
buffer.begin_edit(),
Err(BufferError::ReadOnly { .. })
));
assert!(!buffer.editing_in_progress());
let results = [
buffer.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"x",
}),
buffer.apply_edit_skip_intercepts(EditOp::Replace {
range: Range::new(0, 1),
bytes: b"y",
}),
buffer.undo(),
buffer.redo(),
];
assert!(
results
.iter()
.all(|result| matches!(result, Err(BufferError::ReadOnly { .. })))
);
assert_eq!(
before,
(rope_bytes(&buffer), buffer.revision(), buffer.is_modified())
);
}
#[cfg(feature = "crdt")]
#[test]
fn read_only_empty_crdt_bootstrap_is_immutable_against_remote_content() {
let mut buffer = Buffer::new(BufferId::next(), "*terminal*");
buffer.set_read_only(true);
buffer
.upgrade_to_crdt(1)
.expect("empty immutable bootstrap is allowed");
let donor = pmacs::crdt::CrdtState::new(2).expect("donor");
let version = donor.version();
donor.insert(0, "forged").expect("donor edit");
let update = donor.export_updates_since(&version).expect("update");
assert!(matches!(
buffer.apply_remote_crdt_op(&update),
Err(BufferError::ReadOnly { .. })
));
assert!(buffer.is_empty());
assert_eq!(buffer.revision(), 0);
assert!(!buffer.is_modified());
}
#[test]
fn final_output_precedes_exact_nonzero_annotation_and_buffer_is_retained() {
let mut state = EditorState::new();
let mut spec = TerminalSpec::new("/bin/sh");
spec.args = vec![
"-c".into(),
concat!(
"printf 'main-home'; ",
"printf '\\033'; sleep 0.03; printf '[?1049h'; ",
"printf '\\033[2;'; sleep 0.03; printf '4HALT'; ",
"IFS= read -r gate; ",
"printf '\\033[?1049l'; ",
"printf '\\033[2;'; sleep 0.03; printf '3Hfinal-'; ",
"sleep 0.03; printf 'output'; exit 7"
)
.into(),
];
spec.rows = 8;
spec.cols = 80;
let buffer_id = state.open_terminal(spec).expect("open terminal");
tick_until(&mut state, Duration::from_secs(5), |state| {
state
.terminal_manager
.borrow()
.snapshot(buffer_id)
.is_some_and(|snapshot| {
matches!(snapshot.process, TerminalProcessState::Running)
&& screen_text(&snapshot)
.lines()
.nth(1)
.is_some_and(|row| row.starts_with(" ALT"))
})
});
let alternate = state
.terminal_manager
.borrow()
.snapshot(buffer_id)
.expect("running alternate-screen snapshot");
let alternate_text = screen_text(&alternate);
assert!(alternate_text.contains("ALT"));
assert!(
!alternate_text.contains("main-home"),
"alternate screen must not expose the preserved main grid"
);
{
let manager = state.terminal_manager.borrow();
let mut supervisor = state.process_supervisor.borrow_mut();
manager
.send(buffer_id, b"\n", &mut supervisor)
.expect("raw stdin unblocks child");
}
tick_until(&mut state, Duration::from_secs(5), |state| {
state
.terminal_manager
.borrow()
.snapshot(buffer_id)
.is_some_and(|snapshot| matches!(snapshot.process, TerminalProcessState::Exited(7)))
});
let snapshot = state
.terminal_manager
.borrow()
.snapshot(buffer_id)
.expect("retained terminal snapshot");
let text = screen_text(&snapshot);
assert!(
text.contains("main-home"),
"leaving alternate screen must restore the main grid"
);
assert!(
!text.contains("ALT"),
"alternate-screen output must not enter the retained main grid"
);
assert!(
text.lines()
.nth(1)
.is_some_and(|row| row.starts_with(" final-output")),
"FullScreen parser/profile must honor CSI cursor addressing"
);
let output_at = text
.find("final-output")
.expect("final child output visible");
let annotation = format!("Process {} exited abnormally with code 7", snapshot.pid);
let annotation_at = text
.find(&annotation)
.expect("exact exit annotation visible");
assert!(
output_at < annotation_at,
"final output must precede annotation"
);
assert!(state.core.borrow().registry.borrow().contains(buffer_id));
let core = state.core.borrow();
let registry = core.registry.borrow();
let buffer = registry.get(buffer_id).expect("identity buffer retained");
assert!(buffer.is_read_only());
assert!(buffer.is_empty());
assert!(!buffer.is_modified());
}
#[test]
fn normal_and_signal_annotations_use_exact_pid_and_outcome() {
for (script, expected, annotation_tail) in [
(
"printf normal-output; exit 0",
TerminalProcessState::Exited(0),
"exited normally with code 0",
),
(
"printf signal-output; kill -TERM $$",
TerminalProcessState::Signaled("SIGTERM".into()),
"exited abnormally with signal SIGTERM",
),
] {
let mut state = EditorState::new();
let mut spec = TerminalSpec::new("/bin/sh");
spec.args = vec!["-c".into(), script.into()];
spec.rows = 6;
spec.cols = 80;
let buffer_id = state.open_terminal(spec).expect("open terminal");
tick_until(&mut state, Duration::from_secs(5), |state| {
state
.terminal_manager
.borrow()
.snapshot(buffer_id)
.is_some_and(|snapshot| snapshot.process == expected)
});
let snapshot = state
.terminal_manager
.borrow()
.snapshot(buffer_id)
.expect("snapshot retained");
assert!(
screen_text(&snapshot).contains(&format!("Process {} {annotation_tail}", snapshot.pid)),
"missing exact annotation for {:?}",
snapshot.process
);
}
}
#[test]
fn killing_terminal_buffer_prunes_session_and_reaps_owned_process() {
let mut state = EditorState::new();
let mut spec = TerminalSpec::new("/bin/sh");
spec.args = vec!["-c".into(), "sleep 30".into()];
let buffer_id = state.open_terminal(spec).expect("open terminal");
let process_id = state
.terminal_manager
.borrow()
.process_id(buffer_id)
.expect("owned process");
state
.core
.borrow_mut()
.kill_buffer(buffer_id)
.expect("kill identity buffer");
state.tick_processes();
assert!(!state.terminal_manager.borrow().is_terminal(buffer_id));
tick_until(&mut state, Duration::from_secs(5), |state| {
state
.process_supervisor
.borrow()
.state(process_id)
.is_none()
});
}
#[test]
fn editor_shutdown_kills_term_ignoring_terminal_child() {
let pid = {
let mut state = EditorState::new();
state
.process_supervisor
.borrow_mut()
.set_grace_period(Duration::from_millis(50));
let mut spec = TerminalSpec::new("/bin/sh");
spec.args = vec![
"-c".into(),
"trap '' TERM; while :; do sleep 1; done".into(),
];
let buffer_id = state.open_terminal(spec).expect("open terminal");
state
.terminal_manager
.borrow()
.snapshot(buffer_id)
.expect("snapshot")
.pid
};
let pid = nix::unistd::Pid::from_raw(i32::try_from(pid).expect("pid fits i32"));
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline && nix::sys::signal::kill(pid, None).is_ok() {
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(
nix::sys::signal::kill(pid, None),
Err(nix::errno::Errno::ESRCH),
"terminal child {pid} survived EditorState shutdown"
);
}
#[test]
fn terminal_tick_does_not_take_non_terminal_process_events() {
let mut state = EditorState::new();
let mut process = pmacs::process::ProcessSpec::new("ordinary", "/bin/sh");
process.args = vec!["-c".into(), "printf ordinary".into()];
let ordinary_id = state
.process_supervisor
.borrow_mut()
.spawn(process)
.expect("ordinary process");
tick_until(&mut state, Duration::from_secs(5), |state| {
matches!(
state.process_supervisor.borrow().state(ordinary_id),
Some(ProcessState::Terminated(_))
)
});
let events = state
.process_supervisor
.borrow_mut()
.take_events(ordinary_id);
assert!(
events.iter().any(|event| matches!(
&event.kind,
pmacs::process::ProcessEventKind::Stdout(bytes) if bytes == b"ordinary"
)),
"TerminalManager must not steal ordinary process output"
);
}