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:
commit
4e93596e8a
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 1–3 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 1–14 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
803
src/ansi.rs
803
src/ansi.rs
|
|
@ -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(¶ms, 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(¶ms, events);
|
||||
self.csi.reset();
|
||||
return;
|
||||
}
|
||||
if self.profile == AnsiParserProfile::LineOriented {
|
||||
match (private, final_byte) {
|
||||
(None, b'K') => match param(¶ms, 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(¶ms, 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 ¶ms {
|
||||
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 ¶ms {
|
||||
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(¶ms, 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(¶ms, 0, 1).max(1),
|
||||
)),
|
||||
(None, [], b'd') => Some(AnsiEvent::CursorVerticalAbsolute(
|
||||
param(¶ms, 0, 1).max(1),
|
||||
)),
|
||||
(None, [], b'H' | b'f') => Some(AnsiEvent::CursorPosition {
|
||||
row: param(¶ms, 0, 1).max(1),
|
||||
col: param(¶ms, 1, 1).max(1),
|
||||
}),
|
||||
(None, [], b'J') => erase_mode(param(¶ms, 0, 0)).map(AnsiEvent::EraseDisplay),
|
||||
(None, [], b'K') => erase_mode(param(¶ms, 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(¶ms, 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(¶ms, 0, 0) {
|
||||
0 => Some(AnsiEvent::ClearTabStop),
|
||||
3 => Some(AnsiEvent::ClearAllTabStops),
|
||||
_ => None,
|
||||
},
|
||||
(None, [], b'~') => match param(¶ms, 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 ¶ms {
|
||||
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 ¶ms {
|
||||
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(¶ms, 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
138
src/buffer.rs
138
src/buffer.rs
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
692
src/editor.rs
692
src/editor.rs
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
26
src/lsp.rs
26
src/lsp.rs
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
183
src/process.rs
183
src/process.rs
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
182
src/syntax.rs
182
src/syntax.rs
|
|
@ -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(®, "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(®, "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 [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue