Merge pull request #123 from levineuwirth/json-yaml-grammar

feat(json-yaml): JSON + YAML grammars and language servers
This commit is contained in:
Levi Neuwirth 2026-07-21 13:47:06 +00:00 committed by GitHub
commit bb17ec955e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 840 additions and 4 deletions

22
Cargo.lock generated
View File

@ -2558,6 +2558,7 @@ dependencies = [
"tree-sitter-cuda", "tree-sitter-cuda",
"tree-sitter-go", "tree-sitter-go",
"tree-sitter-javascript", "tree-sitter-javascript",
"tree-sitter-json",
"tree-sitter-lua", "tree-sitter-lua",
"tree-sitter-make", "tree-sitter-make",
"tree-sitter-md", "tree-sitter-md",
@ -2565,6 +2566,7 @@ dependencies = [
"tree-sitter-rust", "tree-sitter-rust",
"tree-sitter-toml-ng", "tree-sitter-toml-ng",
"tree-sitter-typescript", "tree-sitter-typescript",
"tree-sitter-yaml",
"tree-sitter-zig", "tree-sitter-zig",
"unicode-width", "unicode-width",
] ]
@ -3807,6 +3809,16 @@ dependencies = [
"tree-sitter-language", "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]] [[package]]
name = "tree-sitter-language" name = "tree-sitter-language"
version = "0.1.7" version = "0.1.7"
@ -3883,6 +3895,16 @@ dependencies = [
"tree-sitter-language", "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]] [[package]]
name = "tree-sitter-zig" name = "tree-sitter-zig"
version = "1.1.2" version = "1.1.2"

View File

@ -196,6 +196,13 @@ tree-sitter-javascript = "0.25"
tree-sitter-typescript = "0.23" tree-sitter-typescript = "0.23"
tree-sitter-toml-ng = "0.7" tree-sitter-toml-ng = "0.7"
tree-sitter-zig = "1.1" 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 # T M9.7: markdown grammar so prompt result buffers with
# `_meta.format = "markdown"` get structured highlighting through the # `_meta.format = "markdown"` get structured highlighting through the
# same M4 path as rust/lua — no special-case painter in Lua. # same M4 path as rust/lua — no special-case painter in Lua.

View File

@ -200,6 +200,62 @@ pmacs.lsp.config.zig = pmacs.lsp.config.zig or {
args = {}, 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 -- LSP-side extension → language map, deliberately independent of the
-- tree-sitter detection in `pmacs.parse`. Consulted only when -- tree-sitter detection in `pmacs.parse`. Consulted only when
-- `pmacs.parse.language_for_path` finds nothing (an extension with a -- `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. -- 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.zig = pmacs.lsp.filetypes.zig or "zig"
pmacs.lsp.filetypes.zon = pmacs.lsp.filetypes.zon 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 }. -- Per-buffer attachment record: { language, server, uri, version }.
-- Keyed by `tostring(BufferIdLua)` because BufferIdLua hands out fresh -- Keyed by `tostring(BufferIdLua)` because BufferIdLua hands out fresh

View File

@ -323,11 +323,18 @@ runtime/Lua-registered languages (v1 resolves only against
`BUILTIN_LANGUAGES`), and the next injection *consumers* gated on new `BUILTIN_LANGUAGES`), and the next injection *consumers* gated on new
grammars — HTML/CSS/GraphQL/SQL (`<script>`/`<style>`, JS/TS template grammars — HTML/CSS/GraphQL/SQL (`<script>`/`<style>`, JS/TS template
literals, doc-comment code); modeline detection as a 5th layer literals, doc-comment code); modeline detection as a 5th layer
(`-*- mode: … -*-` / `# vim: ft=…`); JSON/YAML grammars+LSP; (`-*- mode: … -*-` / `# vim: ft=…`);
byte-accurate multibyte cursor placement in `move_active_cursor_to` byte-accurate multibyte cursor placement in `move_active_cursor_to`
(still steps one codepoint per LSP byte column). A full Jupyter `.ipynb` (still steps one codepoint per LSP byte column). **JSON + YAML PR #123
setup (reader → editable → kernel execution) is a real arc now gated on OPEN** (grammar-gap style, `tree-sitter-json`/`-yaml`; LSP configs
**JSON** (injections shipped in #122), NOT a one-shot. `vscode-json-language-server` with provider pin
`@t1ckbase/vscode-langservers-extracted@2.0.2`, plus
`yaml-language-server`). The public and checkpoint branches are both at
fully gated `5c202c5`, rebased onto `f8096ff`; the JSON and YAML
PATH-gated pmacs smokes both passed, and the PR awaits user review.
Once merged, YAML `---` and TOML `+++` markdown frontmatter highlight via
the #122 engine and the Jupyter reader → editable → kernel arc has both
grammar prerequisites.
GPU: auto-reconnect after daemon restart, splits/multi-buffer, gutter GPU: auto-reconnect after daemon restart, splits/multi-buffer, gutter
riders (whitespace guides, folding, git markers). riders (whitespace guides, folding, git markers).
Themes (full list in theme-faces framing rev 9 "Deferred (named)"): Themes (full list in theme-faces framing rev 9 "Deferred (named)"):

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

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

View File

@ -414,6 +414,26 @@ fn main() {
}); });
write_frame(&mut stdout, &resp); 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", _) => { ("textDocument/didOpen" | "textDocument/didChange", _) => {
let uri = params let uri = params
.get("textDocument") .get("textDocument")

View File

@ -2497,6 +2497,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( fn handle_response(
&mut self, &mut self,
sid: LspServerId, sid: LspServerId,
@ -2552,6 +2571,9 @@ impl LspManager {
if let Some(client) = self.clients.get(&sid) { if let Some(client) = self.clients.get(&sid) {
let _ = send_frame_to(&self.supervisor, client, &body); 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 // T M4.5 Option B: honour the server's negotiated
// `general.positionEncoding`. Absent ⇒ LSP spec default // `general.positionEncoding`. Absent ⇒ LSP spec default
// (UTF-16). We advertised `["utf-8","utf-16"]`, so a // (UTF-16). We advertised `["utf-8","utf-16"]`, so a

View File

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

View File

@ -6265,6 +6265,9 @@ fn m4_gap_grammars_align_with_lsp_configs() {
("A.tsx", "typescriptreact"), ("A.tsx", "typescriptreact"),
("Cargo.toml", "toml"), ("Cargo.toml", "toml"),
("build.zig", "zig"), ("build.zig", "zig"),
("tsconfig.json", "json"),
("config.yaml", "yaml"),
("ci.yml", "yaml"),
] { ] {
let (grammar, has_cfg): (Option<String>, bool) = s let (grammar, has_cfg): (Option<String>, bool) = s
.lua_host .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 /// Typing-perf: the default bundle coalesces full-document
/// `didChange` notifications instead of sending one per keystroke /// `didChange` notifications instead of sending one per keystroke
/// (each send copies the whole buffer several times and writes /// (each send copies the whole buffer several times and writes