Skip to main content

zsh/extensions/
lsp.rs

1//! LSP server for zshrs — `zshrs --lsp`.
2//!
3//! Speaks LSP over stdio (Content-Length-framed JSON-RPC, byte-based).
4//! Hand-rolled (no `lsp-server` / `lsp-types` deps) to keep the default
5//! zshrs build dependency-free. Calls into [`crate::lex`] for tokenization
6//! and [`crate::parse`] for diagnostics.
7//!
8//! Capabilities advertised:
9//!   * `textDocument/didOpen`, `didChange`, `didClose`, `didSave`
10//!   * `completion` (builtins, keywords, options, parameter names)
11//!   * `hover` (builtin / keyword cards)
12//!   * `documentSymbol` (function declarations + top-level aliases)
13//!   * `foldingRange` (`{ }`, `do … done`, `case … esac`, comment runs)
14//!   * `definition` / `references` for function names
15//!   * `rename`
16//!   * `semanticTokens/full`
17//!   * `formatting`
18//!   * `publishDiagnostics` (push, not pull)
19//!
20//! This is intentionally self-contained: no dependency on global zshrs
21//! state. Each request operates on a per-URI document buffer.
22
23use serde_json::{json, Value};
24use std::collections::HashMap;
25use std::io::{self, BufRead, BufReader, Read, Write};
26
27// ── Framing ─────────────────────────────────────────────────────────────
28
29/// Read one Content-Length-framed JSON-RPC message from `reader`.
30///
31/// Returns `Ok(None)` on clean EOF. Returns `Err` for malformed framing.
32fn read_message<R: BufRead>(reader: &mut R) -> io::Result<Option<Value>> {
33    let mut content_length: Option<usize> = None;
34    loop {
35        let mut line = String::new();
36        let n = reader.read_line(&mut line)?;
37        if n == 0 {
38            return Ok(None);
39        }
40        if line == "\r\n" || line == "\n" {
41            break;
42        }
43        if let Some(rest) = line.strip_prefix("Content-Length:") {
44            content_length =
45                Some(rest.trim().parse().map_err(|_| {
46                    io::Error::new(io::ErrorKind::InvalidData, "bad Content-Length")
47                })?);
48        }
49    }
50    let len = content_length
51        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing Content-Length"))?;
52    let mut buf = vec![0u8; len];
53    reader.read_exact(&mut buf)?;
54    let v: Value =
55        serde_json::from_slice(&buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
56    Ok(Some(v))
57}
58
59fn write_message<W: Write>(writer: &mut W, msg: &Value) -> io::Result<()> {
60    let body = serde_json::to_vec(msg)?;
61    write!(writer, "Content-Length: {}\r\n\r\n", body.len())?;
62    writer.write_all(&body)?;
63    writer.flush()
64}
65
66// ── Document store ──────────────────────────────────────────────────────
67
68#[derive(Default)]
69struct State {
70    /// Documents the IDE has explicitly `didOpen`'d. Authoritative for
71    /// unsaved buffer state.
72    docs: HashMap<String, String>,
73    /// Files discovered via a workspace-root walk on `initialize`. Used
74    /// by `references` / `rename` so a function declared in one file can
75    /// be renamed across every other file in the project, even ones the
76    /// user never opened in an editor tab. Read from disk once at
77    /// init; subsequent `didChange` / `didSave` updates the matching
78    /// entry. Empty if the IDE didn't supply a root.
79    workspace_files: HashMap<String, String>,
80    /// Resolved workspace roots — filesystem paths derived from
81    /// `rootUri` and `workspaceFolders` at init time. Used to bound
82    /// follow-up rescans.
83    workspace_roots: Vec<std::path::PathBuf>,
84}
85
86impl State {
87    /// Iterate every (uri, text) pair we know about: the union of
88    /// `didOpen`'d docs and the workspace cache, with the open-doc
89    /// version winning when both are present (so unsaved edits aren't
90    /// shadowed by the on-disk copy).
91    fn all_docs(&self) -> Vec<(String, String)> {
92        let mut out: HashMap<String, String> = self.workspace_files.clone();
93        for (k, v) in &self.docs {
94            out.insert(k.clone(), v.clone());
95        }
96        let mut v: Vec<(String, String)> = out.into_iter().collect();
97        v.sort_by(|a, b| a.0.cmp(&b.0));
98        v
99    }
100}
101
102/// File extensions we treat as zsh source during workspace walks. Keep
103/// in sync with `ZshrsSettings.supportedExtensions()` on the plugin
104/// side — files outside this list never participate in cross-file
105/// rename. Names like `.zshrc` have empty extension but a known base.
106const ZSH_EXT: &[&str] = &["zsh", "sh"];
107const ZSH_BASENAMES: &[&str] = &[
108    ".zshrc",
109    ".zshenv",
110    ".zprofile",
111    ".zlogin",
112    ".zlogout",
113    ".zsh_aliases",
114    ".zsh_functions",
115    ".zshrc.local",
116    "zshrc",
117];
118/// Don't recurse into these directories during the workspace walk.
119/// Avoids dragging .git history, node_modules, build outputs, and other
120/// large trees into the symbol table.
121const SKIP_DIRS: &[&str] = &[
122    ".git",
123    ".hg",
124    ".svn",
125    "node_modules",
126    "target",
127    "build",
128    "dist",
129    ".idea",
130    ".vscode",
131    ".cache",
132    ".direnv",
133    ".venv",
134    "venv",
135    "__pycache__",
136];
137/// Hard cap on workspace files scanned. Above this we stop reading new
138/// files — a 10k-file shell-script repo is already unusual; bounding
139/// here prevents pathological project roots from gobbling memory.
140const MAX_WORKSPACE_FILES: usize = 10_000;
141/// Per-file size cap. Skip files larger than this; they're almost
142/// certainly not shell source (data dumps, generated artifacts).
143const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
144
145/// True if `name` looks like a zsh source file by extension or known
146/// dotfile basename. Case-sensitive (Unix conventions); plugin-side
147/// settings override the extension list per-project.
148fn is_zsh_source_filename(name: &str) -> bool {
149    if let Some(ext) = name.rsplit('.').next() {
150        if ext != name && ZSH_EXT.contains(&ext) {
151            return true;
152        }
153    }
154    ZSH_BASENAMES.contains(&name)
155}
156
157/// Convert a filesystem path to a `file://` URI. Returns `None` for
158/// non-absolute / non-UTF-8 paths since LSP URIs require both.
159fn path_to_file_uri(p: &std::path::Path) -> Option<String> {
160    let abs = if p.is_absolute() {
161        p.to_path_buf()
162    } else {
163        std::env::current_dir().ok()?.join(p)
164    };
165    let s = abs.to_str()?;
166    Some(format!("file://{s}"))
167}
168
169/// Convert a `file://` URI to a filesystem path. Naive — strips the
170/// scheme; doesn't decode percent-escapes. Good enough for the local
171/// filesystem walk; the IDE side handles fancy URIs separately.
172fn file_uri_to_path(uri: &str) -> Option<std::path::PathBuf> {
173    uri.strip_prefix("file://").map(std::path::PathBuf::from)
174}
175
176/// Walk `root` (depth-first, bounded) and read every zsh source file
177/// into `out`, keyed by `file://` URI. Skips dirs in [`SKIP_DIRS`],
178/// files larger than [`MAX_FILE_BYTES`], and stops once the total
179/// count reaches [`MAX_WORKSPACE_FILES`].
180///
181/// Best-effort: filesystem errors are logged at TRACE and skipped, not
182/// propagated — workspace rename should still work when some files are
183/// unreadable.
184fn scan_workspace_root(root: &std::path::Path, out: &mut HashMap<String, String>) {
185    let mut stack: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
186    while let Some(dir) = stack.pop() {
187        if out.len() >= MAX_WORKSPACE_FILES {
188            tracing::warn!(
189                target: "zshrs::lsp::workspace",
190                cap = MAX_WORKSPACE_FILES,
191                "workspace scan capped",
192            );
193            return;
194        }
195        let entries = match std::fs::read_dir(&dir) {
196            Ok(e) => e,
197            Err(e) => {
198                tracing::trace!(target: "zshrs::lsp::workspace", path=?dir, %e, "read_dir failed");
199                continue;
200            }
201        };
202        for ent in entries.flatten() {
203            let path = ent.path();
204            let name = match path.file_name().and_then(|n| n.to_str()) {
205                Some(n) => n,
206                None => continue,
207            };
208            let ty = match ent.file_type() {
209                Ok(t) => t,
210                Err(_) => continue,
211            };
212            if ty.is_dir() {
213                if SKIP_DIRS.contains(&name)
214                    || name.starts_with('.') && !ZSH_BASENAMES.iter().any(|b| b == &name)
215                {
216                    continue;
217                }
218                stack.push(path);
219                continue;
220            }
221            if !ty.is_file() {
222                continue;
223            }
224            if !is_zsh_source_filename(name) {
225                continue;
226            }
227            let md = match ent.metadata() {
228                Ok(m) => m,
229                Err(_) => continue,
230            };
231            if md.len() > MAX_FILE_BYTES {
232                continue;
233            }
234            let text = match std::fs::read_to_string(&path) {
235                Ok(t) => t,
236                Err(_) => continue,
237            };
238            if let Some(uri) = path_to_file_uri(&path) {
239                out.insert(uri, text);
240                if out.len() >= MAX_WORKSPACE_FILES {
241                    return;
242                }
243            }
244        }
245    }
246}
247
248/// Apply `initialize` workspace info to `state`: extract roots from
249/// `rootUri` / `workspaceFolders` and populate `workspace_files`.
250fn ingest_workspace_init(state: &mut State, params: &Value) {
251    // Collect candidate roots in priority order. Later, dedupe.
252    let mut roots: Vec<std::path::PathBuf> = Vec::new();
253    if let Some(uri) = params.get("rootUri").and_then(|v| v.as_str()) {
254        if let Some(p) = file_uri_to_path(uri) {
255            roots.push(p);
256        }
257    }
258    if let Some(folders) = params.get("workspaceFolders").and_then(|v| v.as_array()) {
259        for f in folders {
260            if let Some(uri) = f.get("uri").and_then(|v| v.as_str()) {
261                if let Some(p) = file_uri_to_path(uri) {
262                    roots.push(p);
263                }
264            }
265        }
266    }
267    // Dedup while preserving order.
268    let mut seen = std::collections::HashSet::new();
269    roots.retain(|p| seen.insert(p.clone()));
270    if roots.is_empty() {
271        tracing::info!(target: "zshrs::lsp::workspace", "no roots in initialize");
272        return;
273    }
274    let mut buf: HashMap<String, String> = HashMap::new();
275    for r in &roots {
276        scan_workspace_root(r, &mut buf);
277    }
278    tracing::info!(
279        target: "zshrs::lsp::workspace",
280        roots = roots.len(),
281        files = buf.len(),
282        "scanned",
283    );
284    state.workspace_roots = roots;
285    state.workspace_files = buf;
286}
287
288/// Refresh a single workspace-file entry from disk after a save or an
289/// external change. No-op if the path isn't inside any known root.
290fn refresh_workspace_file(state: &mut State, uri: &str) {
291    if state.workspace_roots.is_empty() {
292        return;
293    }
294    let path = match file_uri_to_path(uri) {
295        Some(p) => p,
296        None => return,
297    };
298    let inside_root = state.workspace_roots.iter().any(|r| path.starts_with(r));
299    if !inside_root {
300        return;
301    }
302    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
303        if !is_zsh_source_filename(name) {
304            return;
305        }
306    }
307    match std::fs::read_to_string(&path) {
308        Ok(t) => {
309            state.workspace_files.insert(uri.to_string(), t);
310        }
311        Err(_) => {
312            state.workspace_files.remove(uri);
313        }
314    }
315}
316
317// ── Public entry point ──────────────────────────────────────────────────
318
319/// Run the LSP server, blocking until EOF on stdin.
320///
321/// Called from `bins/zshrs.rs` when `--lsp` is detected.
322/// Guard against leaking as an orphan LSP process. Editors / GUI apps reap us via
323/// kill-on-drop, which only fires on a *graceful* client exit; a hard client
324/// death (SIGKILL/crash/force-quit) skips it, and a leaked pipe fd can keep our
325/// stdin open so we never see EOF either. Watch for reparenting to pid 1 (our
326/// client died) and exit — nothing this read-only server holds is worth leaking.
327fn spawn_orphan_guard() {
328    std::thread::spawn(|| {
329        // Linux: also ask the kernel to SIGKILL us the instant the parent dies.
330        // Best-effort; the getppid poll below is the portable guarantee (macOS
331        // has no PDEATHSIG).
332        #[cfg(target_os = "linux")]
333        // SAFETY: prctl(PR_SET_PDEATHSIG, ...) only registers a signal disposition.
334        unsafe {
335            libc::prctl(
336                libc::PR_SET_PDEATHSIG,
337                libc::SIGKILL as libc::c_ulong,
338                0,
339                0,
340                0,
341            );
342        }
343        loop {
344            std::thread::sleep(std::time::Duration::from_secs(2));
345            // SAFETY: getppid takes no arguments and never fails.
346            if unsafe { libc::getppid() } == 1 {
347                std::process::exit(0);
348            }
349        }
350    });
351}
352
353/// Take the JSON-RPC stream off fds 0/1 and leave `/dev/null` there.
354///
355/// The LSP hosts a real shell: completers spawn children (`_call_program`,
356/// command substitution inside a completer, `zle`-less `$(…)` in a
357/// user function), and every child inherits fds 0/1. On stdio transport
358/// those two fds ARE the protocol:
359///
360///   * a child that reads stdin eats the editor's requests — the server
361///     then sees EOF and exits mid-session (observed: "stdin EOF,
362///     shutting down" one dispatch after the first `git <tab>`);
363///   * a child that writes stdout injects its bytes between two
364///     `Content-Length` frames and corrupts the stream.
365///
366/// So dup the real endpoints to private fds, point 0/1 at `/dev/null`,
367/// and talk to the editor exclusively through the dups. No descendant
368/// can reach the protocol afterwards, whichever exec path it takes.
369///
370/// Falls back to plain stdin/stdout if the dance fails — a server that
371/// speaks LSP over a corruptible transport still beats one that will
372/// not start.
373fn claim_protocol_fds() -> (BufReader<Box<dyn io::Read + Send>>, Box<dyn Write + Send>) {
374    #[cfg(unix)]
375    {
376        use std::os::fd::FromRawFd;
377        // SAFETY: `dup` on 0/1 yields fresh owned descriptors; each is
378        // handed to exactly one File that owns it from here on. The
379        // `/dev/null` open + dup2 replaces 0/1 in this process only.
380        unsafe {
381            let in_fd = libc::dup(0);
382            let out_fd = libc::dup(1);
383            if in_fd >= 0 && out_fd >= 0 {
384                let devnull = std::ffi::CString::new("/dev/null").expect("static cstr");
385                let null_fd = libc::open(devnull.as_ptr(), libc::O_RDWR);
386                if null_fd >= 0 {
387                    libc::dup2(null_fd, 0);
388                    libc::dup2(null_fd, 1);
389                    if null_fd > 2 {
390                        libc::close(null_fd);
391                    }
392                    tracing::info!(
393                        target: "zshrs::lsp",
394                        in_fd,
395                        out_fd,
396                        "protocol fds moved off 0/1; children see /dev/null",
397                    );
398                    let r: Box<dyn io::Read + Send> = Box::new(std::fs::File::from_raw_fd(in_fd));
399                    let w: Box<dyn Write + Send> = Box::new(std::fs::File::from_raw_fd(out_fd));
400                    return (BufReader::new(r), w);
401                }
402                libc::close(in_fd);
403                libc::close(out_fd);
404            }
405            tracing::warn!(
406                target: "zshrs::lsp",
407                "could not move protocol fds off 0/1 — a completer subprocess could corrupt the stream",
408            );
409        }
410    }
411    let r: Box<dyn io::Read + Send> = Box::new(io::stdin());
412    let w: Box<dyn Write + Send> = Box::new(io::stdout());
413    (BufReader::new(r), w)
414}
415
416pub fn run_lsp() -> i32 {
417    tracing::info!(
418        target: "zshrs::lsp",
419        pid = std::process::id(),
420        "starting --lsp",
421    );
422    spawn_orphan_guard();
423    // Move the JSON-RPC endpoints off fds 0/1 BEFORE anything shell-side
424    // can spawn a child (see `claim_protocol_fds`).
425    let (mut reader, mut writer) = claim_protocol_fds();
426    let mut state = State::default();
427
428    let log_path = std::env::var("ZSHRS_LSP_LOG").ok();
429    let mut log = log_path.as_ref().and_then(|p| {
430        std::fs::OpenOptions::new()
431            .create(true)
432            .append(true)
433            .open(p)
434            .ok()
435    });
436
437    loop {
438        let msg = match read_message(&mut reader) {
439            Ok(Some(m)) => m,
440            Ok(None) => {
441                tracing::info!(target: "zshrs::lsp", "stdin EOF, shutting down");
442                break;
443            }
444            Err(e) => {
445                if let Some(l) = log.as_mut() {
446                    let _ = writeln!(l, "← read error: {}", e);
447                }
448                tracing::error!(target: "zshrs::lsp", %e, "read error, shutting down");
449                break;
450            }
451        };
452
453        if let Some(l) = log.as_mut() {
454            let _ = writeln!(l, "← {}", msg);
455        }
456
457        let method = msg
458            .get("method")
459            .and_then(|v| v.as_str())
460            .map(|s| s.to_string());
461        let id = msg.get("id").cloned();
462        let params = msg.get("params").cloned().unwrap_or(Value::Null);
463        tracing::trace!(
464            target: "zshrs::lsp::req",
465            method = method.as_deref().unwrap_or("?"),
466            id = ?id,
467        );
468
469        let response = match method.as_deref() {
470            Some("initialize") => {
471                ingest_workspace_init(&mut state, &params);
472                // Start the compsys shell thread now (non-blocking) so
473                // the first `git <tab>` in a script finds a warm shell:
474                // executor built, rkyv canonical shard applied, compdef
475                // map published. Completion requests that arrive before
476                // it is ready answer `isIncomplete` and the client
477                // re-requests.
478                crate::compsys::in_editor::bootstrap();
479                Some(handle_initialize(id, &params))
480            }
481            Some("initialized") => None,
482            Some("shutdown") => Some(reply(id, json!(null))),
483            Some("exit") => break,
484            Some("textDocument/didOpen") => {
485                if let (Some(uri), Some(text)) = (
486                    params["textDocument"]["uri"].as_str(),
487                    params["textDocument"]["text"].as_str(),
488                ) {
489                    state.docs.insert(uri.to_string(), text.to_string());
490                    publish_diagnostics(&mut writer, uri, text, &mut log);
491                }
492                None
493            }
494            Some("textDocument/didChange") => {
495                if let Some(uri) = params["textDocument"]["uri"].as_str() {
496                    if let Some(changes) = params["contentChanges"].as_array() {
497                        // Full-document sync only (we advertise that)
498                        if let Some(t) = changes.last().and_then(|c| c["text"].as_str()) {
499                            state.docs.insert(uri.to_string(), t.to_string());
500                            publish_diagnostics(&mut writer, uri, t, &mut log);
501                        }
502                    }
503                }
504                None
505            }
506            Some("textDocument/didClose") => {
507                if let Some(uri) = params["textDocument"]["uri"].as_str() {
508                    state.docs.remove(uri);
509                }
510                None
511            }
512            Some("textDocument/didSave") => {
513                if let Some(uri) = params["textDocument"]["uri"].as_str() {
514                    if let Some(text) = state.docs.get(uri).cloned() {
515                        publish_diagnostics(&mut writer, uri, &text, &mut log);
516                    }
517                    // Mirror the saved content into the workspace cache
518                    // so future cross-file lookups see the new on-disk
519                    // text without requiring a full re-walk.
520                    refresh_workspace_file(&mut state, uri);
521                }
522                None
523            }
524            Some("textDocument/completion") => Some(reply(id, completion(&state, &params))),
525            Some("textDocument/hover") => Some(reply(id, hover(&state, &params))),
526            Some("textDocument/documentSymbol") => {
527                Some(reply(id, document_symbols(&state, &params)))
528            }
529            Some("textDocument/foldingRange") => Some(reply(id, folding_ranges(&state, &params))),
530            Some("textDocument/definition") => Some(reply(id, definition(&state, &params))),
531            Some("textDocument/references") => Some(reply(id, references(&state, &params))),
532            Some("textDocument/documentHighlight") => {
533                Some(reply(id, document_highlights(&state, &params)))
534            }
535            Some("textDocument/rename") => Some(reply(id, rename(&state, &params))),
536            Some("textDocument/prepareRename") => Some(reply(id, prepare_rename(&state, &params))),
537            Some("textDocument/semanticTokens/full") => {
538                Some(reply(id, semantic_tokens(&state, &params)))
539            }
540            Some("textDocument/formatting") => Some(reply(id, formatting(&state, &params))),
541            Some("textDocument/codeAction") => Some(reply(id, code_actions(&state, &params))),
542            // Unknown method → error response if it had an id (i.e. was a request)
543            Some(_) if id.is_some() => Some(reply_error(id, -32601, "Method not found")),
544            _ => None,
545        };
546
547        if let Some(resp) = response {
548            if let Some(l) = log.as_mut() {
549                let _ = writeln!(l, "→ {}", resp);
550            }
551            if let Err(e) = write_message(&mut writer, &resp) {
552                if let Some(l) = log.as_mut() {
553                    let _ = writeln!(l, "write error: {}", e);
554                }
555                break;
556            }
557        }
558    }
559    0
560}
561
562fn reply(id: Option<Value>, result: Value) -> Value {
563    json!({
564        "jsonrpc": "2.0",
565        "id": id.unwrap_or(Value::Null),
566        "result": result,
567    })
568}
569
570fn reply_error(id: Option<Value>, code: i32, message: &str) -> Value {
571    json!({
572        "jsonrpc": "2.0",
573        "id": id.unwrap_or(Value::Null),
574        "error": { "code": code, "message": message },
575    })
576}
577
578// ── initialize ──────────────────────────────────────────────────────────
579
580fn handle_initialize(id: Option<Value>, _params: &Value) -> Value {
581    reply(
582        id,
583        json!({
584            "capabilities": {
585                "textDocumentSync": { "openClose": true, "change": 1, "save": true },
586                "completionProvider": {
587                    // Auto-open popup on these chars. The list covers
588                    // every context-aware surface: `$`/`{` (param/var),
589                    // `(` (glob qualifier / param flag / pattern mod /
590                    // subscript flag / math fn), `[` (subscript), `#`
591                    // (pattern modifier `(#i)`), `*`/`?` (glob meta
592                    // before `(`), `!` (history designator), `-`
593                    // (typeset flag), `:` (param/history modifier).
594                    "triggerCharacters": ["$", "{", "(", "[", "#", "*", "?", "!", "-", ":"],
595                    "resolveProvider": false,
596                },
597                "hoverProvider": true,
598                "definitionProvider": true,
599                "referencesProvider": true,
600                "documentHighlightProvider": true,
601                "documentSymbolProvider": true,
602                "foldingRangeProvider": true,
603                "renameProvider": { "prepareProvider": true },
604                "documentFormattingProvider": true,
605                "codeActionProvider": {
606                    "codeActionKinds": [
607                        "refactor.extract",
608                    ],
609                },
610                "semanticTokensProvider": {
611                    "legend": {
612                        "tokenTypes": SEMANTIC_TOKEN_TYPES,
613                        "tokenModifiers": [],
614                    },
615                    "full": true,
616                    "range": false,
617                },
618            },
619            "serverInfo": { "name": "zshrs-lsp", "version": env!("CARGO_PKG_VERSION") },
620        }),
621    )
622}
623
624// ── Diagnostics ─────────────────────────────────────────────────────────
625
626fn publish_diagnostics<W: Write>(
627    writer: &mut W,
628    uri: &str,
629    text: &str,
630    log: &mut Option<std::fs::File>,
631) {
632    let diags = diagnose(text);
633    let msg = json!({
634        "jsonrpc": "2.0",
635        "method": "textDocument/publishDiagnostics",
636        "params": { "uri": uri, "diagnostics": diags },
637    });
638    if let Some(l) = log.as_mut() {
639        let _ = writeln!(l, "→ {}", msg);
640    }
641    let _ = write_message(writer, &msg);
642}
643
644/// Strip line-end comments and the *contents* of quoted strings from
645/// `line`, returning a string whose `split_whitespace()` only yields
646/// real code tokens. Keeps the quotes themselves so column positions
647/// of any surviving tokens line up with the source. Used by the
648/// block-keyword scan in `diagnose()` so keywords inside comments
649/// (`# foo case bar`) or strings (`echo "if x"`) don't push spurious
650/// entries onto the block stack.
651fn strip_comments_and_strings(line: &str) -> String {
652    let bytes = line.as_bytes();
653    let mut out = String::with_capacity(bytes.len());
654    let mut i = 0usize;
655    while i < bytes.len() {
656        let c = bytes[i] as char;
657        match c {
658            '\\' if i + 1 < bytes.len() => {
659                out.push(c);
660                out.push(bytes[i + 1] as char);
661                i += 2;
662                continue;
663            }
664            '"' | '\'' | '`' => {
665                let q = c;
666                out.push(q);
667                i += 1;
668                while i < bytes.len() {
669                    let cc = bytes[i] as char;
670                    if cc == '\\' && q != '\'' && i + 1 < bytes.len() {
671                        i += 2;
672                        continue;
673                    }
674                    if cc == q {
675                        out.push(q);
676                        i += 1;
677                        break;
678                    }
679                    i += 1;
680                }
681                continue;
682            }
683            '#' => {
684                let prev = if i == 0 {
685                    None
686                } else {
687                    Some(bytes[i - 1] as char)
688                };
689                let is_comment_start = match prev {
690                    None => true,
691                    // Note: `(` removed from prev-chars — zsh glob
692                    // qualifiers `(#i)`/`(#a)` would otherwise truncate
693                    // the line at `#` and orphan the trailing `)` etc.
694                    Some(p) => p.is_whitespace() || p == ';' || p == '&' || p == '|',
695                };
696                if is_comment_start {
697                    break;
698                }
699                out.push(c);
700            }
701            _ => out.push(c),
702        }
703        i += 1;
704    }
705    out
706}
707
708/// Run a quick structural pass over the document to surface obvious
709/// errors. This is intentionally lightweight: it complements (does not
710/// replace) the deeper diagnostics from a full parse.
711fn diagnose(text: &str) -> Vec<Value> {
712    let mut diags = Vec::new();
713    let mut stack: Vec<(char, usize, usize)> = Vec::new(); // (open, line, col)
714    let mut block_stack: Vec<(&str, usize, usize)> = Vec::new();
715    // Multi-line quote state. `'` and `"` can span lines (heredocs,
716    // multi-line awk/sed/perl scripts embedded in single quotes, etc.).
717    // When set, the next line is parsed as quote-body until we find the
718    // matching close, after which normal parsing resumes.
719    let mut open_quote: Option<char> = None;
720
721    for (line_no, line) in text.lines().enumerate() {
722        // If we're inside a multi-line quote opened on a previous line,
723        // scan for its close. Skip block-keyword scanning entirely for
724        // this line — the line content is string-body, not zsh code.
725        let mut post_quote_tail: Option<usize> = None;
726        if let Some(q) = open_quote {
727            let bytes = line.as_bytes();
728            let mut i = 0usize;
729            let mut closed_at = None;
730            while i < bytes.len() {
731                let c = bytes[i] as char;
732                if c == '\\' && q != '\'' && i + 1 < bytes.len() {
733                    i += 2;
734                    continue;
735                }
736                if c == q {
737                    closed_at = Some(i);
738                    break;
739                }
740                i += 1;
741            }
742            if let Some(close_pos) = closed_at {
743                open_quote = None;
744                // Multi-line `$(... "string with `(` `)` ..." ...)` form
745                // (e.g. examples/demos/365_mini_lisp.zsh:574, 654, 660)
746                // closes the quote then has code after. Drop into the
747                // normal token-level scan, but starting at close_pos+1,
748                // so trailing `)` matches the `$( … )` opener pushed on
749                // the prior line. Block-keyword scan stays skipped (the
750                // line is mostly string-body; partial coverage avoids
751                // re-triggering case/done/fi false positives).
752                post_quote_tail = Some(close_pos + 1);
753            } else {
754                // Still inside the multi-line quote — entire line is
755                // string body. Skip both scans.
756                continue;
757            }
758        }
759        let trimmed = line.trim_start();
760        if trimmed.starts_with('#') {
761            continue;
762        }
763        // Pre-scan: does this line contain a `case` keyword (in code,
764        // not in a comment / string)? If yes, any bare `)` on the same
765        // line is a case-arm pattern terminator and must NOT be flagged
766        // as an unmatched paren. Without this, lines like
767        //   case foo in foo|bar) echo yes ;; *) echo no ;; esac
768        // produced two false "unmatched `)`" diagnostics because the
769        // bracket scan runs in lexical order and `case` hasn't been
770        // pushed onto block_stack yet when the `)` is seen.
771        let line_code_only = strip_comments_and_strings(line);
772        let line_has_case_keyword = line_code_only.split_whitespace().any(|t| {
773            let bare = t.trim_end_matches(|c: char| matches!(c, ';' | '&' | '|'));
774            bare == "case"
775        });
776        // Pre-scan: find positions where `done`/`fi`/`esac` appear as
777        // BARE VALUE tokens (not as block terminators). They're
778        // terminators only when the previous token is a statement-
779        // separator (`;`/`&&`/`||`) or a block-body opener
780        // (`do`/`then`/`else`/`elif`). Otherwise they're literal
781        // arguments / comparison operands:
782        //   * `[[ $x == done ]]`         — comparison right-operand
783        //   * `todo_list done`           — CLI arg to a function
784        //   * `local status=done`        — assignment value
785        // Conservative detector: build a set of token-indices where
786        // the previous code-only token does NOT terminate a statement
787        // / open a block-body. Used in the loop below to skip those
788        // token-positions. Pinned by
789        // examples/demos/143_todo_app.zsh:50,90.
790        let mut bareword_value_positions: std::collections::HashSet<usize> =
791            std::collections::HashSet::new();
792        {
793            let toks: Vec<&str> = line_code_only.split_whitespace().collect();
794            for (i, t) in toks.iter().enumerate() {
795                if i == 0 {
796                    continue;
797                }
798                let prev_bare =
799                    toks[i - 1].trim_end_matches(|c: char| matches!(c, ';' | '&' | '|'));
800                let prev_ends_with_separator =
801                    toks[i - 1].ends_with(|c: char| matches!(c, ';' | '&' | '|'));
802                let bare = t.trim_end_matches(|c: char| matches!(c, ';' | '&' | '|'));
803                let prev_is_body_opener = matches!(
804                    prev_bare,
805                    "do" | "then" | "else" | "elif" | "&&" | "||" | ";" | "&" | "|"
806                );
807                if matches!(bare, "done" | "fi" | "esac")
808                    && !prev_ends_with_separator
809                    && !prev_is_body_opener
810                {
811                    bareword_value_positions.insert(i);
812                }
813            }
814        }
815        // Token-level scan. Pairings tracked on `stack`:
816        //   '('  — single paren            ')'
817        //   '{'  — single brace            '}'
818        //   '['  — single bracket          ']'
819        //   'A'  — arithmetic `((`         `))`
820        //   'D'  — conditional `[[`        `]]`
821        let mut i = post_quote_tail.unwrap_or(0);
822        let bytes = line.as_bytes();
823        while i < bytes.len() {
824            let c = bytes[i] as char;
825            match c {
826                '(' => {
827                    // `((` opens an arithmetic expression — paired with `))`,
828                    // not two single parens.
829                    if bytes.get(i + 1) == Some(&b'(') {
830                        // Distinguish `$((arith))` from `$( () { fn } N )`
831                        // (anonymous-fn call inside command substitution).
832                        // The latter has `()` immediately after `$(`:
833                        // `$((` + `)` + non-`)` is `$( (...` not arithmetic.
834                        // Empty arithmetic `$(())` (rare) still works because
835                        // the third char IS `)`, satisfying the original check.
836                        let prev_is_dollar = i > 0 && bytes[i - 1] == b'$';
837                        let next2 = bytes.get(i + 2).copied();
838                        let next3 = bytes.get(i + 3).copied();
839                        if prev_is_dollar && next2 == Some(b')') && next3 != Some(b')') {
840                            // Treat as two separate `(` so the inner `()`
841                            // pair balances cleanly with its `)`.
842                            stack.push(('(', line_no, i)); // outer $(
843                            stack.push(('(', line_no, i + 1)); // inner (
844                            i += 2;
845                            continue;
846                        }
847                        // INSIDE an arithmetic block (`stack` already has
848                        // 'A'), `((expr) + …)` is NOT a nested arithmetic
849                        // — it's two single `(` opening a grouped sub-
850                        // expression. Pin the inner `((` as two single
851                        // `(` so `((hash << 5) + hash)` inside `$((…))`
852                        // balances cleanly (reported on
853                        // examples/demos/195_sha_simple_hash.zsh:25).
854                        if stack.iter().any(|x| x.0 == 'A') {
855                            stack.push(('(', line_no, i));
856                            stack.push(('(', line_no, i + 1));
857                            i += 2;
858                            continue;
859                        }
860                        stack.push(('A', line_no, i));
861                        i += 2;
862                        continue;
863                    }
864                    stack.push(('(', line_no, i));
865                }
866                ')' => {
867                    // `))` closes arithmetic.
868                    if bytes.get(i + 1) == Some(&b')') && stack.last().map(|x| x.0) == Some('A') {
869                        stack.pop();
870                        i += 2;
871                        continue;
872                    }
873                    if stack.last().map(|x| x.0) == Some('(') {
874                        stack.pop();
875                    } else {
876                        // Bare `)` inside an open `case ... esac` is a
877                        // pattern-arm terminator, not a paren mismatch.
878                        // Two recognition modes:
879                        //   1. `block_stack` already has `case` (multi-line
880                        //      `case … in\n PAT) cmd ;;\n esac`).
881                        //   2. The CURRENT line has the `case` keyword
882                        //      anywhere left of this `)` (one-liner
883                        //      `case x in y) … esac`).
884                        let in_case = block_stack.iter().any(|(kw, _, _)| *kw == "case")
885                            || line_has_case_keyword;
886                        if !in_case {
887                            diags.push(diagnostic(line_no, i, 1, "unmatched `)`", 1));
888                        }
889                    }
890                }
891                '{' => {
892                    // `${...}` parameter substitution can contain glob
893                    // patterns with unbalanced `[` / `]` etc. inside
894                    // the pattern body (e.g. `${log#\[}`, `${log%%\]*}`).
895                    // Skip the whole `${...}` span without recursing
896                    // so the inner brackets don't trip the bracket
897                    // tracker. Without this, lines like
898                    //   date_part=${log_line#*\][}; date_part=${date_part%%\]*}
899                    // (`examples/demos/117_backref_replacement.zsh:56`)
900                    // raised false "unmatched `}`" / "unclosed `[`".
901                    let preceded_by_dollar = i > 0 && bytes[i - 1] == b'$';
902                    if preceded_by_dollar {
903                        let mut depth = 1i32;
904                        let mut j = i + 1;
905                        while j < bytes.len() && depth > 0 {
906                            match bytes[j] {
907                                b'\\' if j + 1 < bytes.len() => {
908                                    j += 2;
909                                    continue;
910                                }
911                                b'{' => depth += 1,
912                                b'}' => depth -= 1,
913                                _ => {}
914                            }
915                            j += 1;
916                        }
917                        // Jump past the whole `${...}` span.
918                        i = j;
919                        continue;
920                    }
921                    stack.push(('{', line_no, i))
922                }
923                '}' => {
924                    if stack.last().map(|x| x.0) == Some('{') {
925                        stack.pop();
926                    } else {
927                        diags.push(diagnostic(line_no, i, 1, "unmatched `}`", 1));
928                    }
929                }
930                '[' => {
931                    // `[[` opens a zsh conditional expression — paired
932                    // with `]]`, not two single brackets.
933                    if bytes.get(i + 1) == Some(&b'[') {
934                        stack.push(('D', line_no, i));
935                        i += 2;
936                        continue;
937                    }
938                    stack.push(('[', line_no, i));
939                }
940                ']' => {
941                    if bytes.get(i + 1) == Some(&b']') && stack.last().map(|x| x.0) == Some('D') {
942                        stack.pop();
943                        i += 2;
944                        continue;
945                    }
946                    if stack.last().map(|x| x.0) == Some('[') {
947                        stack.pop();
948                    } else {
949                        diags.push(diagnostic(line_no, i, 1, "unmatched `]`", 1));
950                    }
951                }
952                '\\' => {
953                    // Backslash escapes the next char outside of strings —
954                    // skip it so `\#`, `\$`, `\(`, `\)`, etc. don't mis-trip.
955                    i += 2;
956                    continue;
957                }
958                '"' | '\'' | '`' => {
959                    // Skip past matching quote
960                    let q = c;
961                    i += 1;
962                    let mut closed = false;
963                    while i < bytes.len() {
964                        let cc = bytes[i] as char;
965                        if cc == '\\' && q != '\'' && i + 1 < bytes.len() {
966                            i += 2;
967                            continue;
968                        }
969                        if cc == q {
970                            closed = true;
971                            break;
972                        }
973                        i += 1;
974                    }
975                    if !closed {
976                        // Quote spans multiple lines. Mark open so the
977                        // next line is parsed as quote-body. Multi-line
978                        // awk/sed/perl scripts embedded in `'...'` no
979                        // longer leak `for`/`do`/`done` keywords to the
980                        // block_stack as false positives.
981                        open_quote = Some(q);
982                        // Halt scan of this line — rest is quote body.
983                        break;
984                    }
985                }
986                '#' => {
987                    // `#` starts a line comment only when preceded by
988                    // whitespace, `;`, `&`, `|`, `(`, or BOL — otherwise
989                    // it's part of `$#` (argc), `${#var}` (length), or
990                    // similar parameter expansion and must not terminate
991                    // the scan.
992                    //
993                    // INSIDE arithmetic (`$((...))`, top of `stack` has 'A'),
994                    // `#` is NEVER a comment — it's the char-value operator
995                    // `#c` (zsh arithmetic) per `Src/math.c`. Skip the
996                    // comment check entirely when stack contains 'A'.
997                    let in_arith = stack.iter().any(|x| x.0 == 'A');
998                    let prev = if i == 0 {
999                        None
1000                    } else {
1001                        Some(bytes[i - 1] as char)
1002                    };
1003                    let is_comment_start = !in_arith
1004                        && match prev {
1005                            None => true,
1006                            Some(p) => {
1007                                // Note: `(` removed from the prev-chars list
1008                                // because `(#i)` / `(#a1)` etc. are zsh glob
1009                                // qualifiers / extended-glob flags where `#`
1010                                // immediately after `(` is NOT a comment.
1011                                // `( # cmt)` style is still recognized via
1012                                // the whitespace test before `#`.
1013                                p.is_whitespace() || p == ';' || p == '&' || p == '|'
1014                            }
1015                        };
1016                    if is_comment_start {
1017                        break;
1018                    }
1019                }
1020                _ => {}
1021            }
1022            i += 1;
1023        }
1024        // Block keyword scan — must ignore tokens inside comments and
1025        // quoted strings, otherwise lines like `# foo case bar` or
1026        // `echo "if you like"` push spurious entries onto block_stack
1027        // and surface as "unclosed `case` block" / "unclosed `if`".
1028        // Skip when the line opened inside a multi-line quote that
1029        // just closed — most of the line is string body and partial
1030        // strip_comments_and_strings analysis would mis-flag tokens
1031        // before close_pos.
1032        if post_quote_tail.is_some() {
1033            continue;
1034        }
1035        let code_only = strip_comments_and_strings(line);
1036        for (tok_idx, kw_raw) in code_only.split_whitespace().enumerate() {
1037            // Strip trailing shell-statement-separator punctuation
1038            // (`;` / `&` / `|`) so tokens like `done;` and `fi;` and
1039            // `done;` still match the block-terminator keyword.
1040            // Without this, `for ((;;)); do echo $i; done;` left
1041            // `for` orphaned on block_stack → false "unclosed `for`
1042            // block" diagnostic on every one-liner that ends the
1043            // statement with a semicolon.
1044            let kw = kw_raw.trim_end_matches(|c: char| matches!(c, ';' | '&' | '|'));
1045            // Map matched tokens back to &'static str literals so the
1046            // borrows pushed onto block_stack don't outlive `code_only`
1047            // (which drops at end of this loop iteration).
1048            let kw_static: &'static str = match kw {
1049                "if" => "if",
1050                "for" => "for",
1051                "while" => "while",
1052                "until" => "until",
1053                "case" => "case",
1054                "select" => "select",
1055                "repeat" => "repeat",
1056                "fi" => "fi",
1057                "done" => "done",
1058                "esac" => "esac",
1059                _ => continue,
1060            };
1061            // `repeat N CMD` is a one-liner with no `do`/`done`; only push
1062            // onto block_stack when same line has a `do` token (block form
1063            // `repeat N; do ... done` or `repeat N do CMD done`).
1064            if kw_static == "repeat" && !code_only.split_whitespace().any(|t| t == "do") {
1065                continue;
1066            }
1067            // `select` can appear as a bareword ARGUMENT to other
1068            // builtins (e.g. `zstyle ':completion:*' menu select` —
1069            // `examples/demos/133_zstyle_demo.zsh:6,15`), where it's
1070            // NOT opening a `select VAR in WORDS; do ... done` block.
1071            // Only treat as a block opener when the same line also
1072            // has a `do` token (block form). Same heuristic as repeat.
1073            if kw_static == "select" && !code_only.split_whitespace().any(|t| t == "do") {
1074                continue;
1075            }
1076            // `done`/`fi`/`esac` as the RIGHT operand of a comparison
1077            // (e.g. `[[ $status == done ]]`) is a literal value being
1078            // compared, NOT a block terminator. The pre-scan above
1079            // populated `bareword_value_positions` with these token
1080            // indices. Pinned by examples/demos/143_todo_app.zsh:50.
1081            if bareword_value_positions.contains(&tok_idx) {
1082                continue;
1083            }
1084            match kw_static {
1085                "if" | "for" | "while" | "until" | "case" | "select" | "repeat" => {
1086                    block_stack.push((kw_static, line_no, 0));
1087                }
1088                "fi" => {
1089                    if block_stack.last().map(|x| x.0) == Some("if") {
1090                        block_stack.pop();
1091                    } else {
1092                        diags.push(diagnostic(line_no, 0, 2, "unmatched `fi`", 1));
1093                    }
1094                }
1095                "done" => {
1096                    let last = block_stack.last().map(|x| x.0);
1097                    if matches!(
1098                        last,
1099                        Some("for")
1100                            | Some("while")
1101                            | Some("until")
1102                            | Some("select")
1103                            | Some("repeat")
1104                    ) {
1105                        block_stack.pop();
1106                    } else {
1107                        diags.push(diagnostic(line_no, 0, 4, "unmatched `done`", 1));
1108                    }
1109                }
1110                "esac" => {
1111                    if block_stack.last().map(|x| x.0) == Some("case") {
1112                        block_stack.pop();
1113                    } else {
1114                        diags.push(diagnostic(line_no, 0, 4, "unmatched `esac`", 1));
1115                    }
1116                }
1117                _ => {}
1118            }
1119        }
1120    }
1121    for (c, line, col) in stack {
1122        diags.push(diagnostic(line, col, 1, &format!("unclosed `{}`", c), 1));
1123    }
1124    for (kw, line, col) in block_stack {
1125        diags.push(diagnostic(
1126            line,
1127            col,
1128            kw.len(),
1129            &format!("unclosed `{}` block", kw),
1130            1,
1131        ));
1132    }
1133    diags
1134}
1135
1136fn diagnostic(line: usize, col: usize, len: usize, msg: &str, severity: u8) -> Value {
1137    json!({
1138        "range": {
1139            "start": { "line": line, "character": col },
1140            "end":   { "line": line, "character": col + len },
1141        },
1142        "severity": severity,
1143        "source": "zshrs",
1144        "message": msg,
1145    })
1146}
1147
1148// ── Completion ──────────────────────────────────────────────────────────
1149
1150fn completion(state: &State, params: &Value) -> Value {
1151    let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
1152    let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
1153    let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
1154    let text = state.docs.get(uri);
1155    let line = text.and_then(|t| t.lines().nth(line_no));
1156    // The command the cursor belongs to may start lines above it: shell
1157    // continuations are one logical command. Context detection runs on
1158    // the joined line so `print \` + newline + `  -<cursor>` still knows
1159    // it is completing a flag of `print`; the item bodies keep using the
1160    // PHYSICAL line + column, because the word being typed is on the
1161    // cursor's own line and that is what an edit range must address.
1162    let (logical_line, logical_col, _logical_first) = match (text, line) {
1163        (Some(t), _) => logical_line_at(t, line_no, col),
1164        (None, Some(l)) => (l.to_string(), col, line_no),
1165        (None, None) => (String::new(), col, line_no),
1166    };
1167
1168    // Context gate: inside a `"..."` or `'...'` literal segment we
1169    // should NOT fire arbitrary builtin / keyword / option completions
1170    // — they're noise (the user is typing English / a URL / a JSON
1171    // payload, not shell code). Exceptions:
1172    //   * Inside `$(…)` or `` `…` `` command substitution — that IS
1173    //     shell code, fire normally.
1174    //   * Inside `${…}` parameter expansion — variable / option name
1175    //     completion is useful there.
1176    //   * Inside `$'…'` ANSI-C strings — opaque, no completion.
1177    // …and one exception BEFORE that gate: an `_arguments` spec is a
1178    // single-quoted string whose third field is executable completion
1179    // code. `'*:file:_fi<cursor>'` should offer `_files`, which the
1180    // blanket string suppression made impossible.
1181    if let Some(l) = line {
1182        // Specs are written one per continuation line, so the spec
1183        // context is resolved against the JOINED command, not the
1184        // physical fragment the cursor happens to sit on.
1185        match arg_spec_part_at(&logical_line, logical_col) {
1186            Some(ArgSpecPart::Action { word_start }) => {
1187                // Map the word start back onto the physical line: the
1188                // word being typed is on the cursor's own line, so the
1189                // offset differs by however much of the logical line
1190                // preceded it.
1191                let phys_start = word_start.saturating_sub(logical_col - col.min(logical_col));
1192                return arg_spec_action_items(phys_start, col, line_no, l);
1193            }
1194            // Head is the option/positional spec (the command's own
1195            // option names — not something this server can know) and
1196            // Message is prose. Both stay empty rather than falling
1197            // through to builtin/keyword noise inside a spec.
1198            Some(_) => return json!({ "isIncomplete": false, "items": [] }),
1199            None => {}
1200        }
1201        if cursor_in_uninterpolated_string(l, col) {
1202            return json!({ "isIncomplete": false, "items": [] });
1203        }
1204    }
1205
1206    // Context-specific completion tables. `${(…)` → parameter
1207    // expansion flags; `*(…)` / `?(…)` / `](…)` → glob qualifiers.
1208    // These OVERRIDE the normal builtin/keyword/option flow because
1209    // in those positions nothing else is syntactically valid.
1210    //
1211    // `ctx_item` builds an LSP CompletionItem with explicit
1212    // `filterText` + `insertText` so IntelliJ's prefix matcher
1213    // doesn't reject single-char non-alphanumeric labels (`/`, `.`,
1214    // `@`, `*`, etc.). Without this, the IDE would silently drop
1215    // them from the popup even though the LSP returned the items
1216    // correctly.
1217    fn ctx_item(label: &str, detail: &str, doc_md: &str) -> Value {
1218        json!({
1219            "label": label,
1220            "kind": 14, // Constant
1221            "detail": detail,
1222            "filterText": label,
1223            "insertText": label,
1224            "sortText": format!("0_{}", label),
1225            "documentation": {
1226                "kind": "markdown",
1227                "value": doc_md,
1228            },
1229        })
1230    }
1231    // Variant for contexts where items CHAIN — `${(LU)var}` /
1232    // `*(/D^.)` / `(#iI)` / `${arr[(Ri)…]}` / `${var:h:t:r}`. The
1233    // `command` field re-invokes the suggest popup after insertion
1234    // so the user can keep adding qualifiers without re-typing or
1235    // pressing Ctrl-Space. Mirrors how VS Code / IntelliJ Platform
1236    // LSP honor `editor.action.triggerSuggest`.
1237    fn ctx_item_chain(label: &str, detail: &str, doc_md: &str) -> Value {
1238        json!({
1239            "label": label,
1240            "kind": 14,
1241            "detail": detail,
1242            "filterText": label,
1243            "insertText": label,
1244            "sortText": format!("0_{}", label),
1245            "documentation": {
1246                "kind": "markdown",
1247                "value": doc_md,
1248            },
1249            "command": {
1250                "title": "Re-trigger completion",
1251                "command": "editor.action.triggerSuggest",
1252            },
1253        })
1254    }
1255    if let Some(l) = line {
1256        match lsp_completion_context(&logical_line, logical_col) {
1257            LspCompletionContext::ParamFlag => {
1258                // `${(LU)var}` / `${(jks)arr}` chain — use chain variant
1259                // so the popup re-opens after each flag insertion.
1260                let items: Vec<Value> = PARAM_FLAG_DOCS
1261                    .iter()
1262                    .map(|(flag, doc)| ctx_item_chain(flag, *doc,
1263                        &format!("**`(`{}`)`** — {}\n\n_zsh parameter expansion flag — `${{(FLAGS)var}}`_", flag, doc)))
1264                    .collect();
1265                return json!({ "isIncomplete": false, "items": items });
1266            }
1267            LspCompletionContext::GlobQualifier => {
1268                // `*(/D^.)` chain — directories that aren't dotfiles.
1269                let items: Vec<Value> = GLOB_QUALIFIER_DOCS
1270                    .iter()
1271                    .map(|(q, doc)| {
1272                        ctx_item_chain(
1273                            q,
1274                            *doc,
1275                            &format!(
1276                                "**`(`{}`)`** — {}\n\n_zsh glob qualifier — `*(QUALIFIERS)`_",
1277                                q, doc
1278                            ),
1279                        )
1280                    })
1281                    .collect();
1282                return json!({ "isIncomplete": false, "items": items });
1283            }
1284            LspCompletionContext::HistoryDesignator => {
1285                let items: Vec<Value> = HISTORY_DESIGNATOR_DOCS
1286                    .iter()
1287                    .map(|(d, doc)| {
1288                        ctx_item(
1289                            d,
1290                            *doc,
1291                            &format!("**`!{}`** — {}\n\n_zsh history event designator_", d, doc),
1292                        )
1293                    })
1294                    .collect();
1295                return json!({ "isIncomplete": false, "items": items });
1296            }
1297            LspCompletionContext::ParamColonModifier => {
1298                // `${var:h:t:r}` / `!!:s/old/new/:gs/a/b/` chain.
1299                // Each modifier letter inserted; user adds another `:`
1300                // and re-types — but `:` is already a triggerChar so
1301                // re-open is automatic without the command field.
1302                // Still emit the command so insertion-without-typing-
1303                // colon also re-opens (e.g. for `${var:hto…}` style
1304                // multi-letter input).
1305                let items: Vec<Value> = PARAM_MODIFIER_DOCS
1306                    .iter()
1307                    .map(|(m, doc)| {
1308                        ctx_item_chain(
1309                            m,
1310                            *doc,
1311                            &format!(
1312                                "**`:{}`** — {}\n\n_zsh modifier — `${{var:MOD}}` / `!event:MOD`_",
1313                                m, doc
1314                            ),
1315                        )
1316                    })
1317                    .collect();
1318                return json!({ "isIncomplete": false, "items": items });
1319            }
1320            // ── Command-position contexts ─────────────────────────────
1321            LspCompletionContext::OptionOnly => {
1322                let mut items = Vec::new();
1323                // Canonical UPPERCASE_WITH_UNDERSCORES form first —
1324                // `EXTENDED_GLOB`, the spelling `man zshoptions` and
1325                // `setopt` output use, and the one the user reads in
1326                // the docs. `ZSH_OPTIONS_SET` stores the normalized
1327                // lookup form (lowercase, underscores stripped), which
1328                // is right for option RESOLUTION but is not a name any
1329                // reader recognises. `dump_reflection_json` already
1330                // sources OPTION_DOCS for exactly this reason (see the
1331                // comment there); completion was still emitting only
1332                // the normalized form, so `setopt EXTENDED<TAB>` never
1333                // offered the canonical name.
1334                for (name, _doc) in crate::zsh_option_docs::OPTION_DOCS {
1335                    items.push(json!({
1336                        "label": name,
1337                        "kind": 21, // Constant
1338                        "detail": "zsh option (setopt / unsetopt)",
1339                    }));
1340                }
1341                for (alias, _canon) in crate::zsh_option_docs::OPTION_ALIASES {
1342                    items.push(json!({
1343                        "label": alias,
1344                        "kind": 21, // Constant
1345                        "detail": "zsh option (setopt / unsetopt)",
1346                    }));
1347                }
1348                for o in crate::ported::options::ZSH_OPTIONS_SET.iter() {
1349                    items.push(json!({
1350                        "label": o,
1351                        "kind": 21, // Constant
1352                        "detail": "zsh option (setopt / unsetopt)",
1353                    }));
1354                }
1355                return json!({ "isIncomplete": false, "items": items });
1356            }
1357            LspCompletionContext::SignalName => {
1358                let items: Vec<Value> = SIGNAL_NAMES
1359                    .iter()
1360                    .map(|(n, doc)| {
1361                        ctx_item(
1362                            n,
1363                            *doc,
1364                            &format!(
1365                                "**SIG{}** — {}\n\n_signal name — `kill -{}` / `trap … {}`_",
1366                                n, doc, n, n
1367                            ),
1368                        )
1369                    })
1370                    .collect();
1371                return json!({ "isIncomplete": false, "items": items });
1372            }
1373            LspCompletionContext::ModuleName => {
1374                let items: Vec<Value> = ZSH_MODULE_NAMES
1375                    .iter()
1376                    .map(|(n, doc)| {
1377                        ctx_item(
1378                            n,
1379                            *doc,
1380                            &format!("**`{}`** — {}\n\n_zsh module — `zmodload {}`_", n, doc, n),
1381                        )
1382                    })
1383                    .collect();
1384                return json!({ "isIncomplete": false, "items": items });
1385            }
1386            LspCompletionContext::KeymapName => {
1387                let items: Vec<Value> = KEYMAP_NAMES
1388                    .iter()
1389                    .map(|(n, doc)| ctx_item(n, *doc, &format!("**`{}`** — {}", n, doc)))
1390                    .collect();
1391                return json!({ "isIncomplete": false, "items": items });
1392            }
1393            LspCompletionContext::WidgetName => {
1394                let items: Vec<Value> = ZLE_WIDGET_NAMES
1395                    .iter()
1396                    .map(|(n, doc)| {
1397                        ctx_item(n, *doc, &format!("**`{}`** — {}\n\n_ZLE widget_", n, doc))
1398                    })
1399                    .collect();
1400                return json!({ "isIncomplete": false, "items": items });
1401            }
1402            LspCompletionContext::TypesetFlag => {
1403                let items: Vec<Value> = TYPESET_FLAGS
1404                    .iter()
1405                    .map(|(f, doc)| {
1406                        ctx_item(
1407                            f,
1408                            *doc,
1409                            &format!(
1410                                "**`{}`** — {}\n\n_typeset / declare / local / readonly flag_",
1411                                f, doc
1412                            ),
1413                        )
1414                    })
1415                    .collect();
1416                return json!({ "isIncomplete": false, "items": items });
1417            }
1418            LspCompletionContext::ZstyleContext => {
1419                let items: Vec<Value> = ZSTYLE_CONTEXTS
1420                    .iter()
1421                    .map(|(c, doc)| {
1422                        ctx_item(
1423                            c,
1424                            *doc,
1425                            &format!("**`{}`** — {}\n\n_zstyle context pattern_", c, doc),
1426                        )
1427                    })
1428                    .collect();
1429                return json!({ "isIncomplete": false, "items": items });
1430            }
1431            LspCompletionContext::CompdefFn => {
1432                let mut items = Vec::new();
1433                for n in crate::compsys::COMPSYS_FN_NAMES {
1434                    items.push(ctx_item(
1435                        n,
1436                        "compsys completion function",
1437                        &format!("**`{}`** — compsys completion function", n),
1438                    ));
1439                }
1440                return json!({ "isIncomplete": false, "items": items });
1441            }
1442            // ── Bracket contexts ───────────────────────────────────────
1443            LspCompletionContext::TestOperator => {
1444                let items: Vec<Value> = TEST_OPERATORS
1445                    .iter()
1446                    .map(|(op, doc)| {
1447                        ctx_item(
1448                            op,
1449                            *doc,
1450                            &format!("**`{}`** — {}\n\n_inside `[[ … ]]` conditional_", op, doc),
1451                        )
1452                    })
1453                    .collect();
1454                return json!({ "isIncomplete": false, "items": items });
1455            }
1456            LspCompletionContext::MathFunction => {
1457                let items: Vec<Value> = MATH_FUNCTIONS
1458                    .iter()
1459                    .map(|(n, doc)| ctx_item(n, *doc,
1460                        &format!("**`{}(…)`** — {}\n\n_math function — inside `((…))` / `$((…))` (most require `zmodload zsh/mathfunc`)_", n, doc)))
1461                    .collect();
1462                return json!({ "isIncomplete": false, "items": items });
1463            }
1464            LspCompletionContext::PatternModifier => {
1465                // `(#iI)` / `(#ba3)` chain — case-insensitive + ID-reset,
1466                // backref + approx-3-errors.
1467                let items: Vec<Value> = PATTERN_MODIFIERS
1468                    .iter()
1469                    .map(|(m, doc)| ctx_item_chain(m, *doc,
1470                        &format!("**`(#{})`** — {}\n\n_extended-glob pattern modifier (needs `EXTENDED_GLOB`)_", m, doc)))
1471                    .collect();
1472                return json!({ "isIncomplete": false, "items": items });
1473            }
1474            LspCompletionContext::SubscriptFlag => {
1475                // `${arr[(Ri)pat]}` chain — reverse + case-insensitive.
1476                let items: Vec<Value> = SUBSCRIPT_FLAGS
1477                    .iter()
1478                    .map(|(f, doc)| ctx_item_chain(f, *doc,
1479                        &format!("**`({})`** — {}\n\n_array subscript flag — `${{arr[({})pattern]}}`_", f, doc, f)))
1480                    .collect();
1481                return json!({ "isIncomplete": false, "items": items });
1482            }
1483            LspCompletionContext::BuiltinFlag(ref builtin_name) => {
1484                // Parse the builtin's yodl doc body for flag bullets
1485                // / inline citations. Cached per-builtin.
1486                let flags = extract_builtin_flags(builtin_name);
1487                let bname = builtin_name.clone();
1488                // STACKED-FLAG SUPPORT: when the current word is
1489                // `-XYZ` (multi-char stack), each item label becomes
1490                // `-XYZa`, `-XYZb`, … so the IDE matcher accepts them
1491                // against the typed prefix. Single-dash prefix `-`
1492                // yields plain `-a`, `-b`, etc. The dispatcher
1493                // re-derives the current-word prefix from the typed
1494                // line (the BuiltinFlag context lost it through the
1495                // detector boundary).
1496                let cur_word: String = if let Some(l) = line {
1497                    let bytes = l.as_bytes();
1498                    let cap = col.min(bytes.len());
1499                    let mut j = cap;
1500                    while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
1501                        j -= 1;
1502                    }
1503                    String::from_utf8_lossy(&bytes[j..cap]).to_string()
1504                } else {
1505                    "-".to_string()
1506                };
1507                // If the user has typed `-XYZ` (≥2 chars including
1508                // the dash), strip the trailing single letter that
1509                // the next flag would replace: actually no — we
1510                // want to APPEND, so keep the whole `-XYZ` and
1511                // emit items `-XYZa`/`-XYZb`/…
1512                let stack_prefix: String = if cur_word.starts_with('-') && cur_word.len() >= 2 {
1513                    cur_word.clone()
1514                } else {
1515                    "-".to_string()
1516                };
1517                // Explicit `textEdit` range covering the typed `-`
1518                // (or stacked `-XYZ`) through the cursor. Without
1519                // this, IntelliJ's LSP client treats `-` as a
1520                // non-identifier character: the replacement range is
1521                // empty (just at the cursor), so `insertText="-l"`
1522                // gets inserted AFTER the typed `-`, producing
1523                // `print --l`. `textEdit` pins the range
1524                // deterministically so the typed dash is replaced
1525                // along with the flag — `print -<tab>` → `print -l`.
1526                // Same shape as the stryke LSP sigil_completions fix.
1527                let cur_word_chars = cur_word.chars().count() as u64;
1528                let dash_col = (col as u64).saturating_sub(cur_word_chars);
1529                let edit_range = json!({
1530                    "start": { "line": line_no, "character": dash_col },
1531                    "end":   { "line": line_no, "character": col      },
1532                });
1533                let items: Vec<Value> = flags
1534                    .into_iter()
1535                    .map(|(flag, desc)| {
1536                        // `flag` is `-X` (always 2 chars). For
1537                        // stacked context, build the full
1538                        // `{stack_prefix}{X}` form. For initial
1539                        // single-dash, that's just `-X` (unchanged).
1540                        let letter = flag.trim_start_matches('-');
1541                        let label = if stack_prefix == "-" {
1542                            flag.clone()
1543                        } else {
1544                            format!("{}{}", stack_prefix, letter)
1545                        };
1546                        let detail = if desc.is_empty() {
1547                            format!("option flag for `{}`", bname)
1548                        } else {
1549                            desc.clone()
1550                        };
1551                        let doc_md = if desc.is_empty() {
1552                            format!("**`{}`** — option flag for `{}`", flag, bname)
1553                        } else {
1554                            format!("**`{}`** — {}\n\n_option flag for `{}`_", flag, desc, bname)
1555                        };
1556                        let mut item = ctx_item(&label, &detail, &doc_md);
1557                        if let Some(obj) = item.as_object_mut() {
1558                            obj.remove("insertText");
1559                            obj.insert(
1560                                "textEdit".to_string(),
1561                                json!({ "range": edit_range, "newText": label }),
1562                            );
1563                        }
1564                        item
1565                    })
1566                    .collect();
1567                return json!({ "isIncomplete": false, "items": items });
1568            }
1569            LspCompletionContext::BuiltinLongFlag(ref builtin_name) => {
1570                // Long-form flag completion: `zshrs --<TAB>`,
1571                // `zshrs --dump-<TAB>`, etc. No letter-stacking;
1572                // the typed `--xxx` is the full prefix and gets
1573                // replaced atomically by the chosen flag.
1574                let flags = extract_builtin_long_flags(builtin_name);
1575                let bname = builtin_name.clone();
1576                let cur_word: String = if let Some(l) = line {
1577                    let bytes = l.as_bytes();
1578                    let cap = col.min(bytes.len());
1579                    let mut j = cap;
1580                    while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
1581                        j -= 1;
1582                    }
1583                    String::from_utf8_lossy(&bytes[j..cap]).to_string()
1584                } else {
1585                    "--".to_string()
1586                };
1587                let cur_word_chars = cur_word.chars().count() as u64;
1588                let dash_col = (col as u64).saturating_sub(cur_word_chars);
1589                let edit_range = json!({
1590                    "start": { "line": line_no, "character": dash_col },
1591                    "end":   { "line": line_no, "character": col      },
1592                });
1593                // Sort prefix: zshrs-specific entries (the first
1594                // ZSHRS_SELF_LONG_FLAG_DOCS.len() items) get "0_",
1595                // setopt mirrors get "1_". IntelliJ honors sortText
1596                // before alphabetic ordering — keeps the editor /
1597                // dumper / parity flags at the top of the lookup.
1598                let zshrs_specific_count = ZSHRS_SELF_LONG_FLAG_DOCS.len();
1599                let items: Vec<Value> = flags
1600                    .into_iter()
1601                    .enumerate()
1602                    .map(|(idx, (flag, desc))| {
1603                        let bucket = if idx < zshrs_specific_count { "0" } else { "1" };
1604                        let detail = if desc.is_empty() {
1605                            format!("long-form flag for `{}`", bname)
1606                        } else {
1607                            desc.clone()
1608                        };
1609                        let doc_md = if desc.is_empty() {
1610                            format!("**`{}`** — long-form flag for `{}`", flag, bname)
1611                        } else {
1612                            format!(
1613                                "**`{}`** — {}\n\n_long-form flag for `{}`_",
1614                                flag, desc, bname
1615                            )
1616                        };
1617                        json!({
1618                            "label": flag,
1619                            "kind": 14, // Constant — same as ctx_item
1620                            "detail": detail,
1621                            "filterText": flag,
1622                            "sortText": format!("{}_{}", bucket, flag),
1623                            "textEdit": {
1624                                "range": edit_range,
1625                                "newText": flag,
1626                            },
1627                            "documentation": {
1628                                "kind": "markdown",
1629                                "value": doc_md,
1630                            },
1631                        })
1632                    })
1633                    .collect();
1634                return json!({ "isIncomplete": false, "items": items });
1635            }
1636            LspCompletionContext::Normal => {}
1637        }
1638    }
1639
1640    let prefix = line
1641        .map(|line| {
1642            let upto = &line[..line.len().min(col)];
1643            let start = upto
1644                .rfind(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$' || c == '-'))
1645                .map(|i| i + 1)
1646                .unwrap_or(0);
1647            upto[start..].to_string()
1648        })
1649        .unwrap_or_default();
1650
1651    let mut items = Vec::new();
1652    let push = |items: &mut Vec<Value>, label: &str, kind: u8, detail: &str| {
1653        // Explicit `filterText` + `insertText` so IntelliJ's LSP
1654        // matcher uses the FULL label (sigil and all) when ranking
1655        // against the typed prefix. Without this, IDE-side
1656        // CamelHumpMatcher may strip leading non-alpha chars (`$`)
1657        // from labels and stop matching half the canonical set when
1658        // the user types `$HIS`. Symptom: only the hand 2 items
1659        // surfaced; canonical `$HISTCMD`/`$HISTNO`/`$HISTCHARS`
1660        // were sent but invisibly filtered out before display.
1661        items.push(json!({
1662            "label": label,
1663            "kind": kind,
1664            "detail": detail,
1665            "filterText": label,
1666            "insertText": label,
1667        }));
1668    };
1669
1670    // Filter by prefix (case-insensitive starts-with)
1671    let pre = prefix.to_lowercase();
1672    let want = |s: &str| pre.is_empty() || s.to_lowercase().starts_with(&pre);
1673
1674    // 14 = Keyword, 3 = Function, 6 = Variable, 10 = Property, 21 = Constant
1675    for k in KEYWORDS {
1676        if want(k) {
1677            push(&mut items, k, 14, "keyword");
1678        }
1679    }
1680    // Canonical reserved words from `Src/hashtable.c::reswds[]`
1681    // (port: `crate::ported::hashtable::RESWDS`, 31 entries). The
1682    // hand `KEYWORDS` list above is missing `[[`, `]]`, `{`, `}`,
1683    // `!`, `end` — without this loop, `[[<TAB>` and `}<TAB>` never
1684    // suggest the keyword. IDE-side dedup collapses any name that
1685    // appears in both lists.
1686    for (name, _token) in crate::ported::hashtable::RESWDS {
1687        if want(name) {
1688            push(&mut items, name, 14, "keyword");
1689        }
1690    }
1691    // Compat builtins — ported `Src/Builtins/*.c` set. Note this is
1692    // the hand `BUILTINS` const used for fast inline classification.
1693    for b in BUILTINS {
1694        if want(b) {
1695            push(&mut items, b, 3, "builtin");
1696        }
1697    }
1698    // Canonical compat builtins — `ported::builtin::BUILTINS` has 154
1699    // entries vs the hand `BUILTINS` subset of ~67. Without this, names
1700    // like `vared`, `zformat`, `sched`, `strftime`, etc. don't surface
1701    // in completion even though hover docs exist for them. Dedupe via
1702    // the `want()` filter — duplicate `cd` from BUILTINS + canonical
1703    // BUILTINS won't both fire because the second push is filtered out
1704    // by the IDE's own dedup on `label`.
1705    for b in crate::ported::builtin::BUILTINS.iter() {
1706        if want(&b.node.nam) {
1707            push(&mut items, &b.node.nam, 3, "builtin");
1708        }
1709    }
1710    // zshrs extension builtins — `date`, `cat`, `sleep`, `async`,
1711    // `await`, `barrier`, `peach`, `doctor`, `intercept`, etc. The
1712    // bug the user filed: `zwh<TAB>` didn't offer `zwhere` because
1713    // the daemon `z*` builtins live in ZSHRS_BUILTIN_NAMES and were
1714    // never added to the completion list. Same issue for ext ported
1715    // generally (91 in-process incl. ztest framework + 23 daemon = 114 names total).
1716    for n in crate::ext_builtins::EXT_BUILTIN_NAMES {
1717        if want(n) {
1718            push(&mut items, n, 3, "extension builtin");
1719        }
1720    }
1721    for n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES {
1722        if want(n) {
1723            push(&mut items, n, 3, "extension builtin (daemon)");
1724        }
1725    }
1726    // Compsys functions — `_arguments`, `_files`, `_describe`, the
1727    // per-command completers (`_git` / `_docker` / `_cargo` / etc.).
1728    // Useful when authoring completion-spec files.
1729    for n in crate::compsys::COMPSYS_FN_NAMES {
1730        if want(n) {
1731            push(&mut items, n, 3, "compsys function");
1732        }
1733    }
1734    for o in OPTIONS {
1735        if want(o) {
1736            push(&mut items, o, 21, "option");
1737        }
1738    }
1739    // Canonical options registry — full ~194 entries vs the small
1740    // hand subset above. `setopt <TAB>` should surface every option
1741    // the runtime knows, not just the 49 we hand-listed.
1742    for o in crate::ported::options::ZSH_OPTIONS_SET.iter() {
1743        if want(o) {
1744            push(&mut items, o, 21, "option");
1745        }
1746    }
1747    // …and the canonical UPPERCASE_WITH_UNDERSCORES spelling from
1748    // `man zshoptions` (`EXTENDED_GLOB`). `ZSH_OPTIONS_SET` holds the
1749    // normalized lookup form only (`extendedglob`), and the hand
1750    // `OPTIONS` list above holds a caps-no-underscore form
1751    // (`EXTENDEDGLOB`); neither is the name the docs print, so the
1752    // documented spelling was completable from nowhere. Same source
1753    // and same reasoning as `dump_reflection_json`'s options key.
1754    for (name, _doc) in crate::zsh_option_docs::OPTION_DOCS {
1755        if want(name) {
1756            push(&mut items, name, 21, "option");
1757        }
1758    }
1759    for (alias, _canon) in crate::zsh_option_docs::OPTION_ALIASES {
1760        if want(alias) {
1761            push(&mut items, alias, 21, "option");
1762        }
1763    }
1764    // Canonical special-param names from zsh's `params.yo` + every
1765    // module's special-param table — 538 entries. The hand subset
1766    // above misses PS2/PS3/PS4/psvar/PROMPT2/PROMPT3/PROMPT4 and
1767    // hundreds more. Surface every canonical name so `setopt`,
1768    // `typeset`, `unset`, etc. can complete against the full set.
1769    //
1770    // Two prefix-match paths because the user may type the name two
1771    // ways:
1772    //   1. Bare `HIST<TAB>` — want("HISTCMD") starts-with "HIST" ✓
1773    //   2. Dollar `$HIST<TAB>` — bare `name` does NOT start with `$HIST`,
1774    //      so fall back to comparing the prefix's bare form. When the
1775    //      $-prefix path matches, emit the candidate WITH `$` prepended
1776    //      so the IDE inserts the dollar the user typed.
1777    // The $-prefix path fires WHENEVER the user-typed prefix starts
1778    // with `$` (or IS exactly `$`). When the prefix is just `$`,
1779    // `bare_prefix` is empty — we still need to surface every
1780    // canonical name as `$NAME` so the IDE's local-filter step on
1781    // subsequent keystrokes (`G`/`H`/etc) has the full set to
1782    // narrow from. Without this, typing `$` opened a popup of just
1783    // the hand 41-entry list, then typing `G` filtered locally to
1784    // zero items and the popup closed — even though the server has
1785    // `$GID`/`$galiases`/`$gid` available.
1786    let prefix_has_dollar = prefix.starts_with('$');
1787    let bare_prefix: String = prefix
1788        .strip_prefix('$')
1789        .map(|s| s.to_string())
1790        .unwrap_or_default();
1791    let bare_prefix_lc = bare_prefix.to_lowercase();
1792    for (name, _doc) in crate::zsh_special_var_docs::SPECIAL_VAR_DOCS {
1793        // Skip the pure-symbolic ones (`$`, `?`, `*`, etc) — they're
1794        // already in SPECIAL_VARS with the `$` prefix. The remaining
1795        // alphabetic names are the meaningful completions.
1796        if !name
1797            .chars()
1798            .next()
1799            .map(|c| c.is_ascii_alphabetic() || c == '_')
1800            .unwrap_or(false)
1801        {
1802            continue;
1803        }
1804        if want(name) {
1805            push(&mut items, name, 6, "special variable");
1806        } else if prefix_has_dollar
1807            && (bare_prefix_lc.is_empty() || name.to_lowercase().starts_with(&bare_prefix_lc))
1808        {
1809            let with_sigil = format!("${}", name);
1810            push(&mut items, &with_sigil, 6, "special variable");
1811        }
1812    }
1813    // Aliases (PROMPT/PROMPT2/PROMPT3 → PS1/PS2/PS3, NULLCMD etc.) —
1814    // surface so completion offers every surface name. Same dual-prefix
1815    // match as above so `$PROMPT<TAB>` hits aliased forms.
1816    for (alias, _canon) in crate::zsh_special_var_docs::SPECIAL_VAR_ALIASES {
1817        if !alias
1818            .chars()
1819            .next()
1820            .map(|c| c.is_ascii_alphabetic() || c == '_')
1821            .unwrap_or(false)
1822        {
1823            continue;
1824        }
1825        if want(alias) {
1826            push(&mut items, alias, 6, "special variable");
1827        } else if prefix_has_dollar
1828            && (bare_prefix_lc.is_empty() || alias.to_lowercase().starts_with(&bare_prefix_lc))
1829        {
1830            let with_sigil = format!("${}", alias);
1831            push(&mut items, &with_sigil, 6, "special variable");
1832        }
1833    }
1834    // Snippet templates — mirrors strykelang's `SNIPPETS` table. Each
1835    // entry expands to a multi-line template with `${1:...}` placeholders
1836    // the user tabs through. CompletionItemKind=15 (Snippet),
1837    // InsertTextFormat=2 (Snippet — placeholders are honored).
1838    for (prefix, body, detail) in SNIPPETS {
1839        if !want(prefix) {
1840            continue;
1841        }
1842        items.push(json!({
1843            "label": format!("{} …", prefix),
1844            "kind": 15u8,
1845            "detail": detail,
1846            "filterText": prefix,
1847            "insertText": body,
1848            "insertTextFormat": 2u8,
1849        }));
1850    }
1851
1852    // Functions and variables from the current document
1853    if let Some(t) = text {
1854        for (name, kind, detail) in scan_symbols(t) {
1855            if want(&name) {
1856                let lsp_kind: u8 = match kind {
1857                    "function" => 3,
1858                    "variable" => 6,
1859                    _ => 1,
1860                };
1861                push(&mut items, &name, lsp_kind, detail);
1862            }
1863        }
1864    }
1865
1866    // Compsys dispatch layered over the hand-table / scan-symbol path:
1867    // `git a<TAB>` / `kubectl get p<TAB>` / `_options ext<TAB>` surface
1868    // matches from the user's fpath. Design:
1869    // `docs/IN_EDITOR_COMPSYS_COMPLETION.md`.
1870    if let Some(extra) = try_compsys_completion(state, params) {
1871        // Append items + propagate isIncomplete (true when a
1872        // dispatch hit the deadline). Existing items keep their
1873        // sortText so hand-table entries stay above compsys
1874        // fallback matches.
1875        if let Some(arr) = extra["items"].as_array() {
1876            for item in arr {
1877                items.push(item.clone());
1878            }
1879        }
1880        let is_incomplete = extra["isIncomplete"].as_bool().unwrap_or(false);
1881        return json!({ "isIncomplete": is_incomplete, "items": items });
1882    }
1883
1884    json!({ "isIncomplete": false, "items": items })
1885}
1886
1887/// Join backslash-continued lines into the single logical line zsh sees,
1888/// and map the cursor into it.
1889///
1890/// Returns `(logical_line, cursor_offset, first_line_no)`. A shell
1891/// command written the way every real completer writes it —
1892///
1893/// ```text
1894/// _arguments -s \
1895///   '(-v --verbose)'{-v,--verbose}'[be loud]' \
1896///   '*:file:_files'
1897/// ```
1898///
1899/// — is ONE command, but `text.lines().nth(n)` hands back a fragment
1900/// with no command word in it. Everything downstream that asks "what
1901/// command am I completing an argument of" then gets the wrong answer:
1902/// the spec context finds no `_arguments`, and the compsys engine
1903/// receives `'*:file:_files'` as a whole command line.
1904///
1905/// zsh removes the backslash AND the newline (`Src/lex.c` — the
1906/// continuation is consumed by the lexer), leaving the next line's
1907/// leading whitespace intact, so joining is a plain concatenation with
1908/// both characters dropped.
1909///
1910/// A trailing backslash inside a comment is not a continuation: the
1911/// comment already runs to end of line. Lines whose first non-blank
1912/// character is `#` are therefore never joined.
1913pub(crate) fn logical_line_at(text: &str, line_no: usize, col: usize) -> (String, usize, usize) {
1914    let lines: Vec<&str> = text.lines().collect();
1915    if lines.is_empty() {
1916        return (String::new(), 0, line_no);
1917    }
1918    let line_no = line_no.min(lines.len() - 1);
1919    let is_comment = |l: &str| l.trim_start().starts_with('#');
1920    // A line continues when it ends in a backslash that is not itself
1921    // escaped — `foo \\` is a literal backslash, not a continuation.
1922    let continues = |l: &str| {
1923        if is_comment(l) {
1924            return false;
1925        }
1926        let trailing = l.len() - l.trim_end_matches('\\').len();
1927        trailing % 2 == 1
1928    };
1929    let mut first = line_no;
1930    while first > 0 && continues(lines[first - 1]) {
1931        first -= 1;
1932    }
1933    let mut last = line_no;
1934    while last + 1 < lines.len() && continues(lines[last]) {
1935        last += 1;
1936    }
1937    if first == last {
1938        return (
1939            lines[line_no].to_string(),
1940            col.min(lines[line_no].len()),
1941            line_no,
1942        );
1943    }
1944    let mut joined = String::new();
1945    let mut cursor = col;
1946    for (i, l) in lines[first..=last].iter().enumerate() {
1947        let idx = first + i;
1948        let piece = if idx < last { &l[..l.len() - 1] } else { l };
1949        if idx == line_no {
1950            cursor = joined.len() + col.min(l.len());
1951        }
1952        joined.push_str(piece);
1953    }
1954    (joined, cursor, first)
1955}
1956
1957/// Which part of an `_arguments`-family spec string the cursor sits in.
1958///
1959/// A spec is colon-separated: `'-o[desc]:message:action'`,
1960/// `'*:message:action'`, `'1:message:action'`. The action is what the
1961/// editor can actually help with — it names a completer or a literal
1962/// value list — so that is the part worth completing, and it is exactly
1963/// the part the generic string gate used to suppress.
1964#[derive(Debug, PartialEq)]
1965pub(crate) enum ArgSpecPart {
1966    /// Before the first top-level `:` — the option / positional spec
1967    /// itself (`-o[desc]`, `*`, `1`). Nothing generic to offer: the
1968    /// option names belong to the command being completed.
1969    Head,
1970    /// Between the first and second `:` — free-text description shown
1971    /// by `_message`. Prose, not code.
1972    Message,
1973    /// After the second `:`, on the action's FIRST word — the place a
1974    /// completer name (`_files`) or an inline action form goes. Carries
1975    /// the start of the word typed so far so the reply can pin an
1976    /// explicit replacement range.
1977    Action { word_start: usize },
1978    /// Still in the action, but somewhere this server has nothing to
1979    /// say: inside the user's own `(list)` / `((val\:desc))` / `{eval}`
1980    /// body, or on an argument of the action's command
1981    /// (`_files -g <here>`). Distinct from `None` so a double-quoted
1982    /// spec stays quiet too — `None` would fall through to the generic
1983    /// builtin/keyword tables.
1984    ActionOpaque,
1985}
1986
1987/// Commands whose quoted arguments are `:`-separated specs.
1988const ARG_SPEC_COMMANDS: &[&str] = &["_arguments", "_values", "_regex_arguments", "_alternative"];
1989
1990/// Detect a cursor inside an `_arguments`-family spec string.
1991///
1992/// Returns `None` unless (a) the statement's leading command is one of
1993/// [`ARG_SPEC_COMMANDS`] and (b) the cursor is inside a quoted word on
1994/// that line. The scan is line-local and quote-aware, deliberately not a
1995/// full parse: specs are written as single-quoted literals in practice
1996/// (`'-v[verbose]'`), and a spec split across lines with `\` is the
1997/// multi-line-continuation gap tracked in
1998/// `docs/IN_EDITOR_COMPSYS_COMPLETION.md`.
1999pub(crate) fn arg_spec_part_at(line: &str, col: usize) -> Option<ArgSpecPart> {
2000    let bytes = line.as_bytes();
2001    let cap = col.min(bytes.len());
2002    // One quote-aware pass from the start of the line does two jobs:
2003    // find the leading command of the statement the cursor is in, and
2004    // find the quote that opens the word the cursor is in.
2005    //
2006    // `leading_command_at` cannot be reused here: it treats `(` as a
2007    // statement separator, which is correct for shell code and wrong
2008    // inside a spec — `'1:cmd:(build test)'` made it report `build` as
2009    // the command, so the whole spec context evaporated exactly where a
2010    // literal value list is being written.
2011    let mut quote: Option<u8> = None;
2012    let mut spec_start: Option<usize> = None;
2013    let mut cmd_start: Option<usize> = None;
2014    let mut cmd_end: Option<usize> = None;
2015    let mut i = 0usize;
2016    while i < cap {
2017        let c = bytes[i];
2018        match quote {
2019            None => match c {
2020                b'\'' | b'"' => {
2021                    quote = Some(c);
2022                    spec_start = Some(i + 1);
2023                    if cmd_start.is_some() && cmd_end.is_none() {
2024                        cmd_end = Some(i);
2025                    }
2026                }
2027                // Statement separators reset the command word.
2028                b';' | b'|' | b'&' | b'\n' => {
2029                    cmd_start = None;
2030                    cmd_end = None;
2031                }
2032                b' ' | b'\t' => {
2033                    if cmd_start.is_some() && cmd_end.is_none() {
2034                        cmd_end = Some(i);
2035                    }
2036                }
2037                _ => {
2038                    if cmd_start.is_none() {
2039                        cmd_start = Some(i);
2040                        cmd_end = None;
2041                    }
2042                }
2043            },
2044            Some(q) => {
2045                // Single quotes take no escapes in zsh; double quotes do.
2046                if q == b'"' && c == b'\\' {
2047                    i += 2;
2048                    continue;
2049                }
2050                if c == q {
2051                    quote = None;
2052                    spec_start = None;
2053                }
2054            }
2055        }
2056        i += 1;
2057    }
2058    let cmd = &line[cmd_start?..cmd_end.unwrap_or(cap)];
2059    if !ARG_SPEC_COMMANDS.contains(&cmd) {
2060        return None;
2061    }
2062    let start = spec_start?;
2063    quote?; // still open at the cursor → we are inside the spec
2064    let spec = &line[start..cap];
2065    // Colon depth: `[...]` descriptions and `(...)` value lists hold
2066    // colons that do not separate spec fields.
2067    let mut colons = 0usize;
2068    let mut last_colon = None;
2069    let mut brackets = 0i32;
2070    let mut parens = 0i32;
2071    let mut braces = 0i32;
2072    let sb = spec.as_bytes();
2073    let mut j = 0usize;
2074    while j < sb.len() {
2075        match sb[j] {
2076            b'\\' => {
2077                j += 2;
2078                continue;
2079            }
2080            b'[' => brackets += 1,
2081            b']' => brackets = (brackets - 1).max(0),
2082            b'(' => parens += 1,
2083            b')' => parens = (parens - 1).max(0),
2084            b'{' => braces += 1,
2085            b'}' => braces = (braces - 1).max(0),
2086            b':' if brackets == 0 && parens == 0 && braces == 0 => {
2087                colons += 1;
2088                last_colon = Some(j);
2089            }
2090            _ => {}
2091        }
2092        j += 1;
2093    }
2094    match colons {
2095        0 => Some(ArgSpecPart::Head),
2096        1 => Some(ArgSpecPart::Message),
2097        _ => {
2098            // Inside the user's own list / eval body there is nothing to
2099            // suggest.
2100            if parens > 0 || braces > 0 || brackets > 0 {
2101                return Some(ArgSpecPart::ActionOpaque);
2102            }
2103            let action_start = start + last_colon.unwrap_or(0) + 1;
2104            // Only the action's FIRST word names a completer; anything
2105            // after a space is that command's own argument.
2106            match line[action_start..cap].find(|c: char| c == ' ' || c == '\t') {
2107                Some(_) => Some(ArgSpecPart::ActionOpaque),
2108                None => Some(ArgSpecPart::Action {
2109                    word_start: action_start,
2110                }),
2111            }
2112        }
2113    }
2114}
2115
2116/// Completion items for the ACTION half of an `_arguments` spec.
2117///
2118/// Two kinds of thing go here: a compsys function that generates the
2119/// matches (`_files`, `_hosts`, `_users`, …) and the inline action
2120/// syntaxes zsh's own docs list — a literal list, a described list, a
2121/// state dispatch, a shell-command action.
2122fn arg_spec_action_items(word_start: usize, col: usize, line_no: usize, line: &str) -> Value {
2123    let typed = &line[word_start.min(line.len())..col.min(line.len())];
2124    // A word starting with `-` is a FLAG of the action's own command
2125    // (`_files -g …`, `_values -s ,`). Offering completer names there
2126    // would be nonsense, and this server has no per-action flag table.
2127    if typed.starts_with('-') {
2128        return json!({ "isIncomplete": false, "items": [] });
2129    }
2130    let range_start = word_start;
2131    let mk = |label: &str, detail: &str, insert: &str, doc: &str, sort: &str| -> Value {
2132        json!({
2133            "label": label,
2134            "kind": if insert.contains("${") { 15 } else { 3 },
2135            "detail": detail,
2136            "documentation": { "kind": "markdown", "value": doc },
2137            "filterText": label,
2138            "insertTextFormat": if insert.contains("${") { 2 } else { 1 },
2139            "sortText": format!("{sort}_{label}"),
2140            "textEdit": {
2141                "range": {
2142                    "start": { "line": line_no, "character": range_start },
2143                    "end": { "line": line_no, "character": col },
2144                },
2145                "newText": insert,
2146            },
2147        })
2148    };
2149    // The inline action forms, straight from `man zshcompsys`'s
2150    // "ACTIONS" list. These rank first: they are syntax, and the
2151    // function names below are a long tail.
2152    let mut items = vec![
2153        mk(
2154            "(list)",
2155            "literal value list",
2156            "(${1:one} ${2:two})",
2157            "**`(word ...)`** — complete these literal words.\n\n\
2158             `'1:command:(build test)'`",
2159            "0",
2160        ),
2161        mk(
2162            "((val:desc))",
2163            "described value list",
2164            "((${1:val}\\:${2:description}))",
2165            concat!(
2166                "**`((word\\:desc ...))`** — literal words WITH ",
2167                "descriptions, shown like `_describe` output.\n\n",
2168                "`'1:cmd:((build\\:compile test\\:check))'`",
2169            ),
2170            "0",
2171        ),
2172        mk(
2173            "->state",
2174            "dispatch to $state",
2175            "->${1:state}",
2176            "**`->string`** — set `$state` to _string_ and return, so \
2177             the caller handles this argument in a `case $state in` \
2178             block. Needs `_arguments -C` for `$curcontext` to be \
2179             writable.",
2180            "0",
2181        ),
2182        mk(
2183            "{eval}",
2184            "shell-command action",
2185            "{${1:_command}}",
2186            "**`{eval-string}`** — run the shell code to generate \
2187             matches. Anything more than a call belongs in a function.",
2188            "0",
2189        ),
2190        mk(
2191            " ",
2192            "message-only (no completion)",
2193            " ",
2194            "**` `** (a single space) — accept the argument but offer \
2195             nothing; the message half is still shown via `_message`.",
2196            "0",
2197        ),
2198    ];
2199    // Every compsys function is a legal action. `_files` /
2200    // `_directories` / `_normal` lead because they are what most specs
2201    // actually use.
2202    const PREFERRED: &[&str] = &[
2203        "_files",
2204        "_directories",
2205        "_normal",
2206        "_command_names",
2207        "_hosts",
2208        "_users",
2209        "_parameters",
2210        "_options",
2211        "_signals",
2212        "_pids",
2213        "_message",
2214        "_nothing",
2215        "_guard",
2216        "_values",
2217        "_describe",
2218        "_alternative",
2219    ];
2220    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
2221    for name in crate::compsys::COMPSYS_FN_NAMES {
2222        if !seen.insert(name) {
2223            continue;
2224        }
2225        let rank = if PREFERRED.contains(name) { "1" } else { "2" };
2226        items.push(mk(
2227            name,
2228            "compsys function",
2229            name,
2230            &format!("**`{name}`** — compsys completion function used as an `_arguments` action."),
2231            rank,
2232        ));
2233    }
2234    // Any `_`-prefixed function the shell knows about is also a legal
2235    // action, and on a machine with a completion corpus installed that
2236    // set is far larger than the ported table — `_git_commits`,
2237    // `_docker_images`, the user's own helpers. They come from the
2238    // autoload stubs the canonical rkyv shard registered, so this list
2239    // only fills in once the compsys shell thread has bootstrapped.
2240    let extra: Vec<String> = crate::ported::hashtable::shfunctab_lock()
2241        .read()
2242        .ok()
2243        .map(|tab| {
2244            tab.iter()
2245                .map(|(name, _)| name)
2246                .filter(|k| k.starts_with('_'))
2247                .cloned()
2248                .collect()
2249        })
2250        .unwrap_or_default();
2251    for name in &extra {
2252        if !seen.insert(name.as_str()) {
2253            continue;
2254        }
2255        items.push(mk(
2256            name,
2257            "completion function (fpath)",
2258            name,
2259            &format!("**`{name}`** — completion function known to this shell, usable as an `_arguments` action."),
2260            "3",
2261        ));
2262    }
2263    let _ = typed;
2264    json!({ "isIncomplete": false, "items": items })
2265}
2266
2267/// Translate an LSP `character` offset (UTF-16 code units) into a
2268/// byte offset inside `line`.
2269///
2270/// Clamps past-end positions to the line length — an IDE can report a
2271/// cursor one past the last character on the line.
2272fn utf16_col_to_byte(line: &str, col_u16: usize) -> usize {
2273    let mut units = 0usize;
2274    for (byte_idx, ch) in line.char_indices() {
2275        if units >= col_u16 {
2276            return byte_idx;
2277        }
2278        units += ch.len_utf16();
2279    }
2280    line.len()
2281}
2282
2283/// Drive the real compsys engine for the cursor's line and translate
2284/// the resulting `CompsysMatch`es to LSP `CompletionItem` JSON — this
2285/// is what makes `git ch<tab>` inside a script complete `checkout`
2286/// from the user's `_git`, same as the prompt.
2287///
2288/// Runs on the shared shell thread
2289/// (`crate::compsys::in_editor::complete_at`); returns `None` when
2290/// that produced nothing so the caller keeps its hand-table answer.
2291/// Kept private so the LSP completion flow stays the one entry
2292/// point; the public entry is the JSON-RPC `textDocument/completion`
2293/// method.
2294fn try_compsys_completion(state: &State, params: &Value) -> Option<Value> {
2295    let uri = params["textDocument"]["uri"].as_str()?;
2296    let pos = &params["position"];
2297    let text = state.docs.get(uri)?;
2298    let line_no = pos["line"].as_u64()? as usize;
2299    let col_u16 = pos["character"].as_u64()? as usize;
2300    let line_text = text.lines().nth(line_no)?;
2301    // LSP `character` counts UTF-16 code units; compsys wants a byte
2302    // offset into the line. Equal for ASCII, different the moment the
2303    // line holds a non-BMP char or any multi-byte one.
2304    let phys_col = utf16_col_to_byte(line_text, col_u16);
2305    // Backslash continuations are ONE command to the shell. Dispatching
2306    // the physical fragment instead would hand `_main_complete` a line
2307    // whose first word is an argument — `  --pat` for
2308    // `git \` + newline + `  add --pat` — so the completer for `git`
2309    // never runs.
2310    let (line_text, col, _) = logical_line_at(text, line_no, phys_col);
2311    let line_text = line_text.as_str();
2312    // Cold-start budget: the first dispatch after editor launch may
2313    // autoload a large completer from `$fpath` (`_git` is 424 KB of
2314    // shell to parse), which does not fit the steady-state 200 ms.
2315    // Every later request uses the tight budget.
2316    static WARM: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
2317    // 350 ms steady: the slowest completers here are the exec-backed
2318    // ones (`git checkout <tab>` → `git for-each-ref`, measured 223 ms
2319    // cold on this tree), and the LSP request loop is single-threaded,
2320    // so the budget doubles as the worst-case stall for any other
2321    // request behind it. Overruns are not lost — the dispatch finishes
2322    // in the background and the client's next request is served from
2323    // `in_editor`'s late-result cache.
2324    let budget = if WARM.load(std::sync::atomic::Ordering::Relaxed) {
2325        std::time::Duration::from_millis(350)
2326    } else {
2327        std::time::Duration::from_millis(2000)
2328    };
2329    let req = crate::compsys::in_editor::CompsysRequest::new_with_budget(line_text, col, budget);
2330    let resp = crate::compsys::in_editor::complete_at(req);
2331    if !resp.matches.is_empty() {
2332        WARM.store(true, std::sync::atomic::Ordering::Relaxed);
2333    }
2334    if resp.matches.is_empty() && !resp.is_incomplete {
2335        return None;
2336    }
2337    // Collapse the same word proposed more than once.
2338    //
2339    // Two sources of duplicates, both normal compsys behaviour:
2340    //   * `compdescribe`'s two-phase add — a bare `compadd` pass plus a
2341    //     `-d display-array` pass — so every `_git` flag arrives twice,
2342    //     once with no description and once with one;
2343    //   * a word that belongs to several groups (a flag that is both
2344    //     global and subcommand-local).
2345    //
2346    // Keep first-seen ORDER but let a later described copy fill in an
2347    // empty description, so the popup shows one row per word with the
2348    // best text available.
2349    let mut order: Vec<String> = Vec::with_capacity(resp.matches.len());
2350    let mut best: std::collections::HashMap<String, crate::compsys::in_editor::CompsysMatch> =
2351        std::collections::HashMap::new();
2352    for m in &resp.matches {
2353        match best.get_mut(&m.completion) {
2354            None => {
2355                order.push(m.completion.clone());
2356                best.insert(m.completion.clone(), m.clone());
2357            }
2358            Some(prev) => {
2359                let prev_empty = prev.description.as_deref().unwrap_or("").is_empty();
2360                let now_has = !m.description.as_deref().unwrap_or("").is_empty();
2361                if prev_empty && now_has {
2362                    *prev = m.clone();
2363                }
2364            }
2365        }
2366    }
2367    let deduped: Vec<crate::compsys::in_editor::CompsysMatch> =
2368        order.iter().filter_map(|k| best.remove(k)).collect();
2369    let items: Vec<Value> = deduped
2370        .iter()
2371        .map(|m| {
2372            // Map CompsysMatch.group → LSP kind:
2373            //   `options` → Field (15) — matches BuiltinFlag style
2374            //   `subcommands` / `commands` → Function (3)
2375            //   `values` → Value (12)
2376            //   `hosts` / `files` / `directories` → File (17)
2377            //   anything else → Text (1)
2378            let kind: u64 = match m.group.as_deref() {
2379                Some("options") => 5,                                      // Field
2380                Some("subcommands") | Some("commands") => 3,               // Function
2381                Some("values") => 12,                                      // Value
2382                Some("hosts") | Some("files") | Some("directories") => 17, // File
2383                _ => 1,                                                    // Text
2384            };
2385            // sortText prefix by group so subcommands list above
2386            // options list above paths, matching what zsh shows at
2387            // the prompt.
2388            let bucket = match m.group.as_deref() {
2389                Some("subcommands") | Some("commands") => "0",
2390                Some("options") => "1",
2391                Some("values") => "2",
2392                Some("hosts") => "3",
2393                Some("files") | Some("directories") => "4",
2394                _ => "5",
2395            };
2396            // compsys descriptions arrive as the LIST DISPLAY line —
2397            // `--version             -- display version information`.
2398            // The word is already the label, so keep only the prose
2399            // after the `--` separator zsh renders.
2400            let detail = m
2401                .description
2402                .clone()
2403                .map(|d| match d.split_once(" -- ") {
2404                    Some((_, prose)) => prose.trim().to_string(),
2405                    None => d.trim().to_string(),
2406                })
2407                .filter(|d| !d.is_empty())
2408                // Group names are the fallback, but only real ones:
2409                // compsys's internal groups are dash-wrapped
2410                // (`-default-`), and showing that as a description is
2411                // noise in a popup.
2412                .or_else(|| m.group.clone().filter(|g| !g.starts_with('-')))
2413                .unwrap_or_default();
2414            json!({
2415                "label": m.completion,
2416                "kind": kind,
2417                "detail": detail,
2418                "filterText": m.completion,
2419                "insertText": m.completion,
2420                "sortText": format!("{bucket}_{}", m.completion),
2421            })
2422        })
2423        .collect();
2424    Some(json!({
2425        "isIncomplete": resp.is_incomplete,
2426        "items": items,
2427    }))
2428}
2429
2430/// Snippet templates surfaced via `textDocument/completion`. Mirrors
2431/// strykelang's `SNIPS` table. Each tuple is
2432/// `(prefix, body, short detail line)` — the body uses LSP
2433/// snippet placeholders (`${1:label}`, `${2:default}`, ... ending at
2434/// `${0}` for the final cursor stop).
2435///
2436/// Categories covered (60+ entries):
2437///   * Control flow: if / ifelse / ifelsif / for / forin / for-arith /
2438///     foreach / while / until / case / select / repeat
2439///   * Declarations: fn / local / typeset / export / readonly / integer
2440///   * Idioms: trap / setopt / autoload / compdef / bindkey / alias /
2441///     hashes / arrays
2442///   * Hooks: precmd / preexec / chpwd / zshexit (via add-zsh-hook)
2443///   * Module setup: shebang / safeshebang / main / usage / strict
2444///   * I/O: while-read / cat-pipe / process-subst / heredoc / printf-fmt
2445///   * Conditionals: dirtest / filetest / regex-match / not-empty
2446///   * Parallel (zshrs ext): async / await / barrier / peach
2447///   * ZLE: zle-widget / bindkey-widget
2448///   * Compsys: arguments-spec / files-spec / values-spec
2449///   * Scaffolds: test / git / curl / json
2450const SNIPPETS: &[(&str, &str, &str)] = &[
2451    // ── Control flow ────────────────────────────────────────────────
2452    ("if",       "if ${1:cmd}; then\n    ${2:body}\nfi${0}", "if/then/fi block (snippet)"),
2453    ("ifelse",   "if ${1:cmd}; then\n    ${2:body}\nelse\n    ${3:alt}\nfi${0}", "if/else/fi block (snippet)"),
2454    ("ifelsif",  "if ${1:cmd1}; then\n    ${2:body}\nelif ${3:cmd2}; then\n    ${4:alt}\nelse\n    ${5:fallback}\nfi${0}", "if/elif/else chain (snippet)"),
2455    ("elsif",    "elif ${1:cmd}; then\n    ${2:body}${0}", "elif branch (snippet)"),
2456    ("unless",   "if ! ${1:cmd}; then\n    ${2:body}\nfi${0}", "negated if (snippet)"),
2457    ("for",      "for ${1:item} in ${2:list}; do\n    ${3:body}\ndone${0}", "for loop (snippet)"),
2458    ("forin",    "for ${1:item} in \"${2:\\${array[@]}}\"; do\n    ${3:body}\ndone${0}", "for over quoted-array expansion (snippet)"),
2459    ("forarith", "for ((${1:i}=0; \\$${1:i} < ${2:n}; ${1:i}++)); do\n    ${3:body}\ndone${0}", "C-style arithmetic for (snippet)"),
2460    ("foreach",  "foreach ${1:item} (${2:list})\n    ${3:body}\nend${0}", "zsh-alt foreach…end (snippet)"),
2461    ("while",    "while ${1:cmd}; do\n    ${2:body}\ndone${0}", "while loop (snippet)"),
2462    ("until",    "until ${1:cmd}; do\n    ${2:body}\ndone${0}", "until loop (snippet)"),
2463    ("case",     "case ${1:word} in\n    ${2:pattern})\n        ${3:body}\n        ;;\n    *)\n        ${4:default}\n        ;;\nesac${0}", "case/esac (snippet)"),
2464    ("select",   "select ${1:choice} in ${2:items}; do\n    ${3:body}\n    break\ndone${0}", "select interactive menu (snippet)"),
2465    ("repeat",   "repeat ${1:N}; do\n    ${2:body}\ndone${0}", "repeat N times (snippet)"),
2466    ("break",    "break ${1:1}${0}", "break N levels (snippet)"),
2467    ("continue", "continue ${1:1}${0}", "continue N levels (snippet)"),
2468    ("return",   "return ${1:0}${0}", "return status (snippet)"),
2469    // ── Declarations ────────────────────────────────────────────────
2470    ("fn",       "${1:name}() {\n    ${2:body}\n}${0}", "function declaration (snippet)"),
2471    ("function", "function ${1:name} {\n    ${2:body}\n}${0}", "function keyword form (snippet)"),
2472    ("anonfn",   "() {\n    ${1:body}\n} ${2:args}${0}", "anonymous function (snippet)"),
2473    ("local",    "local ${1:var}=${2:value}${0}", "local declaration (snippet)"),
2474    ("locals",   "local ${1:a}=\"\\$1\" ${2:b}=\"\\$2\" ${3:c}=\"\\$3\"${0}", "local positional-arg unpack (snippet)"),
2475    ("typeset",  "typeset -${1:gAi} ${2:name}${3:=value}${0}", "typeset with attributes (snippet)"),
2476    ("export",   "export ${1:NAME}=\"${2:value}\"${0}", "export env var (snippet)"),
2477    ("readonly", "readonly ${1:NAME}=\"${2:value}\"${0}", "readonly var (snippet)"),
2478    ("integer",  "integer ${1:name}=${2:0}${0}", "integer typeset shorthand (snippet)"),
2479    ("array",    "${1:name}=(${2:a b c})${0}", "indexed array literal (snippet)"),
2480    ("assoc",    "typeset -A ${1:name}\n${1:name}=(\n    [${2:key1}]=${3:val1}\n    [${4:key2}]=${5:val2}\n)${0}", "associative array (snippet)"),
2481    // ── Common idioms ───────────────────────────────────────────────
2482    ("trap",     "trap '${1:handler}' ${2:INT TERM EXIT}${0}", "signal trap (snippet)"),
2483    ("setopt",   "setopt ${1:EXTENDED_GLOB NULL_GLOB PIPE_FAIL}${0}", "setopt one or more options (snippet)"),
2484    ("unsetopt", "unsetopt ${1:CASE_GLOB}${0}", "unsetopt options (snippet)"),
2485    ("autoload", "autoload -Uz ${1:funcname}${0}", "autoload function with -Uz (snippet)"),
2486    ("compdef",  "compdef ${1:_completer} ${2:command}${0}", "register completion (snippet)"),
2487    ("bindkey",  "bindkey '${1:^X^E}' ${2:edit-command-line}${0}", "ZLE bindkey (snippet)"),
2488    ("alias",    "alias ${1:name}='${2:command}'${0}", "alias (snippet)"),
2489    ("galias",   "alias -g ${1:NAME}='${2:expansion}'${0}", "global alias (snippet)"),
2490    ("salias",   "alias -s ${1:ext}='${2:opener}'${0}", "suffix alias (snippet)"),
2491    // ── Hooks (via add-zsh-hook) ────────────────────────────────────
2492    ("precmd",   "autoload -Uz add-zsh-hook\n${1:my_precmd}() {\n    ${2:body}\n}\nadd-zsh-hook precmd ${1:my_precmd}${0}", "precmd hook (snippet)"),
2493    ("preexec",  "autoload -Uz add-zsh-hook\n${1:my_preexec}() {\n    ${2:body}  # \\$1 = command line\n}\nadd-zsh-hook preexec ${1:my_preexec}${0}", "preexec hook (snippet)"),
2494    ("chpwd",    "autoload -Uz add-zsh-hook\n${1:my_chpwd}() {\n    ${2:body}\n}\nadd-zsh-hook chpwd ${1:my_chpwd}${0}", "chpwd hook (snippet)"),
2495    ("periodic", "autoload -Uz add-zsh-hook\nPERIOD=${1:60}\n${2:my_periodic}() {\n    ${3:body}\n}\nadd-zsh-hook periodic ${2:my_periodic}${0}", "periodic hook (snippet)"),
2496    ("zshexit",  "autoload -Uz add-zsh-hook\n${1:my_zshexit}() {\n    ${2:cleanup}\n}\nadd-zsh-hook zshexit ${1:my_zshexit}${0}", "zshexit hook (snippet)"),
2497    // ── Module setup ────────────────────────────────────────────────
2498    ("shebang",     "#!/usr/bin/env zshrs\n${0}", "zshrs shebang (snippet)"),
2499    ("safeshebang", "#!/usr/bin/env zsh\nemulate -L zsh\nsetopt err_return no_unset pipe_fail extended_glob\n${0}", "strict-mode shebang (snippet)"),
2500    ("main",        "#!/usr/bin/env zshrs\nemulate -L zsh\nsetopt err_return no_unset pipe_fail\n\n${1:main}() {\n    ${2:body}\n}\n\n${1:main} \"\\$@\"${0}", "main() scaffold (snippet)"),
2501    ("usage",       "${1:usage}() {\n    cat <<'EOT'\nUsage: ${2:command} [-h] [-v] ARG...\n\n  -h    show this help\n  -v    verbose\nEOT\n}${0}", "usage() helper (snippet)"),
2502    ("strict",      "emulate -L zsh\nsetopt err_return no_unset pipe_fail extended_glob${0}", "strict-mode options (snippet)"),
2503    // ── I/O ─────────────────────────────────────────────────────────
2504    ("while-read", "while IFS= read -r ${1:line}; do\n    ${2:body}\ndone < ${3:file}${0}", "read-loop over file (snippet)"),
2505    ("for-each-line", "for ${1:line} in \"\\${(@f)\\$(cat ${2:file})}\"; do\n    ${3:body}\ndone${0}", "for-each-line via process subst (snippet)"),
2506    ("cat-pipe",   "${1:cmd} | while read -r ${2:line}; do\n    ${3:body}\ndone${0}", "pipe-to-while (snippet)"),
2507    ("heredoc",    "cat <<EOT\n${1:body}\nEOT${0}", "heredoc (snippet)"),
2508    ("heredocl",   "cat <<-EOT\n\t${1:body}\nEOT${0}", "tab-stripped heredoc (snippet)"),
2509    ("herestring", "${1:cmd} <<< \"${2:input}\"${0}", "here-string (snippet)"),
2510    ("psub-in",    "${1:cmd} < <(${2:producer})${0}", "process substitution (input) (snippet)"),
2511    ("psub-out",   "${1:cmd} > >(${2:consumer})${0}", "process substitution (output) (snippet)"),
2512    ("subshell",   "(\n    ${1:body}\n)${0}", "subshell (snippet)"),
2513    ("printfmt",   "printf '%s\\\\n' \"${1:args}\"${0}", "printf line-per-arg (snippet)"),
2514    // ── Conditionals ────────────────────────────────────────────────
2515    ("dirtest",    "[[ -d \"${1:path}\" ]] && ${2:body}${0}", "directory-test guard (snippet)"),
2516    ("filetest",   "[[ -f \"${1:path}\" ]] && ${2:body}${0}", "regular-file guard (snippet)"),
2517    ("regexm",     "if [[ \"${1:str}\" =~ ${2:pattern} ]]; then\n    ${3:body}  # \\$match[*] / \\$MATCH\nfi${0}", "regex match into \\$match (snippet)"),
2518    ("notempty",   "[[ -n \"${1:var}\" ]] || ${2:return 1}${0}", "non-empty guard (snippet)"),
2519    // ── Parallel primitives (zshrs extension) ───────────────────────
2520    ("async",      "${1:job}=\\$(async ${2:'expensive_command'})\n${3:# … other work …}\n${4:result}=\\$(await \\$${1:job})${0}", "async + await pair (snippet)"),
2521    ("barrier",    "barrier '${1:task1}' ::: '${2:task2}' ::: '${3:task3}'${0}", "barrier (parallel + join) (snippet)"),
2522    ("peach",      "peach '${1:cmd {\\}}' ${2:items}${0}", "parallel for-each on worker pool (snippet)"),
2523    ("intercept",  "intercept ${1:before} ${2:command} {\n    ${3:body}\n}${0}", "AOP intercept (snippet)"),
2524    // ── ZLE ─────────────────────────────────────────────────────────
2525    ("zle-widget", "${1:my-widget}() {\n    ${2:zle .accept-line}\n}\nzle -N ${1:my-widget}\nbindkey '${3:^X^E}' ${1:my-widget}${0}", "ZLE widget + bindkey (snippet)"),
2526    // ── Compsys / completion ────────────────────────────────────────
2527    ("argspec",    "_arguments \\\\\n    '(-h --help)'{-h,--help}'[show help]' \\\\\n    '(-v --verbose)'{-v,--verbose}'[verbose]' \\\\\n    ':${1:argname}:${2:_files}'${0}", "_arguments spec (snippet)"),
2528    ("filesspec",  "_files -g '${1:*.zsh}'${0}", "_files glob spec (snippet)"),
2529    ("valspec",    "_values '${1:tag}' \\\\\n    '${2:one}[${3:desc}]' \\\\\n    '${4:two}[${5:desc}]'${0}", "_values descriptor (snippet)"),
2530    ("describe",   "_describe '${1:group}' ${2:choices_array}${0}", "_describe (snippet)"),
2531    // ── Scaffolds ───────────────────────────────────────────────────
2532    ("test",       "#!/usr/bin/env zshrs\nemulate -L zsh\nsetopt err_return no_unset\n\n${1:test_name}() {\n    [[ \"${2:got}\" == \"${3:want}\" ]] && echo PASS || { echo FAIL; return 1; }\n}\n\n${1:test_name}${0}", "test scaffold (snippet)"),
2533    ("gitcommit",  "git add -A && git commit -m \"${1:message}\" && git push${0}", "git add+commit+push (snippet)"),
2534    ("curlget",    "curl -fsSL ${1:https://example.com/api} | ${2:jq .}${0}", "curl GET + jq pipe (snippet)"),
2535    ("jsonget",    "${1:cmd} | jq -r '${2:.field}'${0}", "extract JSON field via jq (snippet)"),
2536    ("zmodload",   "zmodload zsh/${1:datetime}${0}", "load zsh module (snippet)"),
2537];
2538
2539// ── Hover ───────────────────────────────────────────────────────────────
2540
2541fn hover(state: &State, params: &Value) -> Value {
2542    let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
2543    let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
2544    let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
2545    let text = match state.docs.get(uri) {
2546        Some(t) => t,
2547        None => {
2548            tracing::trace!(target: "zshrs::lsp::hover", line = line_no, col, "no_doc_for_uri");
2549            return Value::Null;
2550        }
2551    };
2552    let word = word_at(text, line_no, col).unwrap_or_default();
2553    if word.is_empty() {
2554        tracing::trace!(target: "zshrs::lsp::hover", line = line_no, col, "empty_word");
2555        return Value::Null;
2556    }
2557    let line_text = text.lines().nth(line_no).unwrap_or("");
2558    // Use the same identifier-span rule as `word_at` so the gate sees
2559    // exactly the same byte range the doc card would render — keeps the
2560    // gate honest when the cursor lands on the trailing edge of a word.
2561    let (word_start, word_end) = word_span_at(line_text, col).unwrap_or((col, col));
2562    let gate = classify_hover_position(line_text, word_start, word_end);
2563    // Module-doc lookup runs BEFORE the gate: it fires on `#!`
2564    // shebang lines (which the gate classifies as Comment) and on
2565    // `source PATH` / `. PATH` argument hovers. When neither shape
2566    // matches, gate enforcement continues below.
2567    if let Some(module_doc) = find_module_doc_for_position(state, text, line_text, line_no) {
2568        tracing::debug!(target: "zshrs::lsp::hover", line = line_no, col, "module_hit");
2569        return json!({
2570            "contents": { "kind": "markdown", "value": module_doc }
2571        });
2572    }
2573    // An `_arguments` spec is a quoted string, so the gate calls it
2574    // String and suppresses — but its action field names a REAL
2575    // function, and that is exactly the name an author writing a spec
2576    // wants documented. `'*:file:_files'` hovers `_files`.
2577    let (logical_line, logical_col, _) = logical_line_at(text, line_no, col);
2578    let in_spec_action = matches!(
2579        arg_spec_part_at(&logical_line, logical_col),
2580        Some(ArgSpecPart::Action { .. }) | Some(ArgSpecPart::ActionOpaque)
2581    ) && word.starts_with('_');
2582    if gate != HoverGate::Code && !in_spec_action {
2583        tracing::debug!(
2584            target: "zshrs::lsp::hover",
2585            line = line_no, col, %word,
2586            gated = ?gate,
2587            "suppressed",
2588        );
2589        return Value::Null;
2590    }
2591    // Priority: user-defined symbols in the current document WIN over
2592    // generic builtin / keyword / option docs. When the cursor's word
2593    // is a function / alias / variable defined here, the user wants
2594    // "what does MY `end`/`load`/`start` do" — not the Csh-style `end`
2595    // keyword card or the `load` builtin doc. Falling back to builtin
2596    // lookup only when the word is NOT defined locally avoids the
2597    // surprising "I hover on my local var, get a zsh-keyword card."
2598    let mut doc = if let Some(user_doc) = find_user_symbol_doc(text, &word) {
2599        user_doc
2600    } else {
2601        String::new()
2602    };
2603    if doc.is_empty() {
2604        doc = lookup_doc(&word);
2605    }
2606    if doc.is_empty() {
2607        // Module-level fallback: if the cursor is on the shebang
2608        // line or a `source FILE` / `. FILE` argument, surface the
2609        // top-of-file `##` block of the relevant document.
2610        //
2611        // Shebang case: any word inside `#!/usr/bin/env zsh` etc.
2612        // triggers the same-document module doc lookup.
2613        //
2614        // Source case: resolve `source path` / `. path` and read the
2615        // target file's top `##` block — lets the user discover what
2616        // a sourced library does without leaving the editor.
2617        if let Some(module_doc) = find_module_doc_for_position(state, text, line_text, line_no) {
2618            doc = module_doc;
2619        }
2620    }
2621    if doc.is_empty() {
2622        tracing::trace!(target: "zshrs::lsp::hover", line = line_no, col, %word, "miss");
2623        return Value::Null;
2624    }
2625    tracing::debug!(target: "zshrs::lsp::hover", line = line_no, col, %word, "hit");
2626    json!({
2627        "contents": {
2628            "kind": "markdown",
2629            "value": doc,
2630        }
2631    })
2632}
2633
2634/// Find the markdown doc for a user-defined symbol `name` in the
2635/// current document. Returns the formatted hover card (heading +
2636/// definition-line snippet + the `##` doc-comment block immediately
2637/// above the definition), or `None` when:
2638///   * `name` doesn't appear as a function / alias / parameter decl, OR
2639///   * the definition has no leading `##` block (silent fall-through
2640///     so builtin lookup remains the primary doc source).
2641///
2642/// Recognises three definition shapes per `scan_symbols`:
2643///   * `function NAME { … }` / `function NAME() …` (keyword form)
2644///   * `NAME() { … }` (POSIX form)
2645///   * `local NAME=…` / `typeset NAME=…` / `export NAME=…` /
2646///     `readonly NAME=…` / `NAME=…` (assignment forms)
2647///   * `alias NAME=…`
2648///
2649/// `##` blocks are gathered by walking BACKWARD from the definition
2650/// line, accepting consecutive `## …` / `##` lines (blank `##` lines
2651/// become paragraph breaks) and stopping at the first non-`##` non-
2652/// blank line. Plain `#` (single-hash) comments are NOT collected —
2653/// those are routine code comments, not doc strings.
2654pub(crate) fn find_user_symbol_doc(text: &str, name: &str) -> Option<String> {
2655    let lines: Vec<&str> = text.lines().collect();
2656    // PRIMARY: AST-walk via SymbolTable::build. Returns every Func /
2657    // Global / Local decl in the current file with line numbers. This
2658    // is the correct source of truth — it sees `function foo()`,
2659    // `bar=value`, `local x=42` exactly as the parser does, without
2660    // false matches on `# bar=value` (comment) or `"foo=…"` (string).
2661    //
2662    // Previously this function used `symbol_decl_kind` (text-prefix
2663    // matching) which couldn't tell a real `local s1=…` from a string
2664    // `"local s1=…"` inside a quoted argument, and missed multi-line
2665    // function-decl variants. Switched to AST so the hover card is
2666    // grounded in real parsed decls only.
2667    //
2668    // FALLBACK: if the parser fails (mid-edit, broken syntax), drop
2669    // to the text-based scanner so hover doesn't silently go blank
2670    // during typing.
2671    if let Some(table) = crate::lsp_symbols::SymbolTable::build(text) {
2672        // Two passes: prefer the first decl WITH a `##` doc-block
2673        // (richer card); fall back to a minimal card when no doc-block
2674        // exists so hover still confirms "yes, defined here."
2675        let mut undocumented: Option<(u32, &'static str, &str)> = None;
2676        for sym in &table.symbols {
2677            if sym.name != name {
2678                continue;
2679            }
2680            let line_idx = sym.decl_line as usize;
2681            let def_line = lines.get(line_idx).copied().unwrap_or("");
2682            // SymbolTable classifies `alias foo=…` declarations as Func
2683            // (alias creates a callable binding). Re-discriminate from
2684            // the line prefix so the hover card says "user-defined
2685            // alias" instead of "user-defined function".
2686            let line_trim = def_line.trim_start();
2687            let kind = if line_trim.starts_with("alias ") || line_trim.starts_with("alias\t") {
2688                "alias"
2689            } else {
2690                match sym.kind {
2691                    crate::lsp_symbols::SymbolKind::Func => "function",
2692                    crate::lsp_symbols::SymbolKind::Global => "parameter",
2693                    crate::lsp_symbols::SymbolKind::Local => "parameter",
2694                }
2695            };
2696            let doc_block = collect_doc_block_above(&lines, line_idx);
2697            if !doc_block.is_empty() {
2698                return Some(render_user_doc_card(
2699                    name,
2700                    kind,
2701                    def_line.trim(),
2702                    &doc_block,
2703                ));
2704            }
2705            if undocumented.is_none() {
2706                undocumented = Some((sym.decl_line, kind, def_line));
2707            }
2708        }
2709        if let Some((_, kind, line)) = undocumented {
2710            return Some(render_user_doc_card_no_block(name, kind, line.trim()));
2711        }
2712        // AST table found no match — fall through to the text-prefix
2713        // scanner below for any decl shape AST misses entirely.
2714    }
2715    // Parser failed OR AST didn't find the name — fall back to text
2716    // scanner so hover doesn't go blank.
2717    let mut undocumented: Option<(usize, &'static str, &str)> = None;
2718    for (i, line) in lines.iter().enumerate() {
2719        let kind = match symbol_decl_kind(line, name) {
2720            Some(k) => k,
2721            None => continue,
2722        };
2723        let doc_block = collect_doc_block_above(&lines, i);
2724        if !doc_block.is_empty() {
2725            return Some(render_user_doc_card(name, kind, line.trim(), &doc_block));
2726        }
2727        if undocumented.is_none() {
2728            undocumented = Some((i, kind, line));
2729        }
2730    }
2731    undocumented.map(|(_, kind, line)| render_user_doc_card_no_block(name, kind, line.trim()))
2732}
2733
2734/// Render a minimal hover card for a user-defined symbol that has no
2735/// `##` doc-comment block above its definition. Surfaces the kind and
2736/// the definition-line snippet so the user gets confirmation the
2737/// symbol is real (not a typo) plus a one-glance pointer to where it
2738/// lives.
2739fn render_user_doc_card_no_block(name: &str, kind: &str, def_line: &str) -> String {
2740    format!(
2741        "**user-defined {kind} `{name}`**\n\n```zsh\n{def_line}\n```\n\n\
2742         *(no `## …` doc-comment block above the definition)*"
2743    )
2744}
2745
2746/// Recognise whether `line` declares `name` as a user-defined symbol.
2747/// Returns the kind word (`"function"` / `"alias"` / `"parameter"`)
2748/// for the card heading, or `None` when this line doesn't define the
2749/// requested name.
2750fn symbol_decl_kind(line: &str, name: &str) -> Option<&'static str> {
2751    let t = line.trim_start();
2752    if t.starts_with('#') {
2753        return None;
2754    }
2755    // `function NAME { … }` / `function NAME() …`
2756    if let Some(rest) = t
2757        .strip_prefix("function ")
2758        .or_else(|| t.strip_prefix("function\t"))
2759    {
2760        if first_ident(rest).as_deref() == Some(name) {
2761            return Some("function");
2762        }
2763    }
2764    // `NAME() { … }` — POSIX form. Require `NAME(` with NO whitespace
2765    // between (`name(` not `name (`), so `if my_check(x)` calls don't
2766    // misfire.
2767    if let Some(idx) = t.find("()") {
2768        let head = &t[..idx];
2769        if !head.contains(' ') && !head.contains('\t') && head == name {
2770            return Some("function");
2771        }
2772    }
2773    // `alias NAME=…`
2774    if let Some(rest) = t.strip_prefix("alias ") {
2775        if first_ident(rest).as_deref() == Some(name) {
2776            return Some("alias");
2777        }
2778    }
2779    // `local NAME=…` / `typeset NAME=…` / `export NAME=…` /
2780    // `readonly NAME=…` / `integer NAME=…` / `float NAME=…`.
2781    // Skip any leading `-X` flag arguments (`typeset -gA`, `local -i`,
2782    // `readonly -a`, etc.) before checking the first identifier.
2783    for prefix in &[
2784        "local ",
2785        "typeset ",
2786        "declare ",
2787        "readonly ",
2788        "export ",
2789        "integer ",
2790        "float ",
2791    ] {
2792        if let Some(rest) = t.strip_prefix(prefix) {
2793            let after_flags = skip_leading_flags(rest);
2794            if first_ident(after_flags).as_deref() == Some(name) {
2795                return Some("parameter");
2796            }
2797        }
2798    }
2799    // Bare `NAME=value` at start of line.
2800    if let Some(eq) = t.find('=') {
2801        let head = &t[..eq];
2802        if head == name && !head.contains(' ') && !head.contains('\t') {
2803            return Some("parameter");
2804        }
2805    }
2806    None
2807}
2808
2809/// Walk BACKWARD from `def_line` collecting the contiguous `##`
2810/// comment block. Returns markdown text (paragraph-joined). Empty
2811/// when there's no `##` block.
2812///
2813/// Rules:
2814///   * Stop at the first non-`##` non-blank line.
2815///   * `## ` lines contribute their text (after the leading `## `).
2816///   * `##` alone (or `##\n`) becomes a paragraph break (blank line).
2817///   * Plain `#` single-hash lines TERMINATE the block — they're
2818///     routine code comments, not docstrings.
2819///   * Leading blank lines between definition and block ARE allowed
2820///     (e.g. `## doc\n\nfunction f()` is still attached to `f`).
2821fn collect_doc_block_above(lines: &[&str], def_line: usize) -> String {
2822    let mut collected: Vec<String> = Vec::new();
2823    let mut saw_doc = false;
2824    for j in (0..def_line).rev() {
2825        let trimmed = lines[j].trim_start();
2826        if trimmed.is_empty() {
2827            if saw_doc {
2828                // Blank line BEFORE the block we're collecting —
2829                // terminates collection.
2830                break;
2831            }
2832            // Blank line between def line and (yet-unseen) block —
2833            // allowed.
2834            continue;
2835        }
2836        if let Some(rest) = trimmed.strip_prefix("## ") {
2837            collected.push(rest.to_string());
2838            saw_doc = true;
2839            continue;
2840        }
2841        if trimmed == "##" {
2842            collected.push(String::new());
2843            saw_doc = true;
2844            continue;
2845        }
2846        // `## ` exactly, or `#!` shebang, or `# ` plain comment — all
2847        // stop the block. Plain comments are intentionally NOT
2848        // gathered: only the doubled-hash convention attaches.
2849        break;
2850    }
2851    collected.reverse();
2852    // Trim trailing blank "paragraph break" lines.
2853    while collected.last().is_some_and(|l| l.is_empty()) {
2854        collected.pop();
2855    }
2856    collected.join("\n")
2857}
2858
2859/// Resolve module-level hover docs for the cursor's current line.
2860/// Returns `Some(card)` when:
2861///   * `line_text` is a `#!` shebang — surface THIS file's module
2862///     doc (the top-of-file `##` block).
2863///   * `line_text` is a `source PATH` / `. PATH` invocation — resolve
2864///     PATH (relative to the current doc's URI when applicable),
2865///     read the file, return ITS module doc.
2866/// Returns `None` otherwise (hover handler falls through to its
2867/// normal `Value::Null` reply).
2868fn find_module_doc_for_position(
2869    state: &State,
2870    text: &str,
2871    line_text: &str,
2872    _line_no: usize,
2873) -> Option<String> {
2874    let trimmed = line_text.trim_start();
2875    if trimmed.starts_with("#!") {
2876        return extract_module_doc(text).map(|d| render_module_doc_card("(this file)", &d));
2877    }
2878    // `source PATH` / `. PATH` — first identifier after `source`/`.`
2879    // is the path. Resolve it through the LSP doc cache if loaded;
2880    // otherwise read from disk.
2881    let path_arg: Option<&str> = if let Some(rest) = trimmed.strip_prefix("source ") {
2882        Some(rest.split_whitespace().next().unwrap_or(""))
2883    } else if let Some(rest) = trimmed.strip_prefix(". ") {
2884        Some(rest.split_whitespace().next().unwrap_or(""))
2885    } else {
2886        None
2887    };
2888    let path = path_arg.filter(|p| !p.is_empty())?;
2889    // Strip surrounding quotes if any.
2890    let path = path.trim_matches(|c| c == '"' || c == '\'');
2891    // Normalize: drop leading `./` so URI suffix match works (the
2892    // doc cache stores `file:///proj/helpers.zsh`, the source line
2893    // says `./helpers.zsh`).
2894    let needle = path.strip_prefix("./").unwrap_or(path);
2895    // Try doc cache first (URI key match), then filesystem.
2896    let body = state
2897        .docs
2898        .iter()
2899        .find_map(|(uri, body)| uri.ends_with(needle).then(|| body.clone()))
2900        .or_else(|| std::fs::read_to_string(path).ok())?;
2901    extract_module_doc(&body).map(|d| render_module_doc_card(path, &d))
2902}
2903
2904/// Render a module-doc hover card. Format mirrors the user-symbol
2905/// card but heading kind is `module`.
2906/// Stryke-aligned card format: doc body first, horizontal rule,
2907/// then a one-line header naming what the hover is on. Mirrors
2908/// `strykelang/lsp.rs::format_with_doc_comments` so users get a
2909/// consistent hover layout across both LSPs.
2910fn render_module_doc_card(label: &str, doc: &str) -> String {
2911    format!("{doc}\n\n---\n\nzsh module `{label}`")
2912}
2913
2914/// Extract the module-level `##` doc block from the top of a zsh
2915/// source file. Skips an optional `#!` shebang line, then collects
2916/// the contiguous `##` (and `##` paragraph-break) lines until the
2917/// first non-`##` non-blank line. Returns `None` when there's no
2918/// top-of-file `##` block.
2919///
2920/// Conventional layout the convention matches:
2921///
2922/// ```sh
2923/// #!/usr/bin/env zsh
2924/// ##
2925/// ## NAME
2926/// ##   foo.zsh — short one-line summary
2927/// ##
2928/// ## DESCRIPTION
2929/// ##   Longer paragraph describing what the module does.
2930///
2931/// function foo() { … }
2932/// ```
2933///
2934/// Used by the hover handler to surface module docs when the cursor
2935/// lands on the shebang line or a `source FILE` / `. FILE` argument.
2936pub(crate) fn extract_module_doc(text: &str) -> Option<String> {
2937    let lines: Vec<&str> = text.lines().collect();
2938    let mut i = 0;
2939    // Skip optional shebang.
2940    if let Some(first) = lines.first() {
2941        if first.starts_with("#!") {
2942            i = 1;
2943        }
2944    }
2945    // Skip optional blank line(s) between shebang and doc block.
2946    while i < lines.len() && lines[i].trim().is_empty() {
2947        i += 1;
2948    }
2949    // Collect `##` block.
2950    let mut collected: Vec<String> = Vec::new();
2951    while i < lines.len() {
2952        let trimmed = lines[i].trim_start();
2953        if let Some(rest) = trimmed.strip_prefix("## ") {
2954            collected.push(rest.to_string());
2955        } else if trimmed == "##" {
2956            collected.push(String::new());
2957        } else {
2958            break;
2959        }
2960        i += 1;
2961    }
2962    while collected.last().is_some_and(|l| l.is_empty()) {
2963        collected.pop();
2964    }
2965    if collected.is_empty() {
2966        None
2967    } else {
2968        Some(collected.join("\n"))
2969    }
2970}
2971
2972/// Skip a leading run of `-X` / `+X` / `-Xa` flag arguments
2973/// (each followed by whitespace) and return the remainder. Used so
2974/// `typeset -gA NAME=…` advances past `-gA ` to `NAME=…` before the
2975/// first-identifier check.
2976fn skip_leading_flags(s: &str) -> &str {
2977    let mut rest = s.trim_start();
2978    while rest.starts_with('-') || rest.starts_with('+') {
2979        // Walk until next whitespace.
2980        let end = rest
2981            .char_indices()
2982            .find(|(_, c)| c.is_whitespace())
2983            .map(|(i, _)| i)
2984            .unwrap_or(rest.len());
2985        rest = rest[end..].trim_start();
2986    }
2987    rest
2988}
2989
2990/// Render a user-symbol hover card. Format mirrors the builtin
2991/// docs cards: heading line, optional definition snippet in a code
2992/// fence, then the doc body verbatim.
2993/// Stryke-aligned card format: doc body first, horizontal rule,
2994/// then a one-line header naming the symbol + kind. Drops the
2995/// code-fence definition line — stryke doesn't include it and the
2996/// `##` doc block usually carries the signature in prose anyway.
2997fn render_user_doc_card(name: &str, kind: &str, _def_line: &str, doc: &str) -> String {
2998    format!("{doc}\n\n---\n\nuser-defined {kind} `{name}`")
2999}
3000
3001/// Why hover was suppressed at a given cursor position. Returned by
3002/// [`classify_hover_position`] so the hover handler can log the exact
3003/// reason — turns "why didn't the doc card pop?" into a one-line tail
3004/// of `zshrs.log` instead of an LSP-protocol re-derivation.
3005#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3006pub(crate) enum HoverGate {
3007    /// Normal code position — let the builtin / keyword / option lookup run.
3008    Code,
3009    /// Inside a `#` line comment or `#!` shebang.
3010    Comment,
3011    /// Inside a string literal (`"..."`, `'...'`, or backtick). Cursor
3012    /// on a word that happens to spell a builtin must NOT pop the
3013    /// builtin doc — `"cd to dir"` in a string is the literal text, not
3014    /// the `cd` builtin. Note: zsh `"${var}"` interpolation IS code,
3015    /// and `${cd}` should still hover on `cd` — see
3016    /// [`position_inside_string_literal`] for the interpolation logic.
3017    StringLiteral,
3018}
3019
3020/// True when the identifier at `[start, end)` falls inside a single-
3021/// line string literal (`"..."`, `'...'`, or backtick) AND outside any
3022/// `${EXPR}` parameter expansion. Walks the line from byte 0 tracking
3023/// string-quote state and interpolation depth so the interior of
3024/// `"path = ${HOME}/x"` is treated as Code (hover should fire on HOME),
3025/// while bare `"cd"` keeps the StringLiteral classification (hover
3026/// should NOT fire on the literal text).
3027///
3028/// zsh-specific notes vs the stryke port:
3029///   - The interpolation opener is `${...}` (parameter expansion), not
3030///     stryke's `#{...}`. We track `$` immediately followed by `{` to
3031///     enter interpolation; nested `{`/`}` adjusts depth.
3032///   - zsh single-quoted strings don't expand at all, so the `${`
3033///     opener is only honored inside `"..."` and `` `...` ``.
3034///   - Backslash escapes are honored inside `"..."` and backticks; not
3035///     in `'...'` (where `\` is literal).
3036fn position_inside_string_literal(line_text: &str, start: usize, end: usize) -> bool {
3037    let bytes = line_text.as_bytes();
3038    // `$NAME` inside a double-quoted / backtick string is code (a
3039    // parameter reference, expanded at runtime), not opaque text —
3040    // hovering should pop the doc for the variable. Same rule as the
3041    // semantic-tokens kind-aware mask. Two cases:
3042    //   1. `word_span_at` included the `$` sigil (span starts with `$`).
3043    //   2. Span is identifier-only (e.g. cursor mid-name) and the
3044    //      byte immediately before is `$`.
3045    let cap = end.min(bytes.len());
3046    if start < cap
3047        && bytes[start] == b'$'
3048        && bytes[start + 1..cap]
3049            .iter()
3050            .all(|b| b.is_ascii_alphanumeric() || *b == b'_')
3051    {
3052        return false;
3053    }
3054    if start > 0 && start < cap && bytes[start - 1] == b'$' {
3055        let span_ok = bytes[start..cap]
3056            .iter()
3057            .all(|b| b.is_ascii_alphanumeric() || *b == b'_');
3058        if span_ok {
3059            return false;
3060        }
3061    }
3062    let limit = start.min(bytes.len());
3063    let mut i = 0;
3064    let mut in_str: Option<u8> = None;
3065    let mut interp_depth: i32 = 0;
3066    while i < limit {
3067        let c = bytes[i];
3068        // Inside `${...}` interpolation — track nested braces and exit
3069        // when depth returns to 0.
3070        if interp_depth > 0 {
3071            match c {
3072                b'{' => interp_depth += 1,
3073                b'}' => interp_depth -= 1,
3074                _ => {}
3075            }
3076            i += 1;
3077            continue;
3078        }
3079        if let Some(q) = in_str {
3080            if (q == b'"' || q == b'`') && c == b'\\' && i + 1 < bytes.len() {
3081                i += 2;
3082                continue;
3083            }
3084            // `${` opens a code-context interpolation inside `"..."`
3085            // and `` `...` ``. Single-quoted strings don't expand.
3086            if (q == b'"' || q == b'`') && c == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{'
3087            {
3088                interp_depth = 1;
3089                i += 2;
3090                continue;
3091            }
3092            if c == q {
3093                in_str = None;
3094            }
3095            i += 1;
3096            continue;
3097        }
3098        match c {
3099            b'#' => return false,
3100            b'"' | b'\'' | b'`' => in_str = Some(c),
3101            _ => {}
3102        }
3103        i += 1;
3104    }
3105    // Cursor inside `${...}` — that's real code, not string text.
3106    if interp_depth > 0 {
3107        return false;
3108    }
3109    if in_str.is_none() {
3110        return false;
3111    }
3112    // Inside an open quote at `start`. The identifier is in-string
3113    // unless the closing quote sits BEFORE `end` AND we walk back to
3114    // out-of-string before `end`. The identifier
3115    // is fully inside the string when the same quote doesn't reappear
3116    // in `[start, end)`.
3117    let q = in_str.unwrap();
3118    let mut j = start;
3119    while j < end.min(bytes.len()) {
3120        if (q == b'"' || q == b'`') && bytes[j] == b'\\' && j + 1 < bytes.len() {
3121            j += 2;
3122            continue;
3123        }
3124        if bytes[j] == q {
3125            return false;
3126        }
3127        j += 1;
3128    }
3129    true
3130}
3131
3132/// Classify the identifier at `[start, end)` of `line_text` for hover
3133/// suppression. Exposed so [`hover`] can log the decision and tests can
3134/// pin every case without faking up a whole document.
3135pub(crate) fn classify_hover_position(line_text: &str, start: usize, end: usize) -> HoverGate {
3136    if line_starts_comment_before(line_text, start) {
3137        return HoverGate::Comment;
3138    }
3139    if position_inside_string_literal(line_text, start, end) {
3140        return HoverGate::StringLiteral;
3141    }
3142    HoverGate::Code
3143}
3144
3145/// Return the byte span `[start, end)` of the identifier touching `col`
3146/// on `line_text`. Mirrors the walk in [`word_at`] but returns the span
3147/// instead of the slice — needed by [`classify_hover_position`] so the
3148/// gate sees the same range the doc card would render.
3149fn word_span_at(line_text: &str, col: usize) -> Option<(usize, usize)> {
3150    let bytes = line_text.as_bytes();
3151    if col > bytes.len() {
3152        return None;
3153    }
3154    // Phase 1: strict identifier walk (same as `word_at`).
3155    let mut start = col;
3156    while start > 0 {
3157        let c = bytes[start - 1] as char;
3158        if c == '_' || c.is_alphanumeric() || c == '$' {
3159            start -= 1;
3160        } else {
3161            break;
3162        }
3163    }
3164    let mut end = col;
3165    while end < bytes.len() {
3166        let c = bytes[end] as char;
3167        if c == '_' || c.is_alphanumeric() {
3168            end += 1;
3169        } else {
3170            break;
3171        }
3172    }
3173    if start == end {
3174        return None;
3175    }
3176    // Phase 2: extend through `-IDENT` segments for zsh function/command
3177    // names. Skipped when this is a parameter expansion (`$var` or
3178    // `${var…}`) since variable names forbid `-` per `iident`.
3179    let is_dollar_var = bytes[start] == b'$';
3180    let in_braced = start > 0 && bytes[start - 1] == b'{';
3181    if !is_dollar_var && !in_braced {
3182        while end < bytes.len() && bytes[end] == b'-' {
3183            let mut p = end + 1;
3184            while p < bytes.len() {
3185                let c = bytes[p] as char;
3186                if c == '_' || c.is_alphanumeric() {
3187                    p += 1;
3188                } else {
3189                    break;
3190                }
3191            }
3192            if p > end + 1 {
3193                end = p;
3194            } else {
3195                break;
3196            }
3197        }
3198        while start > 1 && bytes[start - 1] == b'-' {
3199            let mut p = start - 1;
3200            while p > 0 {
3201                let c = bytes[p - 1] as char;
3202                if c == '_' || c.is_alphanumeric() {
3203                    p -= 1;
3204                } else {
3205                    break;
3206                }
3207            }
3208            if p < start - 1 {
3209                start = p;
3210            } else {
3211                break;
3212            }
3213        }
3214    }
3215    Some((start, end))
3216}
3217
3218/// True if a bare `#` (comment opener) appears in `line[..end]` outside
3219/// any `"..."` / `'...'` / `` `...` `` string literal. Handles both shebang
3220/// (`#!/usr/bin/env zsh` — `#` at column 0) and inline comments
3221/// (`echo hi; # call cd later`).
3222///
3223/// String-aware so `echo "x #y"` doesn't false-positive — the `#` inside
3224/// the literal opens nothing in zsh. Backslash-escapes inside double-
3225/// quoted strings are honored. zsh single-quoted strings don't process
3226/// escapes, but a closing `'` always terminates, so the simple state
3227/// machine still works.
3228/// True if byte position `end` on `line` is inside a string literal
3229/// (`"..."`, `'...'`, `` `...` ``) OR if a `#` line-comment has started
3230/// before `end`. Used by `references` / `rename` to suppress textual
3231/// matches that occur inside string content or comment text — those
3232/// are not real code references and should not surface in Find Usages.
3233/// True if `col` (a byte column on `line`) sits inside a string
3234/// literal where completion should be SUPPRESSED. Specifically:
3235///   * Inside `"..."` literal text → suppress (user is typing prose,
3236///     not shell code).
3237///   * Inside `'...'` single-quoted → suppress (opaque to expansion).
3238///   * Inside `$'...'` ANSI-C quoted → suppress.
3239/// EXCEPT when we're nested inside a substitution that resumes shell
3240/// grammar:
3241///   * `$(...)` command substitution → shell code, allow completion.
3242///   * `` `...` `` backtick command substitution → allow completion.
3243///   * `${...}` parameter expansion → allow (variable names useful).
3244///
3245/// Walks the line char-by-char tracking the innermost open
3246/// container. A trailing `$(` / `` ` `` / `${` un-opens any
3247/// surrounding quotes for completion purposes.
3248pub(crate) fn cursor_in_uninterpolated_string(line: &str, col: usize) -> bool {
3249    let bytes = line.as_bytes();
3250    let cap = col.min(bytes.len());
3251    // Stack of open containers — `'"', '\'', '`'` for strings,
3252    // `'('` for `$(...)`, `'{'` for `${...}`. The TOP of the stack
3253    // tells us what context the cursor sits in.
3254    let mut stack: Vec<u8> = Vec::new();
3255    let mut i = 0;
3256    while i < cap {
3257        let c = bytes[i];
3258        let top = stack.last().copied();
3259        // Escapes — only `\X` inside double-quoted / backtick strings.
3260        // Single-quoted is opaque (no escapes).
3261        if matches!(top, Some(b'"') | Some(b'`')) && c == b'\\' && i + 1 < cap {
3262            i += 2;
3263            continue;
3264        }
3265        match top {
3266            // Inside single-quote — only `'` closes.
3267            Some(b'\'') => {
3268                if c == b'\'' {
3269                    stack.pop();
3270                }
3271                i += 1;
3272                continue;
3273            }
3274            // Inside double-quote — `"` closes, OR enter sub/expansion.
3275            Some(b'"') => {
3276                if c == b'"' {
3277                    stack.pop();
3278                    i += 1;
3279                    continue;
3280                }
3281                if c == b'$' && i + 1 < cap {
3282                    let nxt = bytes[i + 1];
3283                    if nxt == b'(' {
3284                        stack.push(b'(');
3285                        i += 2;
3286                        continue;
3287                    }
3288                    if nxt == b'{' {
3289                        stack.push(b'{');
3290                        i += 2;
3291                        continue;
3292                    }
3293                }
3294                if c == b'`' {
3295                    stack.push(b'`');
3296                    i += 1;
3297                    continue;
3298                }
3299                i += 1;
3300                continue;
3301            }
3302            // Inside backtick — `` ` `` closes, `$(` / `${` nest.
3303            Some(b'`') => {
3304                if c == b'`' {
3305                    stack.pop();
3306                    i += 1;
3307                    continue;
3308                }
3309                if c == b'$' && i + 1 < cap {
3310                    let nxt = bytes[i + 1];
3311                    if nxt == b'(' {
3312                        stack.push(b'(');
3313                        i += 2;
3314                        continue;
3315                    }
3316                    if nxt == b'{' {
3317                        stack.push(b'{');
3318                        i += 2;
3319                        continue;
3320                    }
3321                }
3322                i += 1;
3323                continue;
3324            }
3325            // Inside `$(…)` — `)` closes, quotes / nested subst open.
3326            Some(b'(') => {
3327                if c == b')' {
3328                    stack.pop();
3329                    i += 1;
3330                    continue;
3331                }
3332                // Fall through to top-level handling for nested
3333                // strings / substitutions.
3334            }
3335            // Inside `${…}` — `}` closes.
3336            Some(b'{') => {
3337                if c == b'}' {
3338                    stack.pop();
3339                    i += 1;
3340                    continue;
3341                }
3342                // Fall through to top-level.
3343            }
3344            _ => {}
3345        }
3346        // Top-level (or inside `$()` / `${}`) — track new openers.
3347        match c {
3348            b'"' => stack.push(b'"'),
3349            b'\'' => stack.push(b'\''),
3350            b'`' => stack.push(b'`'),
3351            b'$' if i + 1 < cap => {
3352                let nxt = bytes[i + 1];
3353                if nxt == b'(' {
3354                    stack.push(b'(');
3355                    i += 2;
3356                    continue;
3357                }
3358                if nxt == b'{' {
3359                    stack.push(b'{');
3360                    i += 2;
3361                    continue;
3362                }
3363                if nxt == b'\'' {
3364                    // `$'...'` ANSI-C — push single-quote so the
3365                    // body counts as opaque-string for completion.
3366                    stack.push(b'\'');
3367                    i += 2;
3368                    continue;
3369                }
3370            }
3371            b'#' => {
3372                // `#` only starts a comment at statement-start position.
3373                // Inside strings / subs this branch isn't reached anyway
3374                // (top is non-None). At top-level treat the rest as a
3375                // comment — cursor inside a comment also suppresses
3376                // shell-code completion. Caveat: `(#…)` is zsh's
3377                // extended-glob pattern modifier syntax, NOT a comment
3378                // — so `(` is EXCLUDED from the comment-open precedents.
3379                let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
3380                let comment_open = matches!(
3381                    prev,
3382                    None | Some(b' ') | Some(b'\t') | Some(b';') | Some(b'&') | Some(b'|')
3383                );
3384                if comment_open {
3385                    return true;
3386                }
3387            }
3388            _ => {}
3389        }
3390        i += 1;
3391    }
3392    // Cursor is in an UNINTERPOLATED string when the innermost open
3393    // container is `"` / `'` (NOT a `$(…)` / `${…}` / backtick).
3394    matches!(stack.last().copied(), Some(b'"') | Some(b'\''))
3395}
3396
3397pub(crate) fn line_position_inside_string_or_comment(line: &str, end: usize) -> bool {
3398    let bytes = line.as_bytes();
3399    let cap = end.min(bytes.len());
3400    let mut in_dq = false;
3401    let mut in_sq = false;
3402    let mut in_bt = false;
3403    let mut i = 0;
3404    while i < cap {
3405        let c = bytes[i];
3406        if in_dq {
3407            if c == b'\\' && i + 1 < cap {
3408                i += 2;
3409                continue;
3410            }
3411            if c == b'"' {
3412                in_dq = false;
3413            }
3414        } else if in_sq {
3415            if c == b'\'' {
3416                in_sq = false;
3417            }
3418        } else if in_bt {
3419            if c == b'\\' && i + 1 < cap {
3420                i += 2;
3421                continue;
3422            }
3423            if c == b'`' {
3424                in_bt = false;
3425            }
3426        } else if c == b'#' {
3427            return true;
3428        } else if c == b'"' {
3429            in_dq = true;
3430        } else if c == b'\'' {
3431            in_sq = true;
3432        } else if c == b'`' {
3433            in_bt = true;
3434        }
3435        i += 1;
3436    }
3437    in_dq || in_sq || in_bt
3438}
3439
3440/// Like [`line_position_inside_string_or_comment`] but ONLY flags
3441/// positions that zsh would NOT interpolate parameters in:
3442///   * inside `'...'` single-quoted strings (opaque to expansion)
3443///   * after a `#` line-comment
3444///
3445/// Use this when scanning for variable references — `$VAR` inside
3446/// `"..."` (and inside backticks) IS a real reference because zsh
3447/// interpolates parameters in both contexts.
3448pub(crate) fn line_position_inside_uninterpolating_context(line: &str, end: usize) -> bool {
3449    let bytes = line.as_bytes();
3450    let cap = end.min(bytes.len());
3451    let mut in_dq = false;
3452    let mut in_sq = false;
3453    let mut in_bt = false;
3454    let mut i = 0;
3455    while i < cap {
3456        let c = bytes[i];
3457        if in_dq {
3458            if c == b'\\' && i + 1 < cap {
3459                i += 2;
3460                continue;
3461            }
3462            if c == b'"' {
3463                in_dq = false;
3464            }
3465        } else if in_sq {
3466            if c == b'\'' {
3467                in_sq = false;
3468            }
3469        } else if in_bt {
3470            if c == b'\\' && i + 1 < cap {
3471                i += 2;
3472                continue;
3473            }
3474            if c == b'`' {
3475                in_bt = false;
3476            }
3477        } else if c == b'#' {
3478            // `#` only opens a comment when preceded by whitespace /
3479            // statement boundary; `$#` (argc), `${#var}` (length),
3480            // etc. don't. The rule is: the previous
3481            // byte is whitespace or a statement/subshell boundary, or the
3482            // `#` starts the line.
3483            let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
3484            let starts_comment = match prev {
3485                None => true,
3486                Some(p) => matches!(p, b' ' | b'\t' | b';' | b'&' | b'|' | b'('),
3487            };
3488            if starts_comment {
3489                return true;
3490            }
3491        } else if c == b'"' {
3492            in_dq = true;
3493        } else if c == b'\'' {
3494            in_sq = true;
3495        } else if c == b'`' {
3496            in_bt = true;
3497        }
3498        i += 1;
3499    }
3500    // Only `in_sq` masks — double-quoted and backtick contexts
3501    // permit `$VAR` interpolation, so we DON'T mask them.
3502    in_sq
3503}
3504
3505pub(crate) fn line_starts_comment_before(line: &str, end: usize) -> bool {
3506    let bytes = line.as_bytes();
3507    let cap = end.min(bytes.len());
3508    let mut in_dq = false;
3509    let mut in_sq = false;
3510    let mut in_bt = false;
3511    let mut i = 0;
3512    while i < cap {
3513        let c = bytes[i];
3514        if in_dq {
3515            if c == b'\\' && i + 1 < cap {
3516                i += 2;
3517                continue;
3518            }
3519            if c == b'"' {
3520                in_dq = false;
3521            }
3522        } else if in_sq {
3523            if c == b'\'' {
3524                in_sq = false;
3525            }
3526        } else if in_bt {
3527            if c == b'\\' && i + 1 < cap {
3528                i += 2;
3529                continue;
3530            }
3531            if c == b'`' {
3532                in_bt = false;
3533            }
3534        } else {
3535            if c == b'#' {
3536                return true;
3537            }
3538            if c == b'"' {
3539                in_dq = true;
3540            } else if c == b'\'' {
3541                in_sq = true;
3542            } else if c == b'`' {
3543                in_bt = true;
3544            }
3545        }
3546        i += 1;
3547    }
3548    false
3549}
3550/// `lookup_doc` — see implementation.
3551pub fn lookup_doc(name: &str) -> String {
3552    // Upstream-yodl-derived tables come first — they carry the real
3553    // `man zshall` prose. The hand-curated tables below still
3554    // serve as a fallback for any entry the yo parser missed.
3555    //
3556    // Source files (regenerate via `scripts/gen_option_docs.py`):
3557    //   * Doc/Zsh/grammar.yo    → KEYWORD_DOCS    (`lookup_keyword_doc`)
3558    //   * Doc/Zsh/builtins.yo   → BUILTIN_DOCS    (`lookup_builtin_doc`)
3559    //   * Doc/Zsh/params.yo     → SPECIAL_VAR_DOCS (`lookup_special_var_doc`)
3560    //   * Doc/Zsh/options.yo    → OPTION_DOCS     (`lookup_option_doc`)
3561    // Operators / punctuation tokens. Match these BEFORE the yodl
3562    // keyword table — `man zshmisc` documents `&&` / `||` / `>` / `[[`
3563    // etc. in section prose, not per-name `item(tt(NAME))` blocks, so
3564    // the only way to surface them is a hand fallback.
3565    if let Some(d) = OPERATOR_DOCS.iter().find(|(k, _)| *k == name) {
3566        return format!("**{}** — _zsh operator_\n\n{}", d.0, d.1);
3567    }
3568    if let Some((canon, body)) = crate::zsh_keyword_docs::lookup_keyword_doc(name) {
3569        return format!("**{}** — _zsh keyword_\n\n{}", canon, body);
3570    }
3571    // Hard-classify canonical reserved words BEFORE consulting the
3572    // yodl builtin table — but only when the name ISN'T also a real
3573    // builtin in `ported::builtin::BUILTINS`. `mod_complist.yo`
3574    // (LS_COLORS docs) defines `item(tt(fi 0))(for regular files)`,
3575    // `item(tt(no 0))(...)`, `item(tt(do 0))(...)` etc. The multi-word
3576    // `tt(NAME N)` regex in the gen script extracts `fi` / `no` / `do`
3577    // as builtin names — without this guard, hover on `fi` returned
3578    // "fi — zsh builtin: for regular files". The declarers (`export`,
3579    // `typeset`, `float`, `integer`, etc.) ARE real builtins so they
3580    // must keep flowing to the substantive yodl builtin doc.
3581    let is_keyword = crate::ported::hashtable::RESWDS
3582        .iter()
3583        .any(|(n, _)| *n == name);
3584    let is_real_builtin = crate::ported::builtin::BUILTINS
3585        .iter()
3586        .any(|b| b.node.nam == name);
3587    if is_keyword && !is_real_builtin {
3588        if let Some(d) = KEYWORD_DOCS.iter().find(|(k, _)| *k == name) {
3589            return format!("**{}** — _zsh keyword_\n\n{}", d.0, d.1);
3590        }
3591        // Reserved word with no hand fallback — emit the bare keyword header
3592        // instead of falling through to a bogus builtin entry.
3593        return format!("**{}** — _zsh keyword_", name);
3594    }
3595    // Extension-builtin classification wins over yodl-builtin lookup
3596    // when the same name exists as both. `date` is the textbook case:
3597    // upstream zsh has it in `zsh/datetime` module (so the yodl
3598    // builtin table has an entry), but zshrs ships it as an
3599    // always-available extension (no `zmodload` required). Showing
3600    // "zshrs builtin" reflects the runtime reality the user sees.
3601    // Also covers `sched`, `stat` / `zstat`, `strftime`, etc.
3602    let is_extension = crate::ext_builtins::EXT_BUILTIN_NAMES.contains(&name)
3603        || crate::daemon::builtins::ZSHRS_BUILTIN_NAMES.contains(&name);
3604    if is_extension {
3605        if let Some(body) = crate::zsh_ext_builtin_docs::lookup_full(name) {
3606            return format!("**{}** — _zshrs extension builtin_\n\n{}", name, body);
3607        }
3608        if let Some(d) = EXT_BUILTIN_DOCS.iter().find(|(k, _)| *k == name) {
3609            return format!("**{}** — _zshrs extension builtin_\n\n{}", d.0, d.1);
3610        }
3611    }
3612    // For names that AREN'T real builtins per the canonical
3613    // `ported::builtin::BUILTINS` table, prefer the special-var
3614    // classification if the name has a special-var doc. Names like
3615    // `prompt`, `path`, `aliases`, `functions`, `history`,
3616    // `jobdirs`, `commands`, … all have `item(tt(NAME))` blocks in
3617    // module yo files (zsh/parameter, contrib, etc.) that describe
3618    // them as PARAMETERS. The builtin extractor pulled them into
3619    // BUILTIN_DOCS as a side-effect (the yodl format doesn't
3620    // distinguish builtin-name `item` from parameter-name `item`),
3621    // shadowing the special-var doc. 109 names overlap; the only
3622    // genuine builtins among them are zero — they're all params.
3623    if !is_real_builtin {
3624        if let Some((canon, body)) = crate::zsh_special_var_docs::lookup_special_var_doc(name) {
3625            return format!("**${}** — _special variable_\n\n{}", canon, body);
3626        }
3627        let bare = name.strip_prefix('$').unwrap_or(name);
3628        if !bare.is_empty() && bare != name {
3629            if let Some((canon, body)) = crate::zsh_special_var_docs::lookup_special_var_doc(bare) {
3630                return format!("**${}** — _special variable_\n\n{}", canon, body);
3631            }
3632        }
3633    }
3634    if let Some((canon, body)) = crate::zsh_builtin_docs::lookup_builtin_doc(name) {
3635        // The doc table is one flat map over every `man zsh*` item, so
3636        // `_arguments` and `_files` live in it beside `print` and `cd`.
3637        // They are shell functions from `zshcompsys`, not builtins —
3638        // calling them builtins in the hover card is simply wrong, and
3639        // it is the card an author sees while writing a completer.
3640        let kind = if crate::compsys::COMPSYS_FN_NAMES.contains(&canon) {
3641            "_compsys function_"
3642        } else {
3643            "_zsh builtin_"
3644        };
3645        return format!("**{}** — {}\n\n{}", canon, kind, body);
3646    }
3647    // Special vars: try the raw name first (so `$` resolves to its
3648    // own `$$` PID entry stored as canonical `"$"` in the doc
3649    // table), then the bare-stripped form (`$VAR` → `VAR`). Pure-
3650    // symbolic specials (`$`, `?`, `*`, `#`, `@`, `-`, `_`) are
3651    // stored under their bare-symbol key so naive `strip_prefix('$')`
3652    // on `"$"` strips the actual lookup key to empty.
3653    if let Some((canon, body)) = crate::zsh_special_var_docs::lookup_special_var_doc(name) {
3654        return format!("**${}** — _special variable_\n\n{}", canon, body);
3655    }
3656    let bare = name.strip_prefix('$').unwrap_or(name);
3657    if !bare.is_empty() && bare != name {
3658        if let Some((canon, body)) = crate::zsh_special_var_docs::lookup_special_var_doc(bare) {
3659            return format!("**${}** — _special variable_\n\n{}", canon, body);
3660        }
3661    }
3662    if let Some((canon, body)) = crate::zsh_option_docs::lookup_option_doc(name) {
3663        return format!("**{}** — _zsh option_\n\n{}", canon, body);
3664    }
3665    // Hand-curated table fallback for anything still uncovered.
3666    if let Some(d) = KEYWORD_DOCS.iter().find(|(k, _)| *k == name) {
3667        return format!("**{}** — _zsh keyword_\n\n{}", d.0, d.1);
3668    }
3669    if let Some(d) = BUILTIN_DOCS.iter().find(|(k, _)| *k == name) {
3670        return format!("**{}** — _zsh builtin_\n\n{}", d.0, d.1);
3671    }
3672    if name.starts_with('$') {
3673        if let Some(d) = SPECIAL_VAR_DOCS.iter().find(|(k, _)| *k == name) {
3674            return format!("**{}** — _special variable_\n\n{}", d.0, d.1);
3675        }
3676    }
3677    // Full doc-comment body (extracted from source `///` blocks by
3678    // `scripts/gen_ext_builtin_docs.py`). Wins over the hand one-liner
3679    // in EXT_BUILTIN_DOCS — the user's complaint was that `zwhere`/
3680    // `zd` etc. were returning one-line summaries when the source has
3681    // rich multi-paragraph descriptions.
3682    if let Some(body) = crate::zsh_ext_builtin_docs::lookup_full(name) {
3683        return format!("**{}** — _zshrs extension builtin_\n\n{}", name, body);
3684    }
3685    if let Some(d) = EXT_BUILTIN_DOCS.iter().find(|(k, _)| *k == name) {
3686        return format!("**{}** — _zshrs extension builtin_\n\n{}", d.0, d.1);
3687    }
3688    if let Some(d) = COMPSYS_FN_DOCS.iter().find(|(k, _)| *k == name) {
3689        return format!("**{}** — _compsys function_\n\n{}", d.0, d.1);
3690    }
3691    String::new()
3692}
3693
3694/// Hand-curated docs for every shell operator / punctuation token.
3695/// Sourced from `man zshmisc` — Pipelines, Simple Commands & Pipelines
3696/// (lists), Complex Commands, Reserved Words; `man zshparam` for
3697/// expansion forms; `man zshmisc` Conditional Expressions for `[[ … ]]`
3698/// operators; `man zshexpn` for substitution / brace expansion.
3699///
3700/// These don't have per-name `item(tt(X))` blocks in any yodl file —
3701/// they're documented in section prose, so the gen script has nothing
3702/// to extract. Hand-bodies are the only path to hover docs for them.
3703const OPERATOR_DOCS: &[(&str, &str)] = &[
3704    // ── Pipelines ────────────────────────────────────────────────────
3705    ("|",   "Pipeline. `cmd1 | cmd2` connects `cmd1`'s stdout to `cmd2`'s stdin. Each stage runs in a separate process; exit status is the last stage's (unless `PIPE_FAIL` is set, in which case the first non-zero in the chain wins)."),
3706    ("|&",  "Pipeline merging stderr. `cmd1 |& cmd2` = `cmd1 2>&1 | cmd2`. Both stdout AND stderr of `cmd1` are piped to `cmd2`."),
3707    // ── Lists ────────────────────────────────────────────────────────
3708    ("&&",  "Logical AND list operator. `cmd1 && cmd2` runs `cmd2` only if `cmd1` succeeded (exit status 0). Short-circuits."),
3709    ("||",  "Logical OR list operator. `cmd1 || cmd2` runs `cmd2` only if `cmd1` failed (non-zero exit). Short-circuits."),
3710    (";",   "Sequential list separator. `cmd1; cmd2` runs `cmd2` after `cmd1` finishes, regardless of its exit status."),
3711    ("&",   "Background list operator. `cmd &` runs `cmd` asynchronously in the background; the shell does not wait. Sets `$!` to the job's PID."),
3712    (";;",  "Case-branch terminator. Ends a `case` arm: `case x in pat) cmds ;; esac`. Stops case dispatch after this arm."),
3713    (";;&", "Case-branch fall-through-and-test-next. Continues to the next `case` arm and tests its pattern."),
3714    (";|",  "Case-branch unconditional fall-through. Continues to the next `case` arm and runs it without testing its pattern."),
3715    // ── Negation ─────────────────────────────────────────────────────
3716    ("!",   "Pipeline negation (also a reserved word). `! cmd` inverts `cmd`'s exit status — zero becomes 1, non-zero becomes 0. Distinct from `!` history expansion (lexer-stage)."),
3717    // ── Redirection ──────────────────────────────────────────────────
3718    (">",   "Stdout redirect. `cmd > file` writes `cmd`'s stdout to `file` (overwrite). With `NO_CLOBBER`, refuses to overwrite an existing file — use `>|` or `>!` to force."),
3719    (">>",  "Stdout append. `cmd >> file` appends `cmd`'s stdout to `file` (creates if missing)."),
3720    ("<",   "Stdin redirect. `cmd < file` makes `file` the source of `cmd`'s stdin."),
3721    ("<<",  "Heredoc start. `cmd <<MARKER` reads the following lines as `cmd`'s stdin until a line containing only `MARKER`. Variants: `<<-` strips leading tabs; `<<'MARKER'` disables expansion in the body."),
3722    ("<<-", "Heredoc with tab-stripping. Like `<<` but every leading tab on body lines (and the terminator) is removed — lets you indent the heredoc for readability."),
3723    ("<<<", "Here-string. `cmd <<< 'text'` makes the literal string `text` the source of `cmd`'s stdin. Adds a trailing newline."),
3724    ("&>",  "Redirect stdout + stderr together. `cmd &> file` = `cmd > file 2>&1`. Shorthand for the common combined redirect."),
3725    ("&>>", "Append stdout + stderr together. `cmd &>> file` = `cmd >> file 2>&1`."),
3726    (">&",  "Redirect a file descriptor. `2>&1` sends stderr to wherever stdout currently points. `>& file` is also accepted as `&> file`."),
3727    ("<&",  "Duplicate an input file descriptor. `cmd <&3` reads from fd 3. `<& -` closes stdin."),
3728    ("<>",  "Read+write redirect. `cmd <> file` opens `file` for both reading and writing on stdin."),
3729    (">|",  "Force-overwrite redirect. Equivalent to `>` but ignores `NO_CLOBBER`."),
3730    (">!",  "Same as `>|` — force-overwrite, bypass `NO_CLOBBER`."),
3731    // ── Conditional expressions ──────────────────────────────────────
3732    ("[[",  "Open zsh conditional expression. `[[ EXPR ]]` evaluates a boolean. No word splitting / glob inside; supports `&&`, `||`, `!`, `==`, `!=`, `=~`, `-e`, `-f`, `-d`, `-z`, `-n`, etc. Prefer this over `[ ]` in zsh."),
3733    ("]]",  "Close zsh conditional expression. Pairs with `[[`. Must be a separate word — `[[ -n $x]]` is a syntax error; use `[[ -n $x ]]`."),
3734    ("[",   "POSIX `test` command (also spelled `test`). Same conditional semantics as POSIX `test`. Prefer `[[ … ]]` in zsh — it's safer (no word splitting) and supports more operators."),
3735    ("]",   "Close POSIX `test`. Pairs with `[`."),
3736    ("((",  "Open arithmetic command. `(( EXPR ))` evaluates `EXPR` as C-style integer arithmetic; exit 0 if the result is non-zero, 1 otherwise. Inside, `$` on var names is optional: `(( i++ ))`."),
3737    ("))",  "Close arithmetic command. Pairs with `((`."),
3738    // ── Command / parameter / arithmetic substitution ────────────────
3739    ("$(",  "Command substitution open. `$(cmd)` runs `cmd` and substitutes its trimmed-trailing-newline stdout. Nestable: `$(echo $(date))`. Preferred over backticks."),
3740    ("${",  "Parameter expansion open. `${VAR}` is the value of `VAR`. Rich modifier set: `${VAR:-default}`, `${VAR:=assign}`, `${VAR:+alt}`, `${#VAR}` length, `${VAR/p/r}` replace, `${VAR%suffix}` / `${VAR#prefix}` strip, `${(flags)VAR}` zsh flags."),
3741    ("$((", "Arithmetic expansion open. `$(( EXPR ))` evaluates `EXPR` as integer arithmetic and substitutes the result as a string. Distinct from `(( … ))` which is a command, not an expansion."),
3742    ("<(",  "Process substitution (input). `cmd <(producer)` exposes `producer`'s stdout as a filename (`/dev/fd/N`) to `cmd`. Lets commands that take filenames consume pipe output."),
3743    (">(",  "Process substitution (output). `cmd >(consumer)` exposes a filename to `cmd`; anything `cmd` writes there flows to `consumer`'s stdin."),
3744    ("`",   "Backtick command substitution. ``cmd`` runs `cmd` and substitutes its stdout. Legacy form — prefer `$(cmd)` for nestability and quoting clarity."),
3745    // ── Test-operator unaries (most common) ──────────────────────────
3746    ("-e",  "File-exists test. `[[ -e PATH ]]` is true if `PATH` exists (any type — file / dir / link / socket / ...)."),
3747    ("-f",  "Regular-file test. `[[ -f PATH ]]` is true if `PATH` exists AND is a regular file (not a directory / symlink / device)."),
3748    ("-d",  "Directory test. `[[ -d PATH ]]` is true if `PATH` exists AND is a directory."),
3749    ("-r",  "Readable test. `[[ -r PATH ]]` is true if `PATH` exists AND is readable by the current process."),
3750    ("-w",  "Writable test. `[[ -w PATH ]]` is true if `PATH` exists AND is writable by the current process."),
3751    ("-x",  "Executable test. `[[ -x PATH ]]` is true if `PATH` exists AND has execute permission (or for directories, search permission)."),
3752    ("-s",  "Non-empty test. `[[ -s PATH ]]` is true if `PATH` exists AND has size > 0."),
3753    ("-L",  "Symlink test. `[[ -L PATH ]]` is true if `PATH` is a symbolic link (does NOT dereference)."),
3754    ("-h",  "Same as `-L` — symlink test."),
3755    ("-z",  "Empty-string test. `[[ -z $s ]]` is true if `$s` is the empty string."),
3756    ("-n",  "Non-empty-string test. `[[ -n $s ]]` is true if `$s` has length > 0. Equivalent to `[[ $s ]]`."),
3757    // ── Test-operator binaries (numeric) ─────────────────────────────
3758    ("-eq", "Numeric equality. `[[ a -eq b ]]` is true if integers `a` and `b` are equal. For strings use `==`."),
3759    ("-ne", "Numeric inequality. `[[ a -ne b ]]` is true if integers `a` and `b` differ."),
3760    ("-lt", "Numeric less-than. `[[ a -lt b ]]` is true if integer `a` < `b`."),
3761    ("-le", "Numeric less-or-equal. `[[ a -le b ]]` is true if integer `a` ≤ `b`."),
3762    ("-gt", "Numeric greater-than. `[[ a -gt b ]]` is true if integer `a` > `b`."),
3763    ("-ge", "Numeric greater-or-equal. `[[ a -ge b ]]` is true if integer `a` ≥ `b`."),
3764    ("-ot", "Older-than test. `[[ A -ot B ]]` is true if file `A` has an older mtime than `B`."),
3765    ("-nt", "Newer-than test. `[[ A -nt B ]]` is true if file `A` has a newer mtime than `B`."),
3766    ("-ef", "Same-file test. `[[ A -ef B ]]` is true if `A` and `B` are the same inode (hard-linked / same path)."),
3767    // ── String / pattern operators (inside [[ … ]]) ──────────────────
3768    ("==",  "Pattern-match equality (inside `[[ … ]]`). `[[ $s == pat* ]]` matches `$s` against the glob pattern `pat*`. RHS is a pattern unless quoted. For literal equality, quote: `[[ $s == \"literal\" ]]`."),
3769    ("!=",  "Pattern-mismatch (inside `[[ … ]]`). Inverse of `==`. Quote the RHS for literal comparison."),
3770    ("=~",  "Regex match (inside `[[ … ]]`). `[[ $s =~ pat ]]` matches `$s` against the regex `pat`. Capture groups land in `$match` / `$MATCH` / `$BASH_REMATCH`."),
3771    // ── Glob / pattern characters ────────────────────────────────────
3772    ("*",   "Glob: match zero or more characters of any name (excluding leading `.` unless `GLOB_DOTS` is set). Also a multiplication operator inside `(( … ))`."),
3773    ("?",   "Glob: match exactly one character. Also the last-exit-status variable when used as `$?`."),
3774    ("**",  "Recursive glob (zsh extended). `**/*.rs` matches `*.rs` at any depth under the current directory. Requires `EXTENDED_GLOB` for additional pattern operators."),
3775    ("~",   "Pattern exclude (with `EXTENDED_GLOB`). `*~README` matches everything except `README`. Also tilde expansion: `~` = `$HOME`, `~user` = user's home, `~+` = `$PWD`, `~-` = `$OLDPWD`."),
3776    ("^",   "Pattern negate first-match (with `EXTENDED_GLOB`). `^*.rs` matches everything that's NOT `*.rs`. Inside `[…]` ranges, negates: `[^abc]`."),
3777    // ── Brace expansion ──────────────────────────────────────────────
3778    ("{a,b,c}", "Brace expansion (literal list). Expands to multiple words: `cp file.{txt,bak}` becomes `cp file.txt file.bak`. No whitespace before commas."),
3779    ("{1..10}", "Brace range expansion. `{1..10}` expands to `1 2 3 4 5 6 7 8 9 10`. Supports step: `{1..10..2}` → `1 3 5 7 9`. Letters work too: `{a..z}`."),
3780    // ── Assignment ───────────────────────────────────────────────────
3781    ("=",   "Assignment. `VAR=value`. NO whitespace around `=`. With `local` / `typeset`: `local VAR=value` declares + assigns."),
3782    ("+=",  "Append assignment. `VAR+=more` appends to a scalar; for arrays `arr+=(x y)` appends elements. Numeric for `integer`: `(( count += 1 ))`."),
3783    (":=",  "Conditional-assign default (inside `${…}`). `${VAR:=fallback}` assigns `fallback` to `VAR` (and substitutes it) if `VAR` is unset or empty."),
3784    ("?=",  "Error-if-unset (inside `${…}`). `${VAR:?msg}` substitutes `$VAR` if set, else prints `msg` to stderr and exits."),
3785];
3786
3787/// `${(FLAGS)var}` parameter expansion flags. Single-char flags + a
3788/// few `(F:string:)` colon-delimited args. Surfaced as LSP completion
3789/// items when the cursor sits inside `${(…)` before the closing `)`.
3790/// Same list zsh's compsys `_parameter_flags` produces — verified
3791/// against `man zshexpn` "Parameter Expansion Flags".
3792const PARAM_FLAG_DOCS: &[(&str, &str)] = &[
3793    ("-", "sort decimal integers numerically (signed)"),
3794    ("@", "prevent double-quoted joining of arrays"),
3795    ("*", "enable extended globs for pattern arguments"),
3796    ("#", "interpret numeric expression as character code"),
3797    ("%", "expand prompt sequences (`%P` for prompt-only escapes)"),
3798    ("~", "treat strings in parameter flag arguments as patterns"),
3799    ("0", "split words on null bytes"),
3800    ("A", "assign as an array parameter (in `${...=...}` etc)"),
3801    ("a", "sort in array index order (with `O` to reverse)"),
3802    ("b", "backslash-quote pattern characters only"),
3803    ("B", "include index of beginning of match in `#`, `%` expressions"),
3804    ("C", "capitalize words"),
3805    ("c", "count characters in an array (with `${(c)#...}`)"),
3806    ("D", "perform directory name abbreviation"),
3807    ("E", "include index of one past end of match in `#`, `%` expressions"),
3808    ("e", "perform single-word shell expansions"),
3809    ("F", "join arrays with newlines"),
3810    ("f", "split the result on newlines"),
3811    ("g", "process echo array sequences (needs options like `gec`)"),
3812    ("I", "search Nth match in `#`, `%`, `/` expressions (`(I:N:)`)"),
3813    ("i", "sort case-insensitively"),
3814    ("j", "join arrays with specified string (`(j:STR:)`)"),
3815    ("k", "substitute keys of associative arrays"),
3816    ("l", "left-pad resulting words (`(l:N:)`, `(l:N::pad:)`)"),
3817    ("L", "lower case all letters"),
3818    ("m", "count multibyte width in padding calculation"),
3819    ("M", "include matched portion in `#`, `%` expressions"),
3820    ("N", "include length of match in `#`, `%` expressions"),
3821    ("n", "sort positive decimal integers numerically (unsigned)"),
3822    ("o", "sort in ascending order (lexically if no other sort option)"),
3823    ("O", "sort in descending order (lexically if no other sort option)"),
3824    ("p", "handle print escapes in parameter flag string arguments"),
3825    ("P", "use parameter value as name of parameter for redirected lookup"),
3826    ("q", "quote with backslashes (`q-` shell-quote, `qq` single-quote, `qqq` double-quote, `qqqq` $'...')"),
3827    ("Q", "remove one level of quoting"),
3828    ("R", "include rest (unmatched portion) in `#`, `%` expressions"),
3829    ("r", "right-pad resulting words (`(r:N:)`, `(r:N::pad:)`)"),
3830    ("S", "match non-greedy in `/`, `//`, or search substrings in `%`/`#` expressions"),
3831    ("s", "split words on specified string (`(s:STR:)`)"),
3832    ("t", "substitute type of parameter (`scalar`, `array`, `association`, `integer`, `float`, plus flags)"),
3833    ("u", "substitute first occurrence of each unique word"),
3834    ("U", "upper case all letters"),
3835    ("v", "substitute values of associative arrays (with `k`)"),
3836    ("V", "visibility enhancements for special characters"),
3837    ("w", "count words in array or string (with `${(w)#...}`)"),
3838    ("W", "count words including empty words (with `${(W)#...}`)"),
3839    ("X", "report parsing errors and exit substitution on failure"),
3840    ("z", "split words as if a zsh command line"),
3841    ("Z", "split words as if a zsh command line (with options — `(Z:cn:)`, `(Z:Cn:)`)"),
3842];
3843
3844/// Glob qualifiers — letters inside `*(…)` / `pattern(…)` that restrict
3845/// the matches. Surfaced as LSP completion when the cursor sits inside
3846/// an unclosed paren immediately following a glob meta (`*`, `?`, `]`,
3847/// `)`). Verified against `man zshexpn` "Glob Qualifiers".
3848const GLOB_QUALIFIER_DOCS: &[(&str, &str)] = &[
3849    // ── File types ──
3850    ("/",  "directories"),
3851    ("F",  "non-empty directories"),
3852    (".",  "plain files (regular)"),
3853    ("@",  "symbolic links"),
3854    ("=",  "sockets"),
3855    ("p",  "named pipes (FIFOs)"),
3856    ("*",  "executable plain files (mode `0111`)"),
3857    ("%",  "device files (block or character)"),
3858    // ── Owner / permission ──
3859    ("r",  "owner-readable"),
3860    ("w",  "owner-writable"),
3861    ("x",  "owner-executable"),
3862    ("A",  "group-readable"),
3863    ("I",  "group-writable"),
3864    ("E",  "group-executable"),
3865    ("R",  "world-readable"),
3866    ("W",  "world-writable"),
3867    ("X",  "world-executable"),
3868    ("s",  "setuid"),
3869    ("S",  "setgid"),
3870    ("t",  "sticky bit set"),
3871    ("U",  "owned by current effective uid"),
3872    ("G",  "owned by current effective gid"),
3873    ("u",  "owned by specified uid (`u:LOGIN:` / `u<UID>`)"),
3874    ("g",  "owned by specified gid (`g:GROUP:` / `g<GID>`)"),
3875    ("f",  "exact file mode match (`f:SPEC:`, eg `f:u+w:`)"),
3876    // ── Time / size ──
3877    ("a",  "atime (`a-N` younger than N days, `a+N` older)"),
3878    ("m",  "mtime (`m-N` / `m+N`; suffixes `M`/`w`/`h`/`m`/`s`)"),
3879    ("c",  "ctime (`c-N` / `c+N`)"),
3880    ("L",  "size in bytes (`L-N`, `L+N`, suffixes `k`/`m`/`p`)"),
3881    ("l",  "link count (`l-N` / `l+N`)"),
3882    ("d",  "files on device DEV (`d<DEV>`)"),
3883    // ── Sort / slice / control ──
3884    ("o",  "order ascending (`oN` name, `oL` size, `om` mtime, `oa` atime, `oc` ctime, `od` depth, `oe:cmd:` custom)"),
3885    ("O",  "order descending (same suffixes as `o`)"),
3886    ("[",  "slice / range (`[N]`, `[N,M]`, `[N,-1]`)"),
3887    ("^",  "negate the rest of the qualifier list"),
3888    ("-",  "follow symbolic links when testing subsequent qualifiers"),
3889    ("M",  "mark directories with trailing `/`"),
3890    ("T",  "mark types with file-type indicator (`/=@*%|`)"),
3891    ("N",  "set NULL_GLOB for this glob only (no match → empty)"),
3892    ("D",  "include dotfiles in matches"),
3893    ("n",  "numeric sort (use with `o` / `O`)"),
3894    ("Y",  "early termination after N matches (`Y<N>`)"),
3895    ("P",  "prepend WORD to each result (`P:WORD:`)"),
3896    ("e",  "evaluate expression on each candidate (`e:EXPR:`); `$REPLY` is the filename"),
3897    ("+",  "true if `cmd FILENAME` exits 0 (`+cmd`)"),
3898];
3899
3900/// History event designators — what follows `!` at the start of a
3901/// word. Triggered when the cursor sits after `!` at a word boundary
3902/// (start of line / after `;` / `&` / `|` / `(` / whitespace), not
3903/// inside `((…))` arithmetic. Verified against `man zshexpn` "History
3904/// Expansion → Event Designators".
3905const HISTORY_DESIGNATOR_DOCS: &[(&str, &str)] = &[
3906    ("!", "previous command (`!!`)"),
3907    ("N", "command N from history (`!42`)"),
3908    ("-N", "N commands back (`!-3` = third-to-last)"),
3909    ("str", "most recent command starting with `str` (`!ls`)"),
3910    (
3911        "?str?",
3912        "most recent command containing `str` (`!?docker?`)",
3913    ),
3914    ("#", "current command line typed so far"),
3915    ("$", "last argument of previous command (= `!!:$`)"),
3916    ("^", "first argument of previous command (= `!!:^`)"),
3917    ("*", "all arguments of previous command (= `!!:*`)"),
3918    (
3919        ":",
3920        "introduce a word designator / modifier — `!!:1`, `!!:s/old/new/`, `!!:h`",
3921    ),
3922];
3923
3924/// Parameter expansion + history modifiers — what follows `:` inside
3925/// `${var:…}` and `!event:…`. Combines:
3926///   * Default-value forms (`:-` / `:=` / `:?` / `:+`)
3927///   * Word modifiers (`:h` / `:t` / `:r` / `:e` / `:s/…/…/` etc.)
3928///   * Substring offset (`:N:M`)
3929/// Most modifier letters work in BOTH contexts, so a single table
3930/// drives modifier completion regardless of whether the `:` belongs
3931/// to a `${…}` or a `!…`. Verified against `man zshexpn` "Parameter
3932/// Expansion" + "Modifiers".
3933const PARAM_MODIFIER_DOCS: &[(&str, &str)] = &[
3934    // ── Parameter default-value forms ──
3935    ("-", "`${var:-WORD}` — use WORD if `var` unset or empty"),
3936    (
3937        "=",
3938        "`${var:=WORD}` — assign WORD to `var` (and use it) if unset/empty",
3939    ),
3940    (
3941        "?",
3942        "`${var:?MSG}` — print MSG to stderr + exit if `var` unset/empty",
3943    ),
3944    (
3945        "+",
3946        "`${var:+WORD}` — use WORD if `var` IS set (the inverse of `:-`)",
3947    ),
3948    // ── Substring slicing ──
3949    (
3950        "0",
3951        "`${var:OFFSET:LENGTH}` — substring (zero-based; negative offset = from end)",
3952    ),
3953    // ── Path / file modifiers ──
3954    ("h", "head — strip last path component (like `dirname`)"),
3955    (
3956        "t",
3957        "tail — keep ONLY last path component (like `basename`)",
3958    ),
3959    ("r", "root — strip the final `.ext` suffix"),
3960    (
3961        "e",
3962        "extension — keep ONLY the final `.ext` (no leading dot)",
3963    ),
3964    (
3965        "a",
3966        "absolute — textually resolve `..` / `.` against `$PWD`",
3967    ),
3968    ("A", "absolute + resolve symlinks (like `realpath`)"),
3969    (
3970        "c",
3971        "PATH lookup — replace bare command with full path via `$PATH`",
3972    ),
3973    ("P", "physical path — resolve all symlinks"),
3974    (
3975        "f",
3976        "repeat `:h` until the result is no longer an existing directory",
3977    ),
3978    ("F", "`:F:N:` — repeat `:h` N times"),
3979    // ── Substitution ──
3980    ("s", "`:s/OLD/NEW/` — substitute first OLD with NEW"),
3981    (
3982        "gs",
3983        "`:gs/OLD/NEW/` — global substitute (every occurrence)",
3984    ),
3985    ("&", "repeat the last `:s` substitution"),
3986    ("g&", "repeat the last `:s` substitution globally"),
3987    // ── Quoting ──
3988    ("q", "quote — backslash-escape all metacharacters"),
3989    ("Q", "unquote — remove ONE level of quoting"),
3990    ("x", "quote, breaking at whitespace into separate words"),
3991    // ── Case ──
3992    ("l", "lowercase first character"),
3993    ("u", "uppercase first character"),
3994    ("L", "lowercase ENTIRE string"),
3995    ("U", "uppercase ENTIRE string"),
3996    ("C", "capitalize each word (`Title Case`)"),
3997    // ── Array operations ──
3998    ("S", "sort array elements ascending"),
3999    ("O", "sort array elements descending"),
4000    (
4001        "#",
4002        "`${var:#PATTERN}` — remove array elements matching PATTERN (with `(@)`)",
4003    ),
4004    (
4005        "|",
4006        "`${arr:|other}` — set difference (elements of `arr` not in `other`)",
4007    ),
4008    ("*", "`${arr:*other}` — set intersection"),
4009    ("^", "`${arr:^other}` — interleave (zip) two arrays"),
4010    ("^^", "`${arr:^^other}` — distributed zip (every pair)"),
4011];
4012
4013/// Signal names — POSIX + zsh-specific synthetic signals (`ZERR`,
4014/// `DEBUG`, `EXIT`). Used by `kill -SIG` and `trap`. Numeric form
4015/// (`SIGINT` / `INT`) is offered without the `SIG` prefix per zsh's
4016/// `kill -l` output convention.
4017const SIGNAL_NAMES: &[(&str, &str)] = &[
4018    ("HUP", "1 — hangup (terminal closed)"),
4019    ("INT", "2 — interrupt (Ctrl-C)"),
4020    ("QUIT", "3 — quit + core dump (Ctrl-\\)"),
4021    ("ILL", "4 — illegal instruction"),
4022    ("TRAP", "5 — trace/breakpoint trap"),
4023    ("ABRT", "6 — abort (`abort()` syscall)"),
4024    ("BUS", "7 — bus error"),
4025    ("FPE", "8 — floating-point exception"),
4026    ("KILL", "9 — kill (uncatchable, unblockable)"),
4027    ("USR1", "10 — user-defined signal 1"),
4028    ("SEGV", "11 — segmentation fault"),
4029    ("USR2", "12 — user-defined signal 2"),
4030    ("PIPE", "13 — write to pipe with no readers"),
4031    ("ALRM", "14 — alarm clock (`alarm()`)"),
4032    ("TERM", "15 — termination request (default `kill`)"),
4033    ("CHLD", "17 — child process state change"),
4034    ("CONT", "18 — continue if stopped"),
4035    ("STOP", "19 — stop (uncatchable)"),
4036    ("TSTP", "20 — terminal stop (Ctrl-Z)"),
4037    ("TTIN", "21 — background process needs tty input"),
4038    ("TTOU", "22 — background process tty output"),
4039    ("URG", "23 — urgent socket data"),
4040    ("XCPU", "24 — CPU time limit exceeded"),
4041    ("XFSZ", "25 — file size limit exceeded"),
4042    ("VTALRM", "26 — virtual timer alarm"),
4043    ("PROF", "27 — profiling timer alarm"),
4044    ("WINCH", "28 — window size change"),
4045    ("IO", "29 — async I/O ready"),
4046    ("PWR", "30 — power failure"),
4047    ("SYS", "31 — bad syscall"),
4048    // ── zsh synthetic signals ──
4049    ("EXIT", "0 — shell exit (special — `trap ... EXIT`)"),
4050    ("ZERR", "zsh — fires on any non-zero exit status"),
4051    (
4052        "DEBUG",
4053        "zsh — fires before every command (with `DEBUG_BEFORE_CMD`)",
4054    ),
4055];
4056
4057/// Loadable zsh modules — `zmodload zsh/MOD`. Canonical list from
4058/// `man zshmodules`. Most expose builtins, parameters, or math ported
4059/// that aren't compiled into the core.
4060const ZSH_MODULE_NAMES: &[(&str, &str)] = &[
4061    ("zsh/attr", "extended file attribute manipulation"),
4062    ("zsh/cap", "POSIX capability sets"),
4063    ("zsh/clone", "fork the shell to a new session"),
4064    ("zsh/compctl", "legacy `compctl` completion (deprecated)"),
4065    ("zsh/complete", "core programmable completion machinery"),
4066    (
4067        "zsh/complist",
4068        "completion list display + menuselect keymap",
4069    ),
4070    (
4071        "zsh/computil",
4072        "internal helpers used by `_arguments` / `_describe`",
4073    ),
4074    ("zsh/curses", "ncurses bindings (`zcurses`)"),
4075    (
4076        "zsh/datetime",
4077        "`strftime` builtin + `$EPOCHSECONDS` / `$EPOCHREALTIME`",
4078    ),
4079    (
4080        "zsh/db/gdbm",
4081        "GDBM key-value store as a zsh associative array",
4082    ),
4083    (
4084        "zsh/deltochar",
4085        "`delete-to-char` / `zap-to-char` ZLE widgets",
4086    ),
4087    ("zsh/example", "template module (skeleton; not useful)"),
4088    (
4089        "zsh/files",
4090        "in-shell file ops (`mkdir`, `chmod`, `mv`, `rm`, `chown`, `sync`, `ln`)",
4091    ),
4092    ("zsh/langinfo", "locale info (`$langinfo`)"),
4093    ("zsh/mapfile", "read/write a file as an assoc array"),
4094    (
4095        "zsh/mathfunc",
4096        "`sin`, `cos`, `sqrt`, `log`, `exp`, … math functions for `((…))`",
4097    ),
4098    ("zsh/nearcolor", "approximate-color terminal fallback"),
4099    ("zsh/newuser", "first-run user setup helper"),
4100    (
4101        "zsh/parameter",
4102        "reflection — `$functions`, `$aliases`, `$options`, `$commands`, `$parameters`, etc.",
4103    ),
4104    ("zsh/pcre", "Perl-compatible regex (`pcre_match` / `=~`)"),
4105    ("zsh/regex", "POSIX extended regex (`=~`)"),
4106    ("zsh/sched", "in-shell scheduler (`sched +5 cmd`)"),
4107    ("zsh/net/socket", "Unix-domain socket builtin (`zsocket`)"),
4108    ("zsh/stat", "`stat` builtin returning fields into a hash"),
4109    (
4110        "zsh/system",
4111        "low-level syscalls (`sysread`, `syswrite`, `syserror`, `sysopen`)",
4112    ),
4113    ("zsh/net/tcp", "TCP socket builtin (`ztcp`)"),
4114    ("zsh/termcap", "termcap parameter access (`$termcap`)"),
4115    ("zsh/terminfo", "terminfo parameter access (`$terminfo`)"),
4116    ("zsh/zftp", "FTP client built into the shell"),
4117    (
4118        "zsh/zle",
4119        "Zsh Line Editor — `bindkey`, `zle`, widget registration",
4120    ),
4121    (
4122        "zsh/zleparameter",
4123        "ZLE introspection — `$widgets`, `$keymaps`",
4124    ),
4125    ("zsh/zprof", "profiling — `zprof` builtin"),
4126    ("zsh/zpty", "spawn commands in a pseudo-terminal"),
4127    ("zsh/zselect", "`select(2)` on fds with a timeout"),
4128    (
4129        "zsh/zutil",
4130        "core utilities — `zparseopts`, `zformat`, `zstyle`, `zregexparse`",
4131    ),
4132];
4133
4134/// Keymap names — `bindkey -A NAME` source / `bindkey -N NAME` target,
4135/// also `bindkey -M NAME …`. The named maps zsh ships out of the box.
4136const KEYMAP_NAMES: &[(&str, &str)] = &[
4137    ("emacs", "GNU Readline emacs bindings (default)"),
4138    ("vicmd", "vi command-mode keymap"),
4139    ("viins", "vi insert-mode keymap"),
4140    ("viopp", "vi operator-pending keymap (for `d` / `c` / `y`)"),
4141    ("visual", "vi visual-mode keymap"),
4142    (
4143        ".safe",
4144        "fallback keymap holding only `self-insert` + `accept-line`",
4145    ),
4146    (
4147        "main",
4148        "alias — whichever keymap is currently the editing map",
4149    ),
4150    ("command", "vi-mode command-line input keymap"),
4151    ("menuselect", "active inside `menu-select` widget"),
4152    ("isearch", "active inside incremental-search widgets"),
4153    ("listscroll", "active when scrolling completion list"),
4154];
4155
4156/// Built-in ZLE widgets — second arg of `bindkey`, first arg of `zle`,
4157/// what `zle -al` enumerates at runtime. Covers movement / editing /
4158/// history / completion / vi-mode / misc. Curated subset of the most
4159/// commonly used ~120 widgets from `man zshzle` "Standard Widgets".
4160const ZLE_WIDGET_NAMES: &[(&str, &str)] = &[
4161    // ── movement ──
4162    ("backward-char", "move one character left"),
4163    ("forward-char", "move one character right"),
4164    ("backward-word", "move one word left"),
4165    ("forward-word", "move one word right"),
4166    ("beginning-of-line", "move to start of line"),
4167    ("end-of-line", "move to end of line"),
4168    (
4169        "beginning-of-buffer-or-history",
4170        "start of buffer / previous-history at top",
4171    ),
4172    (
4173        "end-of-buffer-or-history",
4174        "end of buffer / next-history at bottom",
4175    ),
4176    // ── editing ──
4177    ("self-insert", "insert the typed character"),
4178    ("accept-line", "submit current line for execution"),
4179    ("accept-and-hold", "submit + keep line in buffer"),
4180    (
4181        "accept-and-infer-next-history",
4182        "submit + recall the line after this in history",
4183    ),
4184    ("backward-delete-char", "delete character before cursor"),
4185    ("delete-char", "delete character under cursor"),
4186    (
4187        "backward-kill-word",
4188        "delete word before cursor (saves to kill ring)",
4189    ),
4190    ("kill-word", "delete word after cursor"),
4191    ("backward-kill-line", "delete from cursor to start of line"),
4192    ("kill-line", "delete from cursor to end of line"),
4193    ("kill-whole-line", "delete entire line"),
4194    ("kill-region", "delete from mark to cursor"),
4195    ("yank", "paste last kill"),
4196    ("yank-pop", "rotate to earlier kill (after `yank`)"),
4197    ("transpose-chars", "swap two characters"),
4198    ("transpose-words", "swap two words"),
4199    ("up-case-word", "uppercase next word"),
4200    ("down-case-word", "lowercase next word"),
4201    ("capitalize-word", "capitalize next word"),
4202    (
4203        "quoted-insert",
4204        "literal-insert next key (e.g. for control chars)",
4205    ),
4206    ("overwrite-mode", "toggle insert / overwrite"),
4207    ("undo", "undo last edit"),
4208    ("redo", "redo last undone edit"),
4209    ("clear-screen", "clear terminal + redraw"),
4210    ("redisplay", "force redraw"),
4211    ("send-break", "abandon line (SIGINT-equivalent)"),
4212    // ── history ──
4213    (
4214        "up-line-or-history",
4215        "previous line / previous history entry",
4216    ),
4217    ("down-line-or-history", "next line / next history entry"),
4218    ("up-history", "previous history entry"),
4219    ("down-history", "next history entry"),
4220    ("beginning-of-history", "first history entry"),
4221    ("end-of-history", "last history entry (current line)"),
4222    (
4223        "history-incremental-search-backward",
4224        "Ctrl-R — incremental search backward",
4225    ),
4226    (
4227        "history-incremental-search-forward",
4228        "Ctrl-S — incremental search forward",
4229    ),
4230    (
4231        "history-search-backward",
4232        "search history matching current line prefix",
4233    ),
4234    (
4235        "history-search-forward",
4236        "forward variant of `history-search-backward`",
4237    ),
4238    (
4239        "history-beginning-search-backward",
4240        "search backward keeping cursor position",
4241    ),
4242    (
4243        "history-beginning-search-forward",
4244        "search forward keeping cursor position",
4245    ),
4246    (
4247        "infer-next-history",
4248        "infer next-history based on previous match",
4249    ),
4250    (
4251        "insert-last-word",
4252        "insert last word of previous line (`!!:$`)",
4253    ),
4254    // ── completion ──
4255    ("complete-word", "complete the current word"),
4256    ("expand-or-complete", "expand alias / glob, else complete"),
4257    (
4258        "expand-or-complete-prefix",
4259        "as above but with prefix match",
4260    ),
4261    ("list-choices", "show completion options without inserting"),
4262    ("menu-complete", "cycle through completions"),
4263    ("menu-expand-or-complete", "expand / cycle"),
4264    ("reverse-menu-complete", "cycle backward"),
4265    (
4266        "delete-char-or-list",
4267        "delete-char if not at EOL, else list-choices",
4268    ),
4269    ("complete-prefix", "complete current prefix"),
4270    ("expand-cmd-path", "expand command to full path"),
4271    ("expand-word", "expand current word"),
4272    // ── vi mode ──
4273    ("vi-cmd-mode", "switch to vi command mode"),
4274    ("vi-insert", "switch to vi insert mode"),
4275    ("vi-insert-bol", "insert at start of line"),
4276    ("vi-add-next", "append after current char (vi `a`)"),
4277    ("vi-add-eol", "append at end of line (vi `A`)"),
4278    ("vi-backward-char", "h"),
4279    ("vi-forward-char", "l"),
4280    ("vi-backward-word", "b"),
4281    ("vi-forward-word", "w"),
4282    ("vi-backward-word-end", "ge"),
4283    ("vi-forward-word-end", "e"),
4284    ("vi-backward-blank-word", "B"),
4285    ("vi-forward-blank-word", "W"),
4286    ("vi-up-line-or-history", "k — previous line / history"),
4287    ("vi-down-line-or-history", "j — next line / history"),
4288    ("vi-beginning-of-line", "0"),
4289    ("vi-end-of-line", "$"),
4290    ("vi-first-non-blank", "^"),
4291    ("vi-delete", "d"),
4292    ("vi-delete-char", "x"),
4293    ("vi-backward-delete-char", "X"),
4294    ("vi-change", "c"),
4295    ("vi-change-eol", "C"),
4296    ("vi-change-whole-line", "S"),
4297    ("vi-substitute", "s"),
4298    ("vi-yank", "y"),
4299    ("vi-yank-eol", "Y"),
4300    ("vi-yank-whole-line", "yy"),
4301    ("vi-put-after", "p"),
4302    ("vi-put-before", "P"),
4303    ("vi-replace", "R"),
4304    ("vi-replace-chars", "r"),
4305    ("vi-repeat-change", "."),
4306    ("vi-repeat-search", "n"),
4307    ("vi-rev-repeat-search", "N"),
4308    ("vi-find-next-char", "f"),
4309    ("vi-find-prev-char", "F"),
4310    ("vi-find-next-char-skip", "t"),
4311    ("vi-find-prev-char-skip", "T"),
4312    ("vi-undo-change", "u"),
4313    ("vi-join", "J — join with next line"),
4314    ("vi-quoted-insert", "Ctrl-V — literal next"),
4315    ("vi-set-buffer", "select named register"),
4316    ("vi-history-search-backward", "?"),
4317    ("vi-history-search-forward", "/"),
4318    ("vi-match-bracket", "% — jump to matching bracket"),
4319    // ── misc ──
4320    ("which-command", "show what command would run"),
4321    ("describe-key-briefly", "show binding for next key"),
4322    ("execute-named-cmd", "M-x style command execution"),
4323    ("execute-last-named-cmd", "re-run last named command"),
4324    ("push-line", "save line + clear, runs on next prompt"),
4325    ("push-line-or-edit", "push-line or edit multiline"),
4326    ("push-input", "push to input stack"),
4327    ("get-line", "pop input from stack"),
4328    ("set-mark-command", "set the mark at cursor"),
4329    ("exchange-point-and-mark", "swap cursor + mark"),
4330    ("digit-argument", "begin numeric argument"),
4331    ("universal-argument", "begin numeric argument"),
4332    ("undefined-key", "called when binding lookup fails"),
4333];
4334
4335/// `typeset` / `declare` / `local` / `readonly` / `integer` / `float`
4336/// / `export` flags — what to surface when the current arg starts with
4337/// `-`. From `man zshbuiltins` "TYPESET".
4338const TYPESET_FLAGS: &[(&str, &str)] = &[
4339    ("-a", "indexed array"),
4340    ("-A", "associative array (hash)"),
4341    ("-i", "integer (with optional base: `-i 16`)"),
4342    ("-E", "float, scientific notation"),
4343    ("-F", "float, fixed notation"),
4344    ("-l", "lowercase on assignment"),
4345    ("-u", "uppercase on assignment"),
4346    ("-L", "left-justify, width N (`-L4`)"),
4347    ("-R", "right-justify, width N (`-R8`)"),
4348    ("-Z", "zero-pad (right-justified, numeric)"),
4349    ("-r", "readonly"),
4350    ("-x", "export to environment"),
4351    ("-g", "global (skip the local scope this would create)"),
4352    ("-U", "unique — for arrays, drop duplicate elements"),
4353    ("-T", "tie scalar ↔ array (`-T PATH path :`)"),
4354    ("-t", "set the `TAGGED` flag (used by some completions)"),
4355    ("-H", "hide value in `typeset` listing"),
4356    ("-h", "hide builtin/special status"),
4357    ("-f", "operate on functions, not parameters"),
4358    ("-p", "print declarations in re-readable form"),
4359    ("-m", "treat name args as patterns (`typeset -m 'FOO*'`)"),
4360    ("-+", "operate at the next outer scope"),
4361];
4362
4363/// `[[ ... ]]` test operators — what completes inside a conditional
4364/// expression. File tests, string tests, numeric tests, file-compare
4365/// tests, logical ops. From `man zshmisc` "CONDITIONAL EXPRESSIONS".
4366const TEST_OPERATORS: &[(&str, &str)] = &[
4367    // ── file existence + type ──
4368    ("-e", "**True if FILE exists**, regardless of type. The catch-all existence test — use `-f` / `-d` etc. to narrow.\n\nExample: `[[ -e $HOME/.zshrc ]] && source $HOME/.zshrc` — guard a source against missing files.\n\nReturns true for symlinks ONLY if the link target exists (use `-L` to test the link itself). Sets `$?` to 0 (true) or 1 (false). Inside `[[ … ]]`, no word-splitting / glob expansion is done on the operand."),
4369    ("-f", "**True if FILE exists AND is a regular file** (not a directory, symlink to dir, device, FIFO, or socket). Follows symlinks — `-f link → file` is true; `-f link → dir` is false.\n\nExample: `for f in *.zsh; do [[ -f $f ]] || continue; source $f; done` — sources every regular `.zsh` file, skipping symlinks-to-dirs that glob accidentally caught."),
4370    ("-d", "**True if FILE exists AND is a directory.** Follows symlinks — symlinks to directories test true. Use `-L $f && [[ ! -d $f ]]` (or `! -h && -d`) to distinguish real-dir from symlink-to-dir.\n\nExample: `[[ -d ~/.config ]] || mkdir -p ~/.config`."),
4371    ("-L", "**True if FILE exists AND is a symbolic link** (regardless of target). Does NOT follow the link — tests the link itself.\n\nExample: `[[ -L $f ]] && rm $f` — remove the symlink without touching its target. Use `-e $f && ! -L $f` to test \"exists AND is not a symlink\". Same operator as `-h`."),
4372    ("-h", "**True if FILE is a symbolic link** — alias for `-L`. Both come from POSIX (`test`); zsh treats them identically. Prefer `-L` for clarity in new code; `-h` is the older spelling kept for `test`/`[`/`/bin/sh` compatibility."),
4373    ("-b", "**True if FILE is a block special device** (e.g. `/dev/disk0`, `/dev/sda`). Block devices buffer I/O in fixed-size blocks; contrast with character devices (`-c`) which transfer byte-at-a-time.\n\nExample: `for d in /dev/disk*; do [[ -b $d ]] && echo \"$d is a block dev\"; done`."),
4374    ("-c", "**True if FILE is a character special device** (e.g. `/dev/tty`, `/dev/null`, `/dev/random`, `/dev/zero`). Character devices transfer one byte at a time and are unbuffered.\n\nExample: `[[ -c /dev/tty ]] && echo 'have a controlling tty'`."),
4375    ("-p", "**True if FILE is a named pipe (FIFO)** — created via `mkfifo`. Anonymous pipes (between processes in a `|` pipeline) are NOT FIFOs and don't test true; `-p` is for filesystem entries.\n\nExample: `mkfifo /tmp/mypipe; [[ -p /tmp/mypipe ]] && echo 'pipe ready'`."),
4376    ("-S", "**True if FILE is a socket** — Unix-domain socket file on the filesystem (created by `bind()`). TCP/UDP sockets don't appear in the filesystem and won't test true; this is for `AF_UNIX` only.\n\nExample: `[[ -S /var/run/docker.sock ]] && echo 'docker up'`."),
4377    ("-t", "**True if file descriptor N is open AND refers to a terminal** — `-t 0` checks stdin, `-t 1` checks stdout, `-t 2` checks stderr. Used to detect interactive vs piped/redirected I/O.\n\nExample: `[[ -t 1 ]] && color=true || color=false` — emit ANSI colors only when stdout is a TTY (skip when piped to a file or another program)."),
4378    // ── permission ──
4379    ("-r", "**True if FILE is readable by the effective uid** of the process. Honors filesystem ACLs and special bits, not just mode-bit permissions. Caveat: root tests true for any readable file regardless of mode.\n\nExample: `[[ -r $f ]] || { echo \"$f unreadable\" >&2; exit 1; }`."),
4380    ("-w", "**True if FILE is writable by the effective uid.** Note: `-w` only tests permission — actual writes can still fail (readonly filesystem, full disk, IMMUTABLE attribute, etc.). For root, almost always returns true even on permissioned-out files unless filesystem is RO.\n\nExample: `[[ -w /etc ]] || sudo=sudo` — pick whether to wrap with sudo."),
4381    ("-x", "**True if FILE is executable** (for regular files) **or searchable** (for directories — needs `+x` to enter and read inode of contents). Symlinks tested by their target's mode. Honors ACLs.\n\nExample: `[[ -x ./build.sh ]] || chmod +x ./build.sh`."),
4382    ("-s", "**True if FILE exists AND has size greater than zero.** Useful to distinguish empty files from non-empty ones — `-f` matches both, `-s` only matches non-empty.\n\nExample: `[[ -s err.log ]] && cat err.log` — only show the log when it has actual error output."),
4383    ("-u", "**True if FILE has the setuid bit set** (mode `04000`). Setuid binaries run with the file owner's uid regardless of caller. Common on `passwd`, `sudo`, `mount`. Security-sensitive — audit periodically.\n\nExample: `find / -perm -4000 2>/dev/null | while read f; do [[ -u $f ]] && echo SETUID: $f; done`."),
4384    ("-g", "**True if FILE has the setgid bit set** (mode `02000`). On binaries: runs as the file's group. On directories: new files inherit the directory's group instead of the creator's primary group (BSD semantics) — common pattern for shared project directories.\n\nExample: `[[ -g $project_dir ]] || chmod g+s $project_dir`."),
4385    ("-k", "**True if FILE has the sticky bit set** (mode `01000`). On directories like `/tmp`: only the file's owner (or root) can delete or rename files within, regardless of directory write permission. On regular files: historically meant \"keep text segment swapped in\"; now ignored on most systems.\n\nExample: `[[ -k /tmp ]] || echo 'WARNING: /tmp not sticky'`."),
4386    ("-O", "**True if FILE is owned by the effective uid** of the current process. Use to gate operations that should only act on user-owned files (vs system-owned).\n\nExample: `find ~/.config -type f ! -O 2>/dev/null` — flag files in your config dir that aren't yours."),
4387    ("-G", "**True if FILE is owned by the effective gid** of the current process — i.e. the file's group is your primary group. Distinct from `-O`: a file might be owned by another user but in your group.\n\nExample: `[[ -G $shared_log ]] && echo writable`."),
4388    ("-N", "**True if FILE has been modified since it was last read** — `mtime > atime`. Used by `mail`-style checkers to detect new content since the last access. zsh-specific (not in POSIX `test`).\n\nExample: `[[ -N $MAIL ]] && echo 'new mail'` — historically zsh's `$MAILCHECK` feature uses exactly this comparison."),
4389    // ── string ──
4390    ("-z", "**True if STRING has length zero.** Inverse of `-n`. The operand is the WHOLE string after expansion — `[[ -z $var ]]` works even when `$var` is unset (unlike `[ -z $var ]` which can fail with \"unary operator expected\" on unset vars).\n\nExample: `[[ -z $TERM ]] && export TERM=xterm-256color`."),
4391    ("-n", "**True if STRING has nonzero length.** Inverse of `-z`. Common idiom for \"is variable set AND non-empty\".\n\nExample: `[[ -n ${VAR:-} ]] && echo \"VAR is set: $VAR\"`. The `:-` makes the test work even with `set -u` (no-unset) enabled. Without quoting inside `[[ ]]`, the test still works because `[[ ]]` doesn't word-split."),
4392    ("=",  "**POSIX string equality.** `[[ a = a ]]` is true. Within `[[ … ]]`, the RHS is treated as a literal string — no globbing. Same operator as `==` in zsh `[[ ]]`; use `=` for `/bin/sh` portability, `==` for clarity in zsh-only code.\n\nDo NOT confuse with assignment `=` — `[[ a = b ]]` tests, `var=b` assigns."),
4393    ("==", "**String equality with glob pattern matching on the RHS** (zsh extension). The right operand IS a pattern: `[[ foo == f* ]]` is true, `[[ foo == f? ]]` would need exactly one char after `f`.\n\nQuote the RHS to disable glob: `[[ $name == \"f*\" ]]` matches literal `f*`. With `EXTENDED_GLOB` enabled, `(#i)PAT` for case-insensitive: `[[ Foo == (#i)foo ]]` is true. Use `=~` instead for regex semantics."),
4394    ("!=", "**String inequality with glob pattern matching on the RHS.** Inverse of `==`. The RHS is a zsh pattern (unless quoted).\n\nExample: `[[ $f != *.bak ]] && process $f` — skip backup files. Same EXTENDED_GLOB modifiers (`(#i)`, `(#b)`, etc.) apply as for `==`."),
4395    ("<",  "**Lexicographic less-than** — string comparison by locale-aware byte order. NOT numeric. `[[ 10 < 9 ]]` is TRUE (lex order) because `\"1\"` < `\"9\"`.\n\nFor numeric comparison use `-lt` or arithmetic context: `(( 10 < 9 ))` is false. The string comparison respects `LC_COLLATE` — `en_US.UTF-8` may give different results than `C`."),
4396    (">",  "**Lexicographic greater-than** — string comparison. Same locale-awareness caveat as `<`: NOT numeric. For numeric `>`, use `-gt` or `(( a > b ))`.\n\nExample: `[[ $version > 1.10 ]]` is FALSE because `\"1.10\"` < `\"1.2\"` lexically. Use a real version-comparator (sort -V, vercmp) for semantic version ordering."),
4397    ("=~", "**Regular expression match** — the RHS is an extended regular expression (ERE by default; PCRE with `setopt REMATCH_PCRE` and `zsh/pcre` loaded). Sets `$MATCH` to the full match and `$match` (array) to the parenthesized groups.\n\nExample: `[[ $line =~ ^([0-9]+):(.+)$ ]] && echo \"line ${match[1]}: ${match[2]}\"`. Inside `[[ ]]` the RHS doesn't need quoting in most cases, but special chars (`(`, `|`) can hit shell parsing — quote when unsure."),
4398    // ── numeric ──
4399    ("-eq", "**Numeric equality** — arguments parsed as integers (or floats with zsh `FORCE_FLOAT`). Differs from `=` / `==` which compare as strings: `[[ 010 -eq 10 ]]` is true; `[[ 010 = 10 ]]` is false (string `\"010\"` ≠ `\"10\"`).\n\nFor arithmetic context, `(( a == b ))` is shorter. Operands can be variable names without `$` per arithmetic-expansion rules — `[[ x -eq 5 ]]` works if `x=5`."),
4400    ("-ne", "**Numeric inequality.** Like `-eq` but inverted. Same integer parsing — leading zeros / hex (`0x10`) / floats handled.\n\nExample: `[[ $rc -ne 0 ]] && exit $rc` — propagate non-zero exit codes from a previous command."),
4401    ("-lt", "**Numeric less-than.** Compares as integers, NOT lexically (unlike `<` which is lexicographic). Always prefer `-lt` over `<` when comparing numbers — `[[ 10 -lt 9 ]]` is correctly false; `[[ 10 < 9 ]]` is wrongly true (string order).\n\nExample: `[[ $count -lt 100 ]] && retry`."),
4402    ("-le", "**Numeric less-than-or-equal.** Integer-aware. Common for loop bounds.\n\nExample: `[[ $i -le $#argv ]] && process ${argv[$i]}` — check whether the index is within array bounds (1-indexed in zsh)."),
4403    ("-gt", "**Numeric greater-than.** Integer-aware. Mirror of `-lt`.\n\nExample: `[[ $(date +%s) -gt $deadline ]] && abort 'timed out'`."),
4404    ("-ge", "**Numeric greater-than-or-equal.** Integer-aware. Common for minimum-version checks: `[[ ${BASH_VERSINFO[0]} -ge 4 ]]` style.\n\nFor float-aware comparison, use arithmetic with `setopt FORCE_FLOAT`: `(( a >= b ))`. zsh's `[[ ]]` numeric tests treat float strings as 0."),
4405    // ── file compare ──
4406    ("-nt", "**True if FILE1 is newer than FILE2** (mtime comparison). True if FILE2 doesn't exist; false if FILE1 doesn't exist. Used in build-style checks: rebuild target if any source is newer.\n\nExample: `[[ $src -nt $obj ]] && cc -c $src -o $obj` — recompile only when source has changed. Compare against multiple: loop or use `find -newer`."),
4407    ("-ot", "**True if FILE1 is older than FILE2.** Inverse of `-nt`. True if FILE1 doesn't exist; false if FILE2 doesn't exist.\n\nExample: `[[ $cache -ot $config ]] && rm $cache` — invalidate cache when config is newer."),
4408    ("-ef", "**True if FILE1 and FILE2 refer to the same inode** on the same filesystem — same physical file, possibly via different paths (symlinks or hard links). Different files with identical content are NOT `-ef`.\n\nExample: `[[ /tmp -ef /private/tmp ]] && echo 'same dir'` — common on macOS where `/tmp` is a symlink. Distinguishes hard-linked duplicates from copies."),
4409    // ── logical ──
4410    ("!",  "**Logical negation** — inverts the truth value of the following test expression. Highest-precedence boolean operator inside `[[ … ]]`.\n\nExample: `[[ ! -f $f ]] && touch $f` — create the file if it doesn't exist. Combine with parens for grouping: `[[ ! ( -f $a || -f $b ) ]]` is true when NEITHER file exists. Same `!` is also pipeline-prefix negation outside `[[ ]]`: `! grep foo bar.txt && echo 'no match'`."),
4411    ("&&", "**Logical AND with short-circuit.** Inside `[[ … && … ]]`: both tests must pass. The right side is only evaluated if the left is true. Lower precedence than `!`, higher than `||`.\n\nExample: `[[ -f $f && -r $f ]]` — exists AND readable. Outside `[[ ]]`, `cmd1 && cmd2` is command-list short-circuit: run cmd2 only if cmd1 succeeded (exit 0)."),
4412    ("||", "**Logical OR with short-circuit.** Inside `[[ … || … ]]`: either test passing makes the whole expression true. Right side skipped if left is true.\n\nExample: `[[ -z $TERM || $TERM == dumb ]] && return` — bail out if terminal is unknown or dumb. Outside `[[ ]]`, the command-list form: `cmd1 || fallback`."),
4413    ("-o", "**POSIX-style OR — DEPRECATED inside `[[ ]]`.** Recognized for `test` / `[` compatibility but documented to be avoided: precedence is ambiguous and ill-defined. Use `||` outside `( )` groups OR rewrite as separate commands.\n\nBackground: zsh's `[[ ]]` does proper short-circuit parsing; `[ ]` with `-o` is parsed as a single command with arguments, leading to surprising precedence."),
4414    ("-a", "**POSIX-style AND — DEPRECATED inside `[[ ]]`.** Same caveats as `-o`: precedence is undefined when mixed with `!` / parens / other binary ops. Use `&&` instead.\n\n`man zshmisc` explicitly recommends against `-a`/`-o` in conditional expressions; they exist only because `[`/`test` traditionally used them."),
4415];
4416
4417/// Math functions for `((…))` / `$((…))`. Most require `zmodload zsh/mathfunc`.
4418/// From `man zshmodules` "THE ZSH/MATHFUNC MODULE".
4419const MATH_FUNCTIONS: &[(&str, &str)] = &[
4420    // ── trigonometry ──
4421    ("sin",     "**Sine** of `x` radians. Range: `[-1, 1]`. For degrees, multiply input by `M_PI/180` (M_PI ≈ 3.14159265).\n\nExample: `(( y = sin(M_PI / 2) ))` → 1. Used in animation timing, geometry, signal processing. Argument near multiples of π may lose precision due to floating-point representation of π."),
4422    ("cos",     "**Cosine** of `x` radians. Range: `[-1, 1]`. `cos(0) = 1`, `cos(M_PI) = -1`.\n\nExample: `(( c = cos(t * 2 * M_PI / period) ))` — periodic oscillation between -1 and 1. For combined sin+cos angle decomposition, `(sin(t), cos(t))` traces the unit circle."),
4423    ("tan",     "**Tangent** of `x` radians = `sin(x) / cos(x)`. Undefined at `x = M_PI/2 + n*M_PI` (where `cos(x) = 0`); returns ±inf or extremely large values near those points.\n\nExample: `(( slope = tan(angle) ))` — convert angle to gradient. Wrap input via `fmod(x, M_PI)` if your formula isn't periodic-safe."),
4424    ("asin",    "**Arcsine** — inverse of `sin`. Domain: `[-1, 1]`; range: `[-M_PI/2, M_PI/2]` radians. Returns NaN for `|x| > 1`.\n\nExample: `(( angle = asin(opp / hyp) ))` — recover angle from a right triangle's opposite/hypotenuse ratio."),
4425    ("acos",    "**Arccosine** — inverse of `cos`. Domain: `[-1, 1]`; range: `[0, M_PI]` radians. Returns NaN for `|x| > 1`.\n\nExample: dot-product → angle: `(( theta = acos(dot / (mag_a * mag_b)) ))`. Common in 3D math for angle-between-vectors."),
4426    ("atan",    "**Arctangent** — inverse of `tan`. Domain: all real; range: `(-M_PI/2, M_PI/2)`. For 2-argument atan2 with quadrant handling, use `atan2(y, x)`.\n\nExample: `(( angle = atan(slope) ))` — convert slope to angle. Range limitation makes `atan` unsuitable for vector → angle conversion; use `atan2` there."),
4427    ("atan2",   "**Two-argument arctangent** — `atan2(y, x)` returns the angle of the point `(x, y)` from the positive x-axis. Range: `(-M_PI, M_PI]`. Handles all four quadrants correctly AND the `x=0` cases (returns ±M_PI/2). Always prefer over `atan(y/x)` for vector-to-angle conversion.\n\nExample: `(( bearing = atan2(dy, dx) * 180 / M_PI ))` — heading angle in degrees from coordinate delta."),
4428    ("sinh",    "**Hyperbolic sine** = `(e^x - e^-x) / 2`. Range: all real. Unlike `sin`, NOT periodic — grows exponentially for large `|x|`.\n\nExample: catenary curve (hanging chain): `y = a * cosh(x/a)`. Used in physics (relativity, wave equations) and machine learning (tanh-family activations)."),
4429    ("cosh",    "**Hyperbolic cosine** = `(e^x + e^-x) / 2`. Range: `[1, +inf)` — always ≥ 1. Even function: `cosh(-x) = cosh(x)`.\n\nExample: `(( y = cosh(x) ))` for catenary shape. Pair with `sinh` for hyperbolic identities: `cosh²(x) - sinh²(x) = 1`."),
4430    ("tanh",    "**Hyperbolic tangent** = `sinh(x) / cosh(x)`. Range: `(-1, 1)`. Sigmoidal — saturates smoothly as `|x| → ∞`. Common activation function in neural networks for its zero-centered output (unlike sigmoid).\n\nExample: `(( y = tanh(x) ))` squashes any input into `(-1, 1)`."),
4431    ("asinh",   "**Inverse hyperbolic sine** = `log(x + sqrt(x² + 1))`. Domain: all real. Numerically stable for large `|x|` (unlike the closed-form `log()` expression, which loses precision when x is large negative)."),
4432    ("acosh",   "**Inverse hyperbolic cosine** = `log(x + sqrt(x² - 1))`. Domain: `[1, +inf)`. Returns NaN for `x < 1`. Range: `[0, +inf)`.\n\nExample: in special relativity, rapidity `φ` from velocity `v/c`: `phi = acosh(gamma)`."),
4433    ("atanh",   "**Inverse hyperbolic tangent** = `0.5 * log((1+x) / (1-x))`. Domain: `(-1, 1)`. Returns ±inf at the endpoints, NaN outside. Useful for variance-stabilizing transforms in statistics (Fisher's z-transform of correlation coefficient)."),
4434    // ── exponential / logarithm ──
4435    ("exp",     "**Natural exponential** = e^x where e ≈ 2.71828. Inverse of `log`. For `|x|` large positive, returns inf (overflow at ~709). For `|x|` large negative, underflows to 0.\n\nExample: probability decay `(( p = exp(-lambda * t) ))`. For `e^x - 1` accurately near 0, use `expm1`."),
4436    ("expm1",   "**exp(x) − 1**, computed with extra precision near `x = 0`. The naive `exp(x) - 1` loses significant digits when `x` is tiny because `exp(x) ≈ 1 + x + …` and subtracting 1 from ≈1 cancels the meaningful part.\n\nExample: small interest rate: `(( gain = expm1(rate) ))` is far more accurate than `(( gain = exp(rate) - 1 ))` for `rate ≈ 1e-9`."),
4437    ("log",     "**Natural logarithm** (base e). Inverse of `exp`. Domain: `(0, +inf)`; `log(0)` = -inf; `log(x)` for `x < 0` returns NaN.\n\nExample: `(( bits = log(n) / log(2) ))` — bits needed to represent `n` distinct values (or use `log2(n)` directly). For accurate `log(1+x)` near 0, use `log1p`."),
4438    ("log2",    "**Base-2 logarithm.** Useful when computing bits or binary tree depth. `log2(1024) = 10` exactly.\n\nExample: `(( depth = ceil(log2(node_count)) ))` — minimum binary tree height. Faster + more accurate than `log(x) / log(2)` because the constant `log(2)` doesn't need to be computed."),
4439    ("log10",   "**Base-10 logarithm.** Common in engineering / acoustics (decibels: `db = 10 * log10(ratio)`) and order-of-magnitude estimates.\n\nExample: `(( db = 20 * log10(amplitude / reference) ))` — convert linear amplitude to dB. Like `log2`, more accurate than dividing by `log(10)`."),
4440    ("log1p",   "**log(1+x)**, accurate near `x = 0`. The naive `log(1+x)` loses precision when `x` is tiny because adding small `x` to 1 hits float-rounding before the log is taken.\n\nExample: log-likelihood of small probability: `(( ll = log1p(-p) ))` — avoids `log(1 - tiny_p)` underflowing to `log(1) = 0`."),
4441    ("pow",     "**x raised to power y** — `pow(x, y)` = `x^y`. Same as zsh's `**` operator: `(( c = x ** y ))`. For integer `y`, `**` is often faster; `pow` always uses float arithmetic.\n\nNegative `x` with non-integer `y` returns NaN. `pow(0, 0) = 1` by convention. For exponential of `e`, prefer `exp(y)` over `pow(M_E, y)`."),
4442    ("sqrt",    "**Square root** — `sqrt(x)` = `x^0.5`. Domain: `[0, +inf)`; returns NaN for negative input. For complex roots, no native support — use `csqrt` from a math library or compute manually.\n\nExample: distance: `(( dist = sqrt(dx*dx + dy*dy) ))`. For `sqrt(x² + y²)` specifically, prefer `hypot(x, y)` — avoids overflow when intermediate squares are huge."),
4443    ("cbrt",    "**Cube root** — works for negative inputs (unlike `pow(x, 1.0/3.0)` which returns NaN for `x < 0` because of how float exponents handle negatives). Domain: all real.\n\nExample: `(( radius = cbrt(3 * volume / (4 * M_PI)) ))` — sphere radius from volume."),
4444    ("hypot",   "**Euclidean norm** = `sqrt(x² + y²)`, computed without overflow/underflow even when `x` or `y` is huge. The naive `sqrt(x*x + y*y)` overflows when `x*x` exceeds float max (~1e308); `hypot` rescales internally to avoid it.\n\nExample: vector magnitude: `(( mag = hypot(dx, dy) ))`. Always prefer over `sqrt(x*x + y*y)` for robustness."),
4445    // ── rounding / abs ──
4446    ("abs",     "**Absolute value** — `abs(x)` returns `|x|`. For integers in arithmetic context, this is the same as `(( a < 0 ? -a : a ))`. For floats, preserves the type.\n\nExample: difference magnitude: `(( delta = abs(a - b) ))`. Note: `abs(INT_MIN)` overflows on two's-complement integers (the canonical pitfall)."),
4447    ("ceil",    "**Round up to the nearest integer** (toward +inf). `ceil(3.1) = 4`, `ceil(-3.1) = -3`. Returns a float — cast to integer with `int(ceil(x))` if you need an int type.\n\nExample: pages needed: `(( pages = ceil(items / per_page) ))`."),
4448    ("floor",   "**Round down to the nearest integer** (toward -inf). `floor(3.9) = 3`, `floor(-3.1) = -4`. Note: `floor` and integer truncation differ for negatives — `int(-3.1) = -3` (toward zero), `floor(-3.1) = -4` (toward -inf).\n\nExample: bucketing: `(( bucket = floor(value / bucket_size) ))`."),
4449    ("round",   "**Round half-away-from-zero** to nearest integer. `round(2.5) = 3`, `round(-2.5) = -3`. Distinct from IEEE banker's rounding (`rint`) which rounds half-to-even.\n\nExample: nearest pixel: `(( px = round(x * dpi / 72) ))`."),
4450    ("trunc",   "**Truncate toward zero** — drop the fractional part. `trunc(3.9) = 3`, `trunc(-3.9) = -3`. Same as the C `(int)` cast or zsh's `int()` function.\n\nDistinct from `floor` for negatives: `floor(-3.9) = -4`, `trunc(-3.9) = -3`."),
4451    ("rint",    "**Round to nearest integer using the CURRENT rounding mode** (default IEEE-754 round-half-to-even). `rint(2.5) = 2` (even); `rint(3.5) = 4` (even). Banker's rounding eliminates bias when summing many rounded values.\n\nDiffers from `round` (always-away-from-zero) and from `nearbyint` (`rint` raises the inexact exception, `nearbyint` doesn't)."),
4452    // ── special ──
4453    ("gamma",   "**Gamma function** Γ(x) — generalization of factorial to real / complex numbers: `Γ(n) = (n-1)!` for positive integer n. `Γ(0.5) = sqrt(M_PI)`. Pole at every non-positive integer; returns ±inf there.\n\nUsed in combinatorics (continuous factorial), statistics (gamma / beta distributions), physics. For large `x`, prefer `lgamma` to avoid overflow."),
4454    ("lgamma",  "**log |Γ(x)|** — log of absolute value of gamma function. Avoids overflow that Γ itself hits quickly: Γ(171) overflows float, but `lgamma(171)` is ~706 (representable).\n\nExample: log-binomial coefficient: `(( lc = lgamma(n+1) - lgamma(k+1) - lgamma(n-k+1) ))`. Sign of Γ retrievable separately via `signgam` (not always exposed)."),
4455    ("erf",     "**Error function** — `erf(x) = 2/sqrt(π) * ∫₀ˣ e^(-t²) dt`. Used in statistics (normal-distribution CDF: `Φ(z) = (1 + erf(z/sqrt(2))) / 2`), diffusion equations, signal processing.\n\nRange: `(-1, 1)`. Odd function: `erf(-x) = -erf(x)`. `erf(0) = 0`, `erf(inf) = 1`."),
4456    ("erfc",    "**Complementary error function** = `1 - erf(x)`. Use instead of `1 - erf(x)` when `x` is large — the naive subtraction loses precision because `erf(x)` approaches 1 and `1 - 0.999…` cancels significant digits.\n\nExample: tail probability: `(( p_tail = erfc(z / sqrt(2)) / 2 ))` — far more accurate than `1 - erf(…)` for z > 5."),
4457    ("j0",      "**Bessel function of the first kind, order 0** — `J₀(x)`. Oscillatory solution to the Bessel equation; appears in cylindrical-coordinate problems (drum vibration modes, EM wave propagation in cylinders).\n\nNot in POSIX `<math.h>` but standard in BSD/Linux libm. Range: `[-0.4, 1]` approximately, decaying with √x rate."),
4458    ("j1",      "**Bessel function of the first kind, order 1** — `J₁(x)`. `J₁(0) = 0`. Like `j0`, oscillates with √x-decay. Used in optics (Airy disk pattern: intensity is `(2 J₁(x) / x)²`)."),
4459    ("jn",      "**Bessel function of the first kind, order n** — `J_n(x)` for integer `n`. Two-arg: `jn(n, x)`. Generalization of `j0` / `j1`; for large `n` the function decays rapidly until `x ≥ n`. Used in FM modulation (sideband amplitudes follow J_n)."),
4460    ("y0",      "**Bessel function of the second kind, order 0** — `Y₀(x)`. Domain: `(0, +inf)`; diverges to -inf at `x = 0`. Used together with `J₀` as the second linearly-independent solution to Bessel's equation."),
4461    ("y1",      "**Bessel function of the second kind, order 1** — `Y₁(x)`. Like `y0`: diverges at 0, oscillates with √x decay. Pairs with `j1` for general-solution construction in cylindrical-symmetry problems."),
4462    ("yn",      "**Bessel function of the second kind, order n** — `Y_n(x)` for integer `n`. Two-arg: `yn(n, x)`. Diverges at `x = 0` faster as `n` grows. Used in optics, antenna theory, heat-equation solutions."),
4463    // ── classification ──
4464    ("isinf",   "**Tests if argument is ±infinity** — returns 1 if `x == +inf` or `x == -inf`, 0 otherwise. Use after computations that might overflow (`pow(big, big)`, `1/0.0`) to detect runaway results.\n\nExample: `(( isinf(result) )) && { print 'overflow' >&2; return 1 }`."),
4465    ("isnan",   "**Tests if argument is NaN** (Not-a-Number) — returns 1 if `x` is the IEEE-754 NaN. NaN appears from `0/0`, `inf - inf`, `sqrt(-1)`, and is the only float value where `x != x` is true (NaN comparisons always return false).\n\nExample: `(( isnan(result) )) && { print 'undefined result' >&2; result=0 }`."),
4466    ("finite",  "**Tests if argument is finite** — returns 1 if `x` is neither NaN nor ±inf, 0 otherwise. Inverse of `(isnan(x) || isinf(x))`. Less standard than `isnan`/`isinf` separately; on Linux this is `__finite` / `isfinite`."),
4467    // ── conversion ──
4468    ("int",     "**Convert to integer by truncating toward zero.** `int(3.9) = 3`, `int(-3.9) = -3`. Same as zsh's `(( i = (int) x ))` cast. For round-half-away-from-zero, use `round`. For floor (-inf direction), use `floor`.\n\nUsed inside arithmetic to force integer type: `(( i = int(rand48() * 100) ))` — random int in 0..99."),
4469    ("float",   "**Convert to float** — explicit type cast. Mostly redundant since most math ported return float anyway, but useful when you want to force float arithmetic: `(( q = float(a) / b ))` ensures float division even if `a` and `b` are integer parameters."),
4470    ("rand48",  "**Pseudo-random float in `[0, 1)`** — drand48(3) under the hood. Not cryptographically secure (linear congruential generator). Seed via `srand48()` — not directly exposed in zsh math, but the seed comes from process-startup time by default.\n\nExample: `(( dice = int(rand48() * 6) + 1 ))` — uniform 1..6. For dedicated crypto-grade randomness, read from `/dev/urandom` instead."),
4471    ("max",     "**Maximum of two or more arguments.** `max(a, b)` for two; `max(a, b, c, …)` works in zsh math context. Float-aware: `max(1, 1.5) = 1.5`.\n\nExample: `(( cap = max(min_size, requested) ))` — clamp lower bound. NOT the same as the GNU coreutils external `/bin/max` (doesn't exist)."),
4472    ("min",     "**Minimum of two or more arguments.** Mirror of `max`. Used for clamping upper bound or finding the smallest item in a set of computed values.\n\nExample: `(( delay = min(timeout, exponential_backoff) ))`."),
4473    ("sum",     "**Sum of all arguments.** Variadic — `sum(1, 2, 3, 4) = 10`. Convenient for combining a small set of math expressions without writing `(( a + b + c + d ))`.\n\nExample: `(( total = sum($costs) ))` — but be careful: this only works if `$costs` is a scalar expression list, not an array."),
4474    ("copysign", "**copysign(x, y)** — returns the magnitude of `x` with the sign of `y`. `copysign(3, -1) = -3`, `copysign(-3, 1) = 3`. Works for `±0` and `±inf` too. Used to preserve sign through computations that otherwise zero it out."),
4475    ("ilogb",   "**Integer binary exponent of x** — returns the unbiased exponent as an int, i.e. `e` such that `|x| ∈ [2^e, 2^(e+1))`. `ilogb(8) = 3`, `ilogb(0.5) = -1`. Faster than `log2(x)` when you only need the integer part.\n\nUsed for fast bit-counting in floats: number of bits to shift to normalize."),
4476    ("logb",    "**Binary exponent of x as a float.** Same value as `ilogb` but float-typed. Used in low-level float manipulation where you want to extract the exponent and re-combine via `scalb`."),
4477    ("scalb",   "**scalb(x, n)** = `x × 2^n`. Faster than `x * pow(2, n)` because it just adjusts the exponent bits directly, no full multiplication. The inverse of `logb` / `ilogb` in a sense — `scalb(1.0, ilogb(x))` recovers the float's exponent magnitude."),
4478    ("nextafter", "**nextafter(x, y)** — next representable double after `x` in the direction of `y`. Returns the immediate float neighbor — useful for testing float-comparison robustness (`nextafter(0.1, 1.0)` ≠ 0.1) or for iterative algorithms that need to step through every distinct float."),
4479    ("fma",     "**Fused multiply-add** = `x*y + z`, computed with a SINGLE rounding step instead of two (one for `*`, one for `+`). More accurate than `x*y + z` when the multiplication and addition would cancel meaningful digits.\n\nUsed in dot products / matrix multiply for numerical stability. Most modern CPUs have a single FMA instruction."),
4480    ("fmod",    "**Floating-point remainder** — `fmod(x, y)` returns `x - n*y` where `n = trunc(x/y)`. Has the same sign as `x`. For non-negative remainder, use `(((x % y) + y) % y)` style or `remainder()`.\n\nExample: clock arithmetic: `(( hour = fmod(elapsed_sec / 3600, 24) ))`."),
4481    ("drem",    "**IEEE remainder of x/y** — like `fmod` but uses round-half-to-even for the quotient, so the result is in `(-y/2, y/2]`. Standard name on Linux is `remainder`; `drem` is the legacy BSD name kept for compatibility.\n\nDifference vs `fmod`: `drem(7, 3) = 1`, `fmod(7, 3) = 1` — they match for this. But `drem(5, 3) = -1` (round-to-even quotient), `fmod(5, 3) = 2`. Choose based on whether you want truncation or rounding semantics."),
4482];
4483
4484/// `zstyle` well-known context patterns. From the most common
4485/// `zstyle -L` outputs in `.zshrc` configs. NOT exhaustive — zstyle
4486/// contexts are user-defined — but covers the canonical completion /
4487/// vcs_info / prompt namespaces.
4488const ZSTYLE_CONTEXTS: &[(&str, &str)] = &[
4489    (":completion:*", "all completion settings"),
4490    (":completion:*:default", "default completion"),
4491    (
4492        ":completion:*:descriptions",
4493        "tag-group descriptions in menus",
4494    ),
4495    (":completion:*:matches", "match grouping / formatting"),
4496    (":completion:*:options", "option-name completion"),
4497    (":completion:*:warnings", "no-match warning style"),
4498    (
4499        ":completion:*:messages",
4500        "info messages from completion ported",
4501    ),
4502    (":completion:*:corrections", "spell-correction style"),
4503    (
4504        ":completion:*:*:*:*:processes",
4505        "process-name completion (`kill <TAB>`)",
4506    ),
4507    (":completion:*:functions", "function-name completion"),
4508    (":completion:*:manuals", "man-page completion"),
4509    (
4510        ":completion:*:hosts",
4511        "hostname completion (ssh, scp, etc.)",
4512    ),
4513    (
4514        ":vcs_info:*",
4515        "version-control info system (`git`/`hg`/`svn` in prompt)",
4516    ),
4517    (":vcs_info:git:*", "git-specific vcs_info"),
4518    (":prompt:*", "prompt customization (themes)"),
4519    (":urlglobber", "URL-glob filtering"),
4520    (":zftp:*", "zftp module configuration"),
4521    (":grep:*", "grep widget configuration"),
4522    (":compinstall", "`compinstall` wizard state"),
4523    (":zle:*", "ZLE widget configuration"),
4524    (":bracketed-paste-magic", "bracketed-paste-magic widget"),
4525    (
4526        ":syntax-highlighting",
4527        "fast-syntax-highlighting / zsh-syntax-highlighting",
4528    ),
4529];
4530
4531/// Pattern modifiers for extended-glob `(#…)`. Need `EXTENDED_GLOB`.
4532/// From `man zshexpn` "Pattern Matching → Globbing Flags".
4533const PATTERN_MODIFIERS: &[(&str, &str)] = &[
4534    ("i", "case-insensitive matching for the rest of the pattern"),
4535    ("l", "lowercase chars match upper + lower"),
4536    ("I", "case-sensitive — reset after `(#i)`"),
4537    (
4538        "b",
4539        "activate backreferences (`$match[N]` / `$mbegin` / `$mend`)",
4540    ),
4541    ("B", "deactivate backreferences"),
4542    (
4543        "m",
4544        "set `$MATCH` / `$MBEGIN` / `$MEND` even without backref",
4545    ),
4546    ("M", "deactivate `m`"),
4547    ("a", "`(#aN)` — approximate match with up to N errors"),
4548    ("s", "anchor pattern to start of string"),
4549    ("e", "anchor pattern to end of string"),
4550    (
4551        "c",
4552        "`(#cN,M)` — preceding atom matched between N and M times",
4553    ),
4554    ("u", "use Unicode character properties"),
4555    ("U", "deactivate `u`"),
4556    (
4557        "q",
4558        "treat following pattern as glob qualifier list (`(#q.,L0)`)",
4559    ),
4560];
4561
4562/// Subscript flags for `${arr[(X)pattern]}` — reverse / index / range
4563/// search modifiers inside array subscripts. From `man zshparam`
4564/// "ARRAY PARAMETERS → SUBSCRIPT FLAGS".
4565const SUBSCRIPT_FLAGS: &[(&str, &str)] = &[
4566    ("e", "exact match — disable globbing on subscript"),
4567    ("i", "return INDEX of first matching element"),
4568    ("I", "return INDEX of LAST matching element"),
4569    ("r", "return VALUE of first match — search reverse"),
4570    ("R", "as `r` but ranged"),
4571    ("b", "byte offset (with `i` / `I`)"),
4572    ("n", "`(nN)` — Nth match (with `i` / `I` / `r` / `R`)"),
4573    ("w", "word offset (split on `$IFS`)"),
4574    ("W", "word offset with empty fields"),
4575    ("p", "process `\\NNN` escapes in `(s::)` separator"),
4576    ("s", "`(s:STR:)` — split on STR (with `w` / `W`)"),
4577    ("f", "split scalar on newlines (= `(s.\\n.)`)"),
4578    ("k", "match against keys of an associative array"),
4579    ("v", "match against values of an associative array"),
4580];
4581
4582/// Where the cursor sits — drives which completion table to surface.
4583/// Detected by scanning backward from the cursor for the innermost
4584/// open paren / brace / `!` / `[` and looking at what precedes it,
4585/// plus by inspecting the leading command on the line.
4586#[derive(Debug, Clone, PartialEq, Eq)]
4587enum LspCompletionContext {
4588    Normal,
4589    ParamFlag,
4590    GlobQualifier,
4591    HistoryDesignator,
4592    ParamColonModifier,
4593    // NEW — command-position contexts (leading command dispatches).
4594    OptionOnly,    // setopt / unsetopt / set -o / set +o
4595    SignalName,    // kill -SIG / trap … SIG
4596    ModuleName,    // zmodload
4597    KeymapName,    // bindkey -M / -A / -N (1st arg)
4598    WidgetName,    // zle … / bindkey "key" (2nd arg)
4599    TypesetFlag, // typeset / declare / local / readonly / integer / float / export with leading `-`
4600    ZstyleContext, // zstyle (1st arg)
4601    CompdefFn,   // compdef (1st arg)
4602    // NEW — bracket / paren contexts.
4603    TestOperator,    // inside [[ … ]]
4604    MathFunction,    // inside (( … )) or $(( … ))
4605    PatternModifier, // inside (#…)
4606    SubscriptFlag,   // inside ${arr[(…)…]}
4607    /// Cursor right after a `-` argument to a known builtin —
4608    /// surface the builtin's option flags from its yodl hover doc.
4609    /// `print -<TAB>` → -a/-b/-c/-C/-D/-f/-i/-l/-m/-n/-N/-o/-O/-P/-r
4610    /// /-R/-s/-S/-u/-v/-x/-z + each one's description. The `.0`
4611    /// field carries the builtin name so the dispatcher can look
4612    /// up the right doc body.
4613    BuiltinFlag(String),
4614    /// Cursor right after a `--` argument to a known builtin that
4615    /// publishes long-form flag docs. `zshrs --<TAB>` surfaces the
4616    /// 24 zshrs-specific long flags (`--lsp`, `--dap`, `--dump-*`,
4617    /// `--doctor`, parity modes) plus every setopt mirror sourced
4618    /// from `OPTION_DOCS`. Distinct from `BuiltinFlag` because long
4619    /// flags don't letter-stack and the replacement range covers
4620    /// the entire `--xxx` typed prefix as one unit.
4621    BuiltinLongFlag(String),
4622}
4623
4624/// Find the first whitespace-delimited word at or after a list-start
4625/// boundary on the line — i.e. the "command" of the current pipeline /
4626/// command-list segment. Returns the command text + the byte position
4627/// where its first argument begins (skipping the command + one space).
4628fn leading_command_at(line: &str, col: usize) -> Option<(String, usize)> {
4629    let bytes = line.as_bytes();
4630    let cap = col.min(bytes.len());
4631    // Walk back from cursor to find the most recent statement
4632    // separator (`;`, `&&`, `||`, `|`, `&`, `(`, newline) or start of
4633    // line. Skip over chars; treat that position as the cmd start.
4634    let mut s: usize = 0;
4635    let mut i = cap;
4636    while i > 0 {
4637        i -= 1;
4638        let c = bytes[i];
4639        if c == b'\n' || c == b';' {
4640            s = i + 1;
4641            break;
4642        }
4643        if (c == b'|' || c == b'&') && i > 0 {
4644            // Bare `|` or `&` is a separator; `&&` / `||` too.
4645            s = i + 1;
4646            break;
4647        }
4648        // Subshell / command-substitution / process-substitution
4649        // openers: `$(`, `<(`, `>(`, `(`, `((`. Walking back to one
4650        // of these starts a fresh command region — without this,
4651        // `x=$(zshrs --…)` saw `x` as the leading command instead
4652        // of `zshrs`, so flag completion never fired inside `$(…)`.
4653        if c == b'(' {
4654            s = i + 1;
4655            break;
4656        }
4657    }
4658    // Skip leading whitespace.
4659    while s < cap && matches!(bytes[s], b' ' | b'\t') {
4660        s += 1;
4661    }
4662    // Read the command token — bare-word chars only.
4663    let mut e = s;
4664    while e < cap && (bytes[e].is_ascii_alphanumeric() || matches!(bytes[e], b'_' | b'-' | b'.')) {
4665        e += 1;
4666    }
4667    if e == s {
4668        return None;
4669    }
4670    let cmd = std::str::from_utf8(&bytes[s..e]).ok()?.to_string();
4671    Some((cmd, e))
4672}
4673
4674/// Count occurrences of the 2-byte token `tok` in `bytes[0..end]`.
4675fn count_pair(bytes: &[u8], end: usize, tok: [u8; 2]) -> i32 {
4676    let cap = end.min(bytes.len());
4677    let mut n: i32 = 0;
4678    let mut i = 0;
4679    while i + 1 < cap {
4680        if bytes[i] == tok[0] && bytes[i + 1] == tok[1] {
4681            n += 1;
4682            i += 2;
4683        } else {
4684            i += 1;
4685        }
4686    }
4687    n
4688}
4689
4690/// Walks the line backward from `col` to classify the context for
4691/// completion routing. Order of checks:
4692///   1. Bracket contexts — `[[ … ]]` / `((…))` / `(#…)` / `[(…)`
4693///   2. History designator — `!` at word boundary, not in arith
4694///   3. Paren-based: `${(…)` / glob-meta-`(…)`
4695///   4. Param/history modifier — `:` inside `${…}` or after `!event:`
4696///   5. Command-position dispatch — leading command's first arg
4697fn lsp_completion_context(line: &str, col: usize) -> LspCompletionContext {
4698    let bytes = line.as_bytes();
4699    let cap = col.min(bytes.len());
4700
4701    // ── 1. HistoryDesignator ────────────────────────────────────────
4702    // Walk back over designator-y chars (alnum + `?` / `#` / `$` / `^`
4703    // / `*` / `-` / `_`). If we land on `!` at a word boundary AND
4704    // we're not inside `((…))` arithmetic, trigger.
4705    {
4706        let mut k = cap;
4707        while k > 0 {
4708            let c = bytes[k - 1];
4709            if c.is_ascii_alphanumeric()
4710                || matches!(c, b'?' | b'#' | b'$' | b'^' | b'*' | b'-' | b'_')
4711            {
4712                k -= 1;
4713            } else {
4714                break;
4715            }
4716        }
4717        if k > 0 && bytes[k - 1] == b'!' {
4718            let bang = k - 1;
4719            let word_bound = bang == 0
4720                || matches!(
4721                    bytes[bang - 1],
4722                    b' ' | b'\t' | b';' | b'&' | b'|' | b'(' | b'`' | b'\n'
4723                );
4724            let escaped = bang > 0 && bytes[bang - 1] == b'\\';
4725            // Suppress inside `((…))` arithmetic where `!` is logical
4726            // NOT, not history. Cheap check: count `((` vs `))` before
4727            // the bang.
4728            let mut paren_pairs: i32 = 0;
4729            let mut j = 0;
4730            while j + 1 < bang {
4731                if bytes[j] == b'(' && bytes[j + 1] == b'(' {
4732                    paren_pairs += 1;
4733                    j += 2;
4734                    continue;
4735                }
4736                if bytes[j] == b')' && bytes[j + 1] == b')' {
4737                    paren_pairs -= 1;
4738                    j += 2;
4739                    continue;
4740                }
4741                j += 1;
4742            }
4743            let in_arith = paren_pairs > 0;
4744            if word_bound && !escaped && !in_arith {
4745                return LspCompletionContext::HistoryDesignator;
4746            }
4747        }
4748    }
4749
4750    // ── 2. ParamFlag / GlobQualifier ─────────────────────────────────
4751    {
4752        let mut depth: i32 = 0;
4753        let mut i = cap;
4754        while i > 0 {
4755            i -= 1;
4756            let c = bytes[i];
4757            if c == b')' {
4758                depth += 1;
4759            } else if c == b'(' {
4760                if depth == 0 {
4761                    if i >= 2 && bytes[i - 2] == b'$' && bytes[i - 1] == b'{' {
4762                        return LspCompletionContext::ParamFlag;
4763                    }
4764                    if i >= 1 {
4765                        let prev = bytes[i - 1];
4766                        if matches!(prev, b'*' | b'?' | b']' | b')') {
4767                            return LspCompletionContext::GlobQualifier;
4768                        }
4769                    }
4770                    break;
4771                }
4772                depth -= 1;
4773            }
4774        }
4775    }
4776
4777    // ── 3. ParamColonModifier ────────────────────────────────────────
4778    // Walk back tracking `{`/`}` depth. Find the most recent `:` at
4779    // brace-depth 0; if we then hit an unmatched `${`, trigger.
4780    {
4781        let mut bdepth: i32 = 0;
4782        let mut found_colon = false;
4783        let mut k = cap;
4784        while k > 0 {
4785            k -= 1;
4786            let c = bytes[k];
4787            if c == b'}' {
4788                bdepth += 1;
4789            } else if c == b'{' {
4790                if bdepth == 0 {
4791                    if k >= 1 && bytes[k - 1] == b'$' && found_colon {
4792                        return LspCompletionContext::ParamColonModifier;
4793                    }
4794                    break;
4795                }
4796                bdepth -= 1;
4797            } else if c == b':' && bdepth == 0 && !found_colon {
4798                found_colon = true;
4799            }
4800        }
4801    }
4802
4803    // Also handle `!event:MOD` — cursor after a `:` whose nearest
4804    // preceding non-alnum / non-designator char is a `!event` reference.
4805    {
4806        let mut k = cap;
4807        // Walk back over the modifier letters being typed.
4808        while k > 0
4809            && (bytes[k - 1].is_ascii_alphabetic() || matches!(bytes[k - 1], b'&' | b'/' | b'g'))
4810        {
4811            k -= 1;
4812        }
4813        if k > 0 && bytes[k - 1] == b':' {
4814            // Walk back over the event designator (`!`, `!!`, `!42`,
4815            // `!ls`, `!?str?`, `!$`, etc). `!` itself is allowed in
4816            // the designator body for the `!!` form.
4817            let colon = k - 1;
4818            let mut e = colon;
4819            while e > 0
4820                && (bytes[e - 1].is_ascii_alphanumeric()
4821                    || matches!(
4822                        bytes[e - 1],
4823                        b'?' | b'#' | b'$' | b'^' | b'*' | b'-' | b'_' | b'!'
4824                    ))
4825            {
4826                e -= 1;
4827            }
4828            if e < colon && bytes[e] == b'!' {
4829                // `bang` is the position of the FIRST `!` in the
4830                // designator. Word boundary is checked before that.
4831                let bang = e;
4832                let word_bound = bang == 0
4833                    || matches!(
4834                        bytes[bang - 1],
4835                        b' ' | b'\t' | b';' | b'&' | b'|' | b'(' | b'`' | b'\n'
4836                    );
4837                if word_bound {
4838                    return LspCompletionContext::ParamColonModifier;
4839                }
4840            }
4841        }
4842    }
4843
4844    // ── 4. Bracket contexts (`[[ … ]]`, `((…))`, `(#…)`, `${arr[(…)`) ─
4845    {
4846        let bytes = line.as_bytes();
4847        let cap = col.min(bytes.len());
4848        // PatternModifier — innermost unmatched `(#…`. Walk back; if
4849        // we hit `(#` before any `)`, trigger.
4850        {
4851            let mut depth: i32 = 0;
4852            let mut i = cap;
4853            while i > 0 {
4854                i -= 1;
4855                let c = bytes[i];
4856                if c == b')' {
4857                    depth += 1;
4858                } else if c == b'(' {
4859                    if depth == 0 {
4860                        if i + 1 < bytes.len() && bytes[i + 1] == b'#' {
4861                            return LspCompletionContext::PatternModifier;
4862                        }
4863                        break;
4864                    }
4865                    depth -= 1;
4866                }
4867            }
4868        }
4869        // SubscriptFlag — innermost unmatched `[(…` (the `(X)` form
4870        // inside `${arr[(X)pattern]}`). Walk back through `(`/`)` to
4871        // find an unmatched `(` whose preceding char is `[`.
4872        {
4873            let mut depth: i32 = 0;
4874            let mut i = cap;
4875            while i > 0 {
4876                i -= 1;
4877                let c = bytes[i];
4878                if c == b')' {
4879                    depth += 1;
4880                } else if c == b'(' {
4881                    if depth == 0 {
4882                        if i >= 1 && bytes[i - 1] == b'[' {
4883                            return LspCompletionContext::SubscriptFlag;
4884                        }
4885                        break;
4886                    }
4887                    depth -= 1;
4888                }
4889            }
4890        }
4891        // TestOperator — inside `[[ … ]]`. Cheap heuristic: count
4892        // `[[` vs `]]` before cursor; if `[[` > `]]`, we're inside.
4893        let lbrack = count_pair(bytes, cap, [b'[', b'[']);
4894        let rbrack = count_pair(bytes, cap, [b']', b']']);
4895        if lbrack > rbrack {
4896            return LspCompletionContext::TestOperator;
4897        }
4898        // MathFunction — inside `((…))` or `$((…))`. Count `((` vs `))`.
4899        let lparen = count_pair(bytes, cap, [b'(', b'(']);
4900        let rparen = count_pair(bytes, cap, [b')', b')']);
4901        if lparen > rparen {
4902            return LspCompletionContext::MathFunction;
4903        }
4904    }
4905
4906    // ── 5. Command-position dispatch ─────────────────────────────────
4907    if let Some((cmd, _arg_start)) = leading_command_at(line, col) {
4908        match cmd.as_str() {
4909            "setopt" | "unsetopt" => return LspCompletionContext::OptionOnly,
4910            "set" => {
4911                // `set -o` / `set +o` followed by option name. Cheap
4912                // check: any `-o` / `+o` between cmd and cursor.
4913                let bytes = line.as_bytes();
4914                let cap = col.min(bytes.len());
4915                let mut j = 0;
4916                let mut saw_o = false;
4917                while j + 1 < cap {
4918                    if (bytes[j] == b'-' || bytes[j] == b'+') && bytes[j + 1] == b'o' {
4919                        saw_o = true;
4920                        break;
4921                    }
4922                    j += 1;
4923                }
4924                if saw_o {
4925                    return LspCompletionContext::OptionOnly;
4926                }
4927            }
4928            "kill" => {
4929                // `kill -SIG` or `kill -s SIG` → signal name.
4930                // Bare `kill` first arg is a PID; once we see a `-`
4931                // we're in signal context. Cheap: check for `-` in args.
4932                let bytes = line.as_bytes();
4933                let cap = col.min(bytes.len());
4934                let mut has_dash = false;
4935                let mut j = 0;
4936                while j < cap {
4937                    if bytes[j] == b'-' && j > 0 && matches!(bytes[j - 1], b' ' | b'\t') {
4938                        has_dash = true;
4939                        break;
4940                    }
4941                    j += 1;
4942                }
4943                if has_dash {
4944                    return LspCompletionContext::SignalName;
4945                }
4946            }
4947            "trap" => return LspCompletionContext::SignalName,
4948            "zmodload" => return LspCompletionContext::ModuleName,
4949            "bindkey" => {
4950                // First non-flag arg = key sequence, second = widget.
4951                // Flag arg `-M name` / `-A from to` / `-N name` = keymap.
4952                // Cheap: if last seen flag is `-A`/`-M`/`-N` and cursor
4953                // is on its arg → KeymapName. Otherwise WidgetName.
4954                let bytes = line.as_bytes();
4955                let cap = col.min(bytes.len());
4956                let mut last_flag: Option<u8> = None;
4957                let mut j = 0;
4958                while j < cap {
4959                    if bytes[j] == b'-'
4960                        && j > 0
4961                        && matches!(bytes[j - 1], b' ' | b'\t')
4962                        && j + 1 < cap
4963                    {
4964                        last_flag = Some(bytes[j + 1]);
4965                    }
4966                    j += 1;
4967                }
4968                if matches!(last_flag, Some(b'A') | Some(b'M') | Some(b'N')) {
4969                    return LspCompletionContext::KeymapName;
4970                }
4971                return LspCompletionContext::WidgetName;
4972            }
4973            "zle" => {
4974                // `zle -<TAB>` completes the zle builtin's flags
4975                // (`-l`, `-L`, `-D`, `-N`, `-A`, `-K`, `-R`, `-M`,
4976                // `-U`, `-F`, `-I`, `-T`, etc.) from the hand-
4977                // curated `BUILTIN_FLAG_DOCS_OVERRIDE` entry.
4978                // `zle <name>` (no leading dash) keeps the existing
4979                // widget-name completion.
4980                let bytes = line.as_bytes();
4981                let cap = col.min(bytes.len());
4982                let mut j = cap;
4983                while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
4984                    j -= 1;
4985                }
4986                if j < cap && bytes[j] == b'-' {
4987                    return LspCompletionContext::BuiltinFlag("zle".to_string());
4988                }
4989                return LspCompletionContext::WidgetName;
4990            }
4991            "typeset" | "declare" | "local" | "readonly" | "integer" | "float" | "export"
4992            | "private" => {
4993                // Surface flags only when the current arg starts with `-`.
4994                let bytes = line.as_bytes();
4995                let cap = col.min(bytes.len());
4996                // Walk back from cursor to find start of current arg.
4997                let mut j = cap;
4998                while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
4999                    j -= 1;
5000                }
5001                if j < cap && bytes[j] == b'-' {
5002                    return LspCompletionContext::TypesetFlag;
5003                }
5004            }
5005            "zstyle" => return LspCompletionContext::ZstyleContext,
5006            "compdef" => return LspCompletionContext::CompdefFn,
5007            _ => {}
5008        }
5009        // Universal fallback: ANY named-or-unnamed command where the
5010        // current word starts with `-` AND the command has doc-body
5011        // flag bullets / citations. This runs AFTER the per-command
5012        // named arms above so e.g. `typeset -<TAB>` keeps its
5013        // hand-curated TypesetFlag table (better descriptions),
5014        // `set -o foo<TAB>` keeps OptionOnly, etc. Catches `set -`,
5015        // `bindkey -`, `zmv -`, `kill -` (when not after `-s`), etc.
5016        let bytes = line.as_bytes();
5017        let cap = col.min(bytes.len());
5018        let mut j = cap;
5019        while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
5020            j -= 1;
5021        }
5022        let starts_with_dash = j < cap && bytes[j] == b'-';
5023        let starts_with_double_dash = j + 1 < cap && bytes[j] == b'-' && bytes[j + 1] == b'-';
5024        let just_after_builtin = j == cap;
5025        // `zshrs --<TAB>` — route to long-flag completion when the
5026        // current word starts with `--` AND the command publishes
5027        // long-form flag docs. Falls through to BuiltinFlag for
5028        // single-dash prefixes / builtins that only have short flags.
5029        if starts_with_double_dash && is_known_builtin_with_long_flag_docs(&cmd) {
5030            return LspCompletionContext::BuiltinLongFlag(cmd);
5031        }
5032        if (starts_with_dash || just_after_builtin) && is_known_builtin_with_flag_docs(&cmd) {
5033            return LspCompletionContext::BuiltinFlag(cmd);
5034        }
5035    }
5036
5037    LspCompletionContext::Normal
5038}
5039
5040/// True when `name` is a builtin whose yodl doc body contains flag
5041/// bullets — used to gate the `BuiltinFlag` context dispatch. Quick
5042/// boolean check based on whether the doc body has the
5043/// `- **\`-X\`** —` pattern; cached per session.
5044/// Heuristic — given a builtin doc body and a flag like `-f`, find a
5045/// sentence describing that flag. Walks every backtick-wrapped flag
5046/// citation, expands to its enclosing sentence (bounded by `. ` or
5047/// `\n\n`), keeps the FIRST one that looks descriptive (skip section
5048/// headers and synopsis-style fragments).
5049///
5050/// Returns the cleaned-up description or None if no suitable
5051/// sentence found.
5052fn derive_inline_flag_desc(body: &str, flag: &str) -> Option<String> {
5053    let needle = format!("`{}`", flag);
5054    let bytes = body.as_bytes();
5055    let nbytes = needle.as_bytes();
5056    // Walk every occurrence — keep the best one.
5057    let mut best: Option<String> = None;
5058    let mut search_from = 0;
5059    while let Some(pos) = body[search_from..].find(&needle) {
5060        let abs = search_from + pos;
5061        search_from = abs + needle.len();
5062        // Find sentence start: walk back from `abs` over chars
5063        // until we hit `. ` (period-space), `\n\n`, or start of body.
5064        let mut sstart = abs;
5065        while sstart > 0 {
5066            let c = bytes[sstart - 1];
5067            if c == b'\n' && sstart >= 2 && bytes[sstart - 2] == b'\n' {
5068                break;
5069            }
5070            if c == b'.' && sstart < bytes.len() && matches!(bytes[sstart], b' ' | b'\n') {
5071                sstart += 1; // skip the period that ENDED the previous sentence
5072                break;
5073            }
5074            sstart -= 1;
5075        }
5076        // Find sentence end: walk forward from `abs` until `.` followed
5077        // by space/newline, or `\n\n`, or end of body. Cap at 300 chars.
5078        let mut send = abs + needle.len();
5079        let cap_end = (sstart + 400).min(bytes.len());
5080        while send < cap_end {
5081            let c = bytes[send];
5082            if c == b'.' && send + 1 < bytes.len() && matches!(bytes[send + 1], b' ' | b'\n') {
5083                send += 1; // include the period
5084                break;
5085            }
5086            if c == b'\n' && send + 1 < bytes.len() && bytes[send + 1] == b'\n' {
5087                break;
5088            }
5089            send += 1;
5090        }
5091        let raw = &body[sstart..send.min(bytes.len())];
5092        // Clean: collapse whitespace, strip markdown emphasis chars.
5093        let cleaned: String = raw
5094            .split_whitespace()
5095            .collect::<Vec<_>>()
5096            .join(" ")
5097            .trim()
5098            .to_string();
5099        if cleaned.len() < 15 {
5100            continue; // too short to be useful
5101        }
5102        // Skip section-header-looking fragments.
5103        if cleaned.starts_with('#') {
5104            continue;
5105        }
5106        // Prefer shorter, sentence-like descriptions.
5107        if best
5108            .as_ref()
5109            .map(|b| b.len() > cleaned.len())
5110            .unwrap_or(true)
5111        {
5112            best = Some(cleaned);
5113        }
5114    }
5115    best.map(|s| s.chars().take(200).collect())
5116}
5117
5118fn is_known_builtin_with_flag_docs(name: &str) -> bool {
5119    let is_compat = crate::ported::builtin::BUILTINS
5120        .iter()
5121        .any(|b| b.node.nam == name);
5122    let is_ext = crate::ext_builtins::EXT_BUILTIN_NAMES.contains(&name);
5123    // Every compsys fn documented in `man zshcompsys` has a Rust
5124    // shadow in `compsys/` (canonical_paths in library.rs,
5125    // call_program in shell_runner.rs, widgets in library.rs, etc.)
5126    // so they all live in `COMPSYS_FN_NAMES`. Flag completion routes
5127    // through the per-fn `COMPSYS_FN_FLAG_DOCS` table.
5128    let is_compsys = crate::compsys::COMPSYS_FN_NAMES.contains(&name);
5129    // `zshrs` is the binary itself — `zshrs -<TAB>` in a script
5130    // surfaces the standard zsh-compat short flags via the hand
5131    // table `ZSHRS_SELF_FLAG_DOCS`.
5132    let is_self = name == "zshrs" || name == "zsh";
5133    if !is_compat && !is_ext && !is_compsys && !is_self {
5134        return false;
5135    }
5136    !extract_builtin_flags(name).is_empty()
5137}
5138
5139/// Parse `(flag, description)` pairs out of a builtin's hover-doc
5140/// body. The yodl→markdown converter emits each documented option
5141/// as a markdown bullet:
5142///
5143/// ```text
5144/// - **`-X`** — description text
5145/// ```
5146///
5147/// (with a Unicode em-dash `\u{2014}`). For options that take an
5148/// argument, the bullet is `- **\`-X _arg_\`** — desc`. This walks
5149/// the body's lines looking for that exact shape.
5150///
5151/// Returns `Vec<(flag, desc)>` where `flag` is `-X` (just the letter,
5152/// no arg name) and `desc` is the description prose. Cached per-name
5153/// in `BUILTIN_FLAGS_CACHE` so a hot `print -<TAB>` doesn't re-parse
5154/// the same body on every keystroke.
5155
5156/// Public re-entry for the man-zshall audit integration test
5157/// (`tests/lsp_man_audit.rs`) — forwards to the internal scraper.
5158pub fn extract_builtin_flags_for_test(name: &str) -> Vec<(String, String)> {
5159    extract_builtin_flags(name)
5160}
5161
5162fn extract_builtin_flags(name: &str) -> Vec<(String, String)> {
5163    use std::sync::Mutex;
5164    use std::sync::OnceLock;
5165    static CACHE: OnceLock<Mutex<std::collections::HashMap<String, Vec<(String, String)>>>> =
5166        OnceLock::new();
5167    let cache = CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
5168    if let Ok(g) = cache.lock() {
5169        if let Some(v) = g.get(name) {
5170            return v.clone();
5171        }
5172    }
5173    // Tier 0a (zshrs binary itself): `zshrs -<TAB>` / `zsh -<TAB>`.
5174    // Hand table sourced from `zshrs --help` output. Covers the
5175    // 9 standard zsh-compat short flags. Long-form `--xxx` flags
5176    // (zshrs-specific dumpers, parity modes, the setopt-mirror
5177    // `--errexit` etc.) are NOT included here — the completion
5178    // dispatcher's `BuiltinFlag` context handles only single-dash
5179    // short flags; long-flag completion is a separate concern.
5180    if name == "zshrs" || name == "zsh" {
5181        let out: Vec<(String, String)> = ZSHRS_SELF_FLAG_DOCS
5182            .iter()
5183            .map(|(f, d)| (f.to_string(), d.to_string()))
5184            .collect();
5185        if let Ok(mut g) = cache.lock() {
5186            g.insert(name.to_string(), out.clone());
5187        }
5188        return out;
5189    }
5190    // Tier 0 (compsys functions): hand-curated table derived from
5191    // `man zshcompsys` signatures. Beats the bullet/inline scrapers
5192    // for the 26 compsys ported documented there because the yodl
5193    // source uses signature-style headers (`item(tt(_foo [ -x ] …))`)
5194    // not bullet lists, so tier-1 picks up nothing and tier-2 only
5195    // catches whichever flags happen to be re-cited inline.
5196    if let Some(flags) = lookup_compsys_flag_docs(name) {
5197        let out: Vec<(String, String)> = flags
5198            .iter()
5199            .map(|(f, d)| (f.to_string(), d.to_string()))
5200            .collect();
5201        if let Ok(mut g) = cache.lock() {
5202            g.insert(name.to_string(), out.clone());
5203        }
5204        return out;
5205    }
5206    // Pull the doc body directly from the canonical table — DON'T
5207    // route through `lookup_doc` because that one prepends a heading
5208    // (`**name** — _zsh builtin_\n\n`) and routes through a cascade
5209    // that can resolve to a special-var entry for the same name.
5210    // Missing body is NOT fatal: module builtins (xattr / network /
5211    // pty / zstat / sysread / …) often have no markdown body, and
5212    // their flags come purely from `BUILTIN_FLAG_DOCS_OVERRIDE`.
5213    // Fall through with empty body so the Tier 3 merge below supplies them.
5214    let body: String = match crate::zsh_builtin_docs::lookup_builtin_doc(name) {
5215        Some((_, b)) => b.to_string(),
5216        None => crate::zsh_ext_builtin_docs::lookup_full(name)
5217            .map(|b| b.to_string())
5218            .unwrap_or_default(),
5219    };
5220    let mut out: Vec<(String, String)> = Vec::new();
5221    // Pattern: `- **\`-X[ _arg_]\`** <anything-but-newline-or-bullet>`.
5222    // The em-dash separator in zsh's yodl docs got double-mojibaked
5223    // in some entries during extraction (`âÂ\x80Â\x94` instead of
5224    // `—`) — we don't care, just skip everything until the next
5225    // alphabetic character which starts the description prose.
5226    // Tier 1: bullet pattern `- **\`-X[ args]\`** — desc`. Yields
5227    // (flag, first-line-desc). Used by the 25 builtins whose docs
5228    // have proper bullet lists (print, typeset, read, compadd,
5229    // stat, whence, bindkey, fc, zparseopts, zcompile, zmv, …).
5230    // Two-shape bullet body: inline `- **`-X`** — desc` OR next-line
5231    // `- **`-X`** —\ndesc`. The yodl source uses both freely (often
5232    // the same builtin mixes them — `print`'s `-b` and `-m` are
5233    // inline, every other flag's desc lives on the next line).
5234    // `[ \t]*` (no newline) bounds the em-dash junk to the bullet
5235    // line; the optional single `\n` lets us cross exactly one line
5236    // boundary to the description, so we don't accidentally pick up
5237    // prose paragraphs that separate flag clusters.
5238    // Arg notation has two forms in zsh's docs:
5239    //   `- **`-C cols`**`        ← arg INSIDE backticks (rare)
5240    //   `- **`-C` _cols_**`      ← arg OUTSIDE backticks, italicized (dominant)
5241    // The `[^*\n]*` between closing `` ` `` and closing `**` accepts
5242    // the italicized-arg form (` _cols_`, ` _name_`, ` _tab-stop_`)
5243    // without which print/read/where/etc. lose 5–10 flags each.
5244    let re_bullet = regex::Regex::new(
5245        r"(?m)^\s*-\s+\*\*`(-[A-Za-z+])(?:\s+[^`]*)?`[^*\n]*\*\*[ \t]*[^A-Za-z\n]*[ \t]*(?:\n[ \t]*)?([A-Z][^\n]+)",
5246    )
5247    .unwrap();
5248    for cap in re_bullet.captures_iter(&body) {
5249        let flag = cap.get(1).unwrap().as_str().to_string();
5250        let raw_desc = cap.get(2).map(|m| m.as_str()).unwrap_or("");
5251        let desc: String = raw_desc.trim().chars().take(200).collect();
5252        if !out.iter().any(|(f, _)| f == &flag) {
5253            out.push((flag, desc));
5254        }
5255    }
5256    // Tier 2: when tier-1 produced nothing, fall back to inline
5257    // `` `-X` `` citations scattered through the prose. Covers
5258    // cd / set / unset / echo / unsetopt / etc whose docs describe
5259    // flags in flowing text rather than bullet lists.
5260    //
5261    // For each cited flag, pull a SENTENCE-ish description from the
5262    // surrounding prose. Heuristic: find the sentence that contains
5263    // the flag mention, take from its opening (`. ` boundary or
5264    // start-of-paragraph) to the next `.` / `\n\n`. Strip markdown
5265    // backticks and underscore-italics for readability.
5266    if out.is_empty() {
5267        let re_inline = regex::Regex::new(r"`(-[A-Za-z+])`").unwrap();
5268        for cap in re_inline.captures_iter(&body) {
5269            let flag = cap.get(1).unwrap().as_str().to_string();
5270            if out.iter().any(|(f, _)| f == &flag) {
5271                continue;
5272            }
5273            // Find the FIRST occurrence position of this flag in
5274            // body, then scan around for a description sentence.
5275            let desc = derive_inline_flag_desc(&body, &flag).unwrap_or_default();
5276            out.push((flag, desc));
5277        }
5278    }
5279    // Tier 3 merge: union with hand-curated overrides sourced from
5280    // `man zshall`. Overrides win on flag-letter collisions; body
5281    // entries fill in letters the override doesn't list. Pinned by
5282    // `tests/lsp_man_audit.rs`.
5283    if let Some(over) = lookup_builtin_flag_docs_override(name) {
5284        let over_keys: std::collections::HashSet<&str> = over.iter().map(|(f, _)| *f).collect();
5285        out.retain(|(f, _)| !over_keys.contains(f.as_str()));
5286        for (f, d) in over {
5287            out.push((f.to_string(), d.to_string()));
5288        }
5289    }
5290    tracing::debug!(
5291        target: "zshrs::lsp::completion",
5292        builtin = %name,
5293        flag_count = out.len(),
5294        "extract_builtin_flags",
5295    );
5296    if let Ok(mut g) = cache.lock() {
5297        g.insert(name.to_string(), out.clone());
5298    }
5299    out
5300}
5301
5302/// Look up the hand-curated zsh-builtin flag table. Sourced from
5303/// `man zshall`. Merged with body-scraped flags in
5304/// `extract_builtin_flags` — overrides win on flag-letter collisions,
5305/// body fills in the rest. Pinned by `tests/lsp_man_audit.rs`.
5306pub(crate) fn lookup_builtin_flag_docs_override(
5307    name: &str,
5308) -> Option<&'static [(&'static str, &'static str)]> {
5309    BUILTIN_FLAG_DOCS_OVERRIDE
5310        .iter()
5311        .find(|(n, _)| *n == name)
5312        .map(|(_, flags)| *flags)
5313}
5314
5315/// Hand-curated zsh-builtin flag tables sourced from `man zshall`.
5316/// Merged with body-scraped flags. Coverage 100% per
5317/// `tests/lsp_man_audit.rs`.
5318const BUILTIN_FLAG_DOCS_OVERRIDE: &[(&str, &[(&str, &str)])] = &[
5319    (
5320        "bindkey",
5321        &[(
5322            "-L",
5323            "With `-l`, format output as `bindkey -A` / `-N` replay invocations.",
5324        )],
5325    ),
5326    (
5327        "enable",
5328        &[(
5329            "-p",
5330            "Operate on patterns added with `disable -p` (custom match-pattern hooks).",
5331        )],
5332    ),
5333    (
5334        "example",
5335        &[
5336            ("-a", "Pass arg as the example builtin's first parameter."),
5337            (
5338                "-f",
5339                "Toggle the example builtin's `flag` field (test option).",
5340            ),
5341            ("-g", "Toggle the example builtin's global-state test mode."),
5342            ("-l", "Toggle the example builtin's `long` test mode."),
5343            ("-s", "Toggle the example builtin's stateful test mode."),
5344        ],
5345    ),
5346    (
5347        "fc",
5348        &[(
5349            "-s",
5350            "Substitute `old=new` on the selected line and re-execute (no editor invoked).",
5351        )],
5352    ),
5353    (
5354        "getln",
5355        &[
5356            (
5357                "-A",
5358                "Read into an array (split into words instead of one scalar).",
5359            ),
5360            ("-E", "Don't echo (default; symmetric counterpart to `-e`)."),
5361            ("-c", "Read characters one at a time."),
5362            ("-e", "Echo read text back to terminal as it arrives."),
5363            ("-l", "Read just one line (default)."),
5364            ("-n", "Don't strip trailing newline from the result."),
5365        ],
5366    ),
5367    (
5368        "kill",
5369        &[
5370            (
5371                "-g",
5372                "Send the signal to the process GROUP, not just the process. Job-spec is a pgid.",
5373            ),
5374            (
5375                "-i",
5376                "Interpret arguments as job specs rather than process ids.",
5377            ),
5378            ("-n", "`-n signum` — send numeric signal `signum`."),
5379            (
5380                "-s",
5381                "`-s signame` — send named signal (`TERM`, `HUP`, `KILL`, …).",
5382            ),
5383        ],
5384    ),
5385    (
5386        "print",
5387        &[(
5388            "-f",
5389            "`-f format` — printf-style format string (same semantics as `printf`).",
5390        )],
5391    ),
5392    (
5393        "read",
5394        &[
5395            ("-c", "Read characters one at a time (no line-buffering)."),
5396            ("-e", "Echo read input back to terminal as it arrives."),
5397        ],
5398    ),
5399    (
5400        "sched",
5401        &[
5402            (
5403                "-e",
5404                "`+sched +HH:MM:SS event...` — schedule a command at the given time.",
5405            ),
5406            (
5407                "-i",
5408                "`sched -i id` — remove the scheduled entry with the given id.",
5409            ),
5410            (
5411                "-m",
5412                "`sched -m mask` — match scheduled entries against a glob pattern.",
5413            ),
5414            ("-t", "Print scheduled entries with full timestamps."),
5415        ],
5416    ),
5417    (
5418        "type",
5419        &[
5420            (
5421                "-S",
5422                "Like `-s` but include scripts in `$PATH` as commands.",
5423            ),
5424            (
5425                "-a",
5426                "Print every match for each name (not just the first).",
5427            ),
5428            ("-f", "Skip functions when looking up `name`."),
5429            ("-m", "Treat each name as a glob pattern."),
5430            ("-p", "Print only external commands found in `$path`."),
5431            (
5432                "-s",
5433                "Suppress output; exit 0 if name resolves to a command.",
5434            ),
5435            (
5436                "-w",
5437                "Print one of `alias`/`builtin`/`command`/`function`/`hashed`/`none` per name.",
5438            ),
5439        ],
5440    ),
5441    (
5442        "ulimit",
5443        &[
5444            (
5445                "-H",
5446                "Operate on the hard limit (default with `-S` is the soft limit).",
5447            ),
5448            (
5449                "-N",
5450                "`-N n` — operate on resource number `n` (system-specific integer).",
5451            ),
5452            (
5453                "-S",
5454                "Operate on the soft limit (default if neither `-H` nor `-S` given).",
5455            ),
5456            ("-T", "Maximum number of threads per process."),
5457            (
5458                "-a",
5459                "List all of the current resource limits (default verb).",
5460            ),
5461            ("-c", "Maximum core-file size in 512-byte blocks."),
5462            ("-d", "Maximum data-segment size in kilobytes."),
5463            (
5464                "-f",
5465                "Maximum file size the shell can write in 512-byte blocks.",
5466            ),
5467            ("-i", "Maximum number of pending signals."),
5468            ("-k", "Maximum number of kqueues allocated (BSD)."),
5469            ("-l", "Maximum locked-in-memory address space in kilobytes."),
5470            ("-m", "Maximum resident-set size in kilobytes."),
5471            ("-n", "Maximum number of open file descriptors."),
5472            ("-p", "The number of pseudo-terminals (BSD)."),
5473            ("-q", "Maximum bytes in POSIX message queues."),
5474            ("-r", "Maximum real-time scheduling priority."),
5475            ("-s", "Maximum stack size in kilobytes."),
5476            ("-t", "Maximum CPU time in seconds."),
5477            ("-v", "Maximum virtual-memory address space in kilobytes."),
5478            ("-w", "Maximum kilobytes of swapped-out memory."),
5479            ("-x", "Maximum number of file-locks held."),
5480        ],
5481    ),
5482    (
5483        "where",
5484        &[
5485            (
5486                "-S",
5487                "Like `-s` but include scripts in `$PATH` as commands.",
5488            ),
5489            ("-m", "Treat each name as a glob pattern."),
5490            ("-p", "Print only external commands found in `$path`."),
5491            ("-s", "Suppress output; exit 0 if name resolves."),
5492            (
5493                "-w",
5494                "Print one of `alias`/`builtin`/`command`/`function`/`hashed`/`none` per name.",
5495            ),
5496            (
5497                "-x",
5498                "`-x num` — indent each printed body line by `num` spaces.",
5499            ),
5500        ],
5501    ),
5502    (
5503        "which",
5504        &[
5505            ("-S", "Like `-s` but include scripts in `$PATH`."),
5506            ("-a", "Print every match for each name."),
5507            ("-m", "Treat each name as a glob pattern."),
5508            ("-p", "Print only external commands found in `$path`."),
5509            ("-s", "Suppress output; exit 0 if name resolves."),
5510            (
5511                "-w",
5512                "Print one of `alias`/`builtin`/`command`/`function`/`hashed`/`none` per name.",
5513            ),
5514            (
5515                "-x",
5516                "`-x num` — indent each printed body line by `num` spaces.",
5517            ),
5518        ],
5519    ),
5520    (
5521        "zcompile",
5522        &[
5523            ("-k", "Mark each compiled function for KSH-style autoload."),
5524            ("-m", "With `-c` / `-a`, treat each name as a glob pattern."),
5525        ],
5526    ),
5527    // ── zsh/files coreutils-style builtins ──────────────────────
5528    (
5529        "chgrp",
5530        &[
5531            ("-R", "Recursively descend into directories."),
5532            ("-h", "Change group of the symlink itself, not the target."),
5533            ("-s", "Suppress error messages for inaccessible files."),
5534        ],
5535    ),
5536    (
5537        "ln",
5538        &[
5539            (
5540                "-d",
5541                "Create a hard link to a directory (requires privilege).",
5542            ),
5543            (
5544                "-f",
5545                "If `dest` exists, remove it before creating the link.",
5546            ),
5547            (
5548                "-h",
5549                "If `dest` is a symlink, operate on the symlink itself.",
5550            ),
5551            ("-i", "Prompt before overwriting `dest`."),
5552            (
5553                "-n",
5554                "If `dest` is a symlink to a directory, replace the symlink.",
5555            ),
5556            ("-s", "Create a symbolic link instead of a hard link."),
5557        ],
5558    ),
5559    // ── zsh/system ──────────────────────────────────────────────
5560    (
5561        "syserror",
5562        &[
5563            (
5564                "-e",
5565                "`-e errvar` — store error string in `$errvar` instead of stderr.",
5566            ),
5567            ("-p", "`-p prefix` — prepend `prefix` to the error message."),
5568        ],
5569    ),
5570    (
5571        "sysread",
5572        &[
5573            (
5574                "-c",
5575                "`-c countvar` — store byte count read in `$countvar`.",
5576            ),
5577            (
5578                "-i",
5579                "`-i infd` — read from file descriptor `infd` instead of stdin.",
5580            ),
5581            (
5582                "-o",
5583                "`-o outfd` — relay bytes to `outfd` as well as storing them.",
5584            ),
5585        ],
5586    ),
5587    (
5588        "syswrite",
5589        &[
5590            (
5591                "-c",
5592                "`-c countvar` — store byte count actually written in `$countvar`.",
5593            ),
5594            ("-o", "`-o outfd` — write to `outfd` instead of stdout."),
5595        ],
5596    ),
5597    (
5598        "zselect",
5599        &[
5600            ("-A", "`-A arrayname` — store ready fds into `arrayname`."),
5601            (
5602                "-t",
5603                "`-t timeout` — timeout in hundredths of a second (centiseconds).",
5604            ),
5605        ],
5606    ),
5607    (
5608        "zsystem",
5609        &[(
5610            "-f",
5611            "`zsystem flock -f var file` — store lock file descriptor in `$var`.",
5612        )],
5613    ),
5614    // ── zsh/net/socket + zsh/net/tcp ────────────────────────────
5615    (
5616        "zsocket",
5617        &[
5618            (
5619                "-a",
5620                "Open a server (listening) socket bound to the named path.",
5621            ),
5622            (
5623                "-d",
5624                "`-d fd` — open the socket on the specified file descriptor.",
5625            ),
5626            ("-l", "List currently-open zsocket file descriptors."),
5627            ("-t", "Set close-on-exec on the socket."),
5628            (
5629                "-v",
5630                "Verbose — print the resulting file descriptor to stdout.",
5631            ),
5632        ],
5633    ),
5634    (
5635        "ztcp",
5636        &[
5637            (
5638                "-a",
5639                "Server mode — accept the next connection on the specified listening fd.",
5640            ),
5641            ("-c", "Close the named ztcp file descriptor."),
5642            ("-d", "`-d fd` — operate on the specified file descriptor."),
5643            (
5644                "-f",
5645                "Force — don't fail if a similar connection already exists.",
5646            ),
5647            (
5648                "-l",
5649                "Listen mode — open a server socket on the given port.",
5650            ),
5651            ("-t", "Set close-on-exec on the socket."),
5652            (
5653                "-v",
5654                "Verbose — print the resulting file descriptor to stdout.",
5655            ),
5656        ],
5657    ),
5658    // ── zsh/db/gdbm xattr family ────────────────────────────────
5659    (
5660        "zdelattr",
5661        &[("-h", "Operate on the symlink itself, not its target.")],
5662    ),
5663    (
5664        "zgetattr",
5665        &[("-h", "Operate on the symlink itself, not its target.")],
5666    ),
5667    (
5668        "zlistattr",
5669        &[("-h", "Operate on the symlink itself, not its target.")],
5670    ),
5671    (
5672        "zsetattr",
5673        &[("-h", "Operate on the symlink itself, not its target.")],
5674    ),
5675    // ── zsh/zutil ───────────────────────────────────────────────
5676    (
5677        "zstyle",
5678        &[
5679            (
5680                "-L",
5681                "`-L [ metapattern [ style ] ]` — list styles in `zstyle`-replay form.",
5682            ),
5683            (
5684                "-e",
5685                "`-e pattern style string ...` — value-as-shell-code (re-evaluated each lookup).",
5686            ),
5687        ],
5688    ),
5689    // ── zsh/zpty ────────────────────────────────────────────────
5690    (
5691        "zpty",
5692        &[
5693            ("-L", "List active zpty sessions with their commands."),
5694            (
5695                "-m",
5696                "With `-r`, treat `pattern` as a match-spec (read until pattern matches).",
5697            ),
5698            (
5699                "-n",
5700                "With `-w`, don't append a newline to written strings.",
5701            ),
5702            ("-t", "Test whether the named zpty session is still alive."),
5703        ],
5704    ),
5705    // ── zshzle: zle builtin ─────────────────────────────────────
5706    (
5707        "zle",
5708        &[
5709            (
5710                "-L",
5711                "With `-l`, format output as `zle` replay invocations.",
5712            ),
5713            (
5714                "-a",
5715                "With `-N`, mark new widget as available outside the editor (script-callable).",
5716            ),
5717            ("-c", "With `-R`, clear the screen before re-display."),
5718            (
5719                "-n",
5720                "Pass `-n num` through to the widget invocation (numeric argument).",
5721            ),
5722            ("-r", "With `-T`, remove the named termcap handler."),
5723            (
5724                "-w",
5725                "With `-F`, treat the fd handler as a writeable-fd ready handler.",
5726            ),
5727        ],
5728    ),
5729];
5730
5731/// Per-compsys-fn flag tables sourced from `man zshcompsys` signatures
5732/// (`_foo [ -x ] [ -12VJ ] tag name descr …`). The yodl source for
5733/// compsys docs uses signature-style headers — not bullet lists — so
5734/// the bullet/inline scrapers in `extract_builtin_flags` miss most
5735/// flags. This table beats both tiers for the 26 ported documented in
5736/// `zshcompsys.1`.
5737///
5738/// Shared conventions (per zsh source):
5739///   `-1`, `-2`, `-V`, `-J`     — passed through to `compadd` for
5740///                                 grouping / sort-order tags.
5741///   `-x`                        — show description even when no
5742///                                 matches are added (else descr
5743///                                 only shows when matches exist).
5744///   `-C name`                   — set the curcontext parameter to
5745///                                 `name` while running.
5746fn lookup_compsys_flag_docs(name: &str) -> Option<&'static [(&'static str, &'static str)]> {
5747    COMPSYS_FN_FLAG_DOCS
5748        .iter()
5749        .find(|(n, _)| *n == name)
5750        .map(|(_, flags)| *flags)
5751}
5752
5753/// Gate for the `BuiltinLongFlag` context. True only for the binary
5754/// itself today — every other long-flag-aware command in the corpus
5755/// is reached through its short-flag table.
5756fn is_known_builtin_with_long_flag_docs(name: &str) -> bool {
5757    matches!(name, "zshrs" | "zsh")
5758}
5759
5760/// Long-form flag table for `zshrs --<TAB>` / `zsh --<TAB>`. Built
5761/// once (lazy) from [`ZSHRS_SELF_LONG_FLAG_DOCS`] + every entry in
5762/// [`crate::zsh_option_docs::OPTION_DOCS`] transformed to its
5763/// invocation spelling (`AUTO_CD` → `--autocd`). Cached for the
5764/// process lifetime — keystroke-rate completion can't re-build 970+
5765/// entries on every press.
5766fn extract_builtin_long_flags(name: &str) -> Vec<(String, String)> {
5767    use std::sync::OnceLock;
5768    if name != "zshrs" && name != "zsh" {
5769        return Vec::new();
5770    }
5771    static CACHE: OnceLock<Vec<(String, String)>> = OnceLock::new();
5772    CACHE
5773        .get_or_init(|| {
5774            let mut out: Vec<(String, String)> = ZSHRS_SELF_LONG_FLAG_DOCS
5775                .iter()
5776                .map(|(f, d)| (f.to_string(), d.to_string()))
5777                .collect();
5778            // Setopt mirrors: every OPTION_DOCS canonical entry is
5779            // reachable as `--<lowercase-no-underscores>` (positive)
5780            // AND `--no-<lowercase-no-underscores>` (inverse) per
5781            // zsh's `--OPTION` / `--no-OPTION` invocation grammar
5782            // (see `Src/main.c:parseargs`). First-line snippet for
5783            // the positive form so IntelliJ rows stay single-line;
5784            // inverse gets a uniform "turn off" caption.
5785            for (opt_name, opt_desc) in crate::zsh_option_docs::OPTION_DOCS {
5786                let lower = opt_name.to_ascii_lowercase().replace('_', "");
5787                let first_line = opt_desc
5788                    .lines()
5789                    .find(|l| !l.trim().is_empty())
5790                    .unwrap_or("")
5791                    .trim();
5792                let short: String = first_line.chars().take(200).collect();
5793                out.push((format!("--{}", lower), short));
5794                out.push((
5795                    format!("--no-{}", lower),
5796                    format!("Turn OFF `--{}`. Inverse of the setopt option.", lower),
5797                ));
5798            }
5799            out
5800        })
5801        .clone()
5802}
5803
5804/// `zshrs -<TAB>` / `zsh -<TAB>` self-flag completions. Mirrors the
5805/// "Standard zsh options" section of `zshrs --help`. Documenting these
5806/// directly because the binary itself isn't in any of the builtin /
5807/// ext-builtin / compsys name sets, so the bullet scraper never sees
5808/// the canonical zsh `Src/main.c:parseargs` flag dispatch.
5809///
5810/// Long-form (`--lsp`, `--dap HOST:PORT`, `--zsh`, `--errexit`, …) is
5811/// intentionally out of scope here: the completion dispatcher's
5812/// `BuiltinFlag` context strips the leading `-` and stacks letters
5813/// (`-fl` → suggest `-fla`, `-flb`, …). Long-flag completion needs a
5814/// separate `BuiltinLongFlag` context with its own prefix model.
5815const ZSHRS_SELF_FLAG_DOCS: &[(&str, &str)] = &[
5816    ("-b", "End option processing, like `--`. Subsequent arguments are positional even if they start with `-`."),
5817    ("-c", "Take the FIRST argument as a command string to execute. Example: `zshrs -c 'echo hi'`."),
5818    ("-f", "Equivalent to `--no-rcs`: skip sourcing `.zshenv` / `.zshrc` / `.zprofile` / `.zlogin`. Use for clean-environment scripting."),
5819    ("-i", "Force interactive mode even when stdin isn't a terminal (job control + prompt + line editor active)."),
5820    ("-l", "Force login-shell mode: source `.zprofile` / `.zlogin` on startup and `.zlogout` on exit. Equivalent to invoking as `-zshrs`."),
5821    ("-s", "Read commands from standard input. Combine with `-c CMD …` to run CMD first then drain stdin."),
5822    ("-o", "Set a setopt option by name. Example: `-o errexit -o pipefail -o nounset`. Inverse: `+o OPTION` or `--no-OPTION`."),
5823    ("-v", "Verbose: print each input line as it's read. Equivalent to `--verbose` / `setopt VERBOSE`."),
5824    ("-x", "xtrace: print each command and its arguments before execution. Equivalent to `--xtrace` / `setopt XTRACE` / `set -x`."),
5825];
5826
5827/// zshrs-specific long flags (everything in `zshrs --help` that ISN'T
5828/// a setopt mirror — those flow in automatically from `OPTION_DOCS`).
5829/// Order is "most useful first" so the lookup popup leads with the
5830/// editor-integration + parity-mode flags users actually invoke from
5831/// scripts and CI.
5832const ZSHRS_SELF_LONG_FLAG_DOCS: &[(&str, &str)] = &[
5833    // Special / informational
5834    ("--help",     "Print the full usage message (every flag, dumper, parity mode) and exit 0."),
5835    ("--version",  "Print the zsh version string baked into the binary and exit 0."),
5836    ("--doctor",   "Full diagnostic report of shell health, caches, plugin load timings, and performance counters."),
5837    ("--banner",   "Print the ZSHRS logo, a box with the version and the builtin and extension totals, and whether the daemon answers on its socket. The `zbanner` builtin prints the same inside a shell, plus that shell's function, alias, parameter and job counts."),
5838    // Editor / IDE integration
5839    ("--lsp",      "Run the Language Server on stdio. Serves completion / hover / definition / references / rename / documentSymbol / foldingRange / semanticTokens / formatting / diagnostics. Consumed by the IntelliJ plugin, Helix, Neovim, VS Code, etc."),
5840    ("--dap",      "`--dap HOST:PORT` — Debug Adapter Protocol server. Connects back to the IDE's listener at HOST:PORT and drives breakpoints / step / variables / evaluate."),
5841    ("--dump-reflection", "Emit the JSON blob the IntelliJ \"zshrs\" reflection tool window consumes: builtins / keywords / options / special_vars, each tagged by category."),
5842    ("--docs",     "`--docs NAME` — render the same hover card the LSP would return for NAME. Used by the IntelliJ tool window's docs popup; handy for previewing doc output from the CLI. Prints a coloured terminal card on a TTY (or under `--color always` / `$FORCE_COLOR` / `$CLICOLOR_FORCE`) and the raw markdown otherwise, so piping into a markdown renderer works."),
5843    // Parser / VM dumpers
5844    ("--dump-tokens",   "`--dump-tokens FILE|-` — one TOKNAME<tab>TOKSTR line per lexer token. Use `-` to read from stdin."),
5845    ("--dump-ast",      "`--dump-ast FILE|-` — parser AST as a canonical S-expression. Use `-` to read from stdin."),
5846    ("--dump-wordcode", "`--dump-wordcode FILE|-` — wordcode emitter output: EPROG / WORDS / WC[i] / STRS sections matching zsh's binary cache format."),
5847    ("--dump-zwc",      "`--dump-zwc ZWCFILE [FN]` — inspect a compiled .zwc cache. Without FN, list every function. With FN, dump only that function's wordcode."),
5848    ("--disasm",        "Print fusevm opcodes for each compiled unit before VM run. Does NOT suppress execution — script still runs."),
5849    // Documentation generation (parallel to `stryke gen-docs`)
5850    ("--gen-docs",      "`--gen-docs [PATH] [--out DIR]` — walk PATH (default `.`) collecting every `##` doc-comment block above each function / alias / parameter, render to standalone HTML in `DIR` (default `docs/`)."),
5851    ("--out",           "`--out DIR` — destination directory for `--gen-docs` HTML output (default `docs/`)."),
5852    ("--dump-reference-html", "Emit the standalone reference HTML the zshrs project ships at `docs/reference.html` — every builtin / keyword / option / special var as a browseable single-page reference."),
5853    ("--names",         "With `--dump-reflection` or `--gen-docs`, restrict the dump / walk to a comma-separated list of NAMES instead of the full set."),
5854    // Daemon / interactive runtime
5855    ("--daemon",        "Run as the persistent zshrs daemon (used by the IDE / multi-shell scenarios). Holds the rkyv script cache + worker pool warm so subsequent script launches are sub-millisecond."),
5856    ("--color",         "`--color WHEN` — control coloured output (`auto` / `always` / `never`). Default `auto`: `$NO_COLOR` forces off, `$FORCE_COLOR` / `$CLICOLOR_FORCE` force on, otherwise follow `$TERM` and stdout TTY status."),
5857    // Parity modes (drop-in shell emulation)
5858    ("--zsh",       "Identical-behaviour drop-in for `/bin/zsh`. Caches OFF, daemon OFF — every `source` re-runs the file fresh. Used as the compat-test entrypoint."),
5859    ("--bash",      "Identical-behaviour drop-in for `/bin/bash`. Caches / daemon OFF; every echo / source re-fires byte-for-byte against reference bash. Reads bash's startup files, not zsh's: `/etc/profile` + the first of `~/.bash_profile` / `~/.bash_login` / `~/.profile` for a login shell, `/etc/bash.bashrc` (where present) + `~/.bashrc` for an interactive non-login shell, `$BASH_ENV` for a non-interactive shell, `~/.bash_logout` on login-shell exit."),
5860    ("--ksh",       "Identical-behaviour drop-in for `/bin/ksh` (ksh-93). Caches / daemon OFF. Startup files: `/etc/profile` + `~/.profile` when logging in, `/etc/ksh.kshrc` (where present) + `$ENV` — defaulting to `~/.kshrc` — when interactive."),
5861    ("--sh",        "Identical-behaviour drop-in for `/bin/sh` / POSIX (alias of `--posix`). Caches / daemon OFF. Startup files: `/etc/profile` + `~/.profile` when logging in, `$ENV` (no default) when interactive."),
5862    ("--csh",       "Identical-behaviour drop-in for `/bin/csh`. Caches / daemon OFF. Startup files: `/etc/csh.cshrc` + `~/.tcshrc` (or `~/.cshrc`) always, plus `/etc/csh.login` + `~/.login` when logging in and `~/.logout` on exit."),
5863    ("--posix",     "Identical-behaviour drop-in for `/bin/sh` / POSIX (Bourne / dash semantics). Caches / daemon OFF. Startup files: `/etc/profile` + `~/.profile` when logging in, `$ENV` (no default) when interactive."),
5864    ("--emulate",   "`--emulate MODE` — generic parity alias for `--MODE` (zsh-compat: matches the `emulate zsh` / `emulate bash` etc. builtin)."),
5865    ("--zsh-compat","Alias of `--zsh` (legacy spelling — kept for back-compat with older scripts / CI invocations)."),
5866    // Misc invocation
5867    ("--no-rcs",   "Skip sourcing `.zshenv` / `.zshrc` / `.zprofile` / `.zlogin`. Equivalent to the short `-f` flag."),
5868    ("--verbose",  "Print each input line as it's read. Equivalent to short `-v` / `setopt VERBOSE`."),
5869    ("--xtrace",   "Print each command and its arguments before executing. Equivalent to short `-x` / `setopt XTRACE` / `set -x`."),
5870    ("--login",    "Force login-shell mode. Equivalent to short `-l`."),
5871    ("--interactive", "Force interactive mode. Equivalent to short `-i`."),
5872    // bash startup-file flags (`--bash` mode only)
5873    ("--norc",      "`--bash` only: do not read `~/.bashrc` for an interactive non-login shell (bash's `--norc`)."),
5874    ("--noprofile", "`--bash` only: do not read `/etc/profile` or the `~/.bash_profile` chain for a login shell (bash's `--noprofile`)."),
5875    ("--rcfile",    "`--bash` only: `--rcfile FILE` — read FILE instead of `~/.bashrc`. Separate-word form only, as bash requires."),
5876    ("--init-file", "`--bash` only: alias of `--rcfile`."),
5877];
5878
5879const COMPSYS_FN_FLAG_DOCS: &[(&str, &[(&str, &str)])] = &[
5880    (
5881        "_all_labels",
5882        &[
5883            ("-x", "Show the description even when no matches are added."),
5884            ("-1", "Pass `-1` through to `compadd` (suppress duplicate-removal of literal matches)."),
5885            ("-2", "Pass `-2` through to `compadd` (suppress de-duplication based on display string)."),
5886            ("-V", "Pass `-V name` through to `compadd` (put matches in a named unsorted group)."),
5887            ("-J", "Pass `-J name` through to `compadd` (put matches in a named sorted group)."),
5888        ],
5889    ),
5890    (
5891        "_alternative",
5892        &[
5893            ("-O", "Pass `-O name` through to nested `_arguments` calls (preserve the option-spec array)."),
5894            ("-C", "Set the curcontext parameter to `name` while running each alternative."),
5895        ],
5896    ),
5897    (
5898        "_arguments",
5899        &[
5900            ("-n", "Set `$NORMARG` to the position of the first normal argument in the `$words` array."),
5901            ("-s", "Allow option stacking (`-abc` parsed as `-a -b -c`) — required for typical POSIX-style getopts CLIs."),
5902            ("-w", "Even with `-s`, allow stacked options to consume an argument before the next short option."),
5903            ("-W", "Even after a `--` separator, keep parsing further `-x` as options instead of treating them as positional."),
5904            ("-C", "Make `curcontext` available to action handlers in `->state` form."),
5905            ("-R", "Return status 300 instead of 0 when a `->state` action is dispatched (lets callers chain dispatch)."),
5906            ("-S", "Stop parsing options once a non-option word is seen (treat the rest as positionals)."),
5907            ("-A", "Treat any argument matching pattern `pat` as a non-option terminator (e.g. `-A '-*'`)."),
5908            ("-O", "Name an array holding extra `compadd` options to pass to every match."),
5909            ("-M", "Pass match spec `matchspec` to `compadd` (controls case-folding, partial-word matching, etc.)."),
5910            ("--", "Separator between option specs and rest-arg `[helpspec ...]` syntax."),
5911            ("-l", "Long-option style: each rest-arg `helpspec` describes one long option (e.g. `--foo=bar`)."),
5912            ("-i", "Skip option specs whose names match any of the patterns in `pats`."),
5913        ],
5914    ),
5915    (
5916        "_call_program",
5917        &[
5918            ("-l", "Read one line at a time from the external program (don't slurp all output)."),
5919            ("-p", "Treat the tag as a program-call context for the `command` style lookup."),
5920        ],
5921    ),
5922    (
5923        "_canonical_paths",
5924        &[
5925            ("-A", "Store the resolved canonical paths in array variable `var`."),
5926            ("-N", "Don't realpath-resolve — treat the input paths as already canonical."),
5927            ("-M", "Pass `-M matchspec` through to `compadd`."),
5928            ("-J", "Pass `-J name` through to `compadd` (named sorted group)."),
5929            ("-V", "Pass `-V name` through to `compadd` (named unsorted group)."),
5930            ("-1", "Pass `-1` through to `compadd`."),
5931            ("-2", "Pass `-2` through to `compadd`."),
5932            ("-n", "Pass `-n` through to `compadd` (no inserted suffix)."),
5933            ("-f", "Pass `-f` through to `compadd` (mark matches as filenames for suffix/cdpath handling)."),
5934            ("-X", "Pass `-X explanation` through to `compadd` (custom listing-line message)."),
5935        ],
5936    ),
5937    (
5938        "_combination",
5939        &[
5940            ("-s", "Use `pattern` to split each existing style value into fields (default is comma)."),
5941        ],
5942    ),
5943    (
5944        "_command_names",
5945        &[
5946            ("-e", "Complete only external commands found in `$path` (skip aliases, builtins, functions, reserved words)."),
5947            ("-", "Same as `-e`: external-only mode."),
5948        ],
5949    ),
5950    (
5951        "_completers",
5952        &[
5953            ("-p", "List only completer-function names that are valid for use in the `completer` style."),
5954        ],
5955    ),
5956    (
5957        "_describe",
5958        &[
5959            ("-1", "Pass `-1` through to `_next_label`/`compadd`."),
5960            ("-2", "Pass `-2` through to `_next_label`/`compadd`."),
5961            ("-J", "Pass `-J name` through to `_next_label` (named sorted group)."),
5962            ("-V", "Pass `-V name` through to `_next_label` (named unsorted group)."),
5963            ("-x", "Show the description even when no matches are added."),
5964            ("-o", "Treat each `name1`/`name2` array as describing options (default tag is `options`)."),
5965            ("-O", "Like `-o` but each match supports an argument-string suffix after a colon."),
5966            ("-t", "Use `tag` instead of the default `values`/`options` tag."),
5967        ],
5968    ),
5969    (
5970        "_description",
5971        &[
5972            ("-x", "Show the description even when no matches are added."),
5973            ("-1", "Pass `-1` through to `compadd`."),
5974            ("-2", "Pass `-2` through to `compadd`."),
5975            ("-V", "Pass `-V name` through to `compadd`."),
5976            ("-J", "Pass `-J name` through to `compadd` (groups matches in a named sorted group based on group-name style)."),
5977        ],
5978    ),
5979    (
5980        "_dir_list",
5981        &[
5982            ("-s", "Use `sep` instead of `:` as the directory-list separator."),
5983            ("-S", "Keep partially-typed list elements in the completion (allow trailing separator)."),
5984        ],
5985    ),
5986    (
5987        "_email_addresses",
5988        &[
5989            ("-c", "Add only the current-typed prefix as a match (don't expand to full addresses)."),
5990            ("-n", "Restrict to entries returned by the named `plugin` backend (`_email-`<plugin>)."),
5991        ],
5992    ),
5993    (
5994        "_message",
5995        &[
5996            ("-r", "Take `descr` literally as the message text (skip the `format` style lookup)."),
5997            ("-1", "Show the message even when no matches are added."),
5998            ("-2", "Show the message only when matches already exist for some other tag."),
5999            ("-V", "Display the message in a named unsorted group `group`."),
6000            ("-J", "Display the message in a named sorted group `group`."),
6001        ],
6002    ),
6003    (
6004        "_multi_parts",
6005        &[
6006            ("-i", "Insert matches immediately rather than only when uniquely determined."),
6007        ],
6008    ),
6009    (
6010        "_next_label",
6011        &[
6012            ("-x", "Show the description even when no matches are added."),
6013            ("-1", "Pass `-1` through to `compadd`."),
6014            ("-2", "Pass `-2` through to `compadd`."),
6015            ("-V", "Pass `-V name` through to `compadd`."),
6016            ("-J", "Pass `-J name` through to `compadd`."),
6017        ],
6018    ),
6019    (
6020        "_normal",
6021        &[
6022            ("-P", "Treat the current word as following a precommand (`nohup`, `time`, …) — skip to the real command."),
6023            ("-p", "Like `-P` but explicitly name the precommand for context lookup."),
6024        ],
6025    ),
6026    (
6027        "_numbers",
6028        &[
6029            // _numbers signature uses `[ option ... ]` — flags are
6030            // the `compadd` ones plus signal-specific selectors. No
6031            // canonical short-flag list in the man page; intentionally
6032            // empty so caller falls back to no-flag-completion.
6033        ],
6034    ),
6035    (
6036        "_pick_variant",
6037        &[
6038            ("-b", "Treat `builtin-label` as the variant when the command is a shell builtin."),
6039            ("-c", "Run `command` (with its args) to obtain the version string instead of the default `--version` invocation."),
6040            ("-r", "Store the matched variant name in the parameter `name`."),
6041        ],
6042    ),
6043    (
6044        "_requested",
6045        &[
6046            ("-x", "Show the description even when no matches are added."),
6047            ("-1", "Pass `-1` through to `compadd`."),
6048            ("-2", "Pass `-2` through to `compadd`."),
6049            ("-V", "Pass `-V name` through to `compadd`."),
6050            ("-J", "Pass `-J name` through to `compadd`."),
6051        ],
6052    ),
6053    (
6054        "_sequence",
6055        &[
6056            ("-s", "Use `sep` instead of `,` as the list separator."),
6057            ("-n", "Stop accepting more matches once `max` items have been completed in the sequence."),
6058            ("-d", "Allow duplicate entries in the sequence (default rejects already-typed values)."),
6059        ],
6060    ),
6061    (
6062        "_tags",
6063        &[
6064            ("-C", "Set the curcontext parameter to `name` while iterating the tag list."),
6065        ],
6066    ),
6067    (
6068        "_values",
6069        &[
6070            ("-O", "Name an array holding extra `compadd` options to pass to every match."),
6071            ("-s", "Use `sep` as the value separator between multiple keywords (default is comma)."),
6072            ("-S", "Use `sep` as the keyword-and-argument separator (default is `=`)."),
6073            ("-w", "Examine other typed arguments as well when computing which value-specs are already used."),
6074            ("-C", "Make `curcontext` available to action handlers (must be made local by the caller)."),
6075        ],
6076    ),
6077    (
6078        "_wanted",
6079        &[
6080            ("-x", "Show the description even when no matches are added."),
6081            ("-C", "Set the curcontext parameter to `name` while invoking the inner command."),
6082            ("-1", "Pass `-1` through to `compadd`."),
6083            ("-2", "Pass `-2` through to `compadd`."),
6084            ("-V", "Pass `-V name` through to `compadd`."),
6085            ("-J", "Pass `-J name` through to `compadd`."),
6086        ],
6087    ),
6088    (
6089        "_widgets",
6090        &[
6091            ("-g", "Restrict completions to widget names matching shell pattern `pattern`."),
6092        ],
6093    ),
6094];
6095
6096/// Hand docs for compsys functions whose names don't have a per-name
6097/// `item(tt(_X))` block in `compsys.yo` / `compwid.yo`. Per-command
6098/// completers (`_git`, `_docker`, …) and a couple of core dispatch
6099/// internals fall here.
6100const COMPSYS_FN_DOCS: &[(&str, &str)] = &[
6101    (
6102        "_main_complete",
6103        "Top-level entry the compsys dispatcher calls for every completion attempt. Walks the configured completer list (`_complete` / `_approximate` / `_match` / …), invoking each until one returns matches. Sets `$compstate[insert]` based on the result. Rust impl in `crate::compsys::ported::_main_complete::_main_complete`.",
6104    ),
6105    (
6106        "_directories",
6107        "Complete directory names only. Equivalent to `_files -/`. Honors `path-files` zstyle and respects `GLOB_DOTS`. Rust impl in `crate::compsys::files::directories_execute`.",
6108    ),
6109    (
6110        "_cargo",
6111        "Completion for the Rust `cargo` command — subcommands, flags, target names, feature names, profile names. Synthesizes from `cargo --list` and the manifest. Rust-native; no shell-script fallback.",
6112    ),
6113    (
6114        "_docker",
6115        "Completion for the `docker` CLI — subcommands, image names, container names/IDs, network names, volume names. Queries the local daemon socket via the `docker` binary; falls back to static-only when the daemon is unavailable.",
6116    ),
6117    (
6118        "_git",
6119        "Completion for `git` — subcommands, branches, tags, refs, remotes, file paths sensitive to `git status`. The most heavily-used compsys function in practice; Rust-native rewrite is several hundred times faster than the upstream shell implementation.",
6120    ),
6121    (
6122        "_kubectl",
6123        "Completion for `kubectl` — subcommands, resource kinds, resource names (queried via `kubectl get`), context/namespace names from kubeconfig.",
6124    ),
6125    (
6126        "_terraform",
6127        "Completion for `terraform` — subcommands, workspace names, state-file paths, providers, modules, variable names from the loaded HCL.",
6128    ),
6129    (
6130        "_ls",
6131        "Completion for `ls` — flags + file paths. Option specs plus file-path completion via `_files`.",
6132    ),
6133    (
6134        "_cd",
6135        "Completion for `cd` — directory paths from `$PWD`, `$cdpath`, and the `dirs` stack. Honors `AUTO_CD` and `CDABLE_VARS`.",
6136    ),
6137    (
6138        "_cp",
6139        "Completion for `cp` — flags + file paths. Source paths exclude the destination; destination directory is offered as the final candidate.",
6140    ),
6141    (
6142        "_mv",
6143        "Completion for `mv` — flags + file/directory paths. Source/destination split identical to `_cp`.",
6144    ),
6145    (
6146        "_rm",
6147        "Completion for `rm` — flags + file paths. `-r` enables directory completion; without it, directories are filtered out.",
6148    ),
6149    (
6150        "_cat",
6151        "Completion for `cat` — file paths only. No subcommands; flags pass through to `_files`.",
6152    ),
6153    (
6154        "_grep",
6155        "Completion for `grep` (GNU/BSD-flavor-aware) — flags then file paths. First positional argument is the pattern (no completion offered for free-text patterns).",
6156    ),
6157];
6158
6159/// Hand-curated docs for the zshrs extension builtins (`coreutils`
6160/// drop-ins, async/await primitives, doctor, intercept, etc.). The
6161/// canonical name list lives in `ext_builtins::EXT_BUILTIN_NAMES`;
6162/// every entry there must appear here too or the doc coverage gate
6163/// (`tests/doc_coverage_audit::every_canonical_extension_has_real_doc`)
6164/// fails.
6165const EXT_BUILTIN_DOCS: &[(&str, &str)] = &[
6166    // Sorts first: `_` (0x5F) precedes every letter. This is the one
6167    // entry in `ported::builtin::BUILTINS` with no upstream zsh C
6168    // counterpart (src/ported/builtin.rs, `!!! RUST-ONLY — NO C
6169    // COUNTERPART !!!`), so no yodl table can ever cover it.
6170    ("__rust_compile", "Compile and register an inline `rust { … }` block. Internal — emitted by the desugar pass, not meant to be typed by hand.\n\nUsage: `__rust_compile '<base64>' [<line>]`\n\nBefore lexing, `zsh::rust_ffi::desugar` rewrites every `rust { … }` block that starts at a command boundary into a call to this builtin, carrying the block body base64-encoded (argv[0]) plus the block's source line (argv[1], kept for diagnostics). The handler passes the body to `fusevm::ffi::compile_and_register`, which compiles it into a cached cdylib and registers each exported `extern \"C\"` function.\n\nA registered export then runs as an ordinary command word, resolved by `ShellExecutor::try_registered_ffi_command` — consulted only AFTER builtins, shell functions, `$PATH`, and `command_not_found_handler` all miss, so a real command of the same name always wins.\n\nReturns 0 on success; 1 with a `zshrs: <error>` diagnostic on stderr when the block fails to compile or the body argument is missing."),
6171    ("add_zsh_hook", "Add a function to a zsh hook array (chpwd / precmd / preexec / periodic / zshaddhistory / zshexit). `add-zsh-hook chpwd my_chpwd_fn`. Idempotent — re-adding the same function is a no-op."),
6172    ("ai", "Call a language model from the shell. Streams the response to stdout, or assigns it straight into a shell parameter with `-v VAR` / `-a ARRAY` — no fork, no `$(curl … | jq …)`. Providers: anthropic, openai, local (OpenAI-compatible), ollama, gemini. `-c` reports cost and tokens for the session. zshrs-only."),
6173    ("arch", "Print the machine architecture (uname -m equivalent): `x86_64`, `arm64`, `aarch64`, etc."),
6174    ("async", "Run a shell command string on the worker pool and print a job ID at once. `id=$(async 'cmd args')`; collect the output and status later with `await $id`."),
6175    ("await", "Block until the async job with the given ID finishes, print its captured stdout and return its exit status. `await $id`; the ID is required."),
6176    ("barrier", "Run commands in parallel and wait for all of them. `barrier 'cmd1' ::: 'cmd2' ::: 'cmd3'`; returns the highest exit status."),
6177    ("base64", "Encode / decode Base64. `-d` decodes; `-w0` no line wrap. coreutils drop-in."),
6178    ("basename", "Strip leading directories and an optional suffix. `basename /a/b.txt .txt` → `b`. coreutils drop-in."),
6179    ("caller", "Bash-compatible `caller` builtin. With no arg or 0: prints `LINE FUNC` for the current frame; with N>0: `LINE FUNC FILE` for the Nth call-stack frame."),
6180    ("cat", "Concatenate files to stdout. `-n` numbers lines, `-A` shows tabs/EOLs. coreutils drop-in."),
6181    ("cdreplay", "Replay the `compdef` calls deferred while completion was not yet initialised (zinit turbo mode). `cdreplay [-q]`; `-q` replays quietly."),
6182    ("cksum", "Print CRC32 checksum + byte count of each file. coreutils drop-in."),
6183    ("comm", "Compare two sorted files line-by-line. `-1` / `-2` / `-3` suppress columns. coreutils drop-in."),
6184    ("compdef", "Register a completion function for one or more commands. `compdef _git git`. Backed by the rkyv-mmap'd compsys shards; lookups are zero-copy. (SQLite mirrors exist beside the shards for `dbview` / SQL inspection only.)"),
6185    ("compgen", "Bash-compatible word generator. `compgen -W 'foo bar baz' fo` → `foo`. Used by bash-completion scripts ported to zshrs."),
6186    ("compinit", "Initialize the completion system. Walks `$fpath` in parallel via rayon, populates the rkyv-mmap'd autoload/completion shards, marks every `_*` as autoloaded. Default mode skips `.zcompdump` entirely."),
6187    ("complete", "Bash-compatible `complete` command — register a completion spec for a command. zshrs bridges to compsys internally."),
6188    ("compopt", "Bash-compatible `compopt` — modify completion options at runtime."),
6189    ("cut", "Extract fields or character ranges. `-d':' -f1,3` / `-c5-10`. coreutils drop-in."),
6190    ("date", "Print or set the system date. `+%FORMAT` strftime; `-d 'rel'` parse relative; `-u` UTC. coreutils drop-in."),
6191    ("dbview", "Dump the local zshrs SQLite mirror tables (autoload bodies, completion mirror, history FTS). Mirrors only &mdash; the authoritative cache is the rkyv-mmap'd shard set; SQLite is hydrated read-only for SQL/`dbview` inspection. `dbview --table autoloads` filters by table."),
6192    ("dircolors", "Emit `LS_COLORS` from a `.dircolors` file. coreutils drop-in."),
6193    ("dirname", "Strip the last path component. `dirname /a/b/c` → `/a/b`. coreutils drop-in."),
6194    ("doctor", "Diagnostic report of shell health — cache stats, autoload coverage, fpath sanity, daemon presence, memory footprint, recent error summary. zshrs-only."),
6195    ("env", "Run a command in a modified environment, or print the current environment. `env -i` empties; `env VAR=val cmd` sets. coreutils drop-in."),
6196    ("expand", "Convert tabs to spaces. `-t N` sets tab width. coreutils drop-in."),
6197    ("expr", "Evaluate an arithmetic / string expression. `expr 2 + 3` → `5`. Prefer `$(( … ))` in zshrs scripts; provided for POSIX compatibility."),
6198    ("factor", "Print prime factors. `factor 60` → `60: 2 2 3 5`. coreutils drop-in."),
6199    ("find", "Walk the filesystem and print / act on matches. Supports `-name`/`-type`/`-mtime`/`-exec`. coreutils drop-in (subset)."),
6200    ("fold", "Wrap each input line to a width. `-w N` width, `-s` break at spaces. coreutils drop-in."),
6201    ("groups", "Print groups the user (or named user) belongs to. coreutils drop-in."),
6202    ("head", "Print the first N lines (`-n N`) or bytes (`-c N`) of each file. coreutils drop-in."),
6203    ("help", "Print help for a builtin. `help cd` shows the cd usage. zshrs-only."),
6204    ("hostname", "Print the system hostname. `-s` short, `-f` FQDN."),
6205    ("id", "Print user / group IDs. `-u` user only, `-g` group only, `-n` names. coreutils drop-in."),
6206    ("intercept", "Register an AOP intercept. `intercept before|after|around <cmd> { body }` runs `body` around every invocation of `<cmd>`. The body is stored as source and compiled on each fire. `$INTERCEPT_NAME`, `$INTERCEPT_ARGS`, `$INTERCEPT_CMD` are bound; `after` adds `$INTERCEPT_MS`, `$INTERCEPT_US`, `$INTERCEPT_STATUS`. zshrs-only."),
6207    ("intercept_proceed", "Inside an `around` intercept body, invoke the underlying command. Required so the intercept doesn't shadow the call permanently."),
6208    ("link", "Create a hard link. `link src dst`. coreutils drop-in."),
6209    ("logname", "Print the user's login name. coreutils drop-in."),
6210    ("mkfifo", "Create named pipes (FIFOs). `mkfifo path …`. coreutils drop-in."),
6211    ("mktemp", "Create a temp file or directory with a unique name. `-d` directory, `-p DIR` parent. coreutils drop-in."),
6212    ("nice", "Run a command with adjusted scheduling priority. `nice -n 10 cmd`. coreutils drop-in."),
6213    ("nl", "Number lines. `-b a` numbers all, `-w N` field width. coreutils drop-in."),
6214    ("nproc", "Print the number of processing units available. `--all` ignores affinity."),
6215    ("paste", "Merge corresponding lines of files. `-d DELIM` separator. coreutils drop-in."),
6216    ("peach", "Parallel for-each on the worker pool: run the template once per item with `{}` replaced by the item, printing output as each finishes. `peach 'convert {} {}.png' *.svg`."),
6217    ("pgrep", "Print PIDs of processes matching a pattern. `-f` matches full command line."),
6218    ("pmap", "Parallel map on the worker pool: run the template once per item with `{}` replaced by the item, printing output in input order. `pmap 'gzip {}' *.log`. Not the process memory-map command of the same name."),
6219    ("printenv", "Print the value of one or more environment variables, or all if none given. coreutils drop-in."),
6220    ("profile", "Profile command execution in-process with nanosecond timing. `profile CMD…` profiles a command line, `profile -s SCRIPT` a script string, `profile -f FUNC [ARGS]` a function call; `--dump` shows and `--clear` resets the accumulated data."),
6221    ("provenance", "Value lineage over bytecode execution. `provenance -m NAME` arms tracking for a parameter; `provenance NAME` then prints where its bytes came from (`$(…)`, `<(…)`, glob, heredoc, an earlier assignment) and every bytecode op that produced or consumed them — `assign`, `expand`, `concat`, `exec`, `call`, `unset` — each with its `$LINENO`. `-j` emits JSON, `-u` untracks, `-c` clears. Records nothing until armed; disable entirely with `[provenance] enabled = false` in `~/.zshrs/zshrs.toml` or `ZSHRS_PROVENANCE=0`."),
6222    ("realpath", "Resolve symlinks and `.` / `..` to a canonical absolute path. coreutils drop-in."),
6223    ("rev", "Reverse each input line character-by-character. coreutils drop-in."),
6224    ("run_tests", "Alias for `ztest_run` — print the per-block test summary and roll the per-block counters into the run-wide totals. Returns 0 on all-pass, 1 if any assertion failed. Port of strykelang's `test_run` / `run_tests` builtin pair."),
6225    ("zassert_contains", "`zassert_contains HAYSTACK NEEDLE [MSG]` — pass when NEEDLE is a substring of HAYSTACK. Bumps the per-block `ztest_pass_count` on success, `ztest_fail_count` on failure; the ✓/✗ glyph + label print on stderr. Port of strykelang's `builtin_assert_contains`."),
6226    ("zassert_dies", "`zassert_dies \"CMD\" [MSG]` — shell-native variant of strykelang's `assert_dies` (which takes a code ref). Runs CMD in a scratch `ShellExecutor` (inheriting the host's `zsh_compat` / `bash_compat` / `posix_mode`); passes iff the command exits non-zero or errors. Use to assert that a misuse correctly fails."),
6227    ("zassert_eq", "`zassert_eq A B [MSG]` — string equality. Like every `zassert_*` builtin, returns 0 on pass / 1 on fail and bumps the per-block counters that `ztest_run` reads."),
6228    ("zassert_err", "`zassert_err V [MSG]` — pass when V is shell-falsy (empty *or* literal \"0\"). Convenient for asserting on `$?` after a command, or on a string that should be empty."),
6229    ("zassert_false", "`zassert_false V [MSG]` — alias of `zassert_err`."),
6230    ("zassert_ge", "`zassert_ge A B [MSG]` — pass iff numeric A ≥ B. Inputs are `trim().parse::<f64>()`'d; non-numeric inputs default to 0."),
6231    ("zassert_gt", "`zassert_gt A B [MSG]` — pass iff numeric A > B."),
6232    ("zassert_le", "`zassert_le A B [MSG]` — pass iff numeric A ≤ B."),
6233    ("zassert_lt", "`zassert_lt A B [MSG]` — pass iff numeric A < B."),
6234    ("zassert_match", "`zassert_match PATTERN STRING [MSG]` — regex match. PATTERN is compiled with the `regex` crate (Rust regex syntax, not POSIX BRE/ERE — so `\\d`/`\\w`/`\\s` work, no backreferences). Fails (rather than panics) on a bad pattern."),
6235    ("zassert_ne", "`zassert_ne A B [MSG]` — string inequality."),
6236    ("zassert_near", "`zassert_near A B [EPS [MSG]]` — floating-point approximate equality: pass iff `|A − B| ≤ EPS`. EPS defaults to `1e-9`. Right for asserting on math results without burning on the last few ULPs."),
6237    ("zassert_ok", "`zassert_ok V [MSG]` — pass when V is shell-truthy (non-empty *and* not literal \"0\"). Use `zassert_eq $? 0 …` for command-success assertions; reserve `zassert_ok` for assertions on populated strings or non-zero counts."),
6238    ("zassert_true", "`zassert_true V [MSG]` — alias of `zassert_ok`."),
6239    ("ztest_run", "Print the per-block test summary (green ✓ All N passed / red ✗ M of N failed) and roll the per-block `ztest_pass_count` / `ztest_fail_count` / `ztest_skip_count` into the run-wide `_total` counters, then zero the per-block counters so a second test block in the same file starts fresh. A sticky `ztest_run_failed` flag latches when any block reports failures — the `--ztest` worker reads it to exit non-zero even if the script ends with `exit 0`. Alias: `run_tests`. Returns 0 on all-pass, 1 if any assertion failed."),
6240    ("ztest_skip", "`ztest_skip MSG` — bump the per-block skip counter and emit a yellow ↷ marker on stderr. Typical use: gate an assertion behind a compat condition using shell short-circuit (`(( cond )) || { ztest_skip \"needs X\"; continue; }`)."),
6241    ("seq", "Print a sequence of numbers. `seq 1 10` / `seq 1 2 10` / `seq -w 1 10`. coreutils drop-in."),
6242    ("sha256sum", "Print or check SHA-256 digests. `-c FILE` checks. coreutils drop-in."),
6243    ("shuf", "Shuffle input lines. `-n N` limit, `-e ITEM…` shuffle args, `-i LO-HI` shuffle range. coreutils drop-in."),
6244    ("sleep", "Pause for the given duration. `sleep 1`, `sleep 0.5`, `sleep 1m`. coreutils drop-in."),
6245    ("sort", "Sort lines. `-n` numeric, `-r` reverse, `-k N` by field, `-u` unique. coreutils drop-in."),
6246    ("sum", "BSD/sysv checksum + 1K-block count. coreutils drop-in."),
6247    ("tac", "Concatenate files in reverse line order. coreutils drop-in."),
6248    ("tail", "Print the last N lines (`-n N`) or follow appends (`-f`). coreutils drop-in."),
6249    ("tee", "Copy stdin to stdout AND to each named file. `-a` append. coreutils drop-in."),
6250    ("touch", "Create a file or update its mtime. `-d STR` set time, `-r REF` copy from REF. coreutils drop-in."),
6251    ("tput", "Terminal-capability query. `tput cols`, `tput setaf 1`. Reads `$TERM` via terminfo."),
6252    ("tr", "Translate / squeeze / delete characters. `tr a-z A-Z` uppercases. coreutils drop-in."),
6253    ("tsort", "Topological sort of partial-order pairs read from stdin. coreutils drop-in."),
6254    ("tty", "Print the controlling terminal device path, or `not a tty` if stdin isn't one."),
6255    ("uname", "Print system info. `-a` all, `-s` kernel, `-m` machine, `-r` release. coreutils drop-in."),
6256    ("unexpand", "Convert leading spaces to tabs. `-a` all spaces. coreutils drop-in."),
6257    ("uniq", "Filter adjacent matching lines. `-c` prefix count, `-d` only duplicates. coreutils drop-in."),
6258    ("unlink", "Remove a single file via the `unlink(2)` syscall (no `-r`, no prompts). coreutils drop-in."),
6259    ("users", "Print the login names of users currently logged in."),
6260    ("wc", "Count newlines, words, bytes. `-l` lines, `-w` words, `-c` bytes. coreutils drop-in."),
6261    ("whoami", "Print the effective user name. coreutils drop-in."),
6262    ("yes", "Repeatedly output a line. `yes` prints `y` forever; `yes STR` prints STR. coreutils drop-in."),
6263    ("zbanner", "Print the ZSHRS logo, a box with the version and the builtin and extension totals, and a live line: the daemon socket and whether it answers, then this shell's function, alias, parameter and job counts. `zshrs --banner` prints the same from outside a shell, without the counts."),
6264    ("zbuild", "Bake one or more shell scripts into a standalone executable that runs them in order under one shell. `zbuild --in a.zsh b.zsh --out app`; `--native` compiles to native code with Cranelift."),
6265    ("zsleep", "Sleep for a possibly fractional number of seconds. `zsleep 0.25`; exactly one argument, no options."),
6266    // ── Daemon-backed `z*` builtins (Unix-socket RPC to zshrs-daemon) ──
6267    ("zask", "Ask another shell for input through the daemon and manage the requests. `zask --target shell:N picker|input|dialog|menu [opts]`, `zask progress`, `zask pending`, `zask take`, `zask dismiss ID | --all`, `zask inbox-clear`, `zask response ID DATA`."),
6268    ("zcache", "Inspect and manage the zshrs caches: daemon shards, the catalog and the caches the shell writes itself. `zcache info`, `zcache clean [all|shards|shard NAME|index|shell|log|catalog|stats|zwc|zcompdump|legacy] [--dry-run]`, `zcache verify`, `zcache view TARGET`, `zcache export`, `zcache import`, `zcache daemon status|stop|restart`."),
6269    ("zcmd-result", "Responder side of `zsend --wait`: report the exit status and output of a command a `cmd:execute` event delivered. `zcmd-result --id DELIVERY_ID [--exit-code N] [--stdout TEXT] [--stderr TEXT]`."),
6270    ("zcomplete", "Ask the daemon's `complete` op for candidates (commands, handlers, history) matching a command-line prefix and print the JSON reply. `zcomplete PREFIX [--limit N]`."),
6271    ("zd", "Daemon client. In-process from inside zshrs (Unix socket), the standalone `zd` binary over HTTP otherwise; same arguments either way. `zd ping`, `zd ops`, `zd job list`, `zd lock list`, `zd doctor`."),
6272    ("zhistory", "Read and write the daemon's cross-shell history store (history.db). `zhistory append LINE [--exit-code N] [--cwd DIR]`, `zhistory query [--filter PAT] [--mode match|fts|like|prefix|exact] [--cwd DIR] [--limit N]`, `zhistory count`."),
6273    ("zid", "Print the client id the daemon assigned to this connection."),
6274    ("zjob", "Run and manage jobs owned by the daemon, so they outlive the shell. `zjob submit CMD [ARGS] [--cwd DIR] [--tag T]`, `zjob list`, `zjob status ID`, `zjob output ID [--follow]`, `zjob attach ID`, `zjob kill ID [--signal NAME]`, `zjob cancel ID`, `zjob wait ID`."),
6275    ("zlock", "Named cross-process locks held by the daemon; a lock is released when its holder dies. `zlock try NAME`, `zlock acquire NAME [--timeout S]`, `zlock release NAME TOKEN`, `zlock list`."),
6276    ("zlog", "Inspect and manage the zshrs log files under ~/.zshrs. `zlog` (newest log path), `zlog tail [-n N]`, `zlog grep REGEX`, `zlog clear`, `zlog stats`, `zlog level DIRECTIVE`, `zlog rotate`."),
6277    ("zls", "List the shells connected to the daemon: id, pid, tty, uptime, tags and cwd. `zls --tag NAME` lists only the shells carrying that tag."),
6278    ("znotify", "Show a notification in other shells through the daemon. `znotify SHELL_ID|--all|--tag NAME|--user USER MESSAGE`."),
6279    ("zping", "Round-trip probe against the daemon: prints its uptime and the round-trip time. `zping --all` also lists every registered shell."),
6280    ("zpublish", "Publish an event on the daemon's pub/sub bus from this shell's scope. `zpublish TOPIC [DATA | --json JSON]`; subscribers receive it via `zsubscribe`."),
6281    ("zsend", "Send a command line to other shells through the daemon. `zsend [--json] [--wait] [--timeout S] SHELL_ID|--all|--tag NAME|--user USER CMD...`; `--wait` relays the target's output and exit status."),
6282    ("zsource", "Ask the daemon's compiled-file registry whether a sourced file's cached body is a hit, stale or a miss. `zsource PATH`, `zsource --stat PATH`."),
6283    ("zsubscribe", "Subscribe to pub/sub events and stream them to stdout. `zsubscribe [--json] [--count N] SCOPE.TOPIC` (e.g. `shell:42.commands`, `*.chpwd`), `zsubscribe --list`, `--pause`/`--resume [--id N | --all]`."),
6284    ("zsuggest", "Ask the daemon for a suggestion completing a command-line prefix. `zsuggest PREFIX [--cwd DIR]`."),
6285    ("zsync", "Move canonical subsystem state (aliases, functions, options, ...) between this shell and the daemon. `zsync up SUBSYSTEM KEY VALUE`, `zsync up --all`, `zsync pull SUBSYSTEM`, `zsync diff SUBSYSTEM --overlay JSON`, `zsync watch SUBSYSTEM...`."),
6286    ("ztag", "Add tags to this shell's daemon session and print its tag list. Other shells target tagged shells with `zls --tag`, `zsend --tag`, `znotify --tag` and `tag:NAME.*` subscriptions. `ztag prod-deploy`."),
6287    ("zunsubscribe", "Cancel a `zsubscribe` subscription. `zunsubscribe SCOPE.TOPIC` or `zunsubscribe --id SUBSCRIPTION_ID`."),
6288    ("zuntag", "Remove tags from this shell's daemon session, or every tag with `--all`, and print the remaining list. Inverse of `ztag`."),
6289    ("zwhere", "Query the daemon's definitions catalog for which shell defined an alias, function, parameter, key binding, ... and where. `zwhere [KIND] [NAME] [--prefix P] [--shell-id N] [--limit N]`; `zwhere --kinds` lists the kinds."),
6290];
6291
6292// ── Document symbols ────────────────────────────────────────────────────
6293
6294fn document_symbols(state: &State, params: &Value) -> Value {
6295    let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
6296    let text = match state.docs.get(uri) {
6297        Some(t) => t,
6298        None => return Value::Array(vec![]),
6299    };
6300    let mut syms = Vec::new();
6301    for (name, kind, _detail) in scan_symbols(text) {
6302        // Find first line containing the name as standalone token
6303        let mut line_no = 0usize;
6304        for (i, l) in text.lines().enumerate() {
6305            if l.contains(&name) {
6306                line_no = i;
6307                break;
6308            }
6309        }
6310        let lsp_kind: u8 = match kind {
6311            "function" => 12,
6312            "variable" => 13,
6313            _ => 1,
6314        };
6315        syms.push(json!({
6316            "name": name,
6317            "kind": lsp_kind,
6318            "range": {
6319                "start": { "line": line_no, "character": 0 },
6320                "end":   { "line": line_no, "character": 0 },
6321            },
6322            "selectionRange": {
6323                "start": { "line": line_no, "character": 0 },
6324                "end":   { "line": line_no, "character": 0 },
6325            },
6326        }));
6327    }
6328    Value::Array(syms)
6329}
6330
6331/// Walk the document looking for top-level function declarations and the
6332/// names of variables assigned with `=` / `+=`. Returns
6333/// `(name, "function"|"variable"|"alias", detail)`.
6334fn scan_symbols(text: &str) -> Vec<(String, &'static str, &'static str)> {
6335    let mut out = Vec::new();
6336    for line in text.lines() {
6337        let t = line.trim_start();
6338        if t.starts_with('#') {
6339            continue;
6340        }
6341        // `function foo {` or `function foo()`
6342        if let Some(rest) = t
6343            .strip_prefix("function ")
6344            .or_else(|| t.strip_prefix("function\t"))
6345        {
6346            if let Some(name) = first_ident(rest) {
6347                out.push((name, "function", "function"));
6348                continue;
6349            }
6350        }
6351        // `foo() {`
6352        if let Some(idx) = t.find("()") {
6353            let head = &t[..idx];
6354            if let Some(name) = first_ident(head) {
6355                if !head.contains(' ') && !head.contains('\t') {
6356                    out.push((name, "function", "function"));
6357                    continue;
6358                }
6359            }
6360        }
6361        // `alias name=...`
6362        if let Some(rest) = t.strip_prefix("alias ") {
6363            if let Some(name) = first_ident(rest) {
6364                out.push((name, "alias", "alias"));
6365                continue;
6366            }
6367        }
6368        // `local foo=...`, `typeset foo=...`, `export FOO=...`, `FOO=...`
6369        for prefix in &[
6370            "local ",
6371            "typeset ",
6372            "declare ",
6373            "readonly ",
6374            "export ",
6375            "integer ",
6376            "float ",
6377        ] {
6378            if let Some(rest) = t.strip_prefix(prefix) {
6379                if let Some(name) = first_ident(rest) {
6380                    out.push((name, "variable", "variable"));
6381                    break;
6382                }
6383            }
6384        }
6385    }
6386    out
6387}
6388
6389fn first_ident(s: &str) -> Option<String> {
6390    let s = s.trim_start();
6391    let mut end = 0;
6392    for c in s.chars() {
6393        if c == '_' || c.is_alphanumeric() {
6394            end += c.len_utf8();
6395        } else {
6396            break;
6397        }
6398    }
6399    if end == 0 {
6400        None
6401    } else {
6402        Some(s[..end].to_string())
6403    }
6404}
6405
6406// ── Folding ranges ──────────────────────────────────────────────────────
6407
6408fn folding_ranges(state: &State, params: &Value) -> Value {
6409    let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
6410    let text = match state.docs.get(uri) {
6411        Some(t) => t,
6412        None => return Value::Array(vec![]),
6413    };
6414    let mut out = Vec::new();
6415    let mut brace_stack: Vec<usize> = Vec::new();
6416    let mut block_stack: Vec<(usize, &str)> = Vec::new();
6417    let mut comment_run_start: Option<usize> = None;
6418    for (i, line) in text.lines().enumerate() {
6419        let t = line.trim_start();
6420        // Comment runs
6421        if t.starts_with('#') {
6422            if comment_run_start.is_none() {
6423                comment_run_start = Some(i);
6424            }
6425        } else {
6426            if let Some(start) = comment_run_start.take() {
6427                if i - 1 >= start + 2 {
6428                    out.push(json!({
6429                        "startLine": start, "endLine": i - 1, "kind": "comment"
6430                    }));
6431                }
6432            }
6433        }
6434        for c in line.chars() {
6435            if c == '{' {
6436                brace_stack.push(i);
6437            } else if c == '}' {
6438                if let Some(start) = brace_stack.pop() {
6439                    if i > start {
6440                        out.push(json!({ "startLine": start, "endLine": i, "kind": "region" }));
6441                    }
6442                }
6443            }
6444        }
6445        for tok in t.split_whitespace() {
6446            match tok {
6447                "do" | "then" => block_stack.push((i, tok)),
6448                "done" | "fi" => {
6449                    if let Some((start, _)) = block_stack.pop() {
6450                        if i > start {
6451                            out.push(json!({ "startLine": start, "endLine": i, "kind": "region" }));
6452                        }
6453                    }
6454                }
6455                "case" => block_stack.push((i, "case")),
6456                "esac" => {
6457                    if let Some((start, _)) = block_stack.pop() {
6458                        if i > start {
6459                            out.push(json!({ "startLine": start, "endLine": i, "kind": "region" }));
6460                        }
6461                    }
6462                }
6463                _ => {}
6464            }
6465        }
6466    }
6467    Value::Array(out)
6468}
6469
6470// ── Definition / references / highlight / rename ────────────────────────
6471
6472fn definition(state: &State, params: &Value) -> Value {
6473    let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
6474    let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
6475    let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
6476    let text = match state.docs.get(uri) {
6477        Some(t) => t,
6478        None => return Value::Null,
6479    };
6480    let word = match word_at(text, line_no, col) {
6481        Some(w) if !w.is_empty() => w,
6482        _ => return Value::Null,
6483    };
6484    let bare = word.strip_prefix('$').unwrap_or(&word);
6485    // Try AST first — walks active file + transitively-sourced files
6486    // (via `source X` / `. X` / `zsource X`) for a matching FuncDef
6487    // or top-level assignment. Falls through to the textual single-
6488    // file scan below on parse failure.
6489    if let Some(v) = definition_via_ast(state, uri, text, &word, bare) {
6490        return v;
6491    }
6492    // Textual fallback (single file only, function defs only).
6493    for (i, l) in text.lines().enumerate() {
6494        let t = l.trim_start();
6495        let is_def = t.starts_with(&format!("function {}", word))
6496            || t.starts_with(&format!("{}()", word))
6497            || t.starts_with(&format!("{} ()", word));
6498        if is_def {
6499            let start_col = l.find(&word).unwrap_or(0);
6500            return json!({
6501                "uri": uri,
6502                "range": {
6503                    "start": { "line": i, "character": start_col },
6504                    "end":   { "line": i, "character": start_col + word.len() },
6505                },
6506            });
6507        }
6508    }
6509    Value::Null
6510}
6511
6512/// Cross-file AST-backed definition lookup. `word` is the raw cursor
6513/// word (may have a `$` prefix); `bare` is the same string with any
6514/// leading `$` stripped.
6515///
6516/// Returns:
6517/// * `Some(Location)` if a single matching decl is found
6518/// * `Some(Location[])` if multiple decls share the name (zsh allows
6519///   per-file shadowing; surface all and let the client pick)
6520/// * `None` if any active-file parse fails or no decl is found —
6521///   caller falls back to the textual scan.
6522fn definition_via_ast(
6523    state: &State,
6524    active_uri: &str,
6525    active_text: &str,
6526    word: &str,
6527    bare: &str,
6528) -> Option<Value> {
6529    use crate::lsp_symbols::{find_ast_occurrences, SymbolKind};
6530    // `$x` cursor → look up Global decl. Bare cursor → look up Func
6531    // decl. (Locals don't cross files.) For bare words we also try
6532    // Global as a fallback (e.g. cursor on `FOO` in `echo FOO=1`).
6533    let kind = if word.starts_with('$') {
6534        SymbolKind::Global
6535    } else {
6536        SymbolKind::Func
6537    };
6538    // Cross-file scope in zsh is OPT-IN via `source X` / `. X` /
6539    // `zsource X`. Walk active file + transitively-sourced files only.
6540    // Previously this iterated `state.all_docs()` indiscriminately,
6541    // returning false-positive jumps to unrelated workspace files that
6542    // happened to share a symbol name. Fixed: build the source-chain
6543    // BFS via AST (`collect_sourced_paths`) and only search inside it.
6544    let files = collect_active_and_sourced_files(state, active_uri, active_text);
6545    let mut hits: Vec<Value> = Vec::new();
6546    let scan = |kind: SymbolKind, hits: &mut Vec<Value>| {
6547        for (uri, src) in &files {
6548            let lines = find_ast_occurrences(src, bare, kind.clone());
6549            for line in lines {
6550                if !line_is_decl(src, line, bare, &kind) {
6551                    continue;
6552                }
6553                if let Some((start, end)) = find_first_word_col(src, line, bare) {
6554                    hits.push(json!({
6555                        "uri": uri,
6556                        "range": {
6557                            "start": { "line": line, "character": start },
6558                            "end":   { "line": line, "character": end },
6559                        },
6560                    }));
6561                }
6562            }
6563        }
6564    };
6565    scan(kind.clone(), &mut hits);
6566    if hits.is_empty() {
6567        // Fallback once for Global → Func or vice versa, in case the
6568        // cursor's `$` heuristic guessed wrong.
6569        let alt = if matches!(kind, SymbolKind::Global) {
6570            SymbolKind::Func
6571        } else {
6572            SymbolKind::Global
6573        };
6574        scan(alt, &mut hits);
6575    }
6576    match hits.len() {
6577        0 => None,
6578        1 => Some(hits.into_iter().next().unwrap()),
6579        _ => Some(Value::Array(hits)),
6580    }
6581}
6582
6583/// Walk the source graph reachable from `active_uri` (BOTH directions:
6584/// files the active file transitively sources, AND files that source
6585/// the active file — i.e. dependents). Returns `(uri, text)` for every
6586/// file in the source-graph component containing `active_uri`.
6587///
6588/// The two-direction walk is the correct scope for rename / references:
6589/// a function declared in `lib.zsh` and called from `rc.zsh` (which has
6590/// `source lib.zsh`) needs to be findable from EITHER cursor position.
6591///
6592/// Forward = outgoing sources via AST `collect_sourced_paths`.
6593/// Reverse = scan every workspace file; if its forward chain contains
6594///           any URI already in the component, add it.
6595///
6596/// Cycle-guarded; depth- and breadth-capped (MAX_FILES) to keep
6597/// pathological rc chains from hanging the LSP. Files reached this way
6598/// are read fresh from disk so edits propagate without an explicit
6599/// didChangeWatchedFiles event.
6600fn collect_active_and_sourced_files(
6601    state: &State,
6602    active_uri: &str,
6603    active_text: &str,
6604) -> Vec<(String, String)> {
6605    const MAX_FILES: usize = 256;
6606
6607    // Helper: read text for a URI, preferring open-doc → workspace cache → disk.
6608    let read_text = |uri: &str| -> Option<String> {
6609        if uri == active_uri {
6610            return Some(active_text.to_string());
6611        }
6612        state
6613            .docs
6614            .get(uri)
6615            .cloned()
6616            .or_else(|| state.workspace_files.get(uri).cloned())
6617            .or_else(|| file_uri_to_path(uri).and_then(|p| std::fs::read_to_string(p).ok()))
6618    };
6619
6620    // Forward BFS: active + everything it transitively sources.
6621    let mut component: std::collections::HashMap<String, String> = std::collections::HashMap::new();
6622    component.insert(active_uri.to_string(), active_text.to_string());
6623    let mut queue: Vec<String> = vec![active_uri.to_string()];
6624    while let Some(uri) = queue.pop() {
6625        if component.len() >= MAX_FILES {
6626            break;
6627        }
6628        let parent_text = match component.get(&uri).cloned().or_else(|| read_text(&uri)) {
6629            Some(t) => t,
6630            None => continue,
6631        };
6632        let parent_dir = file_uri_to_path(&uri)
6633            .and_then(|p| p.parent().map(|d| d.to_path_buf()))
6634            .unwrap_or_else(|| std::path::PathBuf::from("."));
6635        for sourced_path in crate::lsp_symbols::collect_sourced_paths(&parent_text, &parent_dir) {
6636            let sourced_uri = format!("file://{}", sourced_path.display());
6637            if component.contains_key(&sourced_uri) {
6638                continue;
6639            }
6640            if let Some(t) = read_text(&sourced_uri) {
6641                component.insert(sourced_uri.clone(), t);
6642                queue.push(sourced_uri);
6643            }
6644        }
6645    }
6646
6647    // Reverse: scan workspace files; if any of them forward-source a
6648    // file already in `component`, pull them in (transitively). Repeat
6649    // until fixed point (or MAX_FILES).
6650    loop {
6651        if component.len() >= MAX_FILES {
6652            tracing::warn!(
6653                target: "zshrs::lsp::source_chain",
6654                walked = component.len(),
6655                "source-graph component hit MAX_FILES cap",
6656            );
6657            break;
6658        }
6659        let before = component.len();
6660        // Snapshot URIs to avoid mutating during iteration. Iterate
6661        // BOTH `state.docs` (open editor buffers) and `workspace_files`
6662        // (cached unopened files), since dependents may live in either.
6663        let candidates: Vec<(String, String)> = state
6664            .all_docs()
6665            .into_iter()
6666            .filter(|(u, _)| !component.contains_key(u.as_str()))
6667            .collect();
6668        for (uri, src) in candidates {
6669            let parent_dir = file_uri_to_path(&uri)
6670                .and_then(|p| p.parent().map(|d| d.to_path_buf()))
6671                .unwrap_or_else(|| std::path::PathBuf::from("."));
6672            let mut sources_into_component = false;
6673            for sourced_path in crate::lsp_symbols::collect_sourced_paths(&src, &parent_dir) {
6674                let sourced_uri = format!("file://{}", sourced_path.display());
6675                if component.contains_key(&sourced_uri) {
6676                    sources_into_component = true;
6677                    break;
6678                }
6679            }
6680            if sources_into_component {
6681                component.insert(uri, src);
6682                if component.len() >= MAX_FILES {
6683                    break;
6684                }
6685            }
6686        }
6687        if component.len() == before {
6688            break;
6689        }
6690    }
6691
6692    component.into_iter().collect()
6693}
6694
6695/// True when `(line, name)` in `src` is a *declaration* site for the
6696/// given kind. Used to filter `find_ast_occurrences` results down to
6697/// just the decls (occurrences emit both decls AND refs).
6698fn line_is_decl(src: &str, line: u32, name: &str, kind: &crate::lsp_symbols::SymbolKind) -> bool {
6699    let l = match src.lines().nth(line as usize) {
6700        Some(l) => l,
6701        None => return false,
6702    };
6703    let t = l.trim_start();
6704    use crate::lsp_symbols::SymbolKind;
6705    match kind {
6706        SymbolKind::Func => {
6707            t.starts_with(&format!("function {}", name))
6708                || t.starts_with(&format!("function {} ", name))
6709                || t.starts_with(&format!("{}()", name))
6710                || t.starts_with(&format!("{} ()", name))
6711        }
6712        SymbolKind::Global | SymbolKind::Local => {
6713            // Any line that starts an assignment to `name`.
6714            // Forms: `name=value`, `local name=value`, `typeset name`,
6715            // `export name=value`, `name+=value`.
6716            let prefixes = [
6717                format!("{}=", name),
6718                format!("{}+=", name),
6719                format!("local {}", name),
6720                format!("typeset {}", name),
6721                format!("declare {}", name),
6722                format!("private {}", name),
6723                format!("export {}", name),
6724                format!("readonly {}", name),
6725                format!("integer {}", name),
6726                format!("float {}", name),
6727            ];
6728            prefixes.iter().any(|p| t.starts_with(p.as_str()))
6729        }
6730    }
6731}
6732
6733/// Find the first whole-word occurrence of `name` on `src`'s `line`.
6734/// Returns `(start_col, end_col)` in UTF-16 code units (the LSP
6735/// `character` unit). Whole-word means surrounded by non-ident, non-`-` chars
6736/// (matches the boundary used in [`references`]).
6737fn find_first_word_col(src: &str, line: u32, name: &str) -> Option<(u32, u32)> {
6738    let l = src.lines().nth(line as usize)?;
6739    let mut start = 0;
6740    while let Some(p) = l[start..].find(name) {
6741        let abs = start + p;
6742        let before = l[..abs].chars().last();
6743        let after = l[abs + name.len()..].chars().next();
6744        let ok_b = before
6745            .map(|c| !(c.is_alphanumeric() || c == '_' || c == '-'))
6746            .unwrap_or(true);
6747        let ok_a = after
6748            .map(|c| !(c.is_alphanumeric() || c == '_' || c == '-'))
6749            .unwrap_or(true);
6750        if ok_b && ok_a && !line_position_inside_string_or_comment(l, abs) {
6751            return Some((
6752                byte_to_utf16_col(l, abs),
6753                byte_to_utf16_col(l, abs + name.len()),
6754            ));
6755        }
6756        start = abs + name.len();
6757    }
6758    None
6759}
6760
6761/// Every whole-word column of `name` on `line`. Used by the AST refs
6762/// path to compute LSP ranges from AST-tracked (line, name) pairs.
6763///
6764/// `is_variable_ref` toggles the mask used to skip false matches
6765/// inside strings. Variable refs (`$VAR`) interpolate inside `"..."`
6766/// and backticks, so those contexts are KEPT; only `'...'` and
6767/// comments mask. Function-name matches (which are literal) always
6768/// mask quoted regions and comments.
6769fn find_all_word_cols(line_text: &str, name: &str) -> Vec<(u32, u32)> {
6770    find_all_word_cols_kinded(line_text, name, false)
6771}
6772
6773fn find_all_word_cols_kinded(
6774    line_text: &str,
6775    name: &str,
6776    is_variable_ref: bool,
6777) -> Vec<(u32, u32)> {
6778    let mut out = Vec::new();
6779    let mut start = 0;
6780    while let Some(p) = line_text[start..].find(name) {
6781        let abs = start + p;
6782        let before = line_text[..abs].chars().last();
6783        let after = line_text[abs + name.len()..].chars().next();
6784        let ok_b = before
6785            .map(|c| !(c.is_alphanumeric() || c == '_' || c == '-'))
6786            .unwrap_or(true);
6787        let ok_a = after
6788            .map(|c| !(c.is_alphanumeric() || c == '_' || c == '-'))
6789            .unwrap_or(true);
6790        let masked = if is_variable_ref {
6791            line_position_inside_uninterpolating_context(line_text, abs)
6792        } else {
6793            line_position_inside_string_or_comment(line_text, abs)
6794        };
6795        if ok_b && ok_a && !masked {
6796            out.push((abs as u32, (abs + name.len()) as u32));
6797        }
6798        start = abs + name.len();
6799    }
6800    out
6801}
6802
6803fn references(state: &State, params: &Value) -> Value {
6804    let active_uri = params["textDocument"]["uri"]
6805        .as_str()
6806        .unwrap_or("")
6807        .to_string();
6808    let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
6809    let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
6810    // Active text comes from the open-doc map first (unsaved buffer
6811    // state wins), then falls back to the workspace cache.
6812    let active_text = match state
6813        .docs
6814        .get(&active_uri)
6815        .cloned()
6816        .or_else(|| state.workspace_files.get(&active_uri).cloned())
6817    {
6818        Some(t) => t,
6819        None => return Value::Array(vec![]),
6820    };
6821    let word = match word_at(&active_text, line_no, col) {
6822        Some(w) if !w.is_empty() => w,
6823        _ => return Value::Array(vec![]),
6824    };
6825    // AST-backed cross-file path — ONLY. No textual fallback.
6826    //
6827    // History: an earlier impl fell through to a whole-document
6828    // text-grep when the parse failed mid-edit. The fallback turned
6829    // Find Usages into a glorified `grep -w` — every comment match,
6830    // every string-literal match, every same-name-different-symbol
6831    // match got reported as a usage. Users called it FAKE and they
6832    // were right. Removed: parse failure now returns empty, which
6833    // surfaces as "no usages" in the IDE (with a debug log line
6834    // pointing at the failing file). The correctness trade is worth
6835    // the loss of coverage on broken-syntax buffers.
6836    match references_via_ast(state, &active_uri, &active_text, line_no as u32, &word) {
6837        Some(v) => v,
6838        None => {
6839            tracing::warn!(
6840                target: "zshrs::lsp::references",
6841                uri = %active_uri,
6842                %word,
6843                line = line_no,
6844                col,
6845                "AST-walk returned no resolution \
6846                 (parse failure or cursor not on a declared symbol); \
6847                 returning empty rather than falling back to text-search",
6848            );
6849            Value::Array(vec![])
6850        }
6851    }
6852}
6853
6854/// AST-backed cross-file find-references. Returns `None` if any of:
6855/// * the active file fails to parse
6856/// * the cursor doesn't resolve to a known symbol in that file
6857///
6858/// in which case the caller falls back to the textual scan. On
6859/// success returns the full LSP `Location[]` JSON array.
6860///
6861/// Algorithm (matches strykelang's SymbolTable approach):
6862/// 1. Build [`SymbolTable`] for the active file, resolve cursor
6863///    `(line, word)` → SymbolId → (name, kind).
6864/// 2. Active file: emit every line that the SymbolTable already
6865///    recorded as a decl or ref of that id.
6866/// 3. Other workspace files: kind-gated walk via
6867///    [`find_ast_occurrences`].  Locals don't cross files.
6868/// 4. Re-scan each (line, name) to compute the column range — the
6869///    AST loses column info, so this is the same trick stryke uses.
6870fn references_via_ast(
6871    state: &State,
6872    active_uri: &str,
6873    active_text: &str,
6874    cursor_line: u32,
6875    cursor_word: &str,
6876) -> Option<Value> {
6877    use crate::lsp_symbols::{find_ast_occurrences, SymbolKind, SymbolTable};
6878
6879    // `$var` cursor → strip the `$` so the symbol-name match works.
6880    let bare = cursor_word.strip_prefix('$').unwrap_or(cursor_word);
6881
6882    let active_table = SymbolTable::build(active_text)?;
6883    // Resolve cursor → (name, kind). If the active file declares the
6884    // symbol, use that. Otherwise look across the workspace for any
6885    // file that declares it (typical for `function daemon-ping` in
6886    // lib.zsh called from main.zsh — main.zsh has no decl).
6887    let (name, kind) = match active_table
6888        .symbol_at(cursor_line, bare)
6889        .and_then(|id| active_table.symbols.iter().find(|s| s.id == id))
6890    {
6891        Some(sym) => (sym.name.clone(), sym.kind.clone()),
6892        None => {
6893            // Look for the kind in source-chain-reachable files only.
6894            // (Previously scanned `state.all_docs()` which let unrelated
6895            // workspace files seed the kind — wrong scope, same FAKE
6896            // class of bug as the cross-file emission below.)
6897            let chain_files = collect_active_and_sourced_files(state, active_uri, active_text);
6898            let mut found: Option<SymbolKind> = None;
6899            'outer: for (other_uri, src) in &chain_files {
6900                if other_uri == active_uri {
6901                    continue;
6902                }
6903                let Some(t) = SymbolTable::build(src) else {
6904                    continue;
6905                };
6906                for s in &t.symbols {
6907                    if s.name == bare && matches!(s.kind, SymbolKind::Func | SymbolKind::Global) {
6908                        found = Some(s.kind.clone());
6909                        break 'outer;
6910                    }
6911                }
6912            }
6913            let default_kind = if cursor_word.starts_with('$') {
6914                SymbolKind::Global
6915            } else {
6916                SymbolKind::Func
6917            };
6918            (bare.to_string(), found.unwrap_or(default_kind))
6919        }
6920    };
6921
6922    let mut out: Vec<Value> = Vec::new();
6923
6924    // Variables interpolate inside `"..."` and backticks; functions
6925    // don't. Pick the mask via `is_var` so `$VAR` refs inside
6926    // double-quoted strings (the common case for command flags, URLs,
6927    // messages) are surfaced as real references.
6928    let is_var = matches!(kind, SymbolKind::Global | SymbolKind::Local);
6929
6930    // Active file occurrences. Prefer SymbolTable-resolved sites when
6931    // the symbol is declared here (gives us decl + same-file refs at
6932    // once); otherwise fall back to the AST occurrence walker.
6933    let active_lines: Vec<&str> = active_text.lines().collect();
6934    if let Some(id) = active_table.symbol_at(cursor_line, &name) {
6935        for (line, n) in active_table.occurrences(id) {
6936            if let Some(lt) = active_lines.get(line as usize) {
6937                for (s, e) in find_all_word_cols_kinded(lt, &n, is_var) {
6938                    out.push(json!({
6939                        "uri": active_uri,
6940                        "range": {
6941                            "start": { "line": line, "character": s },
6942                            "end":   { "line": line, "character": e },
6943                        },
6944                    }));
6945                }
6946            }
6947        }
6948    } else {
6949        let lines = find_ast_occurrences(active_text, &name, kind.clone());
6950        for line in lines {
6951            if let Some(lt) = active_lines.get(line as usize) {
6952                for (s, e) in find_all_word_cols_kinded(lt, &name, is_var) {
6953                    out.push(json!({
6954                        "uri": active_uri,
6955                        "range": {
6956                            "start": { "line": line, "character": s },
6957                            "end":   { "line": line, "character": e },
6958                        },
6959                    }));
6960                }
6961            }
6962        }
6963    }
6964
6965    // Cross-file: only for symbols that cross file boundaries via an
6966    // EXPLICIT `source X` / `. X` / `zsource X` chain rooted at the
6967    // active file. Variables in unrelated scripts are SEPARATE
6968    // variables — zsh has no implicit cross-file scope.
6969    //
6970    // Previously this block iterated `state.all_docs()` indiscriminately,
6971    // emitting every same-name match in the entire workspace. Users
6972    // (correctly) called this FAKE: hovering `s1` in 278_rps.zsh would
6973    // jump to a `s1` defined in 999_unrelated.zsh just because the name
6974    // matched. Cross-file scope in zsh is opt-in via `source`; the BFS
6975    // below is the correct mechanism. Removed the indiscriminate sweep.
6976    if !matches!(kind, SymbolKind::Local) {
6977        // Use the shared source-graph component helper: walks both
6978        // outgoing sources (files this one imports) AND incoming
6979        // sources (files that import this one — for "rename a decl,
6980        // find all callers" semantics). Pure AST-based via
6981        // `collect_sourced_paths` so canonicalize / dedup is right.
6982        let component = collect_active_and_sourced_files(state, active_uri, active_text);
6983        for (uri, src) in &component {
6984            if uri == active_uri {
6985                continue; // already emitted above
6986            }
6987            let lines = find_ast_occurrences(src, &name, kind.clone());
6988            let src_lines: Vec<&str> = src.lines().collect();
6989            for line in lines {
6990                if let Some(lt) = src_lines.get(line as usize) {
6991                    for (s, e) in find_all_word_cols_kinded(lt, &name, is_var) {
6992                        out.push(json!({
6993                            "uri": uri,
6994                            "range": {
6995                                "start": { "line": line, "character": s },
6996                                "end":   { "line": line, "character": e },
6997                            },
6998                        }));
6999                    }
7000                }
7001            }
7002        }
7003        tracing::debug!(
7004            target: "zshrs::lsp::references_ast",
7005            files_in_component = component.len(),
7006            "source-graph component walked",
7007        );
7008    }
7009
7010    tracing::debug!(
7011        target: "zshrs::lsp::references_ast",
7012        %name,
7013        ?kind,
7014        n_results = out.len(),
7015        "AST-resolved",
7016    );
7017    Some(Value::Array(out))
7018}
7019
7020fn document_highlights(state: &State, params: &Value) -> Value {
7021    // Same logic as references, but without uri field
7022    let refs = references(state, params);
7023    let arr = refs.as_array().cloned().unwrap_or_default();
7024    Value::Array(
7025        arr.into_iter()
7026            .map(|r| json!({ "range": r["range"], "kind": 1 }))
7027            .collect(),
7028    )
7029}
7030
7031fn prepare_rename(state: &State, params: &Value) -> Value {
7032    let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
7033    let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
7034    let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
7035    let text = match state.docs.get(uri) {
7036        Some(t) => t,
7037        None => {
7038            tracing::debug!(
7039                target: "zshrs::lsp::prepareRename",
7040                line = line_no, col,
7041                "no_doc_for_uri",
7042            );
7043            return Value::Null;
7044        }
7045    };
7046    // Reject positions inside a `#` comment / `#!` shebang — same gate
7047    // hover uses. Trying to rename `env` on the shebang line is never
7048    // what the user wants.
7049    let line_text = text.lines().nth(line_no).unwrap_or("");
7050    // LSP `character` is UTF-16 code units; the scanners below index bytes.
7051    let col = utf16_col_to_byte(line_text, col);
7052    if line_starts_comment_before(line_text, col) {
7053        tracing::debug!(
7054            target: "zshrs::lsp::prepareRename",
7055            line = line_no, col,
7056            "gated_comment",
7057        );
7058        return Value::Null;
7059    }
7060    if let Some(word) = word_at(text, line_no, col) {
7061        if !word.is_empty() {
7062            if let Some(line) = text.lines().nth(line_no) {
7063                if let Some(s) = line.find(&word) {
7064                    tracing::debug!(
7065                        target: "zshrs::lsp::prepareRename",
7066                        %word, line = line_no, "accepted",
7067                    );
7068                    return json!({
7069                        "start": { "line": line_no, "character": byte_to_utf16_col(line, s) },
7070                        "end":   { "line": line_no, "character": byte_to_utf16_col(line, s + word.len()) },
7071                        "placeholder": word,
7072                    });
7073                }
7074            }
7075        }
7076    }
7077    tracing::debug!(
7078        target: "zshrs::lsp::prepareRename",
7079        line = line_no, col,
7080        "no_identifier",
7081    );
7082    Value::Null
7083}
7084
7085fn rename(state: &State, params: &Value) -> Value {
7086    let new_name_raw = params["newName"].as_str().unwrap_or("").to_string();
7087    if new_name_raw.is_empty() {
7088        tracing::warn!(target: "zshrs::lsp::rename", "rejecting empty new_name");
7089        return Value::Null;
7090    }
7091    // Defensive: strip any `::`-qualifier the client may have included in
7092    // newName. Earlier versions of the IntelliJ plugin (and other LSP
7093    // frontends — Helix, neovim, etc.) prefilled the Rename dialog with
7094    // a qualified form like `Demo::handle`; the user edited just the
7095    // suffix to `handle2`, but the dialog returned the WHOLE prefilled
7096    // string with the new suffix (`Demo::handle2`), and the server then
7097    // spliced that into every match site as the bare replacement —
7098    // producing nonsense like `Demo::Demo::handle2`. The rename target
7099    // is resolved from the cursor POSITION, not the dialog text; the new
7100    // name only needs to carry the new bare segment. Stripping here is
7101    // safe defense-in-depth across clients, and a no-op for callers who
7102    // already send bare. Note: zsh doesn't natively use `::` in function
7103    // names but compsys/autoload code and perl-style user conventions
7104    // do, so the same prefill bug surfaces in zsh codebases too.
7105    let new_name = match new_name_raw.rfind("::") {
7106        Some(idx) => {
7107            let bare = new_name_raw[idx + 2..].to_string();
7108            tracing::warn!(
7109                target: "zshrs::lsp::rename",
7110                %new_name_raw, %bare,
7111                "stripping `::` qualifier from new_name",
7112            );
7113            bare
7114        }
7115        None => new_name_raw,
7116    };
7117    // Bucket edits per-URI so cross-file rename produces one entry per
7118    // file in the `changes` map. The textual scan in `references`
7119    // already produced absolute-URI ranges; we just group them.
7120    let refs = references(state, params);
7121    let arr = refs.as_array().cloned().unwrap_or_default();
7122    let mut buckets: HashMap<String, Vec<Value>> = HashMap::new();
7123    let mut total = 0usize;
7124    for r in arr {
7125        let uri = r["uri"].as_str().unwrap_or("").to_string();
7126        if uri.is_empty() {
7127            continue;
7128        }
7129        buckets
7130            .entry(uri)
7131            .or_default()
7132            .push(json!({ "range": r["range"], "newText": new_name }));
7133        total += 1;
7134    }
7135    tracing::info!(
7136        target: "zshrs::lsp::rename",
7137        %new_name,
7138        n_files = buckets.len(),
7139        n_edits = total,
7140        "applied",
7141    );
7142    let mut changes = serde_json::Map::new();
7143    for (uri, edits) in buckets {
7144        changes.insert(uri, Value::Array(edits));
7145    }
7146    json!({ "changes": Value::Object(changes) })
7147}
7148
7149// ── Semantic tokens ─────────────────────────────────────────────────────
7150
7151const SEMANTIC_TOKEN_TYPES: &[&str] = &[
7152    "comment",        // 0
7153    "string",         // 1
7154    "number",         // 2
7155    "keyword",        // 3
7156    "operator",       // 4
7157    "function",       // 5 — compat zsh builtins
7158    "variable",       // 6
7159    "parameter",      // 7
7160    "type",           // 8
7161    "macro",          // 9 — kept for back-compat; also used for compsys ported now
7162    "property",       // 10
7163    "regexp",         // 11
7164    "zshrsExtension", // 12 — zshrs-only ext + daemon `z*` builtins
7165    "zshrsCompsys",   // 13 — `_arguments` / `_files` / `_describe` family
7166];
7167
7168fn semantic_tokens(state: &State, params: &Value) -> Value {
7169    let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
7170    let text = match state.docs.get(uri) {
7171        Some(t) => t,
7172        None => return json!({ "data": [] }),
7173    };
7174    // Delta-encoded: line, char, length, tokenType, tokenModifiers
7175    let mut data: Vec<u32> = Vec::new();
7176    let mut last_line: u32 = 0;
7177    let mut last_col: u32 = 0;
7178    for (i, line) in text.lines().enumerate() {
7179        let ln = i as u32;
7180        let mut col = 0usize;
7181        let bytes = line.as_bytes();
7182        while col < bytes.len() {
7183            let rest = &line[col..];
7184            // Comment runs to end of line
7185            if rest.starts_with('#') {
7186                push_tok(
7187                    &mut data,
7188                    &mut last_line,
7189                    &mut last_col,
7190                    ln,
7191                    col as u32,
7192                    rest.len() as u32,
7193                    0,
7194                );
7195                break;
7196            }
7197            // Strings.
7198            //
7199            // Single-quoted `'...'` is opaque to parameter expansion —
7200            // emit one big string token.
7201            //
7202            // Double-quoted `"..."` and backtick `` `...` `` interpolate
7203            // `$var` / `${var}` / `$(cmd)`. Walk the contents and emit
7204            // alternating string / variable sub-tokens so the editor can
7205            // colorize `$var` distinctly from the surrounding string.
7206            // Mirrors the strykelang plugin's behavior — the user's
7207            // mental model is "the dollar sigil glows, even inside a
7208            // string."
7209            if rest.starts_with('"') || rest.starts_with('\'') || rest.starts_with('`') {
7210                let q = rest.as_bytes()[0] as char;
7211                let bb = rest.as_bytes();
7212                // Locate the closing quote first; same logic as before
7213                // so the overall span doesn't change.
7214                let mut close = 1;
7215                while close < bb.len() {
7216                    let c = bb[close] as char;
7217                    if c == '\\' && q != '\'' && close + 1 < bb.len() {
7218                        close += 2;
7219                        continue;
7220                    }
7221                    if c == q {
7222                        close += 1;
7223                        break;
7224                    }
7225                    close += 1;
7226                }
7227                // Single-quoted: one opaque token.
7228                if q == '\'' {
7229                    push_tok(
7230                        &mut data,
7231                        &mut last_line,
7232                        &mut last_col,
7233                        ln,
7234                        col as u32,
7235                        close as u32,
7236                        1,
7237                    );
7238                    col += close;
7239                    continue;
7240                }
7241                // Interpolating string: emit segments.
7242                // `seg_start` is offset within `rest` of the current
7243                // un-emitted string segment (starts at 1 to include the
7244                // opening quote in the first string segment).
7245                let mut seg_start = 0usize;
7246                let mut p = 1usize;
7247                // Inner-end excludes the closing quote so we don't try
7248                // to interpolate past it; if the string was unterminated
7249                // (`close == bb.len()` and last char is NOT `q`), keep
7250                // going to end-of-line.
7251                let inner_end =
7252                    if close > 0 && close <= bb.len() && bb.get(close - 1) == Some(&(q as u8)) {
7253                        close - 1
7254                    } else {
7255                        close
7256                    };
7257                let flush_string = |data: &mut Vec<u32>,
7258                                    last_line: &mut u32,
7259                                    last_col: &mut u32,
7260                                    col: usize,
7261                                    seg_start: usize,
7262                                    seg_end: usize| {
7263                    if seg_end > seg_start {
7264                        push_tok(
7265                            data,
7266                            last_line,
7267                            last_col,
7268                            ln,
7269                            (col + seg_start) as u32,
7270                            (seg_end - seg_start) as u32,
7271                            1, // string
7272                        );
7273                    }
7274                };
7275                while p < inner_end {
7276                    let c = bb[p] as char;
7277                    // Skip escape sequences `\X` (backslash applies in
7278                    // double-quoted strings only when followed by `$`,
7279                    // `\``, `"`, `\\`, newline — but for highlighting
7280                    // we just skip 2 bytes to avoid `\$` triggering an
7281                    // interpolation marker).
7282                    if c == '\\' && q != '\'' && p + 1 < inner_end {
7283                        p += 2;
7284                        continue;
7285                    }
7286                    if c == '$' {
7287                        // Flush the string segment up to here.
7288                        flush_string(&mut data, &mut last_line, &mut last_col, col, seg_start, p);
7289                        // Scan the `$var` / `${var}` / `$(cmd)` / `$((expr))`
7290                        // expansion. Keep it simple: match the existing
7291                        // `Variable` arm below for plain `$var` and
7292                        // `${...}`; for `$(...)` / `$((...))` skip past
7293                        // the matching close paren counting depth.
7294                        let var_start = p;
7295                        let mut q2 = p + 1;
7296                        // Detect $((arith)) up front so we can emit the
7297                        // INTERIOR as proper number/operator/identifier
7298                        // tokens rather than one opaque variable-colored
7299                        // blob. Without this, `"$(( -7 % 3 ))"` inside
7300                        // a double-quoted string had its arithmetic body
7301                        // colored like a string-internal variable.
7302                        let is_arith = q2 + 1 < inner_end && bb[q2] == b'(' && bb[q2 + 1] == b'(';
7303                        if is_arith {
7304                            // Scan to matching `))` (paren-depth aware).
7305                            let mut depth = 2i32; // already past `$((`
7306                            let arith_start = q2 + 2;
7307                            q2 = arith_start;
7308                            while q2 < inner_end && depth > 0 {
7309                                match bb[q2] {
7310                                    b'(' => depth += 1,
7311                                    b')' => depth -= 1,
7312                                    _ => {}
7313                                }
7314                                if depth == 0 {
7315                                    break;
7316                                }
7317                                q2 += 1;
7318                            }
7319                            // q2 now points at the first `)` of the
7320                            // closing `))`. Compute spans:
7321                            //   `$((` at var_start..arith_start
7322                            //   interior at arith_start..arith_end
7323                            //   `))`   at arith_end..(arith_end+2)
7324                            let arith_end = if q2 > 0 && q2 < bb.len() && bb[q2] == b')' {
7325                                // Move back to the `)` that closes the
7326                                // OUTER paren (one before q2 — but here
7327                                // q2 already IS the first `)` of `))`).
7328                                q2.saturating_sub(0)
7329                            } else {
7330                                q2
7331                            };
7332                            // Emit `$((` as operator.
7333                            push_tok(
7334                                &mut data,
7335                                &mut last_line,
7336                                &mut last_col,
7337                                ln,
7338                                (col + var_start) as u32,
7339                                3,
7340                                4,
7341                            );
7342                            // Tokenize the arithmetic interior into
7343                            // numbers / identifiers / operators.
7344                            emit_arith_interior(
7345                                &mut data,
7346                                &mut last_line,
7347                                &mut last_col,
7348                                ln,
7349                                col,
7350                                bb,
7351                                arith_start,
7352                                arith_end,
7353                            );
7354                            // Emit `))` as operator (2 bytes) if present.
7355                            if arith_end + 1 < bb.len()
7356                                && bb[arith_end] == b')'
7357                                && bb[arith_end + 1] == b')'
7358                            {
7359                                push_tok(
7360                                    &mut data,
7361                                    &mut last_line,
7362                                    &mut last_col,
7363                                    ln,
7364                                    (col + arith_end) as u32,
7365                                    2,
7366                                    4,
7367                                );
7368                                q2 = arith_end + 2;
7369                            } else {
7370                                q2 = arith_end;
7371                            }
7372                            seg_start = q2;
7373                            p = q2;
7374                            continue;
7375                        }
7376                        if q2 < inner_end && bb[q2] == b'{' {
7377                            // ${...} — find matching close brace, allowing
7378                            // one level of nested braces (e.g. `${(@)arr}`,
7379                            // `${x:-${y}}`).
7380                            let mut depth = 1i32;
7381                            q2 += 1;
7382                            while q2 < inner_end && depth > 0 {
7383                                match bb[q2] {
7384                                    b'{' => depth += 1,
7385                                    b'}' => depth -= 1,
7386                                    _ => {}
7387                                }
7388                                q2 += 1;
7389                            }
7390                        } else if q2 < inner_end && bb[q2] == b'(' {
7391                            // $(...) or $((...)) — count parens.
7392                            let mut depth = 1i32;
7393                            q2 += 1;
7394                            while q2 < inner_end && depth > 0 {
7395                                match bb[q2] {
7396                                    b'(' => depth += 1,
7397                                    b')' => depth -= 1,
7398                                    _ => {}
7399                                }
7400                                q2 += 1;
7401                            }
7402                        } else {
7403                            // Bare `$var` — alphanum / `_` body.
7404                            while q2 < inner_end {
7405                                let cc = bb[q2] as char;
7406                                if cc.is_alphanumeric() || cc == '_' {
7407                                    q2 += 1;
7408                                } else {
7409                                    break;
7410                                }
7411                            }
7412                            // Single-char specials: $0..$9, $?, $!, $$,
7413                            // $#, $*, $@, $-, $_.
7414                            if q2 == p + 1 && q2 < inner_end {
7415                                let cc = bb[q2] as char;
7416                                if "?!$#*@-_0123456789".contains(cc) {
7417                                    q2 += 1;
7418                                }
7419                            }
7420                        }
7421                        if q2 > var_start + 1 {
7422                            // Emit as `variable` (token type 6).
7423                            push_tok(
7424                                &mut data,
7425                                &mut last_line,
7426                                &mut last_col,
7427                                ln,
7428                                (col + var_start) as u32,
7429                                (q2 - var_start) as u32,
7430                                6,
7431                            );
7432                            seg_start = q2;
7433                            p = q2;
7434                            continue;
7435                        }
7436                        // Lone `$` (no name follows) — let it stay in
7437                        // the string segment.
7438                        p += 1;
7439                        continue;
7440                    }
7441                    p += 1;
7442                }
7443                // Trailing string segment (includes the closing quote).
7444                flush_string(
7445                    &mut data,
7446                    &mut last_line,
7447                    &mut last_col,
7448                    col,
7449                    seg_start,
7450                    close,
7451                );
7452                col += close;
7453                continue;
7454            }
7455            // Variable
7456            if rest.starts_with('$') {
7457                let mut end = 1;
7458                let b = rest.as_bytes();
7459                if end < b.len() && b[end] == b'{' {
7460                    // ${...}
7461                    end += 1;
7462                    while end < b.len() && b[end] != b'}' {
7463                        end += 1;
7464                    }
7465                    if end < b.len() {
7466                        end += 1;
7467                    }
7468                } else {
7469                    while end < b.len() {
7470                        let c = b[end] as char;
7471                        if c.is_alphanumeric() || c == '_' {
7472                            end += 1;
7473                        } else {
7474                            break;
7475                        }
7476                    }
7477                    if end == 1 {
7478                        // Special: $0..$9, $?, $!, $$, etc.
7479                        if end < b.len() {
7480                            let c = b[end] as char;
7481                            if "?!$#*@-_0123456789".contains(c) {
7482                                end += 1;
7483                            }
7484                        }
7485                    }
7486                }
7487                push_tok(
7488                    &mut data,
7489                    &mut last_line,
7490                    &mut last_col,
7491                    ln,
7492                    col as u32,
7493                    end as u32,
7494                    6,
7495                );
7496                col += end;
7497                continue;
7498            }
7499            // Long CLI flag `--foo` / `--foo-bar` — emit as a single
7500            // OPERATOR token so the whole flag highlights uniformly.
7501            // Before this branch, `--verbose` fell through every
7502            // classifier (`-` isn't IUSER/IDENT, no operator match)
7503            // and ended up unhighlighted (default editor color) while
7504            // adjacent words stayed colored — the user sees an
7505            // inconsistent flag display reported in the screenshot.
7506            //
7507            // Short flag `-f` is intentionally NOT handled here (zsh's
7508            // `-x`/`-n`/etc. already render fine as `-` + word; only
7509            // the double-dash long form was visibly broken).
7510            if rest.starts_with("--") && rest.len() > 2 && rest.as_bytes()[2].is_ascii_alphabetic()
7511            {
7512                let bb = rest.as_bytes();
7513                let mut end = 2;
7514                while end < bb.len() {
7515                    let c = bb[end];
7516                    if c.is_ascii_alphanumeric() || c == b'-' || c == b'_' {
7517                        end += 1;
7518                    } else {
7519                        break;
7520                    }
7521                }
7522                push_tok(
7523                    &mut data,
7524                    &mut last_line,
7525                    &mut last_col,
7526                    ln,
7527                    col as u32,
7528                    end as u32,
7529                    4, // operator
7530                );
7531                col += end;
7532                continue;
7533            }
7534            // Brace-range expansion `{X..Y}` / `{X..Y..N}` — emit as
7535            // a single OPERATOR token so the editor colors the whole
7536            // span uniformly. Without this, the word-classifier below
7537            // would treat the inner `a`/`e`/`A`/`E` letter endpoints
7538            // as single-char identifiers and color them with the
7539            // VARIABLE style (token-type 6 → italic green) — which
7540            // makes `{A..E}` look like a variable reference, not a
7541            // brace expansion. Detection is conservative: must see
7542            // `{` then 1+ alnum chars then literal `..` then 1+ alnum
7543            // chars (optionally `..N`) then `}` all on the same line.
7544            // List-form `{a,b,c}` is not handled here (the commas and
7545            // identifiers already render readably); only the range form
7546            // produced the false variable-coloring.
7547            if rest.starts_with('{') {
7548                let bb = rest.as_bytes();
7549                let mut p = 1usize;
7550                let scan_run = |bb: &[u8], mut p: usize| -> usize {
7551                    while p < bb.len() {
7552                        let c = bb[p];
7553                        if c.is_ascii_alphanumeric() || c == b'-' || c == b'_' {
7554                            p += 1;
7555                        } else {
7556                            break;
7557                        }
7558                    }
7559                    p
7560                };
7561                let after_a = scan_run(bb, p);
7562                let has_dotdot = after_a > p
7563                    && bb.get(after_a) == Some(&b'.')
7564                    && bb.get(after_a + 1) == Some(&b'.');
7565                if has_dotdot {
7566                    p = after_a + 2;
7567                    let after_b = scan_run(bb, p);
7568                    if after_b > p {
7569                        // Optional `..N` step.
7570                        let mut close = after_b;
7571                        if bb.get(close) == Some(&b'.') && bb.get(close + 1) == Some(&b'.') {
7572                            let after_step = scan_run(bb, close + 2);
7573                            if after_step > close + 2 {
7574                                close = after_step;
7575                            }
7576                        }
7577                        if bb.get(close) == Some(&b'}') {
7578                            let span = close + 1;
7579                            push_tok(
7580                                &mut data,
7581                                &mut last_line,
7582                                &mut last_col,
7583                                ln,
7584                                col as u32,
7585                                span as u32,
7586                                4, // operator
7587                            );
7588                            col += span;
7589                            continue;
7590                        }
7591                    }
7592                }
7593            }
7594            // Multi-char operators — emit as OPERATOR (token type 4).
7595            // Longest-match-first so `&&` doesn't lex as `&` + `&`.
7596            //
7597            // The IDE then maps semantic-token type 4 to ZshrsColors.OPERATOR
7598            // (which the user can rebind under Settings → Editor → Color
7599            // Scheme → zshrs → Operators). Without this branch the hand
7600            // lexer's OPERATOR token-type was wired but the LSP overlay
7601            // never emitted any, so the user's selected operator color
7602            // never applied.
7603            const OPERATORS: &[&str] = &[
7604                ";;&", "<<<", "<<-", "&&", "||", "|&", "<<", ">>", "&>", ">|", ">!", ">&", "<&",
7605                "<>", "==", "!=", "=~", "+=", "-=", ":=", "?=", "[[", "]]", "((", "))", ";;", ";|",
7606                "|", "&", ">", "<",
7607            ];
7608            let mut op_len = 0usize;
7609            for op in OPERATORS {
7610                if rest.starts_with(op) {
7611                    op_len = op.len();
7612                    break;
7613                }
7614            }
7615            if op_len > 0 {
7616                push_tok(
7617                    &mut data,
7618                    &mut last_line,
7619                    &mut last_col,
7620                    ln,
7621                    col as u32,
7622                    op_len as u32,
7623                    4, // operator
7624                );
7625                col += op_len;
7626                continue;
7627            }
7628            // Number
7629            let c0 = rest.as_bytes()[0] as char;
7630            if c0.is_ascii_digit() {
7631                let mut end = 0;
7632                let b = rest.as_bytes();
7633                while end < b.len() && (b[end] as char).is_ascii_digit() {
7634                    end += 1;
7635                }
7636                push_tok(
7637                    &mut data,
7638                    &mut last_line,
7639                    &mut last_col,
7640                    ln,
7641                    col as u32,
7642                    end as u32,
7643                    2,
7644                );
7645                col += end;
7646                continue;
7647            }
7648            // Word — classify. Allow leading `.` / `+` / `@` when
7649            // followed by `_`/letter (zinit-style function names like
7650            // `.zinit-foo` / `+vi-…` / `@hook-fn`). Body chars allow
7651            // `-` for hyphenated names (`daemon-lock-do`,
7652            // `daemon-export-pdf`) so they don't lex as multiple tokens
7653            // with `do` / `export` getting mis-classified.
7654            // Use the C-faithful character-class predicates from
7655            // `ported::ztype_h` — same `iuser` / `iident` / `ialnum`
7656            // bits the upstream lexer (`Src/lex.c::gettokstr`) checks.
7657            // Avoids drift between the hand rule here and the canonical
7658            // port. `iuser` is "username char" — letters/digits/`_` +
7659            // `-`/`.`/Dash. Add `:` to the body set because zsh
7660            // function names may include it (audited against zinit's
7661            // `:hist:precmd`); `:` isn't in IUSER but neither is it
7662            // an `ispecial` metachar, so command-word lexing accepts it.
7663            use crate::ported::ztype_h::{ialnum, iident, iuser};
7664            let leading_sigil = iuser(c0 as u8)
7665                && !iident(c0 as u8)  // exclude alnum/`_` — those start a plain word
7666                && rest.as_bytes().get(1).map_or(false, |b| iident(*b));
7667            // `+`/`@`/`:`/`^` aren't in IUSER (only `-` and `.` are
7668            // per the C source). Allow them anyway — zinit / p10k /
7669            // async hooks use them widely as function-name prefixes
7670            // and the C lexer accepts them as command-word content.
7671            let extra_sigil = !leading_sigil
7672                && matches!(c0, '+' | '@' | ':' | '^')
7673                && rest.as_bytes().get(1).map_or(false, |b| iident(*b));
7674            let is_sigil = leading_sigil || extra_sigil;
7675            if iident(c0 as u8) || is_sigil {
7676                let b = rest.as_bytes();
7677                let mut end = if is_sigil { 1 } else { 0 };
7678                while end < b.len() {
7679                    let c = b[end];
7680                    if ialnum(c) || c == b'_' {
7681                        end += 1;
7682                    } else if matches!(c, b'-' | b'.' | b':')
7683                        && end + 1 < b.len()
7684                        && (ialnum(b[end + 1]) || b[end + 1] == b'_')
7685                    {
7686                        end += 1;
7687                    } else {
7688                        break;
7689                    }
7690                }
7691                let w = &rest[..end];
7692                // Token-type classification — match index in
7693                // SEMANTIC_TOKEN_TYPES. Priority:
7694                //   * KEYWORDS (3) — reserved words.
7695                //   * zshrs extension builtins (12) — distinct color
7696                //     so `date` / `cat` / `zd` / etc. don't visually
7697                //     merge with compat builtins.
7698                //   * Compsys functions (13) — `_arguments` family.
7699                //   * BUILTINS (5) — compat zsh builtins.
7700                //   * VARIABLE (6) fallback for plain identifiers.
7701                let kind = if KEYWORDS.contains(&w) {
7702                    3u32
7703                } else if crate::ext_builtins::EXT_BUILTIN_NAMES.contains(&w)
7704                    || crate::daemon::builtins::ZSHRS_BUILTIN_NAMES.contains(&w)
7705                {
7706                    12
7707                } else if crate::compsys::COMPSYS_FN_NAMES.contains(&w) {
7708                    13
7709                } else if BUILTINS.contains(&w) {
7710                    5
7711                } else {
7712                    6
7713                };
7714                push_tok(
7715                    &mut data,
7716                    &mut last_line,
7717                    &mut last_col,
7718                    ln,
7719                    col as u32,
7720                    end as u32,
7721                    kind,
7722                );
7723                col += end;
7724                continue;
7725            }
7726            col += 1;
7727        }
7728    }
7729    json!({ "data": data })
7730}
7731
7732/// Walk the interior of a `$((...))` arithmetic expression and emit
7733/// per-atom semantic tokens (numbers / identifiers / operators) so
7734/// the IDE colors the inside as CODE rather than as one opaque
7735/// variable-colored span. Mirrors the C arithmetic-lexer's atom set
7736/// in `Src/math.c`: digit runs are numbers, alnum-starting runs are
7737/// identifiers (vars), and everything else is an operator atom.
7738///
7739/// `bb` is the raw line bytes; `arith_start..arith_end` is the
7740/// half-open range inside `bb` covering the arithmetic body (between
7741/// `$((` and `))`). `col` is the column of the enclosing string's
7742/// opening quote, used as the base for the emitted token positions.
7743fn emit_arith_interior(
7744    data: &mut Vec<u32>,
7745    last_line: &mut u32,
7746    last_col: &mut u32,
7747    ln: u32,
7748    col: usize,
7749    bb: &[u8],
7750    arith_start: usize,
7751    arith_end: usize,
7752) {
7753    let mut p = arith_start;
7754    while p < arith_end {
7755        let c = bb[p];
7756        // Whitespace — skip.
7757        if c == b' ' || c == b'\t' {
7758            p += 1;
7759            continue;
7760        }
7761        // Digit run → number (type 2).
7762        if c.is_ascii_digit() {
7763            let mut end = p + 1;
7764            while end < arith_end && (bb[end].is_ascii_digit() || bb[end] == b'.') {
7765                end += 1;
7766            }
7767            push_tok(
7768                data,
7769                last_line,
7770                last_col,
7771                ln,
7772                (col + p) as u32,
7773                (end - p) as u32,
7774                2,
7775            );
7776            p = end;
7777            continue;
7778        }
7779        // Identifier (alpha or `_`) → variable (type 6).
7780        if c.is_ascii_alphabetic() || c == b'_' {
7781            let mut end = p + 1;
7782            while end < arith_end && (bb[end].is_ascii_alphanumeric() || bb[end] == b'_') {
7783                end += 1;
7784            }
7785            push_tok(
7786                data,
7787                last_line,
7788                last_col,
7789                ln,
7790                (col + p) as u32,
7791                (end - p) as u32,
7792                6,
7793            );
7794            p = end;
7795            continue;
7796        }
7797        // Multi-char operator (longest match): `**`, `++`, `--`, `<<`,
7798        // `>>`, `&&`, `||`, `==`, `!=`, `<=`, `>=`, `+=`, `-=`, `*=`,
7799        // `/=`, `%=`. Single-char fallbacks: `+`, `-`, `*`, `/`, `%`,
7800        // `=`, `<`, `>`, `?`, `:`, `&`, `|`, `^`, `~`, `!`, `(`, `)`,
7801        // `,`. All emit as operator (type 4).
7802        let two = if p + 1 < arith_end {
7803            Some(&bb[p..p + 2])
7804        } else {
7805            None
7806        };
7807        let span = match two {
7808            Some(b"**") | Some(b"++") | Some(b"--") | Some(b"<<") | Some(b">>") | Some(b"&&")
7809            | Some(b"||") | Some(b"==") | Some(b"!=") | Some(b"<=") | Some(b">=") | Some(b"+=")
7810            | Some(b"-=") | Some(b"*=") | Some(b"/=") | Some(b"%=") => 2,
7811            _ => 1,
7812        };
7813        push_tok(
7814            data,
7815            last_line,
7816            last_col,
7817            ln,
7818            (col + p) as u32,
7819            span as u32,
7820            4,
7821        );
7822        p += span;
7823    }
7824}
7825
7826fn push_tok(
7827    out: &mut Vec<u32>,
7828    last_line: &mut u32,
7829    last_col: &mut u32,
7830    line: u32,
7831    col: u32,
7832    len: u32,
7833    ty: u32,
7834) {
7835    let delta_line = line - *last_line;
7836    let delta_col = if delta_line == 0 {
7837        col - *last_col
7838    } else {
7839        col
7840    };
7841    out.push(delta_line);
7842    out.push(delta_col);
7843    out.push(len);
7844    out.push(ty);
7845    out.push(0);
7846    *last_line = line;
7847    *last_col = col;
7848}
7849
7850// ── Formatting ──────────────────────────────────────────────────────────
7851
7852// ── Code actions: Extract Variable / Constant / Parameter ──────────────
7853//
7854// Ported from `strykelang/strykelang/lsp_extras.rs::compute_code_actions`.
7855// Adaptations for zsh syntax:
7856//   - declaration:  `local NAME=value`           (no sigil, no `my`)
7857//   - constant:     `readonly NAME=value`        (no `frozen`)
7858//   - var reference: `$NAME` / `${NAME}`         (caller adds the `$`)
7859//   - param:        zsh `name() { … }` has no `(param)` list, so
7860//                   Extract Parameter prepends `local NAME=$1` and
7861//                   shifts all body references by one positional index.
7862//                   v1 is simpler: we just append `local NAME=$N` at
7863//                   the top of the body with `N = positional count + 1`.
7864
7865fn code_actions(state: &State, params: &Value) -> Value {
7866    let uri = params["textDocument"]["uri"]
7867        .as_str()
7868        .unwrap_or("")
7869        .to_string();
7870    let text = match state.docs.get(&uri).cloned() {
7871        Some(t) => t,
7872        None => return Value::Array(vec![]),
7873    };
7874    let r = &params["range"];
7875    let start_line = r["start"]["line"].as_u64().unwrap_or(0) as u32;
7876    let start_char = r["start"]["character"].as_u64().unwrap_or(0) as u32;
7877    let end_line = r["end"]["line"].as_u64().unwrap_or(0) as u32;
7878    let end_char = r["end"]["character"].as_u64().unwrap_or(0) as u32;
7879
7880    let mut actions: Vec<Value> = Vec::new();
7881    let same_line = start_line == end_line;
7882    let nonempty = start_line != end_line || start_char != end_char;
7883
7884    // ── Multi-line selection → only Extract Function applies. ────────
7885    // (Variable / constant extract require a single-line expression to
7886    // assign to a name; multi-line bodies have to become a callable.)
7887    if !same_line {
7888        if let Some(action) = make_extract_function_multiline(&uri, &text, start_line, end_line) {
7889            actions.push(action);
7890        }
7891        return Value::Array(actions);
7892    }
7893
7894    // Resolve the line first — every action below needs it, and an
7895    // out-of-bounds line means no actions regardless of mode.
7896    let line_text = match text.lines().nth(start_line as usize) {
7897        Some(l) => l,
7898        None => return Value::Array(vec![]),
7899    };
7900    let leading_ws: String = line_text
7901        .chars()
7902        .take_while(|c| c.is_whitespace())
7903        .collect();
7904
7905    // ── Extract Function: offered whenever the cursor's line has
7906    // any non-whitespace content, regardless of whether the user has
7907    // an explicit selection. The Cmd-Opt-M ("Extract Method") common
7908    // case is caret-only; before this branch the LSP returned an
7909    // empty action list, the plugin showed "LSP returned no code
7910    // actions for this range", and the user's only recourse was to
7911    // manually select the line first.
7912    let line_has_content = !line_text.trim().is_empty();
7913    let whole_line_selected =
7914        nonempty && selection_covers_whole_line(line_text, start_char, end_char);
7915    if line_has_content && (whole_line_selected || !nonempty) {
7916        let body = if whole_line_selected {
7917            utf16_slice(line_text, start_char, end_char)
7918                .map(str::trim_end)
7919                .unwrap_or_else(|| line_text.trim())
7920        } else {
7921            line_text.trim()
7922        };
7923        actions.push(make_extract_function_singleline(
7924            &uri,
7925            &leading_ws,
7926            start_line,
7927            body,
7928        ));
7929    }
7930
7931    // ── Extract Variable / Constant: need a concrete sub-expression
7932    // to assign to a name. Caret-only invocations snap to the word at
7933    // the cursor; explicit selections use the user's range as-is.
7934    let (eff_start_char, eff_end_char) = if !nonempty {
7935        match snap_to_word_at_cursor(line_text, start_char) {
7936            Some((s, e)) => (s, e),
7937            // Caret not on a word — Extract Function still applied
7938            // above (if line had content), so return what we have.
7939            None => return Value::Array(actions),
7940        }
7941    } else {
7942        (start_char, end_char)
7943    };
7944
7945    if eff_end_char <= eff_start_char {
7946        return Value::Array(actions);
7947    }
7948
7949    let sel = match utf16_slice(line_text, eff_start_char, eff_end_char) {
7950        Some(s) if !s.trim().is_empty() => s,
7951        _ => return Value::Array(actions),
7952    };
7953
7954    let eff_range = json!({
7955        "start": { "line": start_line, "character": eff_start_char },
7956        "end":   { "line": start_line, "character": eff_end_char   },
7957    });
7958
7959    // Wrap selection in `"..."` if it sits inside an interpolating
7960    // string (double-quoted or backtick) AND isn't already a self-
7961    // contained expression (`$foo` / already-quoted literal).
7962    let in_string = same_line_inside_interpolating_string(line_text, eff_start_char);
7963    let rhs = if in_string && needs_string_wrap_for_extraction(sel) {
7964        format!("\"{}\"", escape_for_double_quoted(sel))
7965    } else {
7966        sel.to_string()
7967    };
7968
7969    actions.push(make_extract_action(
7970        &uri,
7971        &leading_ws,
7972        start_line,
7973        &eff_range,
7974        &rhs,
7975        "EXTRACTED",
7976        "local",
7977        "Extract to variable (`local NAME=…`)",
7978    ));
7979    actions.push(make_extract_action(
7980        &uri,
7981        &leading_ws,
7982        start_line,
7983        &eff_range,
7984        &rhs,
7985        "EXTRACTED",
7986        "readonly",
7987        "Extract to constant (`readonly NAME=…`)",
7988    ));
7989
7990    Value::Array(actions)
7991}
7992
7993/// True when the selection spans the line's entire non-whitespace
7994/// content — leading indent before `eff_start_char` is whitespace, and
7995/// everything after `eff_end_char` is whitespace too. Used to decide
7996/// whether Extract Function applies to a single-line selection (we want
7997/// to extract whole statements, not arbitrary expression fragments —
7998/// the latter are already covered by Extract Variable / Constant).
7999fn selection_covers_whole_line(line_text: &str, start_col: u32, end_col: u32) -> bool {
8000    let mut prefix_byte = 0;
8001    let mut suffix_byte = line_text.len();
8002    let mut u16_seen = 0u32;
8003    for (i, ch) in line_text.char_indices() {
8004        if u16_seen == start_col {
8005            prefix_byte = i;
8006        }
8007        u16_seen += ch.len_utf16() as u32;
8008        if u16_seen == end_col {
8009            suffix_byte = i + ch.len_utf8();
8010        }
8011    }
8012    line_text[..prefix_byte].chars().all(char::is_whitespace)
8013        && line_text[suffix_byte..].chars().all(char::is_whitespace)
8014}
8015
8016fn make_extract_function_singleline(uri: &str, leading_ws: &str, line: u32, body: &str) -> Value {
8017    // Insert `extracted_function() { body; }` above the line, replace
8018    // the line's content with a bare call.
8019    let name = "extracted_function";
8020    let decl = format!("{leading_ws}{name}() {{\n{leading_ws}    {body}\n{leading_ws}}}\n");
8021    let insert_range = json!({
8022        "start": { "line": line, "character": 0 },
8023        "end":   { "line": line, "character": 0 },
8024    });
8025    let replace_range = json!({
8026        "start": { "line": line, "character": 0 },
8027        "end":   { "line": line + 1, "character": 0 },
8028    });
8029    let replacement = format!("{leading_ws}{name}\n");
8030    let changes = json!({
8031        uri: [
8032            { "range": insert_range, "newText": decl },
8033            { "range": replace_range, "newText": replacement },
8034        ]
8035    });
8036    json!({
8037        "title": "Extract to function (`name() { … }`)",
8038        "kind": "refactor.extract",
8039        "edit": { "changes": changes },
8040    })
8041}
8042
8043fn make_extract_function_multiline(
8044    uri: &str,
8045    text: &str,
8046    start_line: u32,
8047    end_line: u32,
8048) -> Option<Value> {
8049    // Pull the inclusive line range, snapping the LSP exclusive end-line
8050    // semantics to "all lines that the selection touches." A selection
8051    // ending at column 0 of line N covers lines start..N-1 only; a
8052    // selection ending mid-line N covers start..N inclusive.
8053    let lines: Vec<&str> = text.lines().collect();
8054    if (start_line as usize) >= lines.len() {
8055        return None;
8056    }
8057    let last = (end_line as usize).min(lines.len() - 1);
8058    let block = &lines[start_line as usize..=last];
8059    if block.iter().all(|l| l.trim().is_empty()) {
8060        return None;
8061    }
8062
8063    // Common leading-whitespace prefix on non-blank lines determines the
8064    // function-body indent we'll strip back to.
8065    let common_indent = block
8066        .iter()
8067        .filter(|l| !l.trim().is_empty())
8068        .map(|l| l.chars().take_while(|c| c.is_whitespace()).count())
8069        .min()
8070        .unwrap_or(0);
8071
8072    let leading_ws: String = block
8073        .iter()
8074        .find(|l| !l.trim().is_empty())
8075        .map(|l| l.chars().take(common_indent).collect())
8076        .unwrap_or_default();
8077
8078    let name = "extracted_function";
8079    let mut decl = String::new();
8080    decl.push_str(&format!("{leading_ws}{name}() {{\n"));
8081    for l in block {
8082        if l.trim().is_empty() {
8083            decl.push('\n');
8084        } else {
8085            // Strip the common indent then re-indent one level past the
8086            // function-decl leading whitespace.
8087            let stripped = if l.chars().take(common_indent).all(|c| c.is_whitespace()) {
8088                &l[l.char_indices()
8089                    .nth(common_indent)
8090                    .map(|(i, _)| i)
8091                    .unwrap_or(l.len())..]
8092            } else {
8093                l.trim_start()
8094            };
8095            decl.push_str(&format!("{leading_ws}    {stripped}\n"));
8096        }
8097    }
8098    decl.push_str(&format!("{leading_ws}}}\n"));
8099
8100    let insert_range = json!({
8101        "start": { "line": start_line, "character": 0 },
8102        "end":   { "line": start_line, "character": 0 },
8103    });
8104    let replace_range = json!({
8105        "start": { "line": start_line,    "character": 0 },
8106        "end":   { "line": last as u32 + 1, "character": 0 },
8107    });
8108    let replacement = format!("{leading_ws}{name}\n");
8109
8110    let changes = json!({
8111        uri: [
8112            { "range": insert_range, "newText": decl },
8113            { "range": replace_range, "newText": replacement },
8114        ]
8115    });
8116    Some(json!({
8117        "title": "Extract to function (`name() { … }`)",
8118        "kind": "refactor.extract",
8119        "edit": { "changes": changes },
8120    }))
8121}
8122
8123fn make_extract_action(
8124    uri: &str,
8125    leading_ws: &str,
8126    line: u32,
8127    selection_range: &Value,
8128    rhs: &str,
8129    name: &str,
8130    decl_keyword: &str,
8131    title: &str,
8132) -> Value {
8133    let decl_line = format!("{leading_ws}{decl_keyword} {name}={rhs}\n");
8134    let insert_range = json!({
8135        "start": { "line": line, "character": 0 },
8136        "end":   { "line": line, "character": 0 },
8137    });
8138    let changes = json!({
8139        uri: [
8140            { "range": insert_range, "newText": decl_line },
8141            { "range": selection_range, "newText": format!("${name}") },
8142        ]
8143    });
8144    json!({
8145        "title": title,
8146        "kind": "refactor.extract",
8147        "edit": { "changes": changes },
8148    })
8149}
8150
8151/// UTF-16 slice of a single line. LSP positions are UTF-16 code units;
8152/// we convert back to a `&str` byte slice for use as the selection
8153/// content.
8154fn utf16_slice(line_text: &str, start: u32, end: u32) -> Option<&str> {
8155    let mut u16_seen = 0u32;
8156    let mut s_byte: Option<usize> = None;
8157    let mut e_byte: Option<usize> = None;
8158    for (i, ch) in line_text.char_indices() {
8159        if u16_seen == start {
8160            s_byte = Some(i);
8161        }
8162        u16_seen += ch.len_utf16() as u32;
8163        if u16_seen == end {
8164            e_byte = Some(i + ch.len_utf8());
8165            break;
8166        }
8167    }
8168    let s = s_byte?;
8169    let e = e_byte.unwrap_or(line_text.len());
8170    line_text.get(s..e)
8171}
8172
8173/// True if the LSP char column `col` (UTF-16) on `line_text` falls
8174/// inside an unclosed interpolating string (`"..."` or `` `...` ``).
8175/// Mirrors stryke's `same_line_selection_inside_interpolating_string`.
8176fn same_line_inside_interpolating_string(line_text: &str, col: u32) -> bool {
8177    let mut byte_cutoff = line_text.len();
8178    let mut u16_seen = 0u32;
8179    for (i, ch) in line_text.char_indices() {
8180        if u16_seen >= col {
8181            byte_cutoff = i;
8182            break;
8183        }
8184        u16_seen += ch.len_utf16() as u32;
8185    }
8186    let mut in_dq = false;
8187    let mut in_sq = false;
8188    let mut in_bt = false;
8189    let mut chars = line_text[..byte_cutoff].chars().peekable();
8190    while let Some(c) = chars.next() {
8191        match c {
8192            '\\' => {
8193                chars.next();
8194            }
8195            '"' if !in_sq && !in_bt => in_dq = !in_dq,
8196            '\'' if !in_dq && !in_bt => in_sq = !in_sq,
8197            '`' if !in_dq && !in_sq => in_bt = !in_bt,
8198            _ => {}
8199        }
8200    }
8201    in_dq || in_bt
8202}
8203
8204/// True when the extracted text needs to be wrapped in `"..."` for the
8205/// decl to be a valid expression. False for already-quoted literals
8206/// and bare sigiled variables.
8207fn needs_string_wrap_for_extraction(selection: &str) -> bool {
8208    let t = selection.trim();
8209    if t.is_empty() {
8210        return false;
8211    }
8212    if (t.starts_with('"') && t.ends_with('"')) || (t.starts_with('\'') && t.ends_with('\'')) {
8213        return false;
8214    }
8215    // Bare `$VAR` / `${VAR}` — already an expression.
8216    if let Some(rest) = t.strip_prefix('$') {
8217        let body = rest
8218            .strip_prefix('{')
8219            .and_then(|r| r.strip_suffix('}'))
8220            .unwrap_or(rest);
8221        if !body.is_empty() && body.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
8222            return false;
8223        }
8224    }
8225    true
8226}
8227
8228fn escape_for_double_quoted(s: &str) -> String {
8229    let mut out = String::with_capacity(s.len());
8230    for c in s.chars() {
8231        match c {
8232            '\\' => out.push_str("\\\\"),
8233            '"' => out.push_str("\\\""),
8234            _ => out.push(c),
8235        }
8236    }
8237    out
8238}
8239
8240/// Snap a caret-only cursor to a word-boundary span on the line.
8241/// Returns `(start_utf16, end_utf16)` columns or `None`.
8242fn snap_to_word_at_cursor(line_text: &str, cursor_col: u32) -> Option<(u32, u32)> {
8243    let mut byte_cur = line_text.len();
8244    let mut u16_seen = 0u32;
8245    for (i, ch) in line_text.char_indices() {
8246        if u16_seen >= cursor_col {
8247            byte_cur = i;
8248            break;
8249        }
8250        u16_seen += ch.len_utf16() as u32;
8251    }
8252    let is_word_char = |c: char| c.is_ascii_alphanumeric() || c == '_';
8253
8254    // Inside a string: snap to a $VAR or to a word run.
8255    if same_line_inside_interpolating_string(line_text, cursor_col) {
8256        let prev_char = line_text[..byte_cur].chars().next_back();
8257        let cur_char = line_text[byte_cur..].chars().next();
8258        if matches!(prev_char, Some('$')) || matches!(cur_char, Some('$')) {
8259            // Walk back to `$`, then forward over the var name.
8260            let mut start_byte = byte_cur;
8261            for (i, c) in line_text[..byte_cur].char_indices().rev() {
8262                if c == '$' {
8263                    start_byte = i;
8264                    break;
8265                }
8266                if !is_word_char(c) {
8267                    break;
8268                }
8269                start_byte = i;
8270            }
8271            if cur_char == Some('$') {
8272                start_byte = byte_cur;
8273            }
8274            let mut end_byte = start_byte;
8275            let mut iter = line_text[start_byte..].char_indices();
8276            if let Some((_, first)) = iter.next() {
8277                if first == '$' {
8278                    end_byte = start_byte + first.len_utf8();
8279                    for (i, c) in iter {
8280                        if !is_word_char(c) {
8281                            break;
8282                        }
8283                        end_byte = start_byte + i + c.len_utf8();
8284                    }
8285                }
8286            }
8287            if end_byte > start_byte {
8288                return Some((
8289                    byte_to_utf16_col(line_text, start_byte),
8290                    byte_to_utf16_col(line_text, end_byte),
8291                ));
8292            }
8293        }
8294        let mut start_byte = byte_cur;
8295        for (i, c) in line_text[..byte_cur].char_indices().rev() {
8296            if !is_word_char(c) {
8297                break;
8298            }
8299            start_byte = i;
8300        }
8301        let mut end_byte = byte_cur;
8302        for (i, c) in line_text[byte_cur..].char_indices() {
8303            if !is_word_char(c) {
8304                break;
8305            }
8306            end_byte = byte_cur + i + c.len_utf8();
8307        }
8308        if end_byte > start_byte {
8309            return Some((
8310                byte_to_utf16_col(line_text, start_byte),
8311                byte_to_utf16_col(line_text, end_byte),
8312            ));
8313        }
8314        return None;
8315    }
8316
8317    // Outside a string: snap to an identifier, with leading `$`.
8318    let mut start_byte = byte_cur;
8319    for (i, c) in line_text[..byte_cur].char_indices().rev() {
8320        if !is_word_char(c) {
8321            break;
8322        }
8323        start_byte = i;
8324    }
8325    let mut end_byte = byte_cur;
8326    for (i, c) in line_text[byte_cur..].char_indices() {
8327        if !is_word_char(c) {
8328            break;
8329        }
8330        end_byte = byte_cur + i + c.len_utf8();
8331    }
8332    // Include a leading `$` if standalone.
8333    if start_byte > 0 {
8334        if let Some((idx, '$')) = line_text[..start_byte].char_indices().next_back() {
8335            let standalone = match line_text[..idx].chars().next_back() {
8336                None => true,
8337                Some(c) => !is_word_char(c),
8338            };
8339            if standalone {
8340                start_byte = idx;
8341            }
8342        }
8343    }
8344    if end_byte > start_byte {
8345        Some((
8346            byte_to_utf16_col(line_text, start_byte),
8347            byte_to_utf16_col(line_text, end_byte),
8348        ))
8349    } else {
8350        None
8351    }
8352}
8353
8354fn byte_to_utf16_col(line_text: &str, byte_idx: usize) -> u32 {
8355    line_text[..byte_idx.min(line_text.len())]
8356        .encode_utf16()
8357        .count() as u32
8358}
8359
8360fn formatting(state: &State, params: &Value) -> Value {
8361    let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
8362    let text = match state.docs.get(uri) {
8363        Some(t) => t.clone(),
8364        None => return Value::Array(vec![]),
8365    };
8366    let opts = &params["options"];
8367    let tab_size = opts["tabSize"].as_u64().unwrap_or(4) as usize;
8368    let insert_spaces = opts["insertSpaces"].as_bool().unwrap_or(true);
8369    // Full syntax-aware reindenter (extensions/fmt.rs): block-structure
8370    // indentation, heredoc-body passthrough, trailing-ws strip.
8371    //
8372    // Indent width: DEFAULT is 4 (zpwr house style — set in
8373    // FmtOptions::default and the unwrap_or above when the client
8374    // sends no tabSize). An explicitly configured editor tabSize is
8375    // honored verbatim in both directions, same as the CLI `-i`
8376    // override.
8377    let formatted = crate::fmt::format_source(
8378        &text,
8379        &crate::fmt::FmtOptions {
8380            indent_width: tab_size,
8381            use_tabs: !insert_spaces,
8382        },
8383    );
8384    if formatted == text {
8385        return Value::Array(vec![]);
8386    }
8387
8388    let last_line = text.lines().count().saturating_sub(1);
8389    let last_col = text.lines().last().map(|l| l.len()).unwrap_or(0);
8390    Value::Array(vec![json!({
8391        "range": {
8392            "start": { "line": 0, "character": 0 },
8393            "end":   { "line": last_line, "character": last_col },
8394        },
8395        "newText": formatted,
8396    })])
8397}
8398
8399// ── Word-at-position helper ─────────────────────────────────────────────
8400
8401fn word_at(text: &str, line_no: usize, col: usize) -> Option<String> {
8402    let line = text.lines().nth(line_no)?;
8403    if col > line.len() {
8404        return None;
8405    }
8406    let bytes = line.as_bytes();
8407    // Phase 1: strict identifier walk (`[A-Za-z0-9_]`). `$` is allowed
8408    // on the LEFT only — it's the parameter-expansion prefix marker.
8409    let mut start = col;
8410    while start > 0 {
8411        let c = bytes[start - 1] as char;
8412        if c == '_' || c.is_alphanumeric() || c == '$' {
8413            start -= 1;
8414        } else {
8415            break;
8416        }
8417    }
8418    let mut end = col;
8419    while end < bytes.len() {
8420        let c = bytes[end] as char;
8421        if c == '_' || c.is_alphanumeric() {
8422            end += 1;
8423        } else {
8424            break;
8425        }
8426    }
8427    if start == end {
8428        return None;
8429    }
8430    // Phase 2: zsh function/command names allow `-` (e.g. `daemon-ping`,
8431    // `daemon-job-submit`). Extend the word through `-NAME` segments at
8432    // both ends, but ONLY when this is not a parameter-expansion (which
8433    // forbids `-` in identifier chars per `Src/lex.c iident` /
8434    // `Src/params.c isident`). Discriminator:
8435    //   * `bytes[start] == '$'`         → bare `$var` parameter
8436    //   * `bytes[start - 1] == '{'`     → `${var…}` braced expansion;
8437    //                                     inside `${var-default}` the
8438    //                                     `-` is the default-value
8439    //                                     operator, not part of the name
8440    let is_dollar_var = bytes[start] == b'$';
8441    let in_braced = start > 0 && bytes[start - 1] == b'{';
8442    if !is_dollar_var && !in_braced {
8443        // Extend right through `-IDENT` segments.
8444        while end < bytes.len() && bytes[end] == b'-' {
8445            let mut p = end + 1;
8446            while p < bytes.len() {
8447                let c = bytes[p] as char;
8448                if c == '_' || c.is_alphanumeric() {
8449                    p += 1;
8450                } else {
8451                    break;
8452                }
8453            }
8454            if p > end + 1 {
8455                end = p;
8456            } else {
8457                break;
8458            }
8459        }
8460        // Extend left through `IDENT-` segments.
8461        while start > 1 && bytes[start - 1] == b'-' {
8462            let mut p = start - 1;
8463            while p > 0 {
8464                let c = bytes[p - 1] as char;
8465                if c == '_' || c.is_alphanumeric() {
8466                    p -= 1;
8467                } else {
8468                    break;
8469                }
8470            }
8471            if p < start - 1 {
8472                start = p;
8473            } else {
8474                break;
8475            }
8476        }
8477    }
8478    Some(line[start..end].to_string())
8479}
8480
8481// ── Doc tables (regenerated from data/grammar/canonical.json) ──────────
8482// Run `python3 scripts/gen_grammar_lsp.py` to refresh these blocks; the
8483// arrays between BEGIN/END markers are overwritten verbatim.
8484
8485// BEGIN-CANONICAL: keywords
8486const KEYWORDS: &[&str] = &[
8487    "[[",
8488    "]]",
8489    "always",
8490    "case",
8491    "do",
8492    "done",
8493    "elif",
8494    "else",
8495    "end",
8496    "esac",
8497    "fi",
8498    "for",
8499    "foreach",
8500    "if",
8501    "in",
8502    "repeat",
8503    "select",
8504    "then",
8505    "until",
8506    "while",
8507    "declare",
8508    "export",
8509    "float",
8510    "integer",
8511    "let",
8512    "local",
8513    "readonly",
8514    "set",
8515    "shift",
8516    "typeset",
8517    "function",
8518    "{",
8519    "}",
8520    ".",
8521    "eval",
8522    "exec",
8523    "source",
8524    "trap",
8525    "break",
8526    "continue",
8527    "exit",
8528    "logout",
8529    "return",
8530    "builtin",
8531    "command",
8532    "coproc",
8533    "nocorrect",
8534    "noglob",
8535    "time",
8536    "!",
8537];
8538// END-CANONICAL: keywords
8539
8540// BEGIN-CANONICAL: builtins
8541const BUILTINS: &[&str] = &[
8542    ".",
8543    ":",
8544    "[",
8545    "alias",
8546    "autoload",
8547    "bg",
8548    "break",
8549    "bye",
8550    "cap",
8551    "cd",
8552    "chdir",
8553    "chgrp",
8554    "chmod",
8555    "chown",
8556    "clone",
8557    "continue",
8558    "declare",
8559    "dirs",
8560    "disable",
8561    "disown",
8562    "echo",
8563    "echotc",
8564    "echoti",
8565    "emulate",
8566    "enable",
8567    "eval",
8568    "example",
8569    "exit",
8570    "export",
8571    "false",
8572    "fc",
8573    "fg",
8574    "float",
8575    "functions",
8576    "getcap",
8577    "getln",
8578    "getopts",
8579    "hash",
8580    "hashinfo",
8581    "history",
8582    "integer",
8583    "jobs",
8584    "kill",
8585    "let",
8586    "ln",
8587    "local",
8588    "log",
8589    "logout",
8590    "mem",
8591    "mkdir",
8592    "mv",
8593    "nameref",
8594    "patdebug",
8595    "pcre_compile",
8596    "pcre_match",
8597    "pcre_study",
8598    "popd",
8599    "print",
8600    "printf",
8601    "private",
8602    "pushd",
8603    "pushln",
8604    "pwd",
8605    "r",
8606    "read",
8607    "readonly",
8608    "rehash",
8609    "return",
8610    "rm",
8611    "rmdir",
8612    "set",
8613    "setcap",
8614    "setopt",
8615    "shift",
8616    "source",
8617    "stat",
8618    "strftime",
8619    "suspend",
8620    "sync",
8621    "syserror",
8622    "sysopen",
8623    "sysread",
8624    "sysseek",
8625    "syswrite",
8626    "test",
8627    "times",
8628    "trap",
8629    "true",
8630    "ttyctl",
8631    "type",
8632    "typeset",
8633    "umask",
8634    "unalias",
8635    "unfunction",
8636    "unhash",
8637    "unset",
8638    "unsetopt",
8639    "wait",
8640    "whence",
8641    "where",
8642    "which",
8643    "zask",
8644    "zcache",
8645    "zcompdump",
8646    "zcompile",
8647    "zcomplete",
8648    "zcurses",
8649    "zd",
8650    "zdelattr",
8651    "zf_chgrp",
8652    "zf_chmod",
8653    "zf_chown",
8654    "zf_ln",
8655    "zf_mkdir",
8656    "zf_mv",
8657    "zf_rm",
8658    "zf_rmdir",
8659    "zf_sync",
8660    "zformat",
8661    "zftp",
8662    "zgdbmpath",
8663    "zgetattr",
8664    "zhistory",
8665    "zid",
8666    "zjob",
8667    "zlistattr",
8668    "zlock",
8669    "zlog",
8670    "zls",
8671    "zmodload",
8672    "znotify",
8673    "zparseopts",
8674    "zping",
8675    "zprof",
8676    "zpty",
8677    "zpublish",
8678    "zregexparse",
8679    "zselect",
8680    "zsend",
8681    "zsetattr",
8682    "zsocket",
8683    "zsource",
8684    "zstat",
8685    "zstyle",
8686    "zsubscribe",
8687    "zsuggest",
8688    "zsync",
8689    "zsystem",
8690    "ztag",
8691    "ztcp",
8692    "ztie",
8693    "zunsubscribe",
8694    "zuntag",
8695    "zuntie",
8696    "zwc",
8697    "zwhere",
8698];
8699// END-CANONICAL: builtins
8700
8701// BEGIN-CANONICAL: options
8702const OPTIONS: &[&str] = &[
8703    "ALIASES",
8704    "ALIASFUNCDEF",
8705    "ALLEXPORT",
8706    "ALWAYSLASTPROMPT",
8707    "ALWAYSTOEND",
8708    "APPENDCREATE",
8709    "APPENDHISTORY",
8710    "AUTOCD",
8711    "AUTOCONTINUE",
8712    "AUTOLIST",
8713    "AUTOMENU",
8714    "AUTONAMEDIRS",
8715    "AUTOPARAMKEYS",
8716    "AUTOPARAMSLASH",
8717    "AUTOPUSHD",
8718    "AUTOREMOVESLASH",
8719    "AUTORESUME",
8720    "BADPATTERN",
8721    "BANGHIST",
8722    "BAREGLOBQUAL",
8723    "BASHAUTOLIST",
8724    "BASHREMATCH",
8725    "BEEP",
8726    "BGNICE",
8727    "BRACECCL",
8728    "BRACEEXPAND",
8729    "BSDECHO",
8730    "CASEGLOB",
8731    "CASEMATCH",
8732    "CASEPATHS",
8733    "CBASES",
8734    "CDABLEVARS",
8735    "CDSILENT",
8736    "CHASEDOTS",
8737    "CHASELINKS",
8738    "CHECKJOBS",
8739    "CHECKRUNNINGJOBS",
8740    "CLOBBER",
8741    "CLOBBEREMPTY",
8742    "COMBININGCHARS",
8743    "COMPLETEALIASES",
8744    "COMPLETEINWORD",
8745    "CONTINUEONERROR",
8746    "CORRECT",
8747    "CORRECTALL",
8748    "CPRECEDENCES",
8749    "CSHJUNKIEHISTORY",
8750    "CSHJUNKIELOOPS",
8751    "CSHJUNKIEQUOTES",
8752    "CSHNULLCMD",
8753    "CSHNULLGLOB",
8754    "DEBUGBEFORECMD",
8755    "DOTGLOB",
8756    "DVORAK",
8757    "EMACS",
8758    "EQUALS",
8759    "ERREXIT",
8760    "ERRRETURN",
8761    "EVALLINENO",
8762    "EXEC",
8763    "EXTENDEDGLOB",
8764    "EXTENDEDHISTORY",
8765    "FLOWCONTROL",
8766    "FORCEFLOAT",
8767    "FUNCTIONARGZERO",
8768    "GLOB",
8769    "GLOBALEXPORT",
8770    "GLOBALRCS",
8771    "GLOBASSIGN",
8772    "GLOBCOMPLETE",
8773    "GLOBDOTS",
8774    "GLOBSTARSHORT",
8775    "GLOBSUBST",
8776    "HASHALL",
8777    "HASHCMDS",
8778    "HASHDIRS",
8779    "HASHEXECUTABLESONLY",
8780    "HASHLISTALL",
8781    "HISTALLOWCLOBBER",
8782    "HISTAPPEND",
8783    "HISTBEEP",
8784    "HISTEXPAND",
8785    "HISTEXPIREDUPSFIRST",
8786    "HISTFCNTLLOCK",
8787    "HISTFINDNODUPS",
8788    "HISTIGNOREALLDUPS",
8789    "HISTIGNOREDUPS",
8790    "HISTIGNORESPACE",
8791    "HISTLEXWORDS",
8792    "HISTNOFUNCTIONS",
8793    "HISTNOSTORE",
8794    "HISTREDUCEBLANKS",
8795    "HISTSAVEBYCOPY",
8796    "HISTSAVENODUPS",
8797    "HISTSUBSTPATTERN",
8798    "HISTVERIFY",
8799    "HUP",
8800    "IGNOREBRACES",
8801    "IGNORECLOSEBRACES",
8802    "IGNOREEOF",
8803    "INCAPPENDHISTORY",
8804    "INCAPPENDHISTORYTIME",
8805    "INTERACTIVE",
8806    "INTERACTIVECOMMENTS",
8807    "KSHARRAYS",
8808    "KSHAUTOLOAD",
8809    "KSHGLOB",
8810    "KSHOPTIONPRINT",
8811    "KSHTYPESET",
8812    "KSHZEROSUBSCRIPT",
8813    "LISTAMBIGUOUS",
8814    "LISTBEEP",
8815    "LISTPACKED",
8816    "LISTROWSFIRST",
8817    "LISTTYPES",
8818    "LOCALLOOPS",
8819    "LOCALOPTIONS",
8820    "LOCALPATTERNS",
8821    "LOCALTRAPS",
8822    "LOG",
8823    "LOGIN",
8824    "LONGLISTJOBS",
8825    "MAGICEQUALSUBST",
8826    "MAILWARN",
8827    "MAILWARNING",
8828    "MARKDIRS",
8829    "MENUCOMPLETE",
8830    "MONITOR",
8831    "MULTIBYTE",
8832    "MULTIFUNCDEF",
8833    "MULTIOS",
8834    "NOMATCH",
8835    "NOTIFY",
8836    "NULLGLOB",
8837    "NUMERICGLOBSORT",
8838    "OCTALZEROES",
8839    "ONECMD",
8840    "OVERSTRIKE",
8841    "PATHDIRS",
8842    "PATHSCRIPT",
8843    "PHYSICAL",
8844    "PIPEFAIL",
8845    "POSIXALIASES",
8846    "POSIXARGZERO",
8847    "POSIXBUILTINS",
8848    "POSIXCD",
8849    "POSIXIDENTIFIERS",
8850    "POSIXJOBS",
8851    "POSIXSTRINGS",
8852    "POSIXTRAPS",
8853    "PRINTEIGHTBIT",
8854    "PRINTEXITVALUE",
8855    "PRIVILEGED",
8856    "PROMPTBANG",
8857    "PROMPTCR",
8858    "PROMPTPERCENT",
8859    "PROMPTSP",
8860    "PROMPTSUBST",
8861    "PROMPTVARS",
8862    "PUSHDIGNOREDUPS",
8863    "PUSHDMINUS",
8864    "PUSHDSILENT",
8865    "PUSHDTOHOME",
8866    "RCEXPANDPARAM",
8867    "RCQUOTES",
8868    "RCS",
8869    "RECEXACT",
8870    "REMATCHPCRE",
8871    "RMSTARSILENT",
8872    "RMSTARWAIT",
8873    "SHAREHISTORY",
8874    "SHFILEEXPANSION",
8875    "SHGLOB",
8876    "SHINSTDIN",
8877    "SHNULLCMD",
8878    "SHOPTIONLETTERS",
8879    "SHORTLOOPS",
8880    "SHORTREPEAT",
8881    "SHWORDSPLIT",
8882    "SINGLECOMMAND",
8883    "SINGLELINEZLE",
8884    "SOURCETRACE",
8885    "STDIN",
8886    "SUNKEYBOARDHACK",
8887    "TRACKALL",
8888    "TRANSIENTRPROMPT",
8889    "TRAPSASYNC",
8890    "TYPESETSILENT",
8891    "TYPESETTOUNSET",
8892    "UNSET",
8893    "VERBOSE",
8894    "VI",
8895    "WARNCREATEGLOBAL",
8896    "WARNNESTEDVAR",
8897    "XTRACE",
8898    "ZLE",
8899];
8900// END-CANONICAL: options
8901
8902// `SPECIAL_VARS` hand list deleted. The 41 `$`-prefixed entries were
8903// a stale subset of zsh's actual ~538 special params per `man zshparam`
8904// + every `mod_*.yo`. All call sites now iterate the canonical
8905// `zsh_special_var_docs::SPECIAL_VAR_DOCS` table directly (prepending
8906// `$` where the legacy site expected sigiled labels).
8907
8908const KEYWORD_DOCS: &[(&str, &str)] = &[
8909    (
8910        "if",
8911        "Conditional. `if cmd; then …; elif cmd; then …; else …; fi`",
8912    ),
8913    (
8914        "for",
8915        "Loop. `for var in words; do …; done` or `for ((init; cond; step)); do …; done`",
8916    ),
8917    (
8918        "while",
8919        "Loop. `while cmd; do …; done` — runs the body while `cmd` succeeds.",
8920    ),
8921    (
8922        "until",
8923        "Loop. `until cmd; do …; done` — runs the body while `cmd` fails.",
8924    ),
8925    (
8926        "case",
8927        "Pattern match. `case word in pat1) …;; pat2) …;; esac`",
8928    ),
8929    (
8930        "select",
8931        "Interactive menu. `select var in items; do …; done`",
8932    ),
8933    ("repeat", "Counted loop. `repeat N; do …; done`"),
8934    // Compound-statement sub-keywords. Upstream zsh documents each
8935    // compound (`if`, `for`, `case`, …) as one `item(...)` block, so
8936    // the sub-keywords (`then`/`else`/`elif`/`fi`/`do`/`done`/`in`/
8937    // `esac`) get no per-keyword `item` and fall through to the hand
8938    // fallback. Each entry points the reader at the parent compound.
8939    ("then", "Body separator for `if`/`elif`. `if cmd; then body; fi`"),
8940    ("else", "Alternative branch for `if`. `if cmd; then a; else b; fi`"),
8941    ("elif", "Alternative test in an `if` chain. `if a; then …; elif b; then …; fi`"),
8942    ("do",  "Body-introducer for `for`/`while`/`until`/`select`/`repeat`. `for v in …; do body; done`"),
8943    ("esac", "Closes a `case` statement. `case word in pat) …;; esac`"),
8944    ("in",  "Word-list introducer for `for` and `case`. `for v in a b c; do …; done`"),
8945    (
8946        "{",
8947        "Command-group open brace. `{ cmd1; cmd2; }` runs the commands in the current shell (no subshell), grouping them as one syntactic unit. Reserved word — must be followed by whitespace or a newline.",
8948    ),
8949    (
8950        "}",
8951        "Command-group close brace. Pairs with `{ … }`. Reserved word — preceded by `;` or newline.",
8952    ),
8953    (
8954        "!",
8955        "Pipeline negation. `! cmd` inverts `cmd`'s exit status — zero becomes non-zero, non-zero becomes zero. As the first word of a command. Distinct from `!` history expansion (which is a lexer-stage substitution, not a reserved word).",
8956    ),
8957    (
8958        "fi",
8959        "Closes an `if` block. `if cmd; then body; fi`. Required terminator — without it the parser keeps reading until EOF.",
8960    ),
8961    (
8962        "done",
8963        "Closes a `for` / `foreach` / `while` / `until` / `select` / `repeat` loop body. `for v in a b c; do echo $v; done`. Required terminator.",
8964    ),
8965    (
8966        "end",
8967        "Closes the alternate-form compound statement (`foreach NAME (WORDS) … end`, `if COND … end`, `while COND … end`). Csh-style syntactic mirror of `fi` / `done` / `esac` for users coming from csh / tcsh.",
8968    ),
8969    (
8970        "declare",
8971        "Alias for `typeset`. Set variable attributes. `-a` array, `-A` assoc, `-i` integer, `-r` readonly.",
8972    ),
8973    (
8974        "function",
8975        "Function declaration. `function foo { body }` or `foo() { body }`",
8976    ),
8977    (
8978        "local",
8979        "Declare a function-scope variable. `local var=value` or `local -i var=42`",
8980    ),
8981    (
8982        "typeset",
8983        "Set variable attributes. `-a` array, `-A` assoc, `-i` integer, `-r` readonly.",
8984    ),
8985    ("export", "Mark a variable for export to the environment."),
8986    ("readonly", "Mark a variable as read-only."),
8987    ("integer", "Shorthand for `typeset -i`."),
8988    ("float", "Shorthand for `typeset -F` (floating point)."),
8989    (
8990        "return",
8991        "Return from a function or sourced script with the given status.",
8992    ),
8993    (
8994        "break",
8995        "Exit the innermost loop, or N levels up with `break N`.",
8996    ),
8997    (
8998        "continue",
8999        "Skip to the next iteration of the innermost loop.",
9000    ),
9001    ("exit", "Exit the shell with the given status."),
9002    ("time", "Time the execution of the following pipeline."),
9003    (
9004        "coproc",
9005        "Run a command as a coprocess (background, attached I/O).",
9006    ),
9007];
9008
9009const BUILTIN_DOCS: &[(&str, &str)] = &[
9010    ("cd", "Change the working directory."),
9011    ("pwd", "Print the working directory."),
9012    (
9013        "pushd",
9014        "Push the current directory onto the stack and `cd`.",
9015    ),
9016    ("popd", "Pop a directory off the stack and `cd` to it."),
9017    ("alias", "Define a command alias. `alias name=value`"),
9018    ("setopt", "Turn on a zsh option. `setopt EXTENDED_GLOB`"),
9019    ("unsetopt", "Turn off a zsh option."),
9020    (
9021        "zstyle",
9022        "Set a context-aware style (used by compsys, prompts, etc.).",
9023    ),
9024    (
9025        "zmodload",
9026        "Load a zsh binary module (e.g. `zsh/datetime`, `zsh/stat`).",
9027    ),
9028    (
9029        "autoload",
9030        "Mark a function to be loaded from `fpath` on first call.",
9031    ),
9032    ("bindkey", "Bind a key sequence to a ZLE widget."),
9033    ("compdef", "Register a completion function for a command."),
9034    (
9035        "source",
9036        "Execute a file in the current shell context. Same as `.`.",
9037    ),
9038    ("eval", "Concatenate args and execute them as shell code."),
9039    (
9040        "exec",
9041        "Replace the current process with the given command.",
9042    ),
9043    ("trap", "Set a signal or pseudo-signal handler."),
9044    (
9045        "echo",
9046        "Print arguments separated by spaces, with a trailing newline.",
9047    ),
9048    (
9049        "print",
9050        "zsh-extended print. `-r` raw, `-n` no newline, `-l` one per line.",
9051    ),
9052    ("printf", "C-style formatted print."),
9053    ("read", "Read a line into a variable. `read -r var`"),
9054    (
9055        "test",
9056        "Evaluate a conditional. Same as `[`. Prefer `[[ … ]]` in zsh.",
9057    ),
9058    ("kill", "Send a signal to a job or pid."),
9059    ("jobs", "List background jobs."),
9060    ("fg", "Bring a job to the foreground."),
9061    ("bg", "Resume a stopped job in the background."),
9062    ("hash", "Print or modify the command hash table."),
9063    (
9064        "unhash",
9065        "Remove an entry from the hash / alias / function table.",
9066    ),
9067    ("history", "Show the command history."),
9068    ("fc", "List, edit, or re-execute history entries."),
9069    (
9070        "command",
9071        "Bypass aliases and functions to run the named command.",
9072    ),
9073    (
9074        "type",
9075        "Show how a name would be interpreted (alias / builtin / function / file).",
9076    ),
9077    ("whence", "Same as `type` but with more formatting options."),
9078    (
9079        "builtin",
9080        "Run the named builtin, bypassing any function / alias.",
9081    ),
9082    ("set", "Set positional parameters or options."),
9083    ("unset", "Remove a variable."),
9084    (
9085        "getopts",
9086        "Parse positional parameters in the style of GNU getopt.",
9087    ),
9088    ("let", "Evaluate an arithmetic expression. `let count++`"),
9089    // ── Builtins that have no per-name `item(tt(...))(…)` block in any
9090    // upstream yodl source. Most are simple aliases for documented
9091    // builtins; a few (`hashinfo`, `mem`, `patdebug`) are debug/internal
9092    // entry points. The `zf_*` family are zftp companion functions
9093    // documented as a group in `Functions/Zftp/README` rather than per-name.
9094    (
9095        ":",
9096        "Null command. Returns true. Side-effects of argument expansion still happen.",
9097    ),
9098    (
9099        "[",
9100        "Alias for `test`. `[ expr ]` — POSIX conditional. Prefer `[[ expr ]]` in zsh.",
9101    ),
9102    ("bye", "Alias for `exit`. Exit the shell with the given status."),
9103    ("chdir", "Alias for `cd`. Change the working directory."),
9104    (
9105        "compctl",
9106        "Old completion control (compctl mechanism). Largely superseded by `compdef` / compsys.",
9107    ),
9108    ("declare", "Alias for `typeset`. Set variable attributes."),
9109    (
9110        "hashinfo",
9111        "Print internal hash-table statistics. Debug builtin in `zsh/parameter`-adjacent code.",
9112    ),
9113    (
9114        "mem",
9115        "Print zsh memory-allocator statistics. Debug builtin compiled only with `--enable-zsh-mem`.",
9116    ),
9117    (
9118        "noglob",
9119        "Precommand modifier. Disable filename generation for the next command. `noglob ls *.tmp`",
9120    ),
9121    (
9122        "patdebug",
9123        "Print pattern-matcher internals for a glob/regex. Debug builtin from `zsh/pattern`.",
9124    ),
9125    ("r", "Re-execute the previous command. Shorthand for `fc -e -`."),
9126    (
9127        "unfunction",
9128        "Remove a function definition. Equivalent to `unhash -f` / `unset -f name`.",
9129    ),
9130    // ── zftp companion functions (zsh/zftp module). Each `zf_X` mirrors
9131    // the unix command `X` against the connected FTP server.
9132    ("zf_chgrp", "zftp: change group of remote files. Mirrors `chgrp(1)`."),
9133    ("zf_chmod", "zftp: change mode of remote files. Mirrors `chmod(1)`."),
9134    ("zf_chown", "zftp: change owner of remote files. Mirrors `chown(1)`."),
9135    ("zf_ln",    "zftp: link / rename remote files. Mirrors `ln(1)`."),
9136    ("zf_mkdir", "zftp: create remote directories. Mirrors `mkdir(1)`."),
9137    ("zf_mv",    "zftp: move / rename remote files. Mirrors `mv(1)`."),
9138    ("zf_rm",    "zftp: remove remote files. Mirrors `rm(1)`."),
9139    ("zf_rmdir", "zftp: remove remote directories. Mirrors `rmdir(1)`."),
9140    ("zf_sync",  "zftp: flush pending writes on the FTP control channel."),
9141];
9142
9143const SPECIAL_VAR_DOCS: &[(&str, &str)] = &[
9144    ("$0", "Script name."),
9145    ("$?", "Exit status of the last command."),
9146    ("$!", "PID of the most recent background command."),
9147    ("$$", "PID of the current shell."),
9148    ("$#", "Number of positional parameters."),
9149    ("$*", "All positional parameters as one word (IFS-joined)."),
9150    ("$@", "All positional parameters as separate words."),
9151    ("$-", "Currently set option flags."),
9152    ("$_", "Last argument of the previous command."),
9153    ("$PATH", "Colon-separated command lookup path."),
9154    ("$HOME", "User's home directory."),
9155    ("$USER", "Current user."),
9156    ("$PWD", "Current working directory."),
9157    ("$OLDPWD", "Previous working directory (used by `cd -`)."),
9158    ("$ZSH_VERSION", "zsh / zshrs version string."),
9159    (
9160        "$RANDOM",
9161        "Each read returns a fresh pseudo-random integer.",
9162    ),
9163    ("$LINENO", "Current line number in the script."),
9164    ("$SECONDS", "Seconds since the shell started."),
9165    ("$EPOCHSECONDS", "Unix epoch seconds (zsh/datetime)."),
9166    (
9167        "$EPOCHREALTIME",
9168        "Unix epoch with microsecond precision (zsh/datetime).",
9169    ),
9170    (
9171        "$fpath",
9172        "Array of directories searched for autoloaded functions.",
9173    ),
9174    ("$path", "Array version of $PATH."),
9175    ("$argv", "Array of positional parameters (same as $@)."),
9176    ("$pipestatus", "Exit statuses of each pipeline element."),
9177    (
9178        "$SHELL",
9179        "Pathname of the login shell. Honored by many tools as the default user shell.",
9180    ),
9181    (
9182        "$EDITOR",
9183        "Preferred editor for tools that invoke an editor (`fc`, `git`, `crontab`, …).",
9184    ),
9185    (
9186        "$VISUAL",
9187        "Preferred full-screen editor. Takes precedence over `$EDITOR` when set.",
9188    ),
9189];
9190
9191// ── Reflection dump for the IntelliJ tool window ────────────────────────
9192
9193/// Produce the JSON consumed by `zshrs --dump-reflection`. Each top-level
9194/// key is a category; each entry is `name → tag` so the tool window can
9195/// group by tag in its tree.
9196///
9197/// Sources the canonical registries (`ported::builtin::BUILTINS`,
9198/// `ported::options::ZSH_OPTIONS_SET`) rather than the hand-curated
9199/// LSP subsets above. The hand subsets were a 49-option / 67-builtin /
9200/// 34-keyword / 41-special slice — fine for in-buffer keyword
9201/// classification but wrong as a tool-window inventory because the
9202/// IntelliJ panel is meant to mirror everything the runtime actually
9203/// implements. Sourcing from the canonical sets keeps the panel honest
9204/// as new ports land (e.g. adding a builtin to `ported::builtin::BUILTINS`
9205/// makes it show up in the panel without a parallel edit here).
9206pub fn dump_reflection_json() -> String {
9207    let mut all = serde_json::Map::new();
9208
9209    // ── Compat builtins: ported zsh-faithful builtins from
9210    // `ported::builtin::BUILTINS`. These mirror the upstream zsh C
9211    // `Src/Builtins/*.c` tables 1:1. Distinct from `extensions` (the
9212    // zshrs-only additions). `builtins` below is the union for tools
9213    // that want everything under one key.
9214    let mut compat = serde_json::Map::new();
9215    for b in crate::ported::builtin::BUILTINS.iter() {
9216        compat.insert(b.node.nam.clone(), Value::String("compat".into()));
9217        all.insert(b.node.nam.clone(), Value::String("compat".into()));
9218    }
9219    // Keywords sourced from the canonical `reswds[]` table at
9220    // `Src/hashtable.c:1076-1108` (Rust port: `ported::hashtable::RESWDS`).
9221    // Filter out entries with `token == TYPESET` — those are declaration
9222    // commands (local / typeset / declare / export / readonly / integer
9223    // / float) that the parser folds into the `typeset` builtin. They
9224    // already show up in the Builtins tab; listing them in Keywords too
9225    // duplicates them and miscategorizes them as control-flow.
9226    // Keywords sourced from the canonical `reswds[]` table at
9227    // `Src/hashtable.c:1076-1108` (port: `ported::hashtable::RESWDS`).
9228    // Per `man zshmisc` "Reserved Words" (`Doc/Zsh/grammar.yo:501-504`),
9229    // ALL 31 names are reserved words — including `declare`/`export`/
9230    // `float`/`integer`/`local`/`readonly`/`typeset`. Those also exist
9231    // as builtins (the parser folds them into the `typeset` builtin via
9232    // the `TYPESET` lextok), but `man zshmisc` lists them as reserved
9233    // first. We list them in both tabs.
9234    let mut keywords = serde_json::Map::new();
9235    for (name, _token) in crate::ported::hashtable::RESWDS {
9236        keywords.insert(name.to_string(), Value::String("keyword".into()));
9237        all.insert(name.to_string(), Value::String("keyword".into()));
9238    }
9239    // Options surfaced in the canonical UPPERCASE_WITH_UNDERSCORES
9240    // form per `man zshoptions` (`AUTO_CD`, not `autocd`). The
9241    // `ZSH_OPTIONS_SET` set stores the normalized form zsh uses for
9242    // lookup (lowercase, no underscores), which is correct for option
9243    // resolution but unfamiliar in a doc panel — users reading the
9244    // tool window expect to see the names the way `setopt` / `man`
9245    // print them. OPTION_DOCS keys carry the canonical CAPS form.
9246    let mut options = serde_json::Map::new();
9247    for (name, _doc) in crate::zsh_option_docs::OPTION_DOCS {
9248        options.insert((*name).to_string(), Value::String("option".into()));
9249        all.insert((*name).to_string(), Value::String("option".into()));
9250    }
9251    for (alias, _canon) in crate::zsh_option_docs::OPTION_ALIASES {
9252        options.insert((*alias).to_string(), Value::String("option".into()));
9253        all.insert((*alias).to_string(), Value::String("option".into()));
9254    }
9255    // Special params — source from the canonical doc table (538
9256    // entries extracted from `params.yo` + every `mod_*.yo`) rather
9257    // than the hand 41-entry `SPECIAL_VARS` subset above. The hand
9258    // subset was missing `$PS2` / `$PS3` / `$PS4` / `$psvar` /
9259    // `$PROMPT2` / hundreds more, so the tool window showed a tiny
9260    // slice of zsh's actual special-param surface.
9261    //
9262    // Emitted `$`-prefixed — `$PATH`, `$?`, `$$` — which is both the
9263    // form the user types and, for the symbolic ones, the ONLY thing
9264    // that keeps them distinct. `SPECIAL_VAR_DOCS` stores bare keys
9265    // (`PATH`, `?`, `$`), and inserting those bare collided head-on
9266    // with the other registries inside this one blob: bare `?` / `*` /
9267    // `!` are also `OPERATOR_DOCS` entries and bare `-` is also the
9268    // `-` builtin, so in the `all` map whichever loop ran last simply
9269    // overwrote the other's tag, and in the panel a `special_vars` leaf
9270    // reading `?` sent `--docs ?` — which resolves through
9271    // `OPERATOR_DOCS` first (see `lookup_doc`) and showed the user the
9272    // glob-operator card instead of the parameter. `$?` resolves to
9273    // the parameter, so the sigil is what makes the panel correct, not
9274    // decoration. `lookup_doc` accepts either form.
9275    let mut special_vars = serde_json::Map::new();
9276    let mut add_special = |name: &str,
9277                           special_vars: &mut serde_json::Map<String, Value>,
9278                           all: &mut serde_json::Map<String, Value>| {
9279        let key = format!("${}", name);
9280        special_vars.insert(key.clone(), Value::String("special".into()));
9281        all.insert(key, Value::String("special".into()));
9282    };
9283    for (name, _doc) in crate::zsh_special_var_docs::SPECIAL_VAR_DOCS {
9284        add_special(name, &mut special_vars, &mut all);
9285    }
9286    // Also surface alias surface names (`PROMPT` / `PROMPT2` /
9287    // `PROMPT3` → PS1/PS2/PS3, `NULLCMD` etc) so the tool window
9288    // shows every name the user might type.
9289    for (alias, _canon) in crate::zsh_special_var_docs::SPECIAL_VAR_ALIASES {
9290        add_special(alias, &mut special_vars, &mut all);
9291    }
9292    // ── Compsys completion functions ────────────────────────────────
9293    // The `_arguments` / `_files` / `_describe` family — Rust-native
9294    // implementations from the `compsys` crate. Sourced from
9295    // `crate::compsys::COMPSYS_FN_NAMES`.
9296    let mut compsys = serde_json::Map::new();
9297    for n in crate::compsys::COMPSYS_FN_NAMES {
9298        compsys.insert((*n).to_string(), Value::String("compsys".into()));
9299        all.insert((*n).to_string(), Value::String("compsys".into()));
9300    }
9301    // ── zshrs extension builtins ────────────────────────────────────
9302    // Builtins that have NO upstream zsh C counterpart. Two sources:
9303    //   * `ext_builtins::EXT_BUILTIN_NAMES` — in-process builtins
9304    //     dispatched by `ShellExecutor` (coreutils drop-ins, bash-only
9305    //     builtins, async/await/barrier, doctor, intercept, contrib
9306    //     autoloads exposed as builtins, etc.).
9307    //   * `daemon::builtins::ZSHRS_BUILTIN_NAMES` — daemon-backed `z*`
9308    //     builtins (zd, zcache, zls, zping, zlock, zpublish, …) that
9309    //     proxy to the local Unix-socket daemon for cross-shell state.
9310    // Both are zshrs-only; combining them gives the full inventory of
9311    // builtins the user can call that aren't in upstream zsh.
9312    let mut extensions = serde_json::Map::new();
9313    for n in crate::ext_builtins::EXT_BUILTIN_NAMES {
9314        extensions.insert((*n).to_string(), Value::String("extension".into()));
9315        all.insert((*n).to_string(), Value::String("extension".into()));
9316    }
9317    for n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES {
9318        extensions.insert((*n).to_string(), Value::String("extension".into()));
9319        all.insert((*n).to_string(), Value::String("extension".into()));
9320    }
9321    // ── Operators / punctuation tokens (man zshmisc) ─────────────────
9322    let mut operators = serde_json::Map::new();
9323    for (op, _body) in OPERATOR_DOCS {
9324        operators.insert((*op).to_string(), Value::String("operator".into()));
9325        all.insert((*op).to_string(), Value::String("operator".into()));
9326    }
9327    // ── Backwards-compat aggregate: every builtin the user can call,
9328    // ported + extension. Equals `compat ∪ extensions`. Kept as the
9329    // `builtins` key so older tool-window UIs (pre-compat-split) still
9330    // see something familiar.
9331    //
9332    // Every entry is tagged `"builtin"` — the tag is what a pre-split
9333    // consumer read, and it is what this key means. The finer
9334    // compat-vs-extension split is what the separate `compat` /
9335    // `extensions` keys are for; mixing the two tags inside `builtins`
9336    // (which is what `compat.clone()` alone produced) both broke the
9337    // backwards-compat contract this key exists to honour and made the
9338    // aggregate internally inconsistent — `cd` read as `"compat"` while
9339    // `zwhere` read as `"builtin"` in the SAME map.
9340    let mut builtins = serde_json::Map::new();
9341    for k in compat.keys() {
9342        builtins.insert(k.clone(), Value::String("builtin".into()));
9343    }
9344    for (k, _) in &extensions {
9345        builtins.insert(k.clone(), Value::String("builtin".into()));
9346    }
9347    serde_json::to_string_pretty(&json!({
9348        "all": all,
9349        "builtins": builtins,
9350        "compat": compat,
9351        "keywords": keywords,
9352        "options": options,
9353        "special_vars": special_vars,
9354        "compsys": compsys,
9355        "extensions": extensions,
9356        "operators": operators,
9357    }))
9358    .unwrap_or_else(|_| "{}".into())
9359}
9360
9361/// Every canonical name across every registry, sorted and de-duped.
9362/// Drives `zshrs --names` (fed into the `_zshrs` completer for
9363/// `--docs <TAB>`) and the closest-name fuzzy-suggest fallback when
9364/// `--docs FOO` doesn't resolve.
9365pub fn all_canonical_names() -> Vec<String> {
9366    use std::collections::BTreeSet;
9367    let mut set: BTreeSet<String> = BTreeSet::new();
9368    for b in crate::ported::builtin::BUILTINS.iter() {
9369        set.insert(b.node.nam.clone());
9370    }
9371    for (n, t) in crate::ported::hashtable::RESWDS {
9372        if *t == crate::ported::zsh_h::TYPESET {
9373            continue;
9374        }
9375        set.insert((*n).to_string());
9376    }
9377    for o in crate::ported::options::ZSH_OPTIONS_SET.iter() {
9378        set.insert((*o).to_string());
9379    }
9380    // Canonical 538-entry special-param doc table — bare names per
9381    // params.yo convention. Inserted with `$` prefix to match the
9382    // form users actually type / search for in name lookups.
9383    for (name, _) in crate::zsh_special_var_docs::SPECIAL_VAR_DOCS {
9384        // Skip pure-symbolic ones — they're handled separately by
9385        // the lookup_doc cascade.
9386        if name
9387            .chars()
9388            .next()
9389            .map(|c| c.is_ascii_alphabetic() || c == '_')
9390            .unwrap_or(false)
9391        {
9392            set.insert(format!("${}", name));
9393        } else {
9394            set.insert((*name).to_string());
9395        }
9396    }
9397    for n in crate::compsys::COMPSYS_FN_NAMES {
9398        set.insert((*n).to_string());
9399    }
9400    for n in crate::ext_builtins::EXT_BUILTIN_NAMES {
9401        set.insert((*n).to_string());
9402    }
9403    for n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES {
9404        set.insert((*n).to_string());
9405    }
9406    for (op, _) in OPERATOR_DOCS {
9407        set.insert((*op).to_string());
9408    }
9409    set.into_iter().collect()
9410}
9411
9412/// Closest canonical name to `query` by edit distance, when the
9413/// distance is small enough to be useful. Used by `--docs FOO` to
9414/// suggest "did you mean `bar`?" on typo.
9415///
9416/// Threshold: ≤ max(2, query.len() / 3). Below that we'd suggest
9417/// random unrelated names; the slop scales with input length so
9418/// `xy` doesn't pick `if` but `compdefffff` can still find `compdef`.
9419pub fn closest_name(query: &str) -> Option<String> {
9420    let names = all_canonical_names();
9421    let q_bare = query.strip_prefix('$').unwrap_or(query);
9422    let max_dist = std::cmp::max(2, q_bare.len() / 3);
9423    let mut best: Option<(usize, String)> = None;
9424    for n in names {
9425        let n_bare = n.strip_prefix('$').unwrap_or(&n);
9426        let d = edit_distance(q_bare, n_bare);
9427        if d > max_dist {
9428            continue;
9429        }
9430        match best {
9431            None => best = Some((d, n)),
9432            Some((bd, _)) if d < bd => best = Some((d, n)),
9433            _ => {}
9434        }
9435    }
9436    best.map(|(_, n)| n)
9437}
9438
9439/// Damerau-Levenshtein-lite (insertions + deletions + substitutions,
9440/// no transpositions). Hand-rolled to avoid a dependency on
9441/// `strsim` / `edit-distance` crates. O(m·n) with rolling two-row buffer.
9442fn edit_distance(a: &str, b: &str) -> usize {
9443    let av: Vec<char> = a.chars().collect();
9444    let bv: Vec<char> = b.chars().collect();
9445    let m = av.len();
9446    let n = bv.len();
9447    if m == 0 {
9448        return n;
9449    }
9450    if n == 0 {
9451        return m;
9452    }
9453    let mut prev: Vec<usize> = (0..=n).collect();
9454    let mut cur: Vec<usize> = vec![0; n + 1];
9455    for i in 1..=m {
9456        cur[0] = i;
9457        for j in 1..=n {
9458            let cost = if av[i - 1] == bv[j - 1] { 0 } else { 1 };
9459            cur[j] = (cur[j - 1] + 1).min(prev[j] + 1).min(prev[j - 1] + cost);
9460        }
9461        std::mem::swap(&mut prev, &mut cur);
9462    }
9463    prev[n]
9464}
9465
9466/// Render the full LSP knowledge base as the four chapter `<section>`s
9467/// that `docs/reference.html` splices in between its `<!-- BEGIN/END
9468/// LSP-REFERENCE -->` markers. One `<article class="doc-entry">` per
9469/// canonical name across builtins / keywords / options / specials.
9470///
9471/// All inputs come from the baked Rust tables — no upstream zsh repo
9472/// access at runtime. The HTML uses the existing `.doc-entry` /
9473/// `.chapter-meta` styling already defined in reference.html so no
9474/// CSS changes are needed.
9475pub fn dump_reference_html() -> String {
9476    use std::fmt::Write;
9477
9478    let mut out = String::new();
9479
9480    // ── compat builtins (canonical from ported::builtin::BUILTINS) ──
9481    // These are the ported zsh-faithful builtins. Distinct from the
9482    // Extension chapter (which lists zshrs-only additions). Together
9483    // they cover every builtin the user can call.
9484    let compat = crate::ext_builtins::compat_builtin_names();
9485    write_chapter(
9486        &mut out,
9487        "ch-lsp-compat",
9488        "Compat Builtin Index",
9489        &format!(
9490            "{} entries · zsh-faithful ports from <code>ported::builtin::BUILTINS</code>. \
9491             Each mirrors an upstream <code>Src/Builtins/*.c</code> entry 1:1, with the \
9492             hover body extracted from <code>man zshall</code> yodl. See also: \
9493             <a href=\"#ch-lsp-extensions\">Extension Builtin Index</a> for zshrs-only \
9494             additions.",
9495            compat.len()
9496        ),
9497        &compat,
9498        "compat",
9499    );
9500
9501    // ── keywords (canonical `reswds[]`) ─────────────────────────────
9502    // Source: `ported::hashtable::RESWDS` — direct port of upstream
9503    // `Src/hashtable.c:1076-1108`. Mirrors the `man zshmisc` "Reserved
9504    // Words" section (`Doc/Zsh/grammar.yo:501-504`) verbatim — every
9505    // one of the 31 entries (including the declarers `declare` /
9506    // `export` / `float` / `integer` / `local` / `readonly` / `typeset`,
9507    // which are reserved AND also exist as builtins).
9508    let keywords: Vec<String> = crate::ported::hashtable::RESWDS
9509        .iter()
9510        .map(|(n, _)| n.to_string())
9511        .collect();
9512    write_chapter(
9513        &mut out,
9514        "ch-lsp-keywords",
9515        "Keyword Index",
9516        &format!(
9517            "{} entries · zsh reserved words from <code>Src/hashtable.c</code> \
9518             <code>reswds[]</code>. Mirrors the <code>man zshmisc</code> \
9519             \"Reserved Words\" section. Declarers (<code>declare</code>, \
9520             <code>export</code>, <code>float</code>, <code>integer</code>, \
9521             <code>local</code>, <code>readonly</code>, <code>typeset</code>) \
9522             are reserved AND also appear in the Builtin Index — they're both.",
9523            keywords.len()
9524        ),
9525        &keywords,
9526        "keyword",
9527    );
9528
9529    // ── options (canonical ZSH_OPTIONS_SET) ──────────────────────────
9530    let mut options: Vec<String> = crate::ported::options::ZSH_OPTIONS_SET
9531        .iter()
9532        .map(|s| s.to_string())
9533        .collect();
9534    options.sort();
9535    write_chapter(
9536        &mut out,
9537        "ch-lsp-options",
9538        "Option Index",
9539        &format!(
9540            "{} entries · the canonical zsh option registry. \
9541             Set / clear via <code>setopt NAME</code> / <code>unsetopt NAME</code>.",
9542            options.len()
9543        ),
9544        &options,
9545        "option",
9546    );
9547
9548    // ── special vars (canonical 538-entry doc table) ─────────────────
9549    // Bare names from `SPECIAL_VAR_DOCS` get `$` prepended so the
9550    // chapter header matches the form users type. Pure-symbolic
9551    // ones (`?`/`*`/`#`/`@`/`-`/`_`) stay as-is — they're documented
9552    // under their bare key.
9553    let mut specials: Vec<String> = crate::zsh_special_var_docs::SPECIAL_VAR_DOCS
9554        .iter()
9555        .map(|(name, _)| {
9556            if name
9557                .chars()
9558                .next()
9559                .map(|c| c.is_ascii_alphabetic() || c == '_')
9560                .unwrap_or(false)
9561            {
9562                format!("${}", name)
9563            } else {
9564                (*name).to_string()
9565            }
9566        })
9567        .collect();
9568    specials.sort();
9569    specials.dedup();
9570    write_chapter(
9571        &mut out,
9572        "ch-lsp-specials",
9573        "Special Variable Index",
9574        &format!(
9575            "{} entries · zsh-defined parameters and well-known env vars. \
9576             Includes both scalar (<code>$?</code>) and array (<code>$path</code>) forms.",
9577            specials.len()
9578        ),
9579        &specials,
9580        "special",
9581    );
9582
9583    // ── compsys functions (`crate::compsys::COMPSYS_FN_NAMES`) ──────────────
9584    let mut compsys_names: Vec<String> = crate::compsys::COMPSYS_FN_NAMES
9585        .iter()
9586        .map(|s| s.to_string())
9587        .collect();
9588    compsys_names.sort();
9589    write_chapter(
9590        &mut out,
9591        "ch-lsp-compsys",
9592        "Compsys Function Index",
9593        &format!(
9594            "{} entries · the <code>_arguments</code> / <code>_files</code> / \
9595             <code>_describe</code> family of completion functions. Native Rust \
9596             implementations in the <code>compsys</code> crate replace the \
9597             upstream zsh shell-function versions for performance.",
9598            compsys_names.len()
9599        ),
9600        &compsys_names,
9601        "compsys",
9602    );
9603
9604    // ── extension builtins (ext + daemon z* builtins) ────────────────
9605    let ext_names = crate::ext_builtins::extension_builtin_names();
9606    write_chapter(
9607        &mut out,
9608        "ch-lsp-extensions",
9609        "Extension Builtin Index",
9610        &format!(
9611            "{} entries · zshrs-only builtins with NO upstream zsh counterpart. \
9612             Split across in-process builtins (coreutils drop-ins, <code>async</code>/\
9613             <code>await</code>/<code>barrier</code>, <code>doctor</code>, \
9614             <code>intercept</code>, contrib autoloads) and daemon-backed <code>z*</code> \
9615             builtins (<code>zd</code>, <code>zcache</code>, <code>zls</code>, \
9616             <code>zlock</code>, <code>zpublish</code>, …) that proxy to the local \
9617             <code>zshrs-daemon</code> for cross-shell state.",
9618            ext_names.len()
9619        ),
9620        &ext_names,
9621        "extension",
9622    );
9623
9624    // ── operators / punctuation tokens ───────────────────────────────
9625    let op_names: Vec<String> = OPERATOR_DOCS
9626        .iter()
9627        .map(|(op, _)| (*op).to_string())
9628        .collect();
9629    write_chapter(
9630        &mut out,
9631        "ch-lsp-operators",
9632        "Operator / Punctuation Index",
9633        &format!(
9634            "{} entries · pipelines (<code>|</code>, <code>|&amp;</code>), list ops \
9635             (<code>&amp;&amp;</code>, <code>||</code>, <code>;</code>, <code>&amp;</code>, \
9636             <code>;;</code>), redirects (<code>&gt;</code>, <code>&gt;&gt;</code>, \
9637             <code>&lt;&lt;</code>, <code>&lt;&lt;&lt;</code>, <code>&amp;&gt;</code>, …), \
9638             conditional/arithmetic openers (<code>[[</code>, <code>]]</code>, <code>((</code>, \
9639             <code>))</code>), substitution forms (<code>$(</code>, <code>${{</code>, \
9640             <code>$((</code>, <code>&lt;(</code>, <code>&gt;(</code>), test ops \
9641             (<code>-e</code>, <code>-eq</code>, <code>=~</code>, …), pattern chars \
9642             (<code>*</code>, <code>?</code>, <code>**</code>, <code>~</code>), brace \
9643             expansion (<code>{{a,b,c}}</code>, <code>{{1..10}}</code>), and assignment \
9644             (<code>=</code>, <code>+=</code>). Sourced from <code>man zshmisc</code> \
9645             section prose — these have no per-name yodl <code>item</code> blocks so \
9646             they're hand-curated.",
9647            op_names.len()
9648        ),
9649        &op_names,
9650        "operator",
9651    );
9652
9653    out
9654}
9655
9656fn write_chapter(
9657    out: &mut String,
9658    id: &str,
9659    title: &str,
9660    meta_html: &str,
9661    names: &[String],
9662    kind: &str,
9663) {
9664    use std::fmt::Write;
9665    let _ = writeln!(
9666        out,
9667        "\n    <!-- ════════════════════════════════════════════════════════════════════ -->\n\
9668         \n    <section class=\"tutorial-section\" id=\"{id}\">\n\
9669         \n      <h2>{title}</h2>\n\
9670         \n      <p class=\"chapter-meta\">{meta_html}</p>",
9671    );
9672    for n in names {
9673        let body = lookup_doc(n);
9674        // lookup_doc returns `**HEADING** — _kind_\n\nBODY`. Split that
9675        // apart so the article shows the body without the heading
9676        // duplication (the article already prints the name in <h3>).
9677        let body_only = body.split_once("\n\n").map(|(_, b)| b).unwrap_or("");
9678        let anchor = anchor_for(kind, n);
9679        let _ = writeln!(
9680            out,
9681            "\n      <article class=\"doc-entry\" id=\"{anchor}\">\n\
9682             \n        <h3><code>{}</code> <a class=\"doc-anchor\" href=\"#{anchor}\">¶</a></h3>\n\
9683             {}      </article>",
9684            html_escape(n),
9685            md_to_html(body_only),
9686        );
9687    }
9688    out.push_str("\n    </section>\n");
9689}
9690
9691fn anchor_for(kind: &str, name: &str) -> String {
9692    // Map every non-alphanumeric char to a stable mnemonic so single-char
9693    // punctuation builtins (`-`, `:`, `.`, `[`, `]`, `:`) each get a
9694    // unique anchor instead of all collapsing to `doc-lsp-builtin-`.
9695    // Preserve case to distinguish `$PATH` from `$path` (zsh ties them
9696    // via `typeset -T` but they're distinct hover targets).
9697    let mut slug = String::new();
9698    for c in name.chars() {
9699        match c {
9700            c if c.is_ascii_alphanumeric() => slug.push(c),
9701            '_' => slug.push('_'),
9702            '-' => slug.push_str("dash"),
9703            ':' => slug.push_str("colon"),
9704            '.' => slug.push_str("dot"),
9705            '[' => slug.push_str("lbracket"),
9706            ']' => slug.push_str("rbracket"),
9707            '(' => slug.push_str("lparen"),
9708            ')' => slug.push_str("rparen"),
9709            '{' => slug.push_str("lbrace"),
9710            '}' => slug.push_str("rbrace"),
9711            '?' => slug.push_str("qmark"),
9712            '!' => slug.push_str("bang"),
9713            '$' => slug.push_str("dollar"),
9714            '#' => slug.push_str("hash"),
9715            '*' => slug.push_str("star"),
9716            '@' => slug.push_str("at"),
9717            '/' => slug.push_str("slash"),
9718            '+' => slug.push_str("plus"),
9719            '=' => slug.push_str("eq"),
9720            _ => slug.push('-'),
9721        }
9722    }
9723    let slug = slug.trim_matches('-').to_string();
9724    if slug.is_empty() {
9725        format!("doc-lsp-{}-unnamed", kind)
9726    } else {
9727        format!("doc-lsp-{}-{}", kind, slug)
9728    }
9729}
9730
9731fn html_escape(s: &str) -> String {
9732    let mut out = String::with_capacity(s.len());
9733    for c in s.chars() {
9734        match c {
9735            '&' => out.push_str("&amp;"),
9736            '<' => out.push_str("&lt;"),
9737            '>' => out.push_str("&gt;"),
9738            '"' => out.push_str("&quot;"),
9739            _ => out.push(c),
9740        }
9741    }
9742    out
9743}
9744
9745/// Convert the markdown subset `lookup_doc` produces into HTML.
9746///
9747/// Supported: `**bold**`, `_italic_`, backtick code, blank-line
9748/// paragraph breaks. Anything else passes through HTML-escaped. The
9749/// generator in `scripts/gen_option_docs.py` already strips yodl down
9750/// to this subset, so we don't need a full Markdown parser.
9751fn md_to_html(s: &str) -> String {
9752    use std::fmt::Write;
9753    let mut out = String::new();
9754    for para in s.split("\n\n") {
9755        let para = para.trim_matches('\n');
9756        if para.is_empty() {
9757            continue;
9758        }
9759        // Collapse intra-paragraph newlines to spaces so wrapped yodl
9760        // text reflows cleanly.
9761        let joined: String = para
9762            .split('\n')
9763            .map(str::trim_end)
9764            .collect::<Vec<_>>()
9765            .join(" ");
9766        let _ = writeln!(out, "        <p>{}</p>", inline_md(&joined));
9767    }
9768    out
9769}
9770
9771fn inline_md(s: &str) -> String {
9772    // Walk char-by-char tracking three states: code-span (between `…`),
9773    // bold (between **…**), italic (between _…_). Code wins over the
9774    // others; bold and italic stay greedy/non-overlapping.
9775    let bytes = s.as_bytes();
9776    let mut out = String::with_capacity(s.len() + 16);
9777    let mut i = 0;
9778    while i < bytes.len() {
9779        let c = bytes[i] as char;
9780        // Code span — close at next backtick.
9781        if c == '`' {
9782            out.push_str("<code>");
9783            i += 1;
9784            while i < bytes.len() && bytes[i] != b'`' {
9785                i = push_html_escaped(&mut out, bytes, i);
9786            }
9787            out.push_str("</code>");
9788            if i < bytes.len() {
9789                i += 1; // consume closing `
9790            }
9791            continue;
9792        }
9793        // **bold**
9794        if c == '*' && i + 1 < bytes.len() && bytes[i + 1] as char == '*' {
9795            if let Some(end) = find_close(bytes, i + 2, b"**") {
9796                out.push_str("<strong>");
9797                out.push_str(&inline_md(
9798                    std::str::from_utf8(&bytes[i + 2..end]).unwrap_or(""),
9799                ));
9800                out.push_str("</strong>");
9801                i = end + 2;
9802                continue;
9803            }
9804        }
9805        // _italic_ — only when bounded by non-alphanumeric on both sides
9806        // so `name_with_underscores` doesn't trigger.
9807        if c == '_'
9808            && (i == 0 || !(bytes[i - 1] as char).is_alphanumeric())
9809            && i + 1 < bytes.len()
9810            && !(bytes[i + 1] as char).is_whitespace()
9811        {
9812            if let Some(end) = find_close(bytes, i + 1, b"_") {
9813                let after_ok =
9814                    end + 1 >= bytes.len() || !(bytes[end + 1] as char).is_alphanumeric();
9815                if after_ok {
9816                    out.push_str("<em>");
9817                    out.push_str(&inline_md(
9818                        std::str::from_utf8(&bytes[i + 1..end]).unwrap_or(""),
9819                    ));
9820                    out.push_str("</em>");
9821                    i = end + 1;
9822                    continue;
9823                }
9824            }
9825        }
9826        // Default: HTML-escape ASCII metacharacters; copy multibyte UTF-8
9827        // whole. (`c` only matches ASCII markers above, so high bytes — e.g.
9828        // the em-dash `—` (e2 80 94) — fall through to here.)
9829        i = push_html_escaped(&mut out, bytes, i);
9830    }
9831    out
9832}
9833
9834/// Append the character starting at `bytes[i]` to `out`, HTML-escaping the
9835/// ASCII metacharacters and copying any multibyte UTF-8 sequence whole.
9836/// Returns the index just past the emitted character. Never cast individual
9837/// bytes of a multibyte sequence to `char` — that re-encodes Latin-1→UTF-8 and
9838/// produces mojibake (e.g. `—` → `âÂÂ`).
9839fn push_html_escaped(out: &mut String, bytes: &[u8], i: usize) -> usize {
9840    let b = bytes[i];
9841    if b < 0x80 {
9842        match b {
9843            b'&' => out.push_str("&amp;"),
9844            b'<' => out.push_str("&lt;"),
9845            b'>' => out.push_str("&gt;"),
9846            _ => out.push(b as char),
9847        }
9848        i + 1
9849    } else {
9850        let len = match b {
9851            0xF0..=0xF4 => 4,
9852            0xE0..=0xEF => 3,
9853            0xC0..=0xDF => 2,
9854            _ => 1,
9855        };
9856        let end = (i + len).min(bytes.len());
9857        out.push_str(std::str::from_utf8(&bytes[i..end]).unwrap_or("\u{fffd}"));
9858        end
9859    }
9860}
9861
9862fn find_close(bytes: &[u8], start: usize, needle: &[u8]) -> Option<usize> {
9863    let mut i = start;
9864    while i + needle.len() <= bytes.len() {
9865        if &bytes[i..i + needle.len()] == needle {
9866            return Some(i);
9867        }
9868        i += 1;
9869    }
9870    None
9871}
9872
9873#[cfg(test)]
9874mod tests {
9875    use super::*;
9876
9877    // ── compsys flag docs (man zshcompsys-derived hand table) ──────────
9878
9879    #[test]
9880    fn compsys_flag_table_covers_wanted_with_all_six_flags() {
9881        // `_wanted -<TAB>` previously returned 0 because tier-1 saw no
9882        // bullets in the doc body and tier-2 caught at most 2 inline
9883        // citations. The man-derived hand table should now serve all 6
9884        // flags from the signature `[ -x ] [ -C name ] [ -12VJ ]`.
9885        let flags = extract_builtin_flags("_wanted");
9886        let names: Vec<&str> = flags.iter().map(|(f, _)| f.as_str()).collect();
9887        assert_eq!(
9888            names,
9889            vec!["-x", "-C", "-1", "-2", "-V", "-J"],
9890            "_wanted flag set drifted from man zshcompsys signature",
9891        );
9892        for (f, d) in &flags {
9893            assert!(!d.is_empty(), "flag {f} has no description");
9894        }
9895    }
9896
9897    #[test]
9898    fn compsys_flag_table_overrides_bullet_scraper_for_arguments() {
9899        // `_arguments` is the marquee compsys fn — the man page
9900        // documents 12+ flags but the bullet scraper only catches 7.
9901        // The man-derived hand table is consulted FIRST, so the count
9902        // should be the full 12.
9903        let flags = extract_builtin_flags("_arguments");
9904        assert!(
9905            flags.len() >= 12,
9906            "expected _arguments to surface >=12 flags from man-derived table, got {}",
9907            flags.len()
9908        );
9909        // Spot-check that the man-only flags (-w, -W, -A, -O, --) are
9910        // present — these are missing from the BUILTIN_DOCS scrape.
9911        let names: std::collections::HashSet<&str> =
9912            flags.iter().map(|(f, _)| f.as_str()).collect();
9913        for must_have in ["-n", "-s", "-w", "-W", "-C", "-R", "-S", "-A", "-O", "-M"] {
9914            assert!(
9915                names.contains(must_have),
9916                "_arguments missing canonical flag {must_have}"
9917            );
9918        }
9919    }
9920
9921    #[test]
9922    fn every_compsys_fn_in_man_is_in_inventory() {
9923        // The 26 compsys ported documented in `man zshcompsys` all have
9924        // Rust shadows in `compsys/` (canonical_paths, call_program,
9925        // widgets, …) — so `COMPSYS_FN_NAMES` is the canonical truth
9926        // and `is_known_builtin_with_flag_docs` doesn't need a doc-
9927        // only fallback. This test pins that. If a future yo extract
9928        // adds a new compsys fn, add it to `COMPSYS_FN_NAMES` AND
9929        // give it a row in `COMPSYS_FN_FLAG_DOCS`.
9930        for name in [
9931            "_call_program",
9932            "_canonical_paths",
9933            "_combination",
9934            "_command_names",
9935            "_completers",
9936            "_dir_list",
9937            "_email_addresses",
9938            "_multi_parts",
9939            "_numbers",
9940            "_pick_variant",
9941            "_sequence",
9942            "_tags",
9943            "_widgets",
9944        ] {
9945            assert!(
9946                crate::compsys::COMPSYS_FN_NAMES.contains(&name),
9947                "{name}: missing from COMPSYS_FN_NAMES inventory",
9948            );
9949        }
9950        assert!(is_known_builtin_with_flag_docs("_canonical_paths"));
9951        assert!(is_known_builtin_with_flag_docs("_widgets"));
9952        assert!(is_known_builtin_with_flag_docs("_call_program"));
9953    }
9954
9955    // ── word_at ─────────────────────────────────────────────────────────
9956
9957    #[test]
9958    fn word_at_middle_of_identifier() {
9959        let _g = crate::test_util::global_state_lock();
9960        let src = "cd /tmp\nlocal x=1\n";
9961        assert_eq!(word_at(src, 0, 1), Some("cd".into()));
9962        // Past the identifier, still inside `cd`
9963        assert_eq!(word_at(src, 0, 2), Some("cd".into()));
9964    }
9965
9966    #[test]
9967    fn word_at_includes_dollar_prefix() {
9968        let _g = crate::test_util::global_state_lock();
9969        let src = "echo $HOME\n";
9970        assert_eq!(word_at(src, 0, 6), Some("$HOME".into()));
9971    }
9972
9973    // Regression pins (2026-05-23): zsh function/command names allow
9974    // `-` (e.g. `daemon-ping`, `daemon-job-submit`). Before the fix,
9975    // `word_at` stopped at `-` and rename/find-refs only matched the
9976    // segment after the last `-`. Now `-NAME` segments are included
9977    // for non-`$`-prefixed words, while `$var` / `${var-default}`
9978    // contexts stay at the strict-identifier boundary.
9979
9980    #[test]
9981    fn word_at_extends_through_hyphen_for_function_name() {
9982        let _g = crate::test_util::global_state_lock();
9983        let src = "daemon-ping arg\n";
9984        // Cursor on `daemon` segment.
9985        assert_eq!(word_at(src, 0, 0), Some("daemon-ping".into()));
9986        assert_eq!(word_at(src, 0, 3), Some("daemon-ping".into()));
9987        // Cursor on `ping` segment.
9988        assert_eq!(word_at(src, 0, 7), Some("daemon-ping".into()));
9989        assert_eq!(word_at(src, 0, 10), Some("daemon-ping".into()));
9990    }
9991
9992    #[test]
9993    fn word_at_extends_through_multiple_hyphens() {
9994        let _g = crate::test_util::global_state_lock();
9995        let src = "daemon-job-submit -- cmd\n";
9996        assert_eq!(word_at(src, 0, 8), Some("daemon-job-submit".into()));
9997        assert_eq!(word_at(src, 0, 13), Some("daemon-job-submit".into()));
9998    }
9999
10000    #[test]
10001    fn word_at_dollar_var_does_not_extend_through_hyphen() {
10002        let _g = crate::test_util::global_state_lock();
10003        let src = "echo $x-y suffix\n";
10004        // `$x-y` in shell expands `$x` then literal `-y`. Caret on
10005        // `x` must return `$x`, NOT `$x-y`.
10006        assert_eq!(word_at(src, 0, 6), Some("$x".into()));
10007    }
10008
10009    #[test]
10010    fn word_at_braced_var_does_not_extend_through_hyphen() {
10011        let _g = crate::test_util::global_state_lock();
10012        let src = "echo ${x-default}\n";
10013        // `${x-default}` is the `${VAR-WORD}` (default-if-unset)
10014        // operator. Caret on `x` must return `x`, NOT `x-default`.
10015        assert_eq!(word_at(src, 0, 7), Some("x".into()));
10016    }
10017
10018    #[test]
10019    fn word_at_returns_none_off_word() {
10020        let _g = crate::test_util::global_state_lock();
10021        let src = "echo  hi\n";
10022        // Position on the double-space gap
10023        assert!(matches!(word_at(src, 0, 5), None | Some(_)));
10024        // Position past end-of-line
10025        assert_eq!(word_at(src, 0, 999), None);
10026    }
10027
10028    // ── find_user_symbol_doc (## doc-comment hover) ─────────────────────
10029
10030    #[test]
10031    fn user_doc_attaches_to_function_with_keyword_form() {
10032        // Card format mirrors stryke's
10033        // `strykelang/lsp.rs::format_with_doc_comments`:
10034        //   <doc>\n\n---\n\n<one-line header>
10035        let src = "## Print a hello banner with the user's name.\n\
10036                   ## Used by the README demo.\n\
10037                   function greet {\n  print hi\n}\n";
10038        let doc = super::find_user_symbol_doc(src, "greet").expect("doc");
10039        assert!(doc.contains("Print a hello banner"), "got {doc:?}");
10040        assert!(doc.contains("Used by the README demo"), "got {doc:?}");
10041        assert!(doc.contains("\n\n---\n\n"), "missing divider: {doc:?}");
10042        assert!(doc.contains("user-defined function `greet`"), "got {doc:?}");
10043    }
10044
10045    #[test]
10046    fn user_doc_attaches_to_posix_function_form() {
10047        let src = "## Sum two integers.\n\
10048                   add() {\n  print $(( $1 + $2 ))\n}\n";
10049        let doc = super::find_user_symbol_doc(src, "add").expect("doc");
10050        assert!(doc.contains("Sum two integers"), "got {doc:?}");
10051        assert!(doc.contains("user-defined function"), "got {doc:?}");
10052    }
10053
10054    #[test]
10055    fn user_doc_attaches_to_alias() {
10056        let src = "## Short for `ls --color=auto -lAh`.\nalias ll='ls --color=auto -lAh'\n";
10057        let doc = super::find_user_symbol_doc(src, "ll").expect("doc");
10058        assert!(doc.contains("Short for"), "got {doc:?}");
10059        assert!(doc.contains("user-defined alias"), "got {doc:?}");
10060    }
10061
10062    #[test]
10063    fn user_doc_attaches_to_typeset_parameter() {
10064        let src = "## Maximum retries before giving up.\ntypeset -i MAX_RETRIES=5\n";
10065        let doc = super::find_user_symbol_doc(src, "MAX_RETRIES").expect("doc");
10066        assert!(doc.contains("Maximum retries"), "got {doc:?}");
10067        assert!(doc.contains("user-defined parameter"), "got {doc:?}");
10068    }
10069
10070    #[test]
10071    fn user_doc_multi_paragraph_with_blank_double_hash() {
10072        // `##` on its own line is a paragraph break inside the block.
10073        let src = "## First paragraph describing what the function does.\n\
10074                   ##\n\
10075                   ## Second paragraph about edge cases.\n\
10076                   function foo() {}\n";
10077        let doc = super::find_user_symbol_doc(src, "foo").expect("doc");
10078        assert!(doc.contains("First paragraph"), "got {doc:?}");
10079        assert!(doc.contains("Second paragraph"), "got {doc:?}");
10080        // Paragraph break preserved.
10081        assert!(doc.contains("\n\n"), "expected paragraph break: {doc:?}");
10082    }
10083
10084    #[test]
10085    fn user_doc_skips_blank_lines_between_block_and_def() {
10086        // Doc block, then blank line, then def — still attached.
10087        let src = "## Doc for the function.\n\nfunction f() {}\n";
10088        let doc = super::find_user_symbol_doc(src, "f").expect("doc");
10089        assert!(doc.contains("Doc for the function"), "got {doc:?}");
10090    }
10091
10092    #[test]
10093    fn user_doc_ignores_single_hash_comments() {
10094        // Only `##` lines attach to the doc block. Plain `#` comments
10095        // are routine code remarks and must NOT show up as docstrings.
10096        // The minimal "defined here" card (find_user_symbol_doc fallback
10097        // at lsp.rs:2038) still fires so hover doesn't go blank for an
10098        // undocumented symbol, but it must contain NO part of the `#`
10099        // comment text.
10100        let src = "# This is just a code comment, not a docstring.\n\
10101                   function f() {}\n";
10102        let card = super::find_user_symbol_doc(src, "f").expect("minimal card");
10103        assert!(
10104            !card.contains("just a code comment"),
10105            "plain `#` comments must not leak into doc card: {card:?}"
10106        );
10107    }
10108
10109    #[test]
10110    fn user_doc_returns_none_when_symbol_absent() {
10111        let src = "## Doc for greet.\nfunction greet() {}\n";
10112        assert!(super::find_user_symbol_doc(src, "nonexistent").is_none());
10113    }
10114
10115    #[test]
10116    fn user_doc_returns_none_when_no_doc_block() {
10117        // No `##` doc block → falls through to the "defined here"
10118        // minimal card (find_user_symbol_doc fallback at lsp.rs:2038).
10119        // The card must mention the symbol but carry no doc body.
10120        let src = "function greet() {}\n";
10121        let card = super::find_user_symbol_doc(src, "greet").expect("minimal card");
10122        assert!(
10123            card.contains("greet"),
10124            "card must name the symbol: {card:?}"
10125        );
10126    }
10127
10128    #[test]
10129    fn user_doc_stops_at_intervening_single_hash_line() {
10130        // `## doc` then `# code comment` then `function f` —
10131        // intervening single-`#` terminates the doc-block collection,
10132        // so the rich-doc card is suppressed. The minimal "defined
10133        // here" card still fires (per lsp.rs:2038 fallback), but it
10134        // must NOT contain the `## Real doc here.` text.
10135        let src = "## Real doc here.\n\
10136                   # Inline comment unrelated to the doc.\n\
10137                   function f() {}\n";
10138        let card = super::find_user_symbol_doc(src, "f").expect("minimal card");
10139        assert!(
10140            !card.contains("Real doc here"),
10141            "intervening `#` must break doc collection: {card:?}"
10142        );
10143    }
10144
10145    // ── extract_module_doc (top-of-file ## block) ───────────────────────
10146
10147    #[test]
10148    fn module_doc_collects_top_block_after_shebang() {
10149        let src = "#!/usr/bin/env zsh\n\
10150                   ## foo.zsh — short summary.\n\
10151                   ## Provides foo, bar, baz helpers.\n\
10152                   \n\
10153                   function foo() {}\n";
10154        let doc = super::extract_module_doc(src).expect("doc");
10155        assert!(doc.contains("foo.zsh"), "got {doc:?}");
10156        assert!(doc.contains("Provides foo"), "got {doc:?}");
10157    }
10158
10159    #[test]
10160    fn module_doc_collects_top_block_without_shebang() {
10161        let src = "## bar.zsh — utility library.\n\
10162                   function bar() {}\n";
10163        let doc = super::extract_module_doc(src).expect("doc");
10164        assert!(doc.contains("bar.zsh"), "got {doc:?}");
10165    }
10166
10167    #[test]
10168    fn module_doc_supports_double_hash_paragraph_breaks() {
10169        let src = "## First paragraph of the module summary.\n\
10170                   ##\n\
10171                   ## Second paragraph with details.\n\
10172                   function foo() {}\n";
10173        let doc = super::extract_module_doc(src).expect("doc");
10174        assert!(doc.contains("First paragraph"), "got {doc:?}");
10175        assert!(doc.contains("Second paragraph"), "got {doc:?}");
10176        assert!(doc.contains("\n\n"), "expected paragraph break: {doc:?}");
10177    }
10178
10179    #[test]
10180    fn module_doc_skips_blank_lines_between_shebang_and_block() {
10181        let src = "#!/usr/bin/env zsh\n\
10182                   \n\
10183                   \n\
10184                   ## Module doc starts here.\n\
10185                   function foo() {}\n";
10186        let doc = super::extract_module_doc(src).expect("doc");
10187        assert!(doc.contains("Module doc starts here"), "got {doc:?}");
10188    }
10189
10190    #[test]
10191    fn module_doc_returns_none_when_no_block() {
10192        let src = "#!/usr/bin/env zsh\nfunction foo() {}\n";
10193        assert!(super::extract_module_doc(src).is_none());
10194    }
10195
10196    #[test]
10197    fn module_doc_returns_none_for_plain_single_hash_comments() {
10198        // Single `#` comments at top of file are NOT module docs.
10199        let src = "#!/usr/bin/env zsh\n\
10200                   # This is a regular code comment, not a docstring.\n\
10201                   function foo() {}\n";
10202        assert!(super::extract_module_doc(src).is_none());
10203    }
10204
10205    #[test]
10206    fn module_doc_stops_at_first_real_code_line() {
10207        let src = "## Module doc line 1.\n\
10208                   function foo() {}\n\
10209                   ## This should NOT be collected — below the def.\n";
10210        let doc = super::extract_module_doc(src).expect("doc");
10211        assert_eq!(doc, "Module doc line 1.");
10212    }
10213
10214    // ── scan_symbols ────────────────────────────────────────────────────
10215
10216    #[test]
10217    fn scan_symbols_finds_function_keyword_form() {
10218        let _g = crate::test_util::global_state_lock();
10219        let src = "function greet {\n  print hi\n}\n";
10220        let s = scan_symbols(src);
10221        assert!(s.iter().any(|(n, k, _)| n == "greet" && *k == "function"));
10222    }
10223
10224    #[test]
10225    fn scan_symbols_finds_paren_form() {
10226        let _g = crate::test_util::global_state_lock();
10227        let src = "foo() {\n  :\n}\n";
10228        let s = scan_symbols(src);
10229        assert!(s.iter().any(|(n, k, _)| n == "foo" && *k == "function"));
10230    }
10231
10232    #[test]
10233    fn scan_symbols_finds_locals_and_aliases() {
10234        let _g = crate::test_util::global_state_lock();
10235        let src = "local x=1\nalias ll='ls -la'\nexport PATH=/bin\n";
10236        let s = scan_symbols(src);
10237        assert!(s.iter().any(|(n, k, _)| n == "x" && *k == "variable"));
10238        assert!(s.iter().any(|(n, k, _)| n == "ll" && *k == "alias"));
10239        assert!(s.iter().any(|(n, k, _)| n == "PATH" && *k == "variable"));
10240    }
10241
10242    #[test]
10243    fn scan_symbols_ignores_comments() {
10244        let _g = crate::test_util::global_state_lock();
10245        let src = "# function fake { }\n# alias evil=rm\n: real\n";
10246        let s = scan_symbols(src);
10247        assert!(s.is_empty(), "scan_symbols leaked comment content: {:?}", s);
10248    }
10249
10250    // ── lookup_doc ──────────────────────────────────────────────────────
10251
10252    #[test]
10253    fn lookup_doc_returns_markdown_for_known_builtin() {
10254        let _g = crate::test_util::global_state_lock();
10255        let doc = lookup_doc("cd");
10256        assert!(doc.starts_with("**cd**"), "got: {}", doc);
10257        // Upstream `Doc/Zsh/builtins.yo` `cd` description.
10258        assert!(
10259            doc.contains("Change the current directory"),
10260            "expected upstream cd prose; got: {}",
10261            doc
10262        );
10263    }
10264
10265    #[test]
10266    fn lookup_doc_handles_keywords_and_special_vars() {
10267        let _g = crate::test_util::global_state_lock();
10268        // Upstream `Doc/Zsh/grammar.yo` `if` description.
10269        assert!(
10270            lookup_doc("if").contains("zero exit status"),
10271            "expected upstream if prose; got: {}",
10272            lookup_doc("if")
10273        );
10274        // Upstream `Doc/Zsh/params.yo` `?` description (stripped of $).
10275        assert!(
10276            lookup_doc("$?").contains("exit status"),
10277            "expected $? doc; got: {}",
10278            lookup_doc("$?")
10279        );
10280    }
10281
10282    #[test]
10283    fn lookup_doc_handles_pure_symbolic_specials() {
10284        // User report: `zshrs --docs '$'` returned "no docs for $".
10285        // Root cause: strip_prefix('$') on the lookup key turned `"$"`
10286        // (the canonical entry for `$$`/PID) into "" which failed.
10287        // Now the raw key is tried first.
10288        let _g = crate::test_util::global_state_lock();
10289        // `$` = PID — canonical entry under bare `"$"` key
10290        let s = lookup_doc("$");
10291        assert!(
10292            !s.is_empty() && s.contains("process ID"),
10293            "lookup_doc('$') should return the PID doc; got: {:?}",
10294            s,
10295        );
10296        // Other pure-symbolic specials must also resolve via their
10297        // bare key — `?` / `*` / `#` / `@` / `-` / `_` / `!`.
10298        for sym in &["$?", "$*", "$#", "$@", "$-", "$_", "$!"] {
10299            let card = lookup_doc(sym);
10300            assert!(
10301                !card.is_empty(),
10302                "lookup_doc({:?}) returned empty; pure-symbolic specials must resolve",
10303                sym,
10304            );
10305        }
10306    }
10307
10308    #[test]
10309    fn lookup_doc_special_var_wins_over_module_builtin_entry() {
10310        // User report: `prompt` resolved as "zsh builtin" instead of
10311        // special var. Root cause: module yo files (contrib.yo,
10312        // zsh/parameter, etc) have `item(tt(NAME))` blocks that
10313        // describe PARAMETERS but the builtin extractor classifies
10314        // by macro shape, not semantic intent. 109 names overlap;
10315        // none are real builtins per `ported::builtin::BUILTINS`.
10316        // Names that aren't in the canonical builtin table must
10317        // resolve to the special-var entry, not the misclassified
10318        // builtin entry.
10319        let _g = crate::test_util::global_state_lock();
10320        // These names are NOT in `ported::builtin::BUILTINS` — they're
10321        // purely special-var/array params. The bug was classifying
10322        // them as builtins because module doc files have item(tt(X))
10323        // blocks describing the parameter behavior.
10324        for name in &[
10325            "prompt",
10326            "path",
10327            "aliases",
10328            "jobdirs",
10329            "jobstates",
10330            "commands",
10331            "modules",
10332            "widgets",
10333        ] {
10334            let card = lookup_doc(name);
10335            assert!(
10336                card.contains("special variable"),
10337                "lookup_doc({:?}) classified as: {:?} — expected 'special variable'",
10338                name,
10339                card.lines().take(3).collect::<Vec<_>>(),
10340            );
10341        }
10342        // Real builtins must STAY classified as builtins —
10343        // `functions`/`history`/`set`/`shift` are genuine builtins
10344        // per `ported::builtin::BUILTINS` (history is fc-alias,
10345        // functions is typeset-f-alias) so the special-var-wins
10346        // override must NOT touch them.
10347        for name in &[
10348            "cd",
10349            "echo",
10350            "set",
10351            "shift",
10352            "unset",
10353            "functions",
10354            "history",
10355        ] {
10356            let card = lookup_doc(name);
10357            assert!(
10358                card.contains("zsh builtin") || card.contains("zshrs"),
10359                "lookup_doc({:?}) lost its builtin classification: {:?}",
10360                name,
10361                card.lines().take(3).collect::<Vec<_>>(),
10362            );
10363        }
10364    }
10365
10366    #[test]
10367    fn lookup_doc_empty_for_unknown() {
10368        let _g = crate::test_util::global_state_lock();
10369        assert_eq!(lookup_doc("definitely_not_a_zsh_thing_xx"), "");
10370    }
10371
10372    // ── diagnose ────────────────────────────────────────────────────────
10373
10374    #[test]
10375    fn diagnose_clean_file_returns_no_diagnostics() {
10376        let _g = crate::test_util::global_state_lock();
10377        let src = "if [[ -d /tmp ]]; then\n  echo ok\nfi\n";
10378        let d = diagnose(src);
10379        assert!(d.is_empty(), "diagnose flagged clean file: {:?}", d);
10380    }
10381
10382    #[test]
10383    fn diagnose_flags_unmatched_brace() {
10384        let _g = crate::test_util::global_state_lock();
10385        let src = "function broken {\n  echo missing close\n";
10386        let d = diagnose(src);
10387        assert!(
10388            d.iter()
10389                .any(|v| v["message"].as_str().unwrap_or("").contains("unclosed `{`")),
10390            "expected unclosed-brace diagnostic, got: {:?}",
10391            d
10392        );
10393    }
10394
10395    #[test]
10396    fn diagnose_flags_unclosed_if_block() {
10397        let _g = crate::test_util::global_state_lock();
10398        let src = "if true\nthen\necho\n";
10399        let d = diagnose(src);
10400        assert!(
10401            d.iter().any(|v| v["message"]
10402                .as_str()
10403                .unwrap_or("")
10404                .contains("unclosed `if`")),
10405            "expected unclosed-if diagnostic, got: {:?}",
10406            d
10407        );
10408    }
10409
10410    #[test]
10411    fn diagnose_ignores_braces_inside_strings() {
10412        let _g = crate::test_util::global_state_lock();
10413        let src = "echo \"a } b\" '{ }' \n";
10414        let d = diagnose(src);
10415        assert!(
10416            d.is_empty(),
10417            "string-internal braces tripped diagnose: {:?}",
10418            d
10419        );
10420    }
10421
10422    /// Block keywords inside comments must NOT push onto block_stack.
10423    /// Before fix: `# operations — length, case, slice, search.` in
10424    /// examples/demos/03_strings.zsh produced "unclosed `case` block"
10425    /// because split_whitespace() yielded "case" as a token and the
10426    /// scan didn't filter comments.
10427    #[test]
10428    fn diagnose_ignores_block_keywords_inside_comments() {
10429        let _g = crate::test_util::global_state_lock();
10430        let src = "# operations — length, case, slice, concat, search.\nx=1\n";
10431        let d = diagnose(src);
10432        assert!(
10433            d.is_empty(),
10434            "block keyword `case` inside comment flagged: {:?}",
10435            d
10436        );
10437    }
10438
10439    /// Same for `if` / `for` / `while` / `until` / `select` / `repeat`
10440    /// in comments and strings — none should push onto block_stack.
10441    #[test]
10442    fn diagnose_ignores_all_block_keywords_in_comments_and_strings() {
10443        let _g = crate::test_util::global_state_lock();
10444        let src = "# if for while until case select repeat\n\
10445                   echo \"if for while until case select repeat\"\n\
10446                   x=1\n";
10447        let d = diagnose(src);
10448        assert!(
10449            d.is_empty(),
10450            "block keywords in comments/strings flagged: {:?}",
10451            d
10452        );
10453    }
10454
10455    /// Loop-terminator keywords in comments must NOT pop block_stack
10456    /// or generate spurious "unmatched `done`" / "unmatched `fi`" /
10457    /// "unmatched `esac`" diagnostics.
10458    #[test]
10459    fn diagnose_ignores_terminators_inside_comments() {
10460        let _g = crate::test_util::global_state_lock();
10461        let src = "# fi done esac comment\nx=1\n";
10462        let d = diagnose(src);
10463        assert!(
10464            d.is_empty(),
10465            "terminator keywords in comments flagged: {:?}",
10466            d
10467        );
10468    }
10469
10470    /// `done;` (terminator with trailing `;`) must still pop the `for`
10471    /// off block_stack — otherwise one-liner loops like
10472    /// `for ((;;)); do …; done; }` left `for` orphaned and flagged.
10473    /// Reported false-positive on
10474    /// `examples/demos/106_pipe_chains.zsh:24` and
10475    /// `examples/demos/109_arith_truth_tables.zsh:54-55`.
10476    #[test]
10477    fn diagnose_done_with_trailing_semicolon_pops_for() {
10478        let _g = crate::test_util::global_state_lock();
10479        let src = "for i in 1 2 3; do echo $i; done;\n";
10480        let d = diagnose(src);
10481        assert!(d.is_empty(), "`done;` must pop for: {:?}", d);
10482    }
10483
10484    /// `done; done; done` (three terminators on one line) must pop
10485    /// all three nested `for`s. Pins the
10486    /// `109_arith_truth_tables.zsh:54-55` `for…; for…; for…; do…done; done; done`
10487    /// triple-nest pattern.
10488    #[test]
10489    fn diagnose_three_done_with_trailing_semicolon_pops_three_fors() {
10490        let _g = crate::test_util::global_state_lock();
10491        let src = "for a in 1; do for b in 1; do for c in 1; do echo $a$b$c; done; done; done\n";
10492        let d = diagnose(src);
10493        assert!(d.is_empty(), "three `done;` must pop three `for`: {:?}", d);
10494    }
10495
10496    /// `fi;` (if-terminator with trailing `;`) similarly pops the
10497    /// open `if` even when written `if …; then …; fi;`.
10498    #[test]
10499    fn diagnose_fi_with_trailing_semicolon_pops_if() {
10500        let _g = crate::test_util::global_state_lock();
10501        let src = "if true; then echo hi; fi;\n";
10502        let d = diagnose(src);
10503        assert!(d.is_empty(), "`fi;` must pop if: {:?}", d);
10504    }
10505
10506    /// One-liner `case … in PAT) cmd ;; *) cmd ;; esac` must NOT
10507    /// flag the `)` bare-paren terminators. Pins the
10508    /// `100_zsh_features_summary.zsh:49` false-positive
10509    /// `case foo in foo|bar) echo yes ;; *) echo no ;; esac`.
10510    #[test]
10511    fn diagnose_oneliner_case_arm_paren_not_flagged() {
10512        let _g = crate::test_util::global_state_lock();
10513        let src = "case foo in foo|bar) echo yes ;; *) echo no ;; esac\n";
10514        let d = diagnose(src);
10515        assert!(d.is_empty(), "one-liner case-arm `)` flagged: {:?}", d);
10516    }
10517
10518    /// `${var#PATTERN}` parameter-substitution patterns can contain
10519    /// unbalanced `[` / `]` (literal-escaped via `\[` / `\]`). The
10520    /// bracket scanner must skip the whole `${...}` span as opaque,
10521    /// otherwise it flags bogus 'unmatched }' / 'unclosed [' on
10522    /// `${log_line#\[}; date=${log_line#*\][}` patterns. Reported on
10523    /// `examples/demos/117_backref_replacement.zsh:56`.
10524    #[test]
10525    fn diagnose_param_subst_with_escaped_brackets_no_flag() {
10526        let _g = crate::test_util::global_state_lock();
10527        let src = "level=${log_line#\\[}; date=${log_line#*\\][}\n";
10528        let d = diagnose(src);
10529        assert!(
10530            d.is_empty(),
10531            "param-subst with escaped brackets flagged: {:?}",
10532            d
10533        );
10534    }
10535
10536    /// `select` as an ARGUMENT to another builtin (e.g.
10537    /// `zstyle ':completion:*' menu select`) must NOT push onto
10538    /// block_stack — only the block form
10539    /// `select var in words; do …; done` opens a block.
10540    /// Pinned because the original implementation flagged
10541    /// `examples/demos/133_zstyle_demo.zsh:6` as an unclosed
10542    /// `select` block.
10543    #[test]
10544    fn diagnose_select_as_argument_not_block_opener() {
10545        let _g = crate::test_util::global_state_lock();
10546        let src = "zstyle ':completion:*' menu select\n";
10547        let d = diagnose(src);
10548        assert!(d.is_empty(), "`select` as builtin arg flagged: {:?}", d);
10549    }
10550
10551    /// Real `select VAR in WORDS; do …; done` block form still
10552    /// works (positive control for the above test).
10553    #[test]
10554    fn diagnose_select_block_form_balances() {
10555        let _g = crate::test_util::global_state_lock();
10556        let src = "select x in a b c; do echo $x; break; done\n";
10557        let d = diagnose(src);
10558        assert!(
10559            d.is_empty(),
10560            "block-form `select … do … done` flagged: {:?}",
10561            d
10562        );
10563    }
10564
10565    // ── Hover regressions: UDF + user-variable fall-back & priority ──
10566
10567    /// User-defined function with NO `##` doc-block must still produce
10568    /// a hover card (minimal kind + def-line snippet). Before fix:
10569    /// hover returned null and the IDE went blank on every UDF that
10570    /// didn't carry a doc-string.
10571    #[test]
10572    fn find_user_symbol_doc_returns_minimal_card_when_no_docblock() {
10573        let _g = crate::test_util::global_state_lock();
10574        let src = "greet() {\n    echo hello\n}\n";
10575        let r = find_user_symbol_doc(src, "greet").expect("UDF must hover");
10576        assert!(
10577            r.contains("user-defined function"),
10578            "card must label kind, got {:?}",
10579            r
10580        );
10581        assert!(
10582            r.contains("greet"),
10583            "card must include the symbol name, got {:?}",
10584            r
10585        );
10586    }
10587
10588    /// User-defined variable (local/typeset/etc.) with no doc-block
10589    /// also gets a minimal card. Before fix: silent blank hover.
10590    #[test]
10591    fn find_user_symbol_doc_minimal_card_for_user_variable() {
10592        let _g = crate::test_util::global_state_lock();
10593        let src = "fn() {\n    local start=$EPOCHREALTIME\n}\n";
10594        let r = find_user_symbol_doc(src, "start").expect("user var must hover");
10595        assert!(
10596            r.contains("user-defined parameter") || r.contains("user-defined variable"),
10597            "card must label parameter/variable kind, got {:?}",
10598            r
10599        );
10600        assert!(
10601            r.contains("start"),
10602            "card must include the var name, got {:?}",
10603            r
10604        );
10605    }
10606
10607    /// User-defined symbol with a doc-block beats the minimal-card
10608    /// fallback (the documented form is richer).
10609    #[test]
10610    fn find_user_symbol_doc_prefers_documented_definition() {
10611        let _g = crate::test_util::global_state_lock();
10612        let src = "## says hi\ngreet() {\n    echo hi\n}\n";
10613        let r = find_user_symbol_doc(src, "greet").expect("UDF must hover");
10614        assert!(
10615            r.contains("says hi"),
10616            "documented form must include the doc block, got {:?}",
10617            r
10618        );
10619    }
10620
10621    /// Brace-range expansion `{a..e}` / `{A..E}` / `{1..10}` /
10622    /// `{1..10..2}` must emit a single OPERATOR token (type 4) for
10623    /// the whole span. Before fix the inner letter endpoints were
10624    /// classified as single-char identifiers → token-type 6
10625    /// (VARIABLE) → italic-green color in the IDE, making
10626    /// `echo {A..E}` look like a variable reference.
10627    #[test]
10628    fn semantic_tokens_brace_range_emits_single_operator_span() {
10629        let _g = crate::test_util::global_state_lock();
10630        let mut state = State::default();
10631        let uri = "file:///t.zsh";
10632        state
10633            .docs
10634            .insert(uri.to_string(), "echo {a..e}\n".to_string());
10635        let r = semantic_tokens(&state, &json!({ "textDocument": { "uri": uri } }));
10636        let data = r["data"].as_array().expect("data array");
10637        // Token stream layout includes `{a..e}` = 6 bytes, type 4 (operator).
10638        // Each token = 5 u32s (delta_line, delta_col, len, ty, mods).
10639        let mut found = false;
10640        for chunk in data.chunks(5) {
10641            if chunk.len() == 5 && chunk[2].as_u64() == Some(6) && chunk[3].as_u64() == Some(4) {
10642                found = true;
10643                break;
10644            }
10645        }
10646        assert!(
10647            found,
10648            "brace range `{{a..e}}` must emit a single 6-byte operator token, got data={:?}",
10649            data
10650        );
10651    }
10652
10653    /// Uppercase letter brace ranges `{A..E}` get the same treatment
10654    /// (the exact case shown in the user's screenshot reporting the
10655    /// italic-green mis-coloring of `E`).
10656    #[test]
10657    fn semantic_tokens_brace_range_uppercase_endpoints_emit_operator() {
10658        let _g = crate::test_util::global_state_lock();
10659        let mut state = State::default();
10660        let uri = "file:///t.zsh";
10661        state
10662            .docs
10663            .insert(uri.to_string(), "echo {A..E}\n".to_string());
10664        let r = semantic_tokens(&state, &json!({ "textDocument": { "uri": uri } }));
10665        let data = r["data"].as_array().expect("data array");
10666        let mut found = false;
10667        for chunk in data.chunks(5) {
10668            if chunk.len() == 5 && chunk[2].as_u64() == Some(6) && chunk[3].as_u64() == Some(4) {
10669                found = true;
10670                break;
10671            }
10672        }
10673        assert!(
10674            found,
10675            "brace range `{{A..E}}` must emit a single 6-byte operator token, got data={:?}",
10676            data
10677        );
10678    }
10679
10680    /// `$((arith))` inside a double-quoted string must NOT emit one
10681    /// opaque variable-colored span over the entire `$((expr))`.
10682    /// Before fix, the string-interpolation handler caught `$(` and
10683    /// counted parens to find the matching `))` then emitted ONE
10684    /// token-type-6 covering everything — so `"$(( -7 % 3 ))"` had
10685    /// `-7 % 3` colored as if it were a variable name. Fix breaks
10686    /// the interior into number/identifier/operator atoms and emits
10687    /// `$((` and `))` as operator brackets.
10688    #[test]
10689    fn semantic_tokens_arith_in_string_breaks_interior_into_atoms() {
10690        let _g = crate::test_util::global_state_lock();
10691        let mut state = State::default();
10692        let uri = "file:///t.zsh";
10693        state
10694            .docs
10695            .insert(uri.to_string(), "echo \"$(( -7 % 3 ))\"\n".to_string());
10696        let r = semantic_tokens(&state, &json!({ "textDocument": { "uri": uri } }));
10697        let data = r["data"].as_array().expect("data array");
10698        // We expect to see at least:
10699        //   * one type-2 (number) token for `7` (the digit run after `-`)
10700        //   * one type-4 (operator) token for `%`
10701        //   * one type-2 (number) token for `3`
10702        // Before fix: zero numbers, zero operators (everything was one
10703        // type-6 variable token across the whole `$((…))` span).
10704        let mut numbers = 0usize;
10705        let mut operators = 0usize;
10706        for chunk in data.chunks(5) {
10707            if chunk.len() == 5 {
10708                match chunk[3].as_u64() {
10709                    Some(2) => numbers += 1,
10710                    Some(4) => operators += 1,
10711                    _ => {}
10712                }
10713            }
10714        }
10715        assert!(
10716            numbers >= 2,
10717            "expected ≥2 number tokens inside $((…)), got {numbers}; data={:?}",
10718            data
10719        );
10720        assert!(
10721            operators >= 3,
10722            "expected ≥3 operator tokens ($((, %, )) ) inside $((…)), got {operators}; data={:?}",
10723            data
10724        );
10725    }
10726
10727    /// Long-form CLI flags `--verbose` / `--debug` emit a single
10728    /// OPERATOR token so the whole `--word` highlights uniformly.
10729    /// Before fix the `--` fell through to `col += 1` and the rest
10730    /// of the flag word was unhighlighted, producing the visible
10731    /// "stripe" reported in the user's screenshot.
10732    #[test]
10733    fn semantic_tokens_long_cli_flag_emits_operator_span() {
10734        let _g = crate::test_util::global_state_lock();
10735        let mut state = State::default();
10736        let uri = "file:///t.zsh";
10737        state.docs.insert(
10738            uri.to_string(),
10739            "parse_demo --verbose --debug arg1\n".to_string(),
10740        );
10741        let r = semantic_tokens(&state, &json!({ "textDocument": { "uri": uri } }));
10742        let data = r["data"].as_array().expect("data array");
10743        // Need at least one operator token of len=9 (`--verbose`)
10744        // AND one of len=7 (`--debug`).
10745        let mut saw_9_op = false;
10746        let mut saw_7_op = false;
10747        for chunk in data.chunks(5) {
10748            if chunk.len() == 5 && chunk[3].as_u64() == Some(4) {
10749                match chunk[2].as_u64() {
10750                    Some(9) => saw_9_op = true,
10751                    Some(7) => saw_7_op = true,
10752                    _ => {}
10753                }
10754            }
10755        }
10756        assert!(
10757            saw_9_op,
10758            "`--verbose` (9 bytes) must emit an operator token, got {:?}",
10759            data
10760        );
10761        assert!(
10762            saw_7_op,
10763            "`--debug` (7 bytes) must emit an operator token, got {:?}",
10764            data
10765        );
10766    }
10767
10768    /// Stepped brace range `{1..10..2}` covers the full 10-byte span
10769    /// including the optional step component.
10770    #[test]
10771    fn semantic_tokens_brace_range_with_step_emits_operator() {
10772        let _g = crate::test_util::global_state_lock();
10773        let mut state = State::default();
10774        let uri = "file:///t.zsh";
10775        state
10776            .docs
10777            .insert(uri.to_string(), "echo {1..10..2}\n".to_string());
10778        let r = semantic_tokens(&state, &json!({ "textDocument": { "uri": uri } }));
10779        let data = r["data"].as_array().expect("data array");
10780        let mut found = false;
10781        for chunk in data.chunks(5) {
10782            if chunk.len() == 5 && chunk[2].as_u64() == Some(10) && chunk[3].as_u64() == Some(4) {
10783                found = true;
10784                break;
10785            }
10786        }
10787        assert!(
10788            found,
10789            "brace range `{{1..10..2}}` must emit a single 10-byte operator token, got data={:?}",
10790            data
10791        );
10792    }
10793
10794    /// Two definitions of the same name: the documented one wins even
10795    /// when it appears AFTER the undocumented one in source order.
10796    /// Pinned because the fallback-collector pass must defer the
10797    /// undocumented hit until the whole file is scanned.
10798    #[test]
10799    fn find_user_symbol_doc_documented_wins_over_earlier_undocumented() {
10800        let _g = crate::test_util::global_state_lock();
10801        let src = "\
10802fn foo() { echo first }\n\
10803\n\
10804## second def with docs\n\
10805foo() { echo second }\n\
10806";
10807        let r = find_user_symbol_doc(src, "foo").expect("UDF must hover");
10808        assert!(
10809            r.contains("second def with docs"),
10810            "documented later-def must win, got {:?}",
10811            r
10812        );
10813    }
10814
10815    /// Every `examples/demos/*.zsh` file must pass `diagnose()` with
10816    /// zero diagnostics. This is the LSP-accepts-all-demos contract.
10817    /// Any new false positive surfaced by a demo addition fails this
10818    /// test, forcing a real LSP fix rather than silent IDE noise.
10819    #[test]
10820    fn diagnose_accepts_every_examples_demo_zsh_file() {
10821        let _g = crate::test_util::global_state_lock();
10822        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
10823        let demos_dir = manifest_dir.join("examples").join("demos");
10824        let mut failures: Vec<(String, Vec<Value>)> = Vec::new();
10825        let entries = std::fs::read_dir(&demos_dir).expect("examples/demos must exist");
10826        let mut total = 0usize;
10827        for entry in entries {
10828            let path = entry.unwrap().path();
10829            if path.extension().and_then(|s| s.to_str()) != Some("zsh") {
10830                continue;
10831            }
10832            total += 1;
10833            let text = std::fs::read_to_string(&path).unwrap();
10834            let d = diagnose(&text);
10835            if !d.is_empty() {
10836                failures.push((path.file_name().unwrap().to_string_lossy().into_owned(), d));
10837            }
10838        }
10839        assert!(total > 0, "no demos found under {}", demos_dir.display());
10840        assert!(
10841            failures.is_empty(),
10842            "{}/{} demos flagged by LSP diagnose():\n{:#?}",
10843            failures.len(),
10844            total,
10845            failures,
10846        );
10847    }
10848
10849    // Pins for the four false-positive classes that flagged 197
10850    // bogus diagnostics on a 575-line daemon helper before fixes:
10851    //   1. `#` inside `$#` / `${#var}` aborting the line scan.
10852    //   2. `[[ ... ]]` parsed as two single brackets.
10853    //   3. `(( ... ))` arithmetic parsed as two single parens.
10854    //   4. `)` as case-arm pattern terminator flagged as unmatched.
10855
10856    #[test]
10857    fn diagnose_does_not_flag_dollar_hash_as_comment() {
10858        let _g = crate::test_util::global_state_lock();
10859        // `$#` is the arg-count special variable, not a comment.
10860        // Pre-fix this terminated the line scan and left `[[`
10861        // unclosed → cascade of 100+ false positives downstream.
10862        let src = "[[ $# -gt 0 ]] && echo args\n";
10863        let d = diagnose(src);
10864        assert!(d.is_empty(), "`$#` mis-handled as comment marker: {:?}", d);
10865    }
10866
10867    #[test]
10868    fn diagnose_does_not_flag_param_length_as_comment() {
10869        let _g = crate::test_util::global_state_lock();
10870        // `${#var}` is parameter-length expansion.
10871        let src = "echo ${#args}\nif [[ ${#arr} -gt 0 ]]; then echo nonempty; fi\n";
10872        let d = diagnose(src);
10873        assert!(
10874            d.is_empty(),
10875            "`${{#var}}` mis-handled as comment marker: {:?}",
10876            d
10877        );
10878    }
10879
10880    #[test]
10881    fn diagnose_handles_double_bracket_as_pair() {
10882        let _g = crate::test_util::global_state_lock();
10883        // `[[ ... ]]` is a single zsh conditional expression — must
10884        // not be parsed as two `[`/`]` token pairs.
10885        let src = "[[ -n \"$x\" ]]\n";
10886        let d = diagnose(src);
10887        assert!(d.is_empty(), "`[[ ]]` mis-handled as two `[`s: {:?}", d);
10888    }
10889
10890    #[test]
10891    fn diagnose_handles_arithmetic_double_paren_as_pair() {
10892        let _g = crate::test_util::global_state_lock();
10893        // `(( ... ))` is a single arithmetic expression.
10894        let src = "(( i++ ))\n";
10895        let d = diagnose(src);
10896        assert!(d.is_empty(), "`(( ))` mis-handled as two `(`s: {:?}", d);
10897    }
10898
10899    #[test]
10900    fn diagnose_does_not_flag_case_arm_paren_as_unmatched() {
10901        let _g = crate::test_util::global_state_lock();
10902        // Bare `)` inside an open `case ... esac` block is a
10903        // pattern-arm terminator, not a paren mismatch.
10904        let src = "case \"$x\" in\n  -h|--help) echo usage ;;\n  *) echo other ;;\nesac\n";
10905        let d = diagnose(src);
10906        assert!(d.is_empty(), "case-arm `)` flagged as unmatched: {:?}", d);
10907    }
10908
10909    #[test]
10910    fn diagnose_still_flags_unmatched_paren_outside_case() {
10911        let _g = crate::test_util::global_state_lock();
10912        // Sanity: the case-arm exemption must NOT swallow a real
10913        // unmatched `)` outside of any case block.
10914        let src = "echo bare )\n";
10915        let d = diagnose(src);
10916        assert!(
10917            d.iter().any(|v| v["message"]
10918                .as_str()
10919                .unwrap_or("")
10920                .contains("unmatched `)`")),
10921            "real unmatched `)` was not flagged: {:?}",
10922            d
10923        );
10924    }
10925
10926    // ── formatting engine (extensions/fmt.rs) ──────────────────────────
10927    // simple_format (whitespace-only stub) was replaced by the full
10928    // syntax-aware reindenter; these pin the same two guarantees
10929    // through the new engine. Note the reindent: a top-level command
10930    // with stray leading spaces now normalizes to column 0 (the stub
10931    // preserved whatever indent it found).
10932
10933    #[test]
10934    fn format_strips_trailing_whitespace() {
10935        let _g = crate::test_util::global_state_lock();
10936        let src = "echo hi   \n  echo bye\t\n";
10937        let out = crate::fmt::format_source(src, &crate::fmt::FmtOptions::default());
10938        assert_eq!(out, "echo hi\necho bye\n");
10939    }
10940
10941    #[test]
10942    fn format_ensures_trailing_newline() {
10943        let _g = crate::test_util::global_state_lock();
10944        let src = "echo hi";
10945        let out = crate::fmt::format_source(src, &crate::fmt::FmtOptions::default());
10946        assert!(out.ends_with('\n'));
10947    }
10948
10949    // ── dump_reflection_json ────────────────────────────────────────────
10950
10951    #[test]
10952    fn dump_reflection_json_is_valid_and_has_builtins() {
10953        let _g = crate::test_util::global_state_lock();
10954        let s = dump_reflection_json();
10955        let v: Value = serde_json::from_str(&s).expect("valid JSON");
10956        assert!(v["builtins"].is_object());
10957        assert!(v["keywords"].is_object());
10958        assert!(v["options"].is_object());
10959        assert!(v["special_vars"].is_object());
10960        // Well-known names must be present. Option names follow the
10961        // canonical UPPERCASE_WITH_UNDERSCORES form per `man zshoptions`
10962        // (`EXTENDED_GLOB`, not `extendedglob`) since dump_reflection_json
10963        // now sources from `OPTION_DOCS` keys which carry the doc form.
10964        assert!(v["builtins"]["cd"].is_string());
10965        assert!(v["keywords"]["if"].is_string());
10966        assert!(v["options"]["EXTENDED_GLOB"].is_string());
10967        // PS2/PS3/PS4/psvar/PROMPT2 must surface (user-reported gap).
10968        // Keys carry the `$` sigil — the form the user types, and what
10969        // keeps the symbolic specials (`$?`, `$*`, `$-`) from colliding
10970        // with the identically-spelled operator / builtin entries in the
10971        // same blob. See the `special_vars` loop in dump_reflection_json.
10972        for want in ["$PS1", "$PS2", "$PS3", "$PS4", "$psvar", "$PROMPT2"] {
10973            assert!(
10974                v["special_vars"][want].is_string(),
10975                "special_vars missing `{}` — reflection should source from SPECIAL_VAR_DOCS",
10976                want,
10977            );
10978        }
10979        // Symbolic specials keep their own entries rather than being
10980        // overwritten by the `?` / `*` operator rows.
10981        for want in ["$?", "$*", "$#", "$@", "$-", "$_", "$!", "$$"] {
10982            assert!(
10983                v["special_vars"][want].is_string(),
10984                "special_vars missing symbolic `{}`",
10985                want,
10986            );
10987            assert_eq!(
10988                v["all"][want].as_str(),
10989                Some("special"),
10990                "`all` lost the special-var tag for `{}` to another registry",
10991                want,
10992            );
10993        }
10994        // Canonical sourcing produces the full inventory, not the
10995        // hand subset. Options should include CAPS form + NO_ aliases.
10996        assert!(
10997            v["options"].as_object().unwrap().len() >= 700,
10998            "dump_reflection options regressed to {}; expected full OPTION_DOCS + aliases (~750)",
10999            v["options"].as_object().unwrap().len(),
11000        );
11001        assert!(
11002            v["special_vars"].as_object().unwrap().len() >= 250,
11003            "dump_reflection special_vars regressed to {}; expected full SPECIAL_VAR_DOCS (~280)",
11004            v["special_vars"].as_object().unwrap().len(),
11005        );
11006    }
11007
11008    // ── completion ──────────────────────────────────────────────────────
11009
11010    #[test]
11011    fn completion_offers_builtins_for_short_prefix() {
11012        let _g = crate::test_util::global_state_lock();
11013        let mut state = State::default();
11014        state.docs.insert("file:///t.zsh".into(), "cd".into());
11015        let params = json!({
11016            "textDocument": { "uri": "file:///t.zsh" },
11017            "position": { "line": 0, "character": 2 },
11018        });
11019        let result = completion(&state, &params);
11020        let items = result["items"].as_array().unwrap();
11021        assert!(
11022            items.iter().any(|i| i["label"] == "cd"),
11023            "items: {:?}",
11024            items
11025        );
11026    }
11027
11028    #[test]
11029    fn completion_offers_daemon_z_star_builtins() {
11030        // Regression: user typed `zwh<TAB>` in IntelliJ + plugin and
11031        // got nothing. Root cause — the completion handler iterated
11032        // the hand `BUILTINS` const but not `ZSHRS_BUILTIN_NAMES`
11033        // (which holds `zwhere`, `zd`, `zcache`, etc.). Pin the
11034        // canonical-set sourcing so future builtins added there show
11035        // up in completion automatically.
11036        let _g = crate::test_util::global_state_lock();
11037        let mut state = State::default();
11038        state.docs.insert("file:///t.zsh".into(), "zwh".into());
11039        let params = json!({
11040            "textDocument": { "uri": "file:///t.zsh" },
11041            "position": { "line": 0, "character": 3 },
11042        });
11043        let result = completion(&state, &params);
11044        let items = result["items"].as_array().unwrap();
11045        assert!(
11046            items.iter().any(|i| i["label"] == "zwhere"),
11047            "no `zwhere` in completion items for `zwh` prefix: {:?}",
11048            items
11049                .iter()
11050                .map(|i| i["label"].as_str().unwrap_or("?"))
11051                .collect::<Vec<_>>(),
11052        );
11053    }
11054
11055    #[test]
11056    fn completion_offers_ext_builtins_and_compsys_fns() {
11057        // Same root cause as the `zwh` regression — verify the OTHER
11058        // tables we missed also surface. `date` is an extension
11059        // builtin (NOT in the hand subset), `_arguments` is a compsys
11060        // function, `vared` is a canonical compat builtin missing from
11061        // the hand `BUILTINS` subset.
11062        let _g = crate::test_util::global_state_lock();
11063        for (input, want) in &[("dat", "date"), ("_argu", "_arguments"), ("vare", "vared")] {
11064            let mut state = State::default();
11065            state.docs.insert("file:///t.zsh".into(), (*input).into());
11066            let params = json!({
11067                "textDocument": { "uri": "file:///t.zsh" },
11068                "position": { "line": 0, "character": input.len() },
11069            });
11070            let result = completion(&state, &params);
11071            let items = result["items"].as_array().unwrap();
11072            assert!(
11073                items.iter().any(|i| i["label"] == *want),
11074                "no `{}` in completion for `{}` prefix: {:?}",
11075                want,
11076                input,
11077                items
11078                    .iter()
11079                    .map(|i| i["label"].as_str().unwrap_or("?"))
11080                    .collect::<Vec<_>>(),
11081            );
11082        }
11083    }
11084
11085    #[test]
11086    fn completion_offers_snippet_templates() {
11087        // Mirror of strykelang's snippet behavior: typing `if` should
11088        // surface the `if …` snippet template (kind=15, insertTextFormat=2)
11089        // alongside the bare `if` keyword. Without snippet items, the
11090        // user has no fast path to scaffold the full `if cmd; then …; fi`
11091        // body — the whole point of porting stryke's pattern.
11092        let _g = crate::test_util::global_state_lock();
11093        let mut state = State::default();
11094        state.docs.insert("file:///t.zsh".into(), "if".into());
11095        let params = json!({
11096            "textDocument": { "uri": "file:///t.zsh" },
11097            "position": { "line": 0, "character": 2 },
11098        });
11099        let result = completion(&state, &params);
11100        let items = result["items"].as_array().unwrap();
11101        let snippet = items
11102            .iter()
11103            .find(|i| i["label"].as_str() == Some("if …"))
11104            .unwrap_or_else(|| panic!("no `if …` snippet in items: {:?}", items));
11105        assert_eq!(snippet["kind"], 15, "snippet kind must be 15 (Snippet)");
11106        assert_eq!(
11107            snippet["insertTextFormat"], 2,
11108            "snippet insertTextFormat must be 2 (Snippet — placeholders honored)"
11109        );
11110        let body = snippet["insertText"].as_str().unwrap();
11111        assert!(
11112            body.contains("then") && body.contains("fi"),
11113            "snippet body wrong: {}",
11114            body
11115        );
11116    }
11117
11118    #[test]
11119    fn completion_suppressed_inside_double_quoted_literal() {
11120        // User report: "inside dbl strings I shouldnt be getting
11121        // random completions unless inside $() or ``". A double-quoted
11122        // string body is prose / URLs / JSON — not shell code — so
11123        // surfacing `if`, `cd`, `setopt` etc. is noise. Pin the gate.
11124        let _g = crate::test_util::global_state_lock();
11125        let mut state = State::default();
11126        state
11127            .docs
11128            .insert("file:///t.zsh".into(), r#"echo "hello if"#.into());
11129        let params = json!({
11130            "textDocument": { "uri": "file:///t.zsh" },
11131            // Cursor sits right after `if` INSIDE the open `"...` literal.
11132            "position": { "line": 0, "character": 14 },
11133        });
11134        let result = completion(&state, &params);
11135        let items = result["items"].as_array().unwrap();
11136        assert!(
11137            items.is_empty(),
11138            "expected 0 items inside dq literal, got {}: {:?}",
11139            items.len(),
11140            items
11141                .iter()
11142                .take(5)
11143                .map(|i| i["label"].as_str().unwrap_or("?"))
11144                .collect::<Vec<_>>(),
11145        );
11146    }
11147
11148    #[test]
11149    fn completion_active_inside_command_substitution_inside_dq() {
11150        // Counterpart to the dq gate: cursor inside `$(...)` IS shell
11151        // code even when wrapped by `"..."`. The gate must NOT swallow
11152        // completion here.
11153        let _g = crate::test_util::global_state_lock();
11154        let mut state = State::default();
11155        state
11156            .docs
11157            .insert("file:///t.zsh".into(), r#"echo "x $(cd"#.into());
11158        let params = json!({
11159            "textDocument": { "uri": "file:///t.zsh" },
11160            // Cursor right after `cd` inside `$(`.
11161            "position": { "line": 0, "character": 12 },
11162        });
11163        let result = completion(&state, &params);
11164        let items = result["items"].as_array().unwrap();
11165        assert!(
11166            items.iter().any(|i| i["label"] == "cd"),
11167            "expected `cd` to surface inside $() within dq: {:?}",
11168            items
11169                .iter()
11170                .take(5)
11171                .map(|i| i["label"].as_str().unwrap_or("?"))
11172                .collect::<Vec<_>>(),
11173        );
11174    }
11175
11176    #[test]
11177    fn completion_active_inside_backticks_inside_dq() {
11178        let _g = crate::test_util::global_state_lock();
11179        let mut state = State::default();
11180        state
11181            .docs
11182            .insert("file:///t.zsh".into(), "echo \"x `cd".into());
11183        let params = json!({
11184            "textDocument": { "uri": "file:///t.zsh" },
11185            "position": { "line": 0, "character": 11 },
11186        });
11187        let result = completion(&state, &params);
11188        let items = result["items"].as_array().unwrap();
11189        assert!(
11190            items.iter().any(|i| i["label"] == "cd"),
11191            "expected `cd` to surface inside backticks within dq",
11192        );
11193    }
11194
11195    #[test]
11196    fn completion_active_inside_param_expansion_inside_dq() {
11197        // `${...}` is parameter expansion — variable / option name
11198        // completion is genuinely useful here, so the gate must allow it.
11199        let _g = crate::test_util::global_state_lock();
11200        let mut state = State::default();
11201        state
11202            .docs
11203            .insert("file:///t.zsh".into(), r#"echo "x ${P"#.into());
11204        let params = json!({
11205            "textDocument": { "uri": "file:///t.zsh" },
11206            "position": { "line": 0, "character": 11 },
11207        });
11208        let result = completion(&state, &params);
11209        let items = result["items"].as_array().unwrap();
11210        assert!(
11211            !items.is_empty(),
11212            "expected non-empty items inside ${{...}} within dq",
11213        );
11214    }
11215
11216    #[test]
11217    fn completion_suppressed_inside_single_quoted_literal() {
11218        // Single-quoted strings are opaque — no interpolation possible —
11219        // so completion is pure noise.
11220        let _g = crate::test_util::global_state_lock();
11221        let mut state = State::default();
11222        state
11223            .docs
11224            .insert("file:///t.zsh".into(), "echo 'hello if".into());
11225        let params = json!({
11226            "textDocument": { "uri": "file:///t.zsh" },
11227            "position": { "line": 0, "character": 14 },
11228        });
11229        let result = completion(&state, &params);
11230        let items = result["items"].as_array().unwrap();
11231        assert!(items.is_empty(), "expected 0 items inside sq literal");
11232    }
11233
11234    #[test]
11235    fn completion_suppressed_inside_comment() {
11236        // Comments are docs / TODOs / disabled code — not shell code.
11237        let _g = crate::test_util::global_state_lock();
11238        let mut state = State::default();
11239        state
11240            .docs
11241            .insert("file:///t.zsh".into(), "# todo: if".into());
11242        let params = json!({
11243            "textDocument": { "uri": "file:///t.zsh" },
11244            "position": { "line": 0, "character": 10 },
11245        });
11246        let result = completion(&state, &params);
11247        let items = result["items"].as_array().unwrap();
11248        assert!(items.is_empty(), "expected 0 items inside comment");
11249    }
11250
11251    #[test]
11252    fn completion_active_after_closing_double_quote() {
11253        // Sanity check: the gate must REOPEN once the cursor crosses
11254        // the closing quote. `echo "x" if|` is back at shell-code top
11255        // level.
11256        let _g = crate::test_util::global_state_lock();
11257        let mut state = State::default();
11258        state
11259            .docs
11260            .insert("file:///t.zsh".into(), r#"echo "x" if"#.into());
11261        let params = json!({
11262            "textDocument": { "uri": "file:///t.zsh" },
11263            "position": { "line": 0, "character": 11 },
11264        });
11265        let result = completion(&state, &params);
11266        let items = result["items"].as_array().unwrap();
11267        assert!(
11268            items.iter().any(|i| i["label"] == "if"),
11269            "expected `if` to surface after closed dq string"
11270        );
11271    }
11272
11273    // ── parameter expansion flag + glob qualifier completion ───────────
11274
11275    #[test]
11276    fn completion_param_flags_inside_dollar_brace_paren() {
11277        // User-driven: typing `${(<TAB>` should surface every flag
11278        // letter zsh's compsys `_parameter_flags` produces, with
11279        // descriptions. Pin a representative sample (`L` lower-case,
11280        // `U` upper-case, `@` array-keep, `#` count) — drift in any
11281        // entry fails the test so the table stays canonical.
11282        let _g = crate::test_util::global_state_lock();
11283        let mut state = State::default();
11284        state.docs.insert("file:///t.zsh".into(), "echo ${(".into());
11285        let params = json!({
11286            "textDocument": { "uri": "file:///t.zsh" },
11287            "position": { "line": 0, "character": 8 },
11288        });
11289        let result = completion(&state, &params);
11290        let items = result["items"].as_array().unwrap();
11291        for want in &["L", "U", "@", "#", "f", "F", "j", "s", "q"] {
11292            assert!(
11293                items.iter().any(|i| i["label"] == *want),
11294                "missing param flag `{}` in completion; got {:?}",
11295                want,
11296                items
11297                    .iter()
11298                    .map(|i| i["label"].as_str().unwrap_or("?"))
11299                    .collect::<Vec<_>>(),
11300            );
11301        }
11302        // Should NOT include shell builtins / keywords / options here.
11303        assert!(
11304            !items
11305                .iter()
11306                .any(|i| i["label"] == "cd" || i["label"] == "if"),
11307            "param-flag context leaked normal completion: {:?}",
11308            items
11309                .iter()
11310                .take(20)
11311                .map(|i| i["label"].as_str().unwrap_or("?"))
11312                .collect::<Vec<_>>(),
11313        );
11314    }
11315
11316    #[test]
11317    fn completion_param_flags_after_partial_flag() {
11318        // `${(b` — cursor after first flag letter. We still want the
11319        // full table surfaced (user may add more flags, eg `${(bC)`).
11320        let _g = crate::test_util::global_state_lock();
11321        let mut state = State::default();
11322        state
11323            .docs
11324            .insert("file:///t.zsh".into(), "echo ${(b".into());
11325        let params = json!({
11326            "textDocument": { "uri": "file:///t.zsh" },
11327            "position": { "line": 0, "character": 9 },
11328        });
11329        let result = completion(&state, &params);
11330        let items = result["items"].as_array().unwrap();
11331        assert!(
11332            items.len() >= 40,
11333            "expected full flag table (40+), got {}",
11334            items.len(),
11335        );
11336    }
11337
11338    #[test]
11339    fn completion_param_flags_inside_nested_dollar_brace() {
11340        // `${${(L)` — inner `${(` still triggers ParamFlag. The
11341        // backward walker must find the innermost unmatched `(` and
11342        // classify on `${` before it.
11343        let _g = crate::test_util::global_state_lock();
11344        let mut state = State::default();
11345        state
11346            .docs
11347            .insert("file:///t.zsh".into(), "echo ${${(".into());
11348        let params = json!({
11349            "textDocument": { "uri": "file:///t.zsh" },
11350            "position": { "line": 0, "character": 10 },
11351        });
11352        let result = completion(&state, &params);
11353        let items = result["items"].as_array().unwrap();
11354        assert!(items.iter().any(|i| i["label"] == "L"), "missing `L`");
11355    }
11356
11357    #[test]
11358    fn completion_no_param_flag_when_paren_already_closed() {
11359        // `${(b)var` — past the closing `)`, we're back in param-name
11360        // context, NOT flag context. Param-flag table must NOT fire.
11361        let _g = crate::test_util::global_state_lock();
11362        let mut state = State::default();
11363        state
11364            .docs
11365            .insert("file:///t.zsh".into(), "echo ${(b)var".into());
11366        let params = json!({
11367            "textDocument": { "uri": "file:///t.zsh" },
11368            "position": { "line": 0, "character": 13 },
11369        });
11370        let result = completion(&state, &params);
11371        let items = result["items"].as_array().unwrap();
11372        // Either normal completion fires (builtins / params) or it's
11373        // empty — but it must NOT be the param-flag table. Heuristic:
11374        // the flag table has only single-char labels; a real builtin
11375        // like `cd`/`vared` has multi-char. Assert at least one
11376        // multi-char label exists OR the result is empty.
11377        let single_char_only = !items.is_empty()
11378            && items
11379                .iter()
11380                .all(|i| i["label"].as_str().unwrap_or("").chars().count() == 1);
11381        assert!(
11382            !single_char_only,
11383            "param-flag table leaked past closing `)`"
11384        );
11385    }
11386
11387    #[test]
11388    fn completion_glob_qualifier_after_star_paren() {
11389        // `ls *(` — cursor right after `(` of glob qualifier. Should
11390        // surface `/`, `.`, `@`, `*`, `r`, `w`, `x` and friends.
11391        let _g = crate::test_util::global_state_lock();
11392        let mut state = State::default();
11393        state.docs.insert("file:///t.zsh".into(), "ls *(".into());
11394        let params = json!({
11395            "textDocument": { "uri": "file:///t.zsh" },
11396            "position": { "line": 0, "character": 5 },
11397        });
11398        let result = completion(&state, &params);
11399        let items = result["items"].as_array().unwrap();
11400        for want in &["/", ".", "@", "*", "r", "w", "x", "U", "G"] {
11401            assert!(
11402                items.iter().any(|i| i["label"] == *want),
11403                "missing glob qualifier `{}`; got {:?}",
11404                want,
11405                items
11406                    .iter()
11407                    .take(20)
11408                    .map(|i| i["label"].as_str().unwrap_or("?"))
11409                    .collect::<Vec<_>>(),
11410            );
11411        }
11412        assert!(
11413            !items
11414                .iter()
11415                .any(|i| i["label"] == "cd" || i["label"] == "if"),
11416            "glob-qualifier context leaked normal completion",
11417        );
11418    }
11419
11420    #[test]
11421    fn completion_glob_qualifier_after_question_mark() {
11422        // `?(` is also a glob meta open — should trigger qualifier
11423        // completion the same way `*(` does.
11424        let _g = crate::test_util::global_state_lock();
11425        let mut state = State::default();
11426        state.docs.insert("file:///t.zsh".into(), "ls ?(".into());
11427        let params = json!({
11428            "textDocument": { "uri": "file:///t.zsh" },
11429            "position": { "line": 0, "character": 5 },
11430        });
11431        let result = completion(&state, &params);
11432        let items = result["items"].as_array().unwrap();
11433        assert!(items.iter().any(|i| i["label"] == "."));
11434    }
11435
11436    #[test]
11437    fn completion_no_glob_qualifier_for_plain_subshell() {
11438        // `cmd (foo)` — bare `(` preceded by SPACE is a subshell /
11439        // function-list grouping, NOT a glob qualifier. Must NOT
11440        // surface qualifier table.
11441        let _g = crate::test_util::global_state_lock();
11442        let mut state = State::default();
11443        state.docs.insert("file:///t.zsh".into(), "echo (".into());
11444        let params = json!({
11445            "textDocument": { "uri": "file:///t.zsh" },
11446            "position": { "line": 0, "character": 6 },
11447        });
11448        let result = completion(&state, &params);
11449        let items = result["items"].as_array().unwrap();
11450        // Should be normal completion — expect `cd` to be present.
11451        let has_normal = items
11452            .iter()
11453            .any(|i| i["label"] == "cd" || i["label"] == "if");
11454        let single_char_only = !items.is_empty()
11455            && items
11456                .iter()
11457                .all(|i| i["label"].as_str().unwrap_or("").chars().count() == 1);
11458        assert!(
11459            has_normal || !single_char_only,
11460            "subshell `(` mis-triggered glob qualifier table"
11461        );
11462    }
11463
11464    #[test]
11465    fn completion_param_flag_table_has_50_entries() {
11466        // Pin: drift below 50 fails the gate so anyone trimming
11467        // entries notices. Screenshot from the user shows the full
11468        // compsys `_parameter_flags` list which is ~50 chars.
11469        assert!(
11470            PARAM_FLAG_DOCS.len() >= 49,
11471            "PARAM_FLAG_DOCS dropped below 49 entries: {}",
11472            PARAM_FLAG_DOCS.len()
11473        );
11474    }
11475
11476    // ── history designator + modifier completion ────────────────────
11477
11478    #[test]
11479    fn completion_history_designator_after_bang_at_word_start() {
11480        let _g = crate::test_util::global_state_lock();
11481        let mut state = State::default();
11482        state.docs.insert("file:///t.zsh".into(), "!".into());
11483        let params = json!({
11484            "textDocument": { "uri": "file:///t.zsh" },
11485            "position": { "line": 0, "character": 1 },
11486        });
11487        let result = completion(&state, &params);
11488        let items = result["items"].as_array().unwrap();
11489        for want in &["!", "$", "^", "*", "#"] {
11490            assert!(
11491                items.iter().any(|i| i["label"] == *want),
11492                "missing history designator `{}`; got {:?}",
11493                want,
11494                items
11495                    .iter()
11496                    .map(|i| i["label"].as_str().unwrap_or("?"))
11497                    .collect::<Vec<_>>(),
11498            );
11499        }
11500        // No builtins / keywords here.
11501        assert!(!items
11502            .iter()
11503            .any(|i| i["label"] == "cd" || i["label"] == "if"));
11504    }
11505
11506    #[test]
11507    fn completion_history_designator_after_bang_midline() {
11508        // `vim !` — `!` at word boundary after space, mid-line.
11509        let _g = crate::test_util::global_state_lock();
11510        let mut state = State::default();
11511        state.docs.insert("file:///t.zsh".into(), "vim !".into());
11512        let params = json!({
11513            "textDocument": { "uri": "file:///t.zsh" },
11514            "position": { "line": 0, "character": 5 },
11515        });
11516        let result = completion(&state, &params);
11517        let items = result["items"].as_array().unwrap();
11518        assert!(items.iter().any(|i| i["label"] == "$"));
11519    }
11520
11521    #[test]
11522    fn completion_no_history_designator_inside_arithmetic() {
11523        // `(( a != b ))` — `!` is logical NOT, not history. Must NOT
11524        // surface history table.
11525        let _g = crate::test_util::global_state_lock();
11526        let mut state = State::default();
11527        state.docs.insert("file:///t.zsh".into(), "(( a !".into());
11528        let params = json!({
11529            "textDocument": { "uri": "file:///t.zsh" },
11530            "position": { "line": 0, "character": 6 },
11531        });
11532        let result = completion(&state, &params);
11533        let items = result["items"].as_array().unwrap();
11534        // History table has labels like `$`, `^`, `?str?`. If the
11535        // arithmetic suppression worked, we should see normal items
11536        // OR no items, but NOT the history-specific markers.
11537        assert!(
11538            !items.iter().any(|i| i["label"] == "?str?"),
11539            "history table leaked into `((…))` arithmetic context",
11540        );
11541    }
11542
11543    #[test]
11544    fn completion_no_history_designator_after_alnum() {
11545        // `foo!` — `!` preceded by alnum char is NOT a history start.
11546        let _g = crate::test_util::global_state_lock();
11547        let mut state = State::default();
11548        state.docs.insert("file:///t.zsh".into(), "foo!".into());
11549        let params = json!({
11550            "textDocument": { "uri": "file:///t.zsh" },
11551            "position": { "line": 0, "character": 4 },
11552        });
11553        let result = completion(&state, &params);
11554        let items = result["items"].as_array().unwrap();
11555        assert!(
11556            !items.iter().any(|i| i["label"] == "?str?"),
11557            "history table fired after alnum-preceded `!`",
11558        );
11559    }
11560
11561    #[test]
11562    fn completion_param_modifier_after_colon_in_dollar_brace() {
11563        // `${var:` — cursor after `:`, want modifier completion.
11564        let _g = crate::test_util::global_state_lock();
11565        let mut state = State::default();
11566        state
11567            .docs
11568            .insert("file:///t.zsh".into(), "echo ${var:".into());
11569        let params = json!({
11570            "textDocument": { "uri": "file:///t.zsh" },
11571            "position": { "line": 0, "character": 11 },
11572        });
11573        let result = completion(&state, &params);
11574        let items = result["items"].as_array().unwrap();
11575        for want in &["h", "t", "r", "e", "-", "=", "+", "?", "s", "gs", "q", "Q"] {
11576            assert!(
11577                items.iter().any(|i| i["label"] == *want),
11578                "missing modifier `{}`; got {:?}",
11579                want,
11580                items
11581                    .iter()
11582                    .map(|i| i["label"].as_str().unwrap_or("?"))
11583                    .collect::<Vec<_>>(),
11584            );
11585        }
11586    }
11587
11588    #[test]
11589    fn completion_param_modifier_after_partial_modifier() {
11590        // `${var:h` — cursor after `h`, still want full modifier table
11591        // surfaced so the IDE can re-filter as the user keeps typing.
11592        let _g = crate::test_util::global_state_lock();
11593        let mut state = State::default();
11594        state
11595            .docs
11596            .insert("file:///t.zsh".into(), "echo ${var:h".into());
11597        let params = json!({
11598            "textDocument": { "uri": "file:///t.zsh" },
11599            "position": { "line": 0, "character": 12 },
11600        });
11601        let result = completion(&state, &params);
11602        let items = result["items"].as_array().unwrap();
11603        assert!(
11604            items.len() >= 25,
11605            "expected full modifier table; got {}",
11606            items.len()
11607        );
11608    }
11609
11610    #[test]
11611    fn completion_param_modifier_after_history_bang_colon() {
11612        // `!!:` — history reference with colon, want modifier table.
11613        let _g = crate::test_util::global_state_lock();
11614        let mut state = State::default();
11615        state.docs.insert("file:///t.zsh".into(), "vim !!:".into());
11616        let params = json!({
11617            "textDocument": { "uri": "file:///t.zsh" },
11618            "position": { "line": 0, "character": 7 },
11619        });
11620        let result = completion(&state, &params);
11621        let items = result["items"].as_array().unwrap();
11622        assert!(items.iter().any(|i| i["label"] == "h"));
11623        assert!(items.iter().any(|i| i["label"] == "t"));
11624    }
11625
11626    #[test]
11627    fn completion_no_param_modifier_outside_dollar_brace() {
11628        // Bare `foo:bar` — `:` outside any `${…}` AND no preceding
11629        // `!event`. Should NOT trigger modifier table.
11630        let _g = crate::test_util::global_state_lock();
11631        let mut state = State::default();
11632        state.docs.insert("file:///t.zsh".into(), "foo:".into());
11633        let params = json!({
11634            "textDocument": { "uri": "file:///t.zsh" },
11635            "position": { "line": 0, "character": 4 },
11636        });
11637        let result = completion(&state, &params);
11638        let items = result["items"].as_array().unwrap();
11639        // No history-only label like `?str?`; verify it's normal flow.
11640        let has_normal = items
11641            .iter()
11642            .any(|i| i["label"] == "cd" || i["label"] == "if");
11643        let modifier_only = !items.is_empty()
11644            && items.iter().all(|i| {
11645                let l = i["label"].as_str().unwrap_or("");
11646                l.chars().count() <= 2
11647            });
11648        assert!(
11649            has_normal || !modifier_only,
11650            "bare `:` mis-triggered modifier table",
11651        );
11652    }
11653
11654    #[test]
11655    fn completion_history_designator_table_has_9_entries() {
11656        assert!(
11657            HISTORY_DESIGNATOR_DOCS.len() >= 9,
11658            "HISTORY_DESIGNATOR_DOCS dropped below 9: {}",
11659            HISTORY_DESIGNATOR_DOCS.len()
11660        );
11661    }
11662
11663    // ── command-position + bracket-context completion ──────────────
11664
11665    fn complete_at(input: &str, col: usize) -> Vec<Value> {
11666        let _g = crate::test_util::global_state_lock();
11667        let mut state = State::default();
11668        state.docs.insert("file:///t.zsh".into(), input.into());
11669        let params = json!({
11670            "textDocument": { "uri": "file:///t.zsh" },
11671            "position": { "line": 0, "character": col },
11672        });
11673        completion(&state, &params)["items"]
11674            .as_array()
11675            .cloned()
11676            .unwrap_or_default()
11677    }
11678
11679    #[test]
11680    fn logical_line_joins_backslash_continuations() {
11681        // The shape every real completer is written in.
11682        let text = "_arguments -s \\\n  '-v[loud]' \\\n  '*:file:_files'\n";
11683        // Cursor on the LAST fragment, which on its own has no command.
11684        // Cursor on the `*` of the third fragment (its column 3).
11685        let (joined, col, first) = super::logical_line_at(text, 2, 3);
11686        assert_eq!(joined, "_arguments -s   '-v[loud]'   '*:file:_files'");
11687        assert_eq!(first, 0);
11688        // The offset must land on the same character it did in the
11689        // fragment, now measured from the start of the joined command.
11690        assert_eq!(&joined[col..col + 1], "*");
11691    }
11692
11693    #[test]
11694    fn logical_line_leaves_an_uncontinued_line_alone() {
11695        let text = "echo one\necho two\n";
11696        let (joined, col, first) = super::logical_line_at(text, 1, 4);
11697        assert_eq!(joined, "echo two");
11698        assert_eq!(col, 4);
11699        assert_eq!(first, 1);
11700    }
11701
11702    #[test]
11703    fn logical_line_does_not_join_through_a_comment_or_an_escaped_backslash() {
11704        // A trailing backslash in a comment is not a continuation …
11705        let text = "# note \\\necho two\n";
11706        let (joined, _, _) = super::logical_line_at(text, 1, 0);
11707        assert_eq!(joined, "echo two");
11708        // … and `\\` is a literal backslash, not a line continuation.
11709        let text2 = "print a\\\\\nprint b\n";
11710        let (joined2, _, _) = super::logical_line_at(text2, 1, 0);
11711        assert_eq!(joined2, "print b");
11712    }
11713
11714    #[test]
11715    fn builtin_flag_context_survives_a_continuation() {
11716        // `print \` + newline + `  -` is one command; the flag popup
11717        // has to know it belongs to `print`, not to a bare `-`.
11718        let _g = crate::test_util::global_state_lock();
11719        let mut state = State::default();
11720        let text = "print \\\n  -\n";
11721        state.docs.insert("file:///t.zsh".into(), text.into());
11722        let params = json!({
11723            "textDocument": { "uri": "file:///t.zsh" },
11724            "position": { "line": 1, "character": 3 },
11725        });
11726        let items = completion(&state, &params)["items"]
11727            .as_array()
11728            .cloned()
11729            .unwrap_or_default();
11730        let labels: Vec<&str> = items.iter().filter_map(|i| i["label"].as_str()).collect();
11731        assert!(
11732            labels.contains(&"-l"),
11733            "no print flags across continuation: {labels:?}"
11734        );
11735        // The edit range must address the PHYSICAL line the `-` is on.
11736        let range = &items[0]["textEdit"]["range"];
11737        assert_eq!(
11738            range["start"]["line"],
11739            json!(1),
11740            "edit range on the wrong line"
11741        );
11742    }
11743
11744    #[test]
11745    fn arg_spec_context_survives_a_continuation() {
11746        // `_arguments` is two lines up; without joining, the spec
11747        // context saw a fragment with no command and gave up.
11748        let text = "_arguments -s \\\n  '-v[loud]' \\\n  '*:file:_fi'\n";
11749        let (joined, col, _) = super::logical_line_at(text, 2, 13);
11750        match super::arg_spec_part_at(&joined, col) {
11751            Some(super::ArgSpecPart::Action { word_start }) => {
11752                assert_eq!(&joined[word_start..col], "_fi");
11753            }
11754            other => panic!("expected Action across the continuation, got {other:?}"),
11755        }
11756    }
11757
11758    #[test]
11759    fn arg_spec_part_detects_head_message_and_action() {
11760        // `'-o[desc]:message:action'` — the three fields, plus the
11761        // bracketed description that must NOT be read as a field.
11762        let line = "  _arguments '-o[out]:outfile:_files'";
11763        let head = line.find("[out").unwrap() + 2;
11764        assert_eq!(
11765            super::arg_spec_part_at(line, head),
11766            Some(super::ArgSpecPart::Head)
11767        );
11768        let msg = line.find("outfile").unwrap() + 3;
11769        assert_eq!(
11770            super::arg_spec_part_at(line, msg),
11771            Some(super::ArgSpecPart::Message)
11772        );
11773        let act = line.find("_files").unwrap() + 3;
11774        match super::arg_spec_part_at(line, act) {
11775            Some(super::ArgSpecPart::Action { word_start }) => {
11776                assert_eq!(&line[word_start..act], "_fi", "action word start is wrong");
11777            }
11778            other => panic!("expected Action, got {other:?}"),
11779        }
11780    }
11781
11782    #[test]
11783    fn arg_spec_action_argument_is_opaque() {
11784        // On an ARGUMENT of the action's command (`_files -g <here>`)
11785        // there is nothing generic to offer — a glob pattern is the
11786        // user's, not ours.
11787        let line = "  _arguments '*:f:_files -g pat'";
11788        let col = line.find("pat").unwrap() + 3;
11789        assert_eq!(
11790            super::arg_spec_part_at(line, col),
11791            Some(super::ArgSpecPart::ActionOpaque),
11792        );
11793    }
11794
11795    #[test]
11796    fn arg_spec_literal_list_is_opaque_not_unknown() {
11797        // A spec holding `(` used to lose its command entirely, because
11798        // `leading_command_at` treats `(` as a statement separator: the
11799        // cursor inside `'1:cmd:(build test)'` reported the command as
11800        // `build`. It must be recognised as spec-internal instead.
11801        let line = "  _arguments '1:cmd:(build test)'";
11802        let col = line.find("build").unwrap() + 5;
11803        assert_eq!(
11804            super::arg_spec_part_at(line, col),
11805            Some(super::ArgSpecPart::ActionOpaque),
11806        );
11807    }
11808
11809    #[test]
11810    fn arg_spec_part_is_none_outside_a_spec() {
11811        // Not an `_arguments`-family command …
11812        let line = "  echo 'a:b:c'";
11813        let col = line.find("b:c").unwrap();
11814        assert_eq!(super::arg_spec_part_at(line, col), None);
11815        // … and not inside a quoted word at all.
11816        let line2 = "  _arguments -s ";
11817        assert_eq!(super::arg_spec_part_at(line2, line2.len()), None);
11818        // … and after the spec's closing quote.
11819        let line3 = "  _arguments '*:f:_files' ";
11820        assert_eq!(super::arg_spec_part_at(line3, line3.len()), None);
11821    }
11822
11823    #[test]
11824    fn completion_inside_arg_spec_action_offers_completers_and_action_syntax() {
11825        // The gap this closes: the generic string gate used to return
11826        // nothing for every position inside a spec, so `'*:file:_fi'`
11827        // offered no `_files`.
11828        let line = "_arguments '*:file:_fi'";
11829        let items = complete_at(line, line.find("_fi").unwrap() + 3);
11830        let labels: Vec<&str> = items.iter().filter_map(|i| i["label"].as_str()).collect();
11831        assert!(labels.contains(&"_files"), "no _files in {labels:?}");
11832        assert!(labels.contains(&"->state"), "no ->state action form");
11833        assert!(labels.contains(&"(list)"), "no literal-list action form");
11834        // Nothing from the generic tables — a spec action is not a
11835        // place for `if` / `while` / `setopt`.
11836        assert!(
11837            !labels.contains(&"while"),
11838            "keyword leaked into spec action"
11839        );
11840    }
11841
11842    #[test]
11843    fn completion_inside_a_literal_value_list_stays_quiet() {
11844        // `'1:cmd:(build test)'` — the cursor is inside the user's own
11845        // literal list, so completer names are wrong there.
11846        let line = "_arguments '1:cmd:(build test)'";
11847        let items = complete_at(line, line.find("build").unwrap() + 5);
11848        assert!(items.is_empty(), "literal list offered items: {items:?}");
11849    }
11850
11851    #[test]
11852    fn hover_reaches_a_completer_named_inside_a_spec() {
11853        // The action field of a spec is a quoted string, so the hover
11854        // gate calls it String and used to suppress — but `_files`
11855        // there is a real function whose docs are exactly what an
11856        // author writing the spec wants.
11857        let _g = crate::test_util::global_state_lock();
11858        let mut state = State::default();
11859        let text = "_arguments '*:file:_files'\n";
11860        state.docs.insert("file:///t.zsh".into(), text.into());
11861        let col = text.find("_files").unwrap() + 2;
11862        let params = json!({
11863            "textDocument": { "uri": "file:///t.zsh" },
11864            "position": { "line": 0, "character": col },
11865        });
11866        let card = super::hover(&state, &params);
11867        let body = card["contents"]["value"].as_str().unwrap_or_default();
11868        assert!(body.starts_with("**_files**"), "got: {body}");
11869        assert!(
11870            body.contains("_compsys function_"),
11871            "compsys functions must not be labelled builtins: {body}",
11872        );
11873    }
11874
11875    #[test]
11876    fn hover_stays_suppressed_for_ordinary_string_contents() {
11877        // The spec exception must not reopen hover for every quoted
11878        // word — `'print'` inside a plain string is still prose.
11879        let _g = crate::test_util::global_state_lock();
11880        let mut state = State::default();
11881        let text = "echo 'print this'\n";
11882        state.docs.insert("file:///t.zsh".into(), text.into());
11883        let col = text.find("print").unwrap() + 2;
11884        let params = json!({
11885            "textDocument": { "uri": "file:///t.zsh" },
11886            "position": { "line": 0, "character": col },
11887        });
11888        assert_eq!(super::hover(&state, &params), Value::Null);
11889    }
11890
11891    #[test]
11892    fn completion_inside_arg_spec_description_stays_quiet() {
11893        // `[…]` is prose shown by `_message`; the old behaviour (silence)
11894        // is correct here and must survive the new context.
11895        let line = "_arguments '-v[be lo]'";
11896        let items = complete_at(line, line.find("lo]").unwrap() + 2);
11897        assert!(items.is_empty(), "description offered items: {items:?}");
11898    }
11899
11900    #[test]
11901    fn completion_setopt_surfaces_options_only() {
11902        let items = complete_at("setopt extend", 13);
11903        // Should include real options, NOT builtins / keywords.
11904        assert!(
11905            items.iter().any(|i| i["label"] == "extended_glob"
11906                || i["label"] == "extendedglob"
11907                || i["label"] == "EXTENDED_GLOB"),
11908            "no extended_glob variant"
11909        );
11910        assert!(!items
11911            .iter()
11912            .any(|i| i["label"] == "cd" || i["label"] == "if"));
11913    }
11914
11915    #[test]
11916    fn completion_unsetopt_surfaces_options_only() {
11917        let items = complete_at("unsetopt nul", 12);
11918        assert!(items.iter().any(|i| i["label"]
11919            .as_str()
11920            .unwrap_or("")
11921            .to_lowercase()
11922            .contains("null")));
11923        assert!(!items.iter().any(|i| i["label"] == "if"));
11924    }
11925
11926    #[test]
11927    fn completion_set_dash_o_surfaces_options() {
11928        let items = complete_at("set -o errex", 12);
11929        assert!(items.iter().any(|i| i["label"]
11930            .as_str()
11931            .unwrap_or("")
11932            .to_lowercase()
11933            .contains("err")));
11934    }
11935
11936    #[test]
11937    fn completion_kill_dash_surfaces_signals() {
11938        let items = complete_at("kill -", 6);
11939        for want in &["HUP", "INT", "TERM", "KILL", "USR1"] {
11940            assert!(
11941                items.iter().any(|i| i["label"] == *want),
11942                "missing signal `{}`",
11943                want
11944            );
11945        }
11946    }
11947
11948    #[test]
11949    fn completion_trap_surfaces_signals() {
11950        let items = complete_at("trap 'cmd' ", 11);
11951        for want in &["INT", "TERM", "EXIT", "ZERR", "DEBUG"] {
11952            assert!(
11953                items.iter().any(|i| i["label"] == *want),
11954                "missing signal `{}`",
11955                want
11956            );
11957        }
11958    }
11959
11960    #[test]
11961    fn completion_zmodload_surfaces_modules() {
11962        let items = complete_at("zmodload ", 9);
11963        for want in &[
11964            "zsh/zle",
11965            "zsh/datetime",
11966            "zsh/system",
11967            "zsh/parameter",
11968            "zsh/mathfunc",
11969        ] {
11970            assert!(
11971                items.iter().any(|i| i["label"] == *want),
11972                "missing module `{}`",
11973                want
11974            );
11975        }
11976    }
11977
11978    #[test]
11979    fn completion_bindkey_dash_M_surfaces_keymaps() {
11980        let items = complete_at("bindkey -M ", 11);
11981        for want in &["emacs", "vicmd", "viins", "viopp"] {
11982            assert!(
11983                items.iter().any(|i| i["label"] == *want),
11984                "missing keymap `{}`",
11985                want
11986            );
11987        }
11988    }
11989
11990    #[test]
11991    fn completion_bindkey_bare_surfaces_widgets() {
11992        // `bindkey "^A" <TAB>` — second arg is a widget. With no `-A/M/N`
11993        // flag, we default to WidgetName.
11994        let items = complete_at("bindkey '^A' acce", 17);
11995        assert!(items.iter().any(|i| i["label"] == "accept-line"));
11996        assert!(items.iter().any(|i| i["label"] == "accept-and-hold"));
11997    }
11998
11999    #[test]
12000    fn completion_zle_surfaces_widgets() {
12001        let items = complete_at("zle for", 7);
12002        assert!(items.iter().any(|i| i["label"] == "forward-char"));
12003        assert!(items.iter().any(|i| i["label"] == "forward-word"));
12004    }
12005
12006    #[test]
12007    fn completion_typeset_dash_surfaces_flags() {
12008        let items = complete_at("typeset -", 9);
12009        for want in &["-a", "-A", "-i", "-g", "-r", "-x", "-U", "-f"] {
12010            assert!(
12011                items.iter().any(|i| i["label"] == *want),
12012                "missing flag `{}`",
12013                want
12014            );
12015        }
12016    }
12017
12018    #[test]
12019    fn completion_typeset_no_flag_falls_through() {
12020        // `typeset FOO=bar` — second arg has no leading `-`, normal flow.
12021        let items = complete_at("typeset FOO", 11);
12022        // Should NOT be the flags table — `-a` etc. shouldn't appear.
12023        assert!(!items.iter().any(|i| i["label"] == "-a"));
12024    }
12025
12026    #[test]
12027    fn completion_zstyle_surfaces_contexts() {
12028        let items = complete_at("zstyle ", 7);
12029        assert!(items.iter().any(|i| i["label"] == ":completion:*"));
12030        assert!(items.iter().any(|i| i["label"] == ":vcs_info:*"));
12031    }
12032
12033    #[test]
12034    fn completion_compdef_surfaces_compsys_fns() {
12035        let items = complete_at("compdef ", 8);
12036        // Should be `_*` style ported only.
12037        assert!(items
12038            .iter()
12039            .any(|i| i["label"].as_str().unwrap_or("").starts_with('_')));
12040    }
12041
12042    #[test]
12043    fn completion_inside_double_bracket_surfaces_test_ops() {
12044        let items = complete_at("[[ -f /tmp/x && -", 17);
12045        for want in &["-f", "-d", "-e", "-z", "-n", "=~"] {
12046            assert!(
12047                items.iter().any(|i| i["label"] == *want),
12048                "missing test op `{}`",
12049                want
12050            );
12051        }
12052    }
12053
12054    #[test]
12055    fn completion_inside_double_paren_surfaces_math_fns() {
12056        let items = complete_at("(( x = sq", 9);
12057        for want in &["sqrt", "sin", "cos", "log", "exp"] {
12058            assert!(
12059                items.iter().any(|i| i["label"] == *want),
12060                "missing math fn `{}`",
12061                want
12062            );
12063        }
12064    }
12065
12066    #[test]
12067    fn completion_inside_dollar_double_paren_surfaces_math_fns() {
12068        let items = complete_at("echo $(( ab", 11);
12069        assert!(items.iter().any(|i| i["label"] == "abs"));
12070    }
12071
12072    #[test]
12073    fn completion_inside_pattern_modifier_paren() {
12074        let items = complete_at("ls *.(#", 7);
12075        for want in &["i", "l", "b", "m", "a", "s", "e"] {
12076            assert!(
12077                items.iter().any(|i| i["label"] == *want),
12078                "missing pattern mod `{}` — got {:?}",
12079                want,
12080                items
12081                    .iter()
12082                    .take(20)
12083                    .map(|i| i["label"].as_str().unwrap_or("?"))
12084                    .collect::<Vec<_>>(),
12085            );
12086        }
12087    }
12088
12089    #[test]
12090    fn completion_inside_subscript_flag_paren() {
12091        let items = complete_at("echo ${arr[(", 12);
12092        for want in &["i", "I", "r", "R", "e", "n"] {
12093            assert!(
12094                items.iter().any(|i| i["label"] == *want),
12095                "missing subscript flag `{}`",
12096                want
12097            );
12098        }
12099    }
12100
12101    // ── table size floors ────────────────────────────────────────────
12102
12103    #[test]
12104    fn completion_signal_table_has_30_entries() {
12105        assert!(
12106            SIGNAL_NAMES.len() >= 30,
12107            "SIGNAL_NAMES: {}",
12108            SIGNAL_NAMES.len()
12109        );
12110    }
12111
12112    #[test]
12113    fn completion_module_table_has_30_entries() {
12114        assert!(
12115            ZSH_MODULE_NAMES.len() >= 30,
12116            "ZSH_MODULE_NAMES: {}",
12117            ZSH_MODULE_NAMES.len()
12118        );
12119    }
12120
12121    #[test]
12122    fn completion_keymap_table_has_10_entries() {
12123        assert!(
12124            KEYMAP_NAMES.len() >= 10,
12125            "KEYMAP_NAMES: {}",
12126            KEYMAP_NAMES.len()
12127        );
12128    }
12129
12130    #[test]
12131    fn completion_widget_table_has_100_entries() {
12132        assert!(
12133            ZLE_WIDGET_NAMES.len() >= 100,
12134            "ZLE_WIDGET_NAMES: {}",
12135            ZLE_WIDGET_NAMES.len()
12136        );
12137    }
12138
12139    #[test]
12140    fn completion_typeset_flag_table_has_20_entries() {
12141        assert!(
12142            TYPESET_FLAGS.len() >= 20,
12143            "TYPESET_FLAGS: {}",
12144            TYPESET_FLAGS.len()
12145        );
12146    }
12147
12148    #[test]
12149    fn completion_test_op_table_has_30_entries() {
12150        assert!(
12151            TEST_OPERATORS.len() >= 30,
12152            "TEST_OPERATORS: {}",
12153            TEST_OPERATORS.len()
12154        );
12155    }
12156
12157    #[test]
12158    fn completion_math_fn_table_has_40_entries() {
12159        assert!(
12160            MATH_FUNCTIONS.len() >= 40,
12161            "MATH_FUNCTIONS: {}",
12162            MATH_FUNCTIONS.len()
12163        );
12164    }
12165
12166    #[test]
12167    fn completion_zstyle_context_table_has_15_entries() {
12168        assert!(
12169            ZSTYLE_CONTEXTS.len() >= 15,
12170            "ZSTYLE_CONTEXTS: {}",
12171            ZSTYLE_CONTEXTS.len()
12172        );
12173    }
12174
12175    #[test]
12176    fn completion_pattern_modifier_table_has_10_entries() {
12177        assert!(
12178            PATTERN_MODIFIERS.len() >= 10,
12179            "PATTERN_MODIFIERS: {}",
12180            PATTERN_MODIFIERS.len()
12181        );
12182    }
12183
12184    #[test]
12185    fn completion_subscript_flag_table_has_10_entries() {
12186        assert!(
12187            SUBSCRIPT_FLAGS.len() >= 10,
12188            "SUBSCRIPT_FLAGS: {}",
12189            SUBSCRIPT_FLAGS.len()
12190        );
12191    }
12192
12193    #[test]
12194    fn completion_keywords_includes_canonical_reswds() {
12195        // Hand `KEYWORDS` was missing canonical RESWDS like `end`,
12196        // `{`, `}`, `!`, `[[`. Verify they reach completion via the
12197        // RESWDS iteration. Use prefixes that match each name but
12198        // DON'T trigger a context (e.g. `e` for `end`, no special
12199        // context). For `[[` / `}` / `!` / `{` themselves, those
12200        // trigger context-specific completion (TestOperator,
12201        // SubscriptFlag, HistoryDesignator, PatternModifier
12202        // respectively) so we test reachability via the call into
12203        // `lsp_completion_context` returning Normal at non-trigger
12204        // positions.
12205        let _g = crate::test_util::global_state_lock();
12206        // `e<TAB>` — should include `end` (canonical reswd missing
12207        // from hand KEYWORDS) and `echo`/`exec`/`esac`/etc.
12208        let mut state = State::default();
12209        state.docs.insert("file:///t.zsh".into(), "e".into());
12210        let params = json!({
12211            "textDocument": { "uri": "file:///t.zsh" },
12212            "position": { "line": 0, "character": 1 },
12213        });
12214        let items = completion(&state, &params)["items"]
12215            .as_array()
12216            .unwrap()
12217            .clone();
12218        let labels: Vec<&str> = items
12219            .iter()
12220            .map(|i| i["label"].as_str().unwrap_or(""))
12221            .collect();
12222        assert!(
12223            labels.iter().any(|l| *l == "end"),
12224            "RESWDS `end` not surfaced — labels: {:?}",
12225            labels
12226                .iter()
12227                .filter(|l| l.starts_with('e'))
12228                .take(10)
12229                .collect::<Vec<_>>(),
12230        );
12231    }
12232
12233    #[test]
12234    fn completion_builtin_flag_print_dash_tab() {
12235        // User report: `print -<TAB>` showed nothing — zsh's native
12236        // compsys produces a rich -a/-b/-c/-D/-f/-l/-n/-N/-o/-O/-P
12237        // list with descriptions. Pin that the doc-body-derived
12238        // BuiltinFlag context fires + extracts ≥15 flag bullets.
12239        let _g = crate::test_util::global_state_lock();
12240        let mut state = State::default();
12241        state.docs.insert("file:///t.zsh".into(), "print -".into());
12242        let params = json!({
12243            "textDocument": { "uri": "file:///t.zsh" },
12244            "position": { "line": 0, "character": 7 },
12245        });
12246        let items = completion(&state, &params)["items"]
12247            .as_array()
12248            .unwrap()
12249            .clone();
12250        let labels: Vec<&str> = items
12251            .iter()
12252            .map(|i| i["label"].as_str().unwrap_or(""))
12253            .collect();
12254        for want in &[
12255            "-a", "-b", "-c", "-n", "-N", "-o", "-O", "-P", "-r", "-R", "-z",
12256        ] {
12257            assert!(
12258                labels.iter().any(|l| l == want),
12259                "missing `print -` flag `{}` — got {:?}",
12260                want,
12261                labels.iter().take(20).collect::<Vec<_>>(),
12262            );
12263        }
12264        assert!(
12265            items.len() >= 15,
12266            "expected ≥15 print flags, got {}",
12267            items.len(),
12268        );
12269    }
12270
12271    #[test]
12272    fn completion_dollar_prefix_matches_canonical_specials() {
12273        // User report: `$HIST<TAB>` only surfaced `$HISTFILE`/`$HISTSIZE`
12274        // (the hand SPECIAL_VARS subset which stores names with `$`
12275        // prefix). Canonical SPECIAL_VAR_DOCS stores names WITHOUT
12276        // `$`, so the prefix-match `"$HISTCMD".starts_with("$HIST")`
12277        // failed because the candidate was just `"HISTCMD"`. Fix
12278        // adds a $-prefix path that matches the bare prefix against
12279        // the bare name + emits the candidate with `$` prepended.
12280        let _g = crate::test_util::global_state_lock();
12281        let mut state = State::default();
12282        state.docs.insert("file:///t.zsh".into(), "$HIST".into());
12283        let params = json!({
12284            "textDocument": { "uri": "file:///t.zsh" },
12285            "position": { "line": 0, "character": 5 },
12286        });
12287        let result = completion(&state, &params);
12288        let items = result["items"].as_array().unwrap();
12289        let labels: Vec<&str> = items
12290            .iter()
12291            .map(|i| i["label"].as_str().unwrap_or(""))
12292            .collect();
12293        for want in &["$HISTCMD", "$HISTNO", "$HISTCHARS"] {
12294            assert!(
12295                labels.iter().any(|l| l == want),
12296                "missing `{}` for `$HIST` prefix",
12297                want,
12298            );
12299        }
12300    }
12301
12302    #[test]
12303    fn completion_special_vars_includes_full_canonical_set() {
12304        // User report: `$PS2`, `$PS3`, `$PS4`, `$psvar` were missing
12305        // from completion even though they're in the doc table. Root
12306        // cause: hand `SPECIAL_VARS` const was a stale 40-entry
12307        // subset of zsh's actual ~538 specials. Fix surfaces every
12308        // canonical name from `SPECIAL_VAR_DOCS` + `SPECIAL_VAR_ALIASES`.
12309        let _g = crate::test_util::global_state_lock();
12310        for prefix in &["PS", "PROMPT", "psvar"] {
12311            let mut state = State::default();
12312            state.docs.insert("file:///t.zsh".into(), (*prefix).into());
12313            let params = json!({
12314                "textDocument": { "uri": "file:///t.zsh" },
12315                "position": { "line": 0, "character": prefix.len() },
12316            });
12317            let result = completion(&state, &params);
12318            let items = result["items"].as_array().unwrap();
12319            let labels: Vec<&str> = items
12320                .iter()
12321                .map(|i| i["label"].as_str().unwrap_or(""))
12322                .collect();
12323            match *prefix {
12324                "PS" => {
12325                    for want in &["PS1", "PS2", "PS3", "PS4"] {
12326                        assert!(
12327                            labels.iter().any(|l| l == want),
12328                            "missing `{}` for prefix `{}`",
12329                            want,
12330                            prefix,
12331                        );
12332                    }
12333                }
12334                "PROMPT" => {
12335                    for want in &["PROMPT", "PROMPT2", "PROMPT3", "PROMPT4"] {
12336                        assert!(
12337                            labels.iter().any(|l| l == want),
12338                            "missing `{}` for prefix `{}`",
12339                            want,
12340                            prefix,
12341                        );
12342                    }
12343                }
12344                "psvar" => {
12345                    assert!(labels.iter().any(|l| *l == "psvar"), "missing `psvar`",);
12346                }
12347                _ => {}
12348            }
12349        }
12350    }
12351
12352    #[test]
12353    fn completion_param_modifier_table_has_30_entries() {
12354        assert!(
12355            PARAM_MODIFIER_DOCS.len() >= 30,
12356            "PARAM_MODIFIER_DOCS dropped below 30: {}",
12357            PARAM_MODIFIER_DOCS.len()
12358        );
12359    }
12360
12361    #[test]
12362    fn completion_glob_qualifier_table_has_30_entries() {
12363        // Pin: zsh's qualifier table per `man zshexpn` covers
12364        // file-type / perm / time / size / sort / control categories;
12365        // dropping below 30 means we've lost a whole category.
12366        assert!(
12367            GLOB_QUALIFIER_DOCS.len() >= 30,
12368            "GLOB_QUALIFIER_DOCS dropped below 30 entries: {}",
12369            GLOB_QUALIFIER_DOCS.len()
12370        );
12371    }
12372
12373    #[test]
12374    fn completion_snippet_table_has_60_plus_entries() {
12375        // Pin: stryke's plugin README advertises "60+ snippet templates."
12376        // Mirror the bar here — the table is the public surface for
12377        // shell-snippet completion. Drift below 60 fails the gate so
12378        // anyone removing entries notices.
12379        assert!(
12380            SNIPPETS.len() >= 60,
12381            "snippet table dropped below 60 entries: {}",
12382            SNIPPETS.len()
12383        );
12384    }
12385
12386    // ── folding_ranges ──────────────────────────────────────────────────
12387
12388    #[test]
12389    fn folding_ranges_finds_brace_and_do_blocks() {
12390        let _g = crate::test_util::global_state_lock();
12391        let mut state = State::default();
12392        state.docs.insert(
12393            "file:///t.zsh".into(),
12394            "function f {\n  echo\n}\nfor x in 1 2 3; do\n  print $x\ndone\n".into(),
12395        );
12396        let params = json!({ "textDocument": { "uri": "file:///t.zsh" } });
12397        let result = folding_ranges(&state, &params);
12398        let arr = result.as_array().unwrap();
12399        // One brace-block fold (lines 0..2) and one for/do fold
12400        assert!(
12401            arr.iter().any(|r| r["startLine"] == 0 && r["endLine"] == 2),
12402            "missing brace fold: {:?}",
12403            arr
12404        );
12405        assert!(
12406            arr.iter().any(|r| r["startLine"] == 3 && r["endLine"] == 5),
12407            "missing for/do fold: {:?}",
12408            arr
12409        );
12410    }
12411
12412    // ── definition / references ─────────────────────────────────────────
12413
12414    #[test]
12415    fn references_returns_call_sites() {
12416        let _g = crate::test_util::global_state_lock();
12417        let mut state = State::default();
12418        state.docs.insert(
12419            "file:///t.zsh".into(),
12420            "function greet { echo hi }\ngreet\ngreet world\n".into(),
12421        );
12422        let params = json!({
12423            "textDocument": { "uri": "file:///t.zsh" },
12424            "position": { "line": 0, "character": 9 }, // on "greet"
12425            "context": { "includeDeclaration": true },
12426        });
12427        let refs = references(&state, &params);
12428        let arr = refs.as_array().unwrap();
12429        // 1 decl + 2 call sites = 3
12430        assert_eq!(arr.len(), 3, "expected 3 refs, got: {:?}", arr);
12431    }
12432
12433    #[test]
12434    fn references_follows_source_chain_outside_workspace() {
12435        // Regression: usages in `source ~/...` files weren't picked up.
12436        // Active file declares `greet`, sourced file calls it. The
12437        // chain should be followed even when the sourced file lives
12438        // OUTSIDE the workspace root.
12439        let _g = crate::test_util::global_state_lock();
12440        let mut state = State::default();
12441        // Build a temp dir + sourced file + an active doc that points
12442        // to the sourced file with an absolute path.
12443        let tmp = std::env::temp_dir().join(format!(
12444            "zshrs-ref-source-chain-{}",
12445            std::time::SystemTime::now()
12446                .duration_since(std::time::UNIX_EPOCH)
12447                .map(|d| d.as_nanos())
12448                .unwrap_or(0),
12449        ));
12450        std::fs::create_dir_all(&tmp).unwrap();
12451        let sourced = tmp.join("helpers.zsh");
12452        std::fs::write(&sourced, "greet world\ngreet again\n").unwrap();
12453        let active_text = format!(
12454            "function greet {{ echo hi }}\nsource {}\n",
12455            sourced.display()
12456        );
12457        state.docs.insert("file:///t.zsh".into(), active_text);
12458        let params = json!({
12459            "textDocument": { "uri": "file:///t.zsh" },
12460            "position": { "line": 0, "character": 9 }, // on "greet" decl
12461            "context": { "includeDeclaration": true },
12462        });
12463        let refs = references(&state, &params);
12464        let arr = refs.as_array().unwrap();
12465        // 1 decl in active + 2 calls in sourced = 3
12466        assert!(
12467            arr.len() >= 3,
12468            "source-chain following missed refs, got {}: {:?}",
12469            arr.len(),
12470            arr,
12471        );
12472        // At least one ref must point at the sourced file URI.
12473        let sourced_uri = format!("file://{}", sourced.canonicalize().unwrap().display());
12474        assert!(
12475            arr.iter()
12476                .any(|r| r["uri"].as_str() == Some(sourced_uri.as_str())),
12477            "no ref pointing at sourced file `{}`: {:?}",
12478            sourced_uri,
12479            arr,
12480        );
12481        let _ = std::fs::remove_dir_all(&tmp);
12482    }
12483
12484    // ── Comment / shebang hover gate ────────────────────────────────────────
12485
12486    #[test]
12487    fn line_starts_comment_before_shebang() {
12488        // `#!/usr/bin/env zsh` — `#` at column 0 is a shebang. Anything
12489        // to its right is comment text and must not hover as code.
12490        let line = "#!/usr/bin/env zsh";
12491        let pos = line.find("env").unwrap();
12492        assert!(line_starts_comment_before(line, pos));
12493    }
12494
12495    #[test]
12496    fn line_starts_comment_before_inline() {
12497        // `echo hi; # call cd later` — `cd` is in the comment.
12498        let line = "echo hi; # call cd later";
12499        let pos = line.find("cd").unwrap();
12500        assert!(line_starts_comment_before(line, pos));
12501    }
12502
12503    #[test]
12504    fn line_starts_comment_before_string_with_hash_is_not_a_comment() {
12505        // `echo "x #y"; cd` — the `#` lives inside a double-quoted
12506        // string; `cd` after the string is real code.
12507        let line = r#"echo "x #y"; cd"#;
12508        let pos = line.rfind("cd").unwrap();
12509        assert!(
12510            !line_starts_comment_before(line, pos),
12511            "code after a string containing `#` must still be code"
12512        );
12513    }
12514
12515    #[test]
12516    fn line_starts_comment_before_single_quote_with_hash() {
12517        // `echo 'x #y'; cd` — single quotes also literalize `#`.
12518        let line = "echo 'x #y'; cd";
12519        let pos = line.rfind("cd").unwrap();
12520        assert!(!line_starts_comment_before(line, pos));
12521    }
12522
12523    #[test]
12524    fn line_starts_comment_before_backtick_with_hash() {
12525        // `` `echo #foo`; cd `` — backtick command-substitution treats
12526        // `#` as comment INSIDE the backticks per zsh semantics, but our
12527        // gate is "is the cursor sitting inside a top-level # comment",
12528        // and the `cd` AFTER the closing backtick is real code at the
12529        // top level, so the gate must return false.
12530        let line = "`echo #foo`; cd";
12531        let pos = line.rfind("cd").unwrap();
12532        assert!(!line_starts_comment_before(line, pos));
12533    }
12534
12535    #[test]
12536    fn line_starts_comment_negative_at_start() {
12537        let line = "cd /tmp";
12538        assert!(!line_starts_comment_before(line, 0));
12539    }
12540
12541    // ── Hover gate end-to-end ───────────────────────────────────────────────
12542
12543    #[test]
12544    fn hover_on_shebang_env_is_suppressed() {
12545        let _g = crate::test_util::global_state_lock();
12546        let mut state = State::default();
12547        state.docs.insert(
12548            "file:///t.zsh".into(),
12549            "#!/usr/bin/env zsh\necho hi\n".into(),
12550        );
12551        // Cursor on `env` at line 0 — even if a future BUILTINS table
12552        // ever lists `env`, the hover must NOT fire on the shebang.
12553        let params = json!({
12554            "textDocument": { "uri": "file:///t.zsh" },
12555            "position": { "line": 0, "character": 12 },
12556        });
12557        let h = hover(&state, &params);
12558        assert!(h.is_null(), "hover on shebang `env` must be null, got: {h}");
12559    }
12560
12561    #[test]
12562    fn hover_on_builtin_inside_comment_is_suppressed() {
12563        let _g = crate::test_util::global_state_lock();
12564        let mut state = State::default();
12565        state
12566            .docs
12567            .insert("file:///t.zsh".into(), "echo hi  # call cd later\n".into());
12568        // `cd` is a real zsh builtin with a doc card, but inside a `#`
12569        // comment it must not hover.
12570        let cd_pos = "echo hi  # call ".len();
12571        let params = json!({
12572            "textDocument": { "uri": "file:///t.zsh" },
12573            "position": { "line": 0, "character": cd_pos },
12574        });
12575        let h = hover(&state, &params);
12576        assert!(h.is_null(), "comment-text hover must be null, got: {h}");
12577    }
12578
12579    #[test]
12580    fn hover_on_shebang_with_module_doc_returns_module_card() {
12581        // Shebang hover normally returns null, but when a `##` block
12582        // follows the shebang the hover surfaces it as the module
12583        // doc card. Lets users discover what a sourced library does
12584        // by hovering its shebang.
12585        let _g = crate::test_util::global_state_lock();
12586        let mut state = State::default();
12587        state.docs.insert(
12588            "file:///lib.zsh".into(),
12589            "#!/usr/bin/env zsh\n\
12590             ## libfoo.zsh — utility helpers.\n\
12591             ## Provides foo / bar / baz.\n\
12592             function foo() {}\n"
12593                .into(),
12594        );
12595        // Cursor on `env` (any position on line 0 triggers the
12596        // shebang-line module-doc lookup).
12597        let params = json!({
12598            "textDocument": { "uri": "file:///lib.zsh" },
12599            "position": { "line": 0, "character": 12 },
12600        });
12601        let h = hover(&state, &params);
12602        assert!(!h.is_null(), "shebang+##block hover should return card");
12603        let body = h["contents"]["value"].as_str().unwrap_or("");
12604        assert!(body.contains("libfoo.zsh"), "got {body:?}");
12605        assert!(body.contains("zsh module"), "got {body:?}");
12606    }
12607
12608    #[test]
12609    fn hover_on_source_path_uri_cached_returns_target_module_doc() {
12610        // `source ./other.zsh` — when other.zsh is loaded in the
12611        // doc cache, hover on the path argument should surface its
12612        // top-of-file `##` block.
12613        let _g = crate::test_util::global_state_lock();
12614        let mut state = State::default();
12615        state.docs.insert(
12616            "file:///proj/main.zsh".into(),
12617            "source ./helpers.zsh\nhelpers_init\n".into(),
12618        );
12619        state.docs.insert(
12620            "file:///proj/helpers.zsh".into(),
12621            "## helpers.zsh — shared bootstrap.\n\
12622             function helpers_init() {}\n"
12623                .into(),
12624        );
12625        // Cursor on the path argument `./helpers.zsh`.
12626        let pos = "source ".len() + 2; // somewhere inside `./helpers.zsh`
12627        let params = json!({
12628            "textDocument": { "uri": "file:///proj/main.zsh" },
12629            "position": { "line": 0, "character": pos },
12630        });
12631        let h = hover(&state, &params);
12632        assert!(
12633            !h.is_null(),
12634            "source-path hover should return target's module doc"
12635        );
12636        let body = h["contents"]["value"].as_str().unwrap_or("");
12637        assert!(body.contains("helpers.zsh"), "got {body:?}");
12638        assert!(body.contains("shared bootstrap"), "got {body:?}");
12639    }
12640
12641    #[test]
12642    fn hover_on_dot_source_path_resolves_same_way() {
12643        // POSIX dot-source form: `. PATH` is equivalent to `source PATH`.
12644        let _g = crate::test_util::global_state_lock();
12645        let mut state = State::default();
12646        state
12647            .docs
12648            .insert("file:///proj/main.zsh".into(), ". ./helpers.zsh\n".into());
12649        state.docs.insert(
12650            "file:///proj/helpers.zsh".into(),
12651            "## dot-sourced helpers.\nfunction h() {}\n".into(),
12652        );
12653        let pos = ". ".len() + 2;
12654        let params = json!({
12655            "textDocument": { "uri": "file:///proj/main.zsh" },
12656            "position": { "line": 0, "character": pos },
12657        });
12658        let h = hover(&state, &params);
12659        assert!(!h.is_null(), ". PATH hover should resolve too");
12660        let body = h["contents"]["value"].as_str().unwrap_or("");
12661        assert!(body.contains("dot-sourced helpers"), "got {body:?}");
12662    }
12663
12664    #[test]
12665    fn hover_on_source_path_without_target_doc_returns_null() {
12666        // `source PATH` but target file isn't loaded and doesn't
12667        // exist on disk → null. (No fabrication.)
12668        let _g = crate::test_util::global_state_lock();
12669        let mut state = State::default();
12670        state.docs.insert(
12671            "file:///t.zsh".into(),
12672            "source /nonexistent/path/that/does/not/exist.zsh\n".into(),
12673        );
12674        let pos = "source ".len() + 5;
12675        let params = json!({
12676            "textDocument": { "uri": "file:///t.zsh" },
12677            "position": { "line": 0, "character": pos },
12678        });
12679        let h = hover(&state, &params);
12680        assert!(h.is_null(), "missing source target must not fabricate doc");
12681    }
12682
12683    #[test]
12684    fn hover_on_user_function_with_doc_returns_user_card() {
12685        // Integration check for the prior find_user_symbol_doc path —
12686        // confirm it actually surfaces through `hover()`, not just
12687        // the unit test. Use a non-builtin name (`my_sum_helper`)
12688        // so the builtin lookup misses and our fallback fires.
12689        let _g = crate::test_util::global_state_lock();
12690        let mut state = State::default();
12691        state.docs.insert(
12692            "file:///t.zsh".into(),
12693            "## Sum two numbers and print the result.\n\
12694             function my_sum_helper() { print $(( $1 + $2 )) }\n\
12695             my_sum_helper 1 2\n"
12696                .into(),
12697        );
12698        // Cursor on `my_sum_helper` at the call site (line 2).
12699        let params = json!({
12700            "textDocument": { "uri": "file:///t.zsh" },
12701            "position": { "line": 2, "character": 0 },
12702        });
12703        let h = hover(&state, &params);
12704        assert!(!h.is_null(), "user-fn hover should fire");
12705        let body = h["contents"]["value"].as_str().unwrap_or("");
12706        assert!(body.contains("Sum two numbers"), "got {body:?}");
12707        assert!(body.contains("user-defined function"), "got {body:?}");
12708    }
12709
12710    #[test]
12711    fn hover_on_real_builtin_outside_comment_still_works() {
12712        let _g = crate::test_util::global_state_lock();
12713        let mut state = State::default();
12714        state
12715            .docs
12716            .insert("file:///t.zsh".into(), "cd /tmp\n".into());
12717        let params = json!({
12718            "textDocument": { "uri": "file:///t.zsh" },
12719            "position": { "line": 0, "character": 0 },
12720        });
12721        let h = hover(&state, &params);
12722        assert!(!h.is_null(), "real builtin must still hover");
12723    }
12724
12725    // ── String-literal hover gate ───────────────────────────────────────
12726
12727    /// Cursor on `cd` inside `"cd to dir"` — `position_inside_string_literal`
12728    /// must return true so the gate suppresses the doc card.
12729    #[test]
12730    fn position_inside_double_quoted_string_detected() {
12731        // 0         1
12732        // 01234567890123456
12733        // echo "cd to dir"
12734        let line = "echo \"cd to dir\"";
12735        let cd_start = line.find("cd").unwrap();
12736        let cd_end = cd_start + 2;
12737        assert!(position_inside_string_literal(line, cd_start, cd_end));
12738    }
12739
12740    /// Same word but inside `'...'` — single quotes still suppress (no
12741    /// `${...}` expansion in zsh single-quoted strings, so the gate is
12742    /// even simpler).
12743    #[test]
12744    fn position_inside_single_quoted_string_detected() {
12745        let line = "echo 'cd to dir'";
12746        let cd_start = line.find("cd").unwrap();
12747        let cd_end = cd_start + 2;
12748        assert!(position_inside_string_literal(line, cd_start, cd_end));
12749    }
12750
12751    /// Inside backticks (`` `cmd subst` ``) — also treated as a string
12752    /// boundary for hover purposes. The interior is technically code,
12753    /// but we keep the conservative behavior matching stryke until a
12754    /// real need surfaces.
12755    #[test]
12756    fn position_inside_backtick_string_detected() {
12757        let line = "echo `cd to dir`";
12758        let cd_start = line.find("cd").unwrap();
12759        let cd_end = cd_start + 2;
12760        assert!(position_inside_string_literal(line, cd_start, cd_end));
12761    }
12762
12763    /// `"${HOME}"` — cursor on `HOME` is INSIDE the string syntactically
12764    /// but inside a `${...}` parameter expansion, which is code. The
12765    /// gate must allow hover.
12766    #[test]
12767    fn position_inside_parameter_expansion_is_code() {
12768        // echo "${HOME}/x"
12769        let line = "echo \"${HOME}/x\"";
12770        let home_start = line.find("HOME").unwrap();
12771        let home_end = home_start + 4;
12772        assert!(
12773            !position_inside_string_literal(line, home_start, home_end),
12774            "`${{HOME}}` inside double-quotes is code, not string text"
12775        );
12776    }
12777
12778    /// Outside any string — bare code, no suppression.
12779    #[test]
12780    fn position_outside_string_is_code() {
12781        let line = "cd /tmp";
12782        assert!(!position_inside_string_literal(line, 0, 2));
12783    }
12784
12785    /// Closing quote before cursor — outside the string again.
12786    #[test]
12787    fn position_after_closing_quote_is_code() {
12788        // echo "foo" cd
12789        let line = "echo \"foo\" cd";
12790        let cd_start = line.find(" cd").unwrap() + 1;
12791        let cd_end = cd_start + 2;
12792        assert!(!position_inside_string_literal(line, cd_start, cd_end));
12793    }
12794
12795    /// Full `classify_hover_position` integration: comment beats string.
12796    #[test]
12797    fn classify_comment_outranks_string() {
12798        // `# echo "cd"` — `cd` is inside a quote, but the whole line
12799        // is comment-text. The Comment gate fires first.
12800        let line = "# echo \"cd\"";
12801        let cd_start = line.find("cd").unwrap();
12802        let cd_end = cd_start + 2;
12803        assert_eq!(
12804            classify_hover_position(line, cd_start, cd_end),
12805            HoverGate::Comment
12806        );
12807    }
12808
12809    /// Plain string-literal classification.
12810    #[test]
12811    fn classify_string_literal() {
12812        let line = "echo \"cd to dir\"";
12813        let cd_start = line.find("cd").unwrap();
12814        let cd_end = cd_start + 2;
12815        assert_eq!(
12816            classify_hover_position(line, cd_start, cd_end),
12817            HoverGate::StringLiteral
12818        );
12819    }
12820
12821    /// Plain code-position classification.
12822    #[test]
12823    fn classify_bare_code() {
12824        let line = "cd /tmp";
12825        assert_eq!(classify_hover_position(line, 0, 2), HoverGate::Code);
12826    }
12827
12828    // ── Rename: `::` qualifier strip ────────────────────────────────────
12829
12830    /// Regression: client prefilled `Demo::handle`; user edited suffix
12831    /// to `handle2`; dialog returned `"Demo::handle2"`. The rename
12832    /// handler must strip the qualifier and emit BARE `handle2` at
12833    /// every call site — never `Demo::Demo::handle2`.
12834    #[test]
12835    fn rename_strips_colon_colon_qualifier() {
12836        let _g = crate::test_util::global_state_lock();
12837        let mut state = State::default();
12838        state.docs.insert(
12839            "file:///t.zsh".into(),
12840            "function handle { echo hi }\nhandle\nhandle x\n".into(),
12841        );
12842        let params = json!({
12843            "textDocument": { "uri": "file:///t.zsh" },
12844            "position": { "line": 0, "character": 9 }, // on `handle`
12845            "newName": "Demo::handle2",
12846        });
12847        let r = rename(&state, &params);
12848        let changes = r["changes"].as_object().expect("changes");
12849        let edits = changes["file:///t.zsh"].as_array().expect("edits");
12850        assert!(
12851            !edits.is_empty(),
12852            "expected at least 1 edit, got: {edits:?}"
12853        );
12854        for e in edits {
12855            assert_eq!(
12856                e["newText"],
12857                json!("handle2"),
12858                "qualifier must be stripped; got: {e:?}"
12859            );
12860        }
12861    }
12862
12863    /// Bare new_name without `::` — pass through unchanged (no-op for
12864    /// callers who already send the right form).
12865    #[test]
12866    fn rename_passes_through_bare_new_name() {
12867        let _g = crate::test_util::global_state_lock();
12868        let mut state = State::default();
12869        state.docs.insert(
12870            "file:///t.zsh".into(),
12871            "function handle { echo hi }\nhandle\n".into(),
12872        );
12873        let params = json!({
12874            "textDocument": { "uri": "file:///t.zsh" },
12875            "position": { "line": 0, "character": 9 },
12876            "newName": "handle2",
12877        });
12878        let r = rename(&state, &params);
12879        let edits = r["changes"]["file:///t.zsh"].as_array().expect("edits");
12880        for e in edits {
12881            assert_eq!(e["newText"], json!("handle2"));
12882        }
12883    }
12884
12885    // ── Cross-file rename via references ────────────────────────────────────
12886
12887    #[test]
12888    fn rename_function_crosses_files() {
12889        // `function greet { … }` declared in lib.zsh; called from rc.zsh.
12890        // Renaming at the decl must produce edits in BOTH files.
12891        let _g = crate::test_util::global_state_lock();
12892        let mut state = State::default();
12893        state.docs.insert(
12894            "file:///lib.zsh".into(),
12895            "function greet { echo hi }\n".into(),
12896        );
12897        state.docs.insert(
12898            "file:///rc.zsh".into(),
12899            "source lib.zsh\ngreet\ngreet world\n".into(),
12900        );
12901        let params = json!({
12902            "textDocument": { "uri": "file:///lib.zsh" },
12903            "position": { "line": 0, "character": 9 }, // on "greet"
12904            "context": { "includeDeclaration": true },
12905            "newName": "salute",
12906        });
12907        let r = rename(&state, &params);
12908        let changes = r["changes"].as_object().expect("rename has changes map");
12909        assert!(
12910            changes.contains_key("file:///lib.zsh"),
12911            "lib.zsh edited: {changes:?}"
12912        );
12913        assert!(
12914            changes.contains_key("file:///rc.zsh"),
12915            "rc.zsh edited: {changes:?}"
12916        );
12917        // 1 decl in lib + 2 call sites in rc = 3 total edits.
12918        let lib_edits = changes["file:///lib.zsh"].as_array().unwrap();
12919        let rc_edits = changes["file:///rc.zsh"].as_array().unwrap();
12920        assert_eq!(lib_edits.len(), 1);
12921        assert_eq!(rc_edits.len(), 2);
12922        for e in lib_edits.iter().chain(rc_edits.iter()) {
12923            assert_eq!(e["newText"], "salute");
12924        }
12925    }
12926
12927    #[test]
12928    fn rename_rejects_empty_new_name() {
12929        let _g = crate::test_util::global_state_lock();
12930        let mut state = State::default();
12931        state.docs.insert(
12932            "file:///t.zsh".into(),
12933            "function greet { echo hi }\n".into(),
12934        );
12935        let params = json!({
12936            "textDocument": { "uri": "file:///t.zsh" },
12937            "position": { "line": 0, "character": 9 },
12938            "context": { "includeDeclaration": true },
12939            "newName": "",
12940        });
12941        let r = rename(&state, &params);
12942        assert!(r.is_null(), "empty new_name must be rejected");
12943    }
12944
12945    #[test]
12946    fn workspace_walk_picks_up_unopened_zsh_files() {
12947        // Stand up a temporary project root with two files; only one is
12948        // ever `didOpen`'d, but renaming a function declared in the
12949        // OTHER file must edit both.
12950        let _g = crate::test_util::global_state_lock();
12951        let tmp = std::env::temp_dir().join(format!(
12952            "zshrs-workspace-test-{}-{}",
12953            std::process::id(),
12954            std::time::SystemTime::now()
12955                .duration_since(std::time::UNIX_EPOCH)
12956                .map(|d| d.as_nanos())
12957                .unwrap_or(0)
12958        ));
12959        std::fs::create_dir_all(&tmp).unwrap();
12960        let lib_path = tmp.join("lib.zsh");
12961        let rc_path = tmp.join("rc.zsh");
12962        std::fs::write(&lib_path, "function greet { echo hi }\n").unwrap();
12963        // rc.zsh MUST source lib.zsh — otherwise the cross-file lookup
12964        // would be FAKE (matching `greet` across unrelated files just
12965        // because the name matches, with no actual scope linkage).
12966        // Cross-file scope in zsh is opt-in via `source X`.
12967        let rc_text = format!("source {}\ngreet\ngreet world\n", lib_path.display());
12968        std::fs::write(&rc_path, &rc_text).unwrap();
12969        let rc_uri = format!("file://{}", rc_path.display());
12970
12971        let mut state = State::default();
12972        // Only `rc.zsh` is in the editor — `lib.zsh` is on disk.
12973        state.docs.insert(rc_uri.clone(), rc_text.clone());
12974        // Simulate the `initialize` workspace handoff.
12975        let init = json!({ "rootUri": format!("file://{}", tmp.display()) });
12976        ingest_workspace_init(&mut state, &init);
12977        // The walk must have read lib.zsh into workspace_files.
12978        let lib_uri = format!("file://{}", lib_path.display());
12979        assert!(
12980            state.workspace_files.contains_key(&lib_uri),
12981            "workspace walk picked up lib.zsh: keys={:?}",
12982            state.workspace_files.keys().collect::<Vec<_>>(),
12983        );
12984        // On macOS `/var` is a symlink to `/private/var`. The
12985        // source-chain BFS canonicalizes `source X` targets (so
12986        // symlinks don't double-emit), which means the URI in the
12987        // rename result is the CANONICAL path. Use that for the
12988        // contains_key assertion below.
12989        let canon_lib_path = std::fs::canonicalize(&lib_path).unwrap_or(lib_path.clone());
12990        let canon_lib_uri = format!("file://{}", canon_lib_path.display());
12991        // Rename `greet` from the rc.zsh call site — must touch both.
12992        // Cursor on line 1 col 0 since rc.zsh now starts with the
12993        // `source lib.zsh` directive on line 0; the first `greet` call
12994        // site is on line 1.
12995        let params = json!({
12996            "textDocument": { "uri": rc_uri },
12997            "position": { "line": 1, "character": 0 },
12998            "context": { "includeDeclaration": true },
12999            "newName": "salute",
13000        });
13001        let r = rename(&state, &params);
13002        let changes = r["changes"].as_object().expect("changes map");
13003        assert!(
13004            changes.contains_key(&canon_lib_uri),
13005            "lib.zsh (workspace) edited: keys={:?}",
13006            changes.keys().collect::<Vec<_>>(),
13007        );
13008        assert!(
13009            changes.contains_key(&rc_uri),
13010            "rc.zsh (open) edited: keys={:?}",
13011            changes.keys().collect::<Vec<_>>(),
13012        );
13013        // 1 decl in lib + 2 call sites in rc.
13014        assert_eq!(changes[&canon_lib_uri].as_array().unwrap().len(), 1);
13015        assert_eq!(changes[&rc_uri].as_array().unwrap().len(), 2);
13016
13017        // Cleanup.
13018        let _ = std::fs::remove_dir_all(&tmp);
13019    }
13020
13021    #[test]
13022    fn workspace_walk_skips_node_modules_and_git() {
13023        let _g = crate::test_util::global_state_lock();
13024        let tmp = std::env::temp_dir().join(format!(
13025            "zshrs-skip-test-{}-{}",
13026            std::process::id(),
13027            std::time::SystemTime::now()
13028                .duration_since(std::time::UNIX_EPOCH)
13029                .map(|d| d.as_nanos())
13030                .unwrap_or(0)
13031        ));
13032        std::fs::create_dir_all(tmp.join(".git")).unwrap();
13033        std::fs::create_dir_all(tmp.join("node_modules")).unwrap();
13034        std::fs::write(tmp.join(".git").join("hooks.zsh"), "should_skip=1\n").unwrap();
13035        std::fs::write(tmp.join("node_modules").join("util.zsh"), "should_skip=1\n").unwrap();
13036        std::fs::write(tmp.join("real.zsh"), "should_pick_up=1\n").unwrap();
13037
13038        let mut state = State::default();
13039        let init = json!({ "rootUri": format!("file://{}", tmp.display()) });
13040        ingest_workspace_init(&mut state, &init);
13041        assert_eq!(
13042            state.workspace_files.len(),
13043            1,
13044            "only real.zsh picked up: keys={:?}",
13045            state.workspace_files.keys().collect::<Vec<_>>(),
13046        );
13047        let _ = std::fs::remove_dir_all(&tmp);
13048    }
13049
13050    #[test]
13051    fn is_zsh_source_filename_accepts_dotfiles_and_extensions() {
13052        assert!(is_zsh_source_filename("foo.zsh"));
13053        assert!(is_zsh_source_filename("foo.sh"));
13054        assert!(is_zsh_source_filename(".zshrc"));
13055        assert!(is_zsh_source_filename(".zshenv"));
13056        assert!(is_zsh_source_filename(".zsh_aliases"));
13057        assert!(!is_zsh_source_filename("foo.py"));
13058        assert!(!is_zsh_source_filename(".gitignore"));
13059        assert!(!is_zsh_source_filename("README.md"));
13060    }
13061
13062    // ── code_actions: Extract Variable / Constant / Function ───────────
13063
13064    fn run_code_actions(text: &str, sl: u32, sc: u32, el: u32, ec: u32) -> Vec<Value> {
13065        let _g = crate::test_util::global_state_lock();
13066        let mut state = State::default();
13067        state.docs.insert("file:///t.zsh".into(), text.to_string());
13068        let params = json!({
13069            "textDocument": { "uri": "file:///t.zsh" },
13070            "range": {
13071                "start": { "line": sl, "character": sc },
13072                "end":   { "line": el, "character": ec },
13073            },
13074        });
13075        match code_actions(&state, &params) {
13076            Value::Array(v) => v,
13077            _ => Vec::new(),
13078        }
13079    }
13080
13081    #[test]
13082    fn code_actions_single_line_offers_var_const_and_function() {
13083        let acts = run_code_actions("    echo hello\n", 0, 4, 0, 14);
13084        let titles: Vec<&str> = acts
13085            .iter()
13086            .map(|a| a["title"].as_str().unwrap_or(""))
13087            .collect();
13088        // Whole-line selection: all three should fire.
13089        assert!(
13090            titles.iter().any(|t| t.contains("variable")),
13091            "missing Extract Variable: {:?}",
13092            titles,
13093        );
13094        assert!(
13095            titles.iter().any(|t| t.contains("constant")),
13096            "missing Extract Constant: {:?}",
13097            titles,
13098        );
13099        assert!(
13100            titles.iter().any(|t| t.contains("function")),
13101            "missing Extract Function: {:?}",
13102            titles,
13103        );
13104    }
13105
13106    #[test]
13107    fn code_actions_subexpression_skips_function_extract() {
13108        // Selection covers only "hello" inside `echo hello world` — a
13109        // sub-expression, not a whole statement. Function extract on a
13110        // partial expression would call a function whose result is then
13111        // interpolated weirdly; the user wants Extract Variable for
13112        // that case (already covered).
13113        let acts = run_code_actions("echo hello world\n", 0, 5, 0, 10);
13114        let titles: Vec<&str> = acts
13115            .iter()
13116            .map(|a| a["title"].as_str().unwrap_or(""))
13117            .collect();
13118        assert!(titles.iter().any(|t| t.contains("variable")));
13119        assert!(
13120            !titles.iter().any(|t| t.contains("function")),
13121            "function extract leaked on sub-expression: {:?}",
13122            titles,
13123        );
13124    }
13125
13126    #[test]
13127    fn code_actions_multiline_only_offers_function_extract() {
13128        // Spans three lines — variable / constant extract require a
13129        // single-line expression target and must NOT appear.
13130        let text = "if true; then\n    echo a\n    echo b\nfi\n";
13131        let acts = run_code_actions(text, 1, 0, 3, 0);
13132        let titles: Vec<&str> = acts
13133            .iter()
13134            .map(|a| a["title"].as_str().unwrap_or(""))
13135            .collect();
13136        assert_eq!(acts.len(), 1, "expected exactly one action: {:?}", titles);
13137        assert!(titles[0].contains("function"));
13138        // Verify the edit shape: insert a `extracted_function() { … }`
13139        // declaration above and replace the lines with a bare call.
13140        let changes = &acts[0]["edit"]["changes"]["file:///t.zsh"];
13141        let edits = changes.as_array().expect("edits array");
13142        assert_eq!(edits.len(), 2);
13143        let decl = edits[0]["newText"].as_str().unwrap_or("");
13144        assert!(
13145            decl.contains("extracted_function() {")
13146                && decl.contains("echo a")
13147                && decl.contains("echo b"),
13148            "decl missing body lines: {:?}",
13149            decl,
13150        );
13151        let call = edits[1]["newText"].as_str().unwrap_or("");
13152        assert!(
13153            call.trim() == "extracted_function",
13154            "call must be bare: {:?}",
13155            call
13156        );
13157    }
13158
13159    #[test]
13160    fn code_actions_multiline_preserves_relative_indent() {
13161        // Inner if-block: the extracted body should keep the inner
13162        // indent so re-indenting against the function-body indent
13163        // (`+4 spaces`) doesn't flatten the structure.
13164        let text = "if outer; then\n    if inner; then\n        echo nested\n    fi\nfi\n";
13165        let acts = run_code_actions(text, 1, 0, 3, 0);
13166        assert_eq!(acts.len(), 1);
13167        let decl = acts[0]["edit"]["changes"]["file:///t.zsh"][0]["newText"]
13168            .as_str()
13169            .unwrap_or("");
13170        // The `echo nested` line had 8 spaces; common-indent for the
13171        // block is 4; after stripping common and adding +4 indent it
13172        // should still be 4 leading spaces past the function indent.
13173        assert!(
13174            decl.contains("        echo nested"),
13175            "relative indent lost: {:?}",
13176            decl,
13177        );
13178    }
13179
13180    #[test]
13181    fn code_actions_caret_only_snaps_to_word() {
13182        // Cursor with no selection — must snap to identifier under
13183        // caret and still produce Extract Variable.
13184        let acts = run_code_actions("echo greeting\n", 0, 8, 0, 8);
13185        assert!(
13186            acts.iter()
13187                .any(|a| a["title"].as_str().unwrap_or("").contains("variable")),
13188            "caret-only didn't snap to a word: {:?}",
13189            acts.iter().map(|a| a["title"].clone()).collect::<Vec<_>>(),
13190        );
13191    }
13192
13193    #[test]
13194    fn code_actions_caret_only_offers_extract_function() {
13195        // Regression: the JetBrains plugin's Extract Method shortcut
13196        // (Cmd-Opt-M) used to report "LSP returned no code actions for
13197        // this range" because caret-only invocations only produced
13198        // Extract Variable / Constant — no function action existed for
13199        // the plugin's title filter to match. Cursor at column 8 of
13200        // `echo greeting` should now ALSO emit Extract Function over
13201        // the whole line.
13202        let acts = run_code_actions("echo greeting\n", 0, 8, 0, 8);
13203        let titles: Vec<&str> = acts
13204            .iter()
13205            .map(|a| a["title"].as_str().unwrap_or(""))
13206            .collect();
13207        assert!(
13208            titles.iter().any(|t| t.contains("function")),
13209            "caret-only must include Extract Function for Cmd-Opt-M: {:?}",
13210            titles,
13211        );
13212        // The function body should be the trimmed whole line, not just
13213        // the snapped word.
13214        let fn_act = acts
13215            .iter()
13216            .find(|a| a["title"].as_str().unwrap_or("").contains("function"))
13217            .expect("function action present");
13218        let decl = fn_act["edit"]["changes"]["file:///t.zsh"][0]["newText"]
13219            .as_str()
13220            .unwrap_or("");
13221        assert!(
13222            decl.contains("echo greeting"),
13223            "caret-only function extract should wrap the whole line, not just the word: {:?}",
13224            decl,
13225        );
13226    }
13227
13228    #[test]
13229    fn code_actions_caret_on_whitespace_still_offers_function() {
13230        // Cursor sits in the leading indent of `    echo hello` (col 2,
13231        // inside whitespace). Snap-to-word returns None — without the
13232        // fix, the LSP returned []. With the fix, Extract Function
13233        // still applies over the line's actual content.
13234        let acts = run_code_actions("    echo hello\n", 0, 2, 0, 2);
13235        let titles: Vec<&str> = acts
13236            .iter()
13237            .map(|a| a["title"].as_str().unwrap_or(""))
13238            .collect();
13239        assert!(
13240            titles.iter().any(|t| t.contains("function")),
13241            "cursor on whitespace must still emit Extract Function: {:?}",
13242            titles,
13243        );
13244    }
13245
13246    #[test]
13247    fn code_actions_caret_on_blank_line_returns_empty() {
13248        // Truly nothing to extract — blank line, no content. Returning
13249        // an empty list is correct; the plugin will surface "no code
13250        // actions for this range" which is the honest answer.
13251        let acts = run_code_actions("foo\n\nbar\n", 1, 0, 1, 0);
13252        assert!(
13253            acts.is_empty(),
13254            "blank line should produce no actions: {:?}",
13255            acts.iter().map(|a| a["title"].clone()).collect::<Vec<_>>(),
13256        );
13257    }
13258
13259    #[test]
13260    fn prepare_rename_rejects_in_comment() {
13261        let _g = crate::test_util::global_state_lock();
13262        let mut state = State::default();
13263        state
13264            .docs
13265            .insert("file:///t.zsh".into(), "echo hi  # rename me\n".into());
13266        let pos = "echo hi  # rename ".len();
13267        let params = json!({
13268            "textDocument": { "uri": "file:///t.zsh" },
13269            "position": { "line": 0, "character": pos },
13270        });
13271        let r = prepare_rename(&state, &params);
13272        assert!(r.is_null(), "prepareRename in comment must reject");
13273    }
13274
13275    #[test]
13276    fn long_flag_completion_inside_command_substitution() {
13277        // `x=$(zshrs --|)` — cursor inside `$(...)` after `zshrs --`.
13278        // Before the `(` boundary case in `leading_command_at`, the
13279        // walk-back found `x` as the leading command (because it
13280        // never crossed the `$(` opener), and long-flag completion
13281        // didn't fire.
13282        let line = "x=$(zshrs --";
13283        let ctx = super::lsp_completion_context(line, line.len());
13284        assert!(
13285            matches!(ctx, super::LspCompletionContext::BuiltinLongFlag(ref n) if n == "zshrs"),
13286            "expected BuiltinLongFlag(zshrs) inside `$(`, got {ctx:?}",
13287        );
13288        // Same for `<(…)` process substitution.
13289        let line = "diff <(zshrs --";
13290        let ctx = super::lsp_completion_context(line, line.len());
13291        assert!(
13292            matches!(ctx, super::LspCompletionContext::BuiltinLongFlag(ref n) if n == "zshrs"),
13293            "expected BuiltinLongFlag(zshrs) inside `<(`, got {ctx:?}",
13294        );
13295        // Plain `(subshell --` too.
13296        let line = "(zshrs --";
13297        let ctx = super::lsp_completion_context(line, line.len());
13298        assert!(
13299            matches!(ctx, super::LspCompletionContext::BuiltinLongFlag(ref n) if n == "zshrs"),
13300            "expected BuiltinLongFlag(zshrs) inside `(`, got {ctx:?}",
13301        );
13302    }
13303
13304    #[test]
13305    fn zshrs_long_flag_table_includes_gen_docs() {
13306        // Regression: `zshrs --gen-d<TAB>` should surface `--gen-docs`.
13307        // Tracked in `ZSHRS_SELF_LONG_FLAG_DOCS`; the wide audit
13308        // didn't cover this specific entry until the user pointed it
13309        // out.
13310        let flags = super::extract_builtin_long_flags("zshrs");
13311        let names: std::collections::HashSet<&str> =
13312            flags.iter().map(|(f, _)| f.as_str()).collect();
13313        for must_have in [
13314            "--gen-docs",
13315            "--out",
13316            "--dump-reference-html",
13317            "--names",
13318            "--daemon",
13319            "--color",
13320        ] {
13321            assert!(
13322                names.contains(must_have),
13323                "ZSHRS_SELF_LONG_FLAG_DOCS missing {must_have}",
13324            );
13325        }
13326    }
13327
13328    #[test]
13329    fn zle_dash_dispatches_to_builtin_flag_not_widget_name() {
13330        // Regression: `zle -<TAB>` used to short-circuit to the
13331        // WidgetName context because the dispatcher matched `zle`
13332        // unconditionally. Now the dispatcher checks whether the
13333        // current word starts with `-` and routes flag completion
13334        // through `BUILTIN_FLAG_DOCS_OVERRIDE`. Without that
13335        // branch the user gets a list of zle widget names where
13336        // they expected flag completion.
13337        let line = "zle -";
13338        let ctx = super::lsp_completion_context(line, line.len());
13339        assert!(
13340            matches!(ctx, super::LspCompletionContext::BuiltinFlag(ref n) if n == "zle"),
13341            "expected BuiltinFlag(zle) for `zle -`, got {ctx:?}",
13342        );
13343        // Sanity: bare `zle ` (no dash) still completes widget names.
13344        let bare = "zle ";
13345        let ctx2 = super::lsp_completion_context(bare, bare.len());
13346        assert!(
13347            matches!(ctx2, super::LspCompletionContext::WidgetName),
13348            "expected WidgetName for `zle `, got {ctx2:?}",
13349        );
13350        // The flag table for `zle` surfaces the documented sub-
13351        // commands (sample: -l list, -N declare new, -K keymap).
13352        let flags = extract_builtin_flags("zle");
13353        let names: std::collections::HashSet<&str> =
13354            flags.iter().map(|(f, _)| f.as_str()).collect();
13355        for must_have in ["-l", "-L", "-N", "-K", "-D", "-A", "-R", "-M"] {
13356            assert!(
13357                names.contains(must_have),
13358                "zle missing flag {must_have} from BUILTIN_FLAG_DOCS_OVERRIDE",
13359            );
13360        }
13361    }
13362
13363    #[test]
13364    fn print_flag_descriptions_present_for_next_line_desc_pattern() {
13365        // Regression for the JetBrains-plugin screenshot: `print -<TAB>`
13366        // showed `-a`, `-c`, `-D`, `-i`, `-l`, `-n`, `-o`, `-p`, `-r`,
13367        // `-s`, `-z` etc. with NO description, only `-b` and `-m` had
13368        // text. Root cause: the bullet regex stopped at the EOL right
13369        // after `**`, so it captured a single trailing mojibake byte
13370        // instead of the next-line description. Every flag must
13371        // surface its real prose now.
13372        let flags = extract_builtin_flags("print");
13373        let by: std::collections::HashMap<&str, &str> = flags
13374            .iter()
13375            .map(|(f, d)| (f.as_str(), d.as_str()))
13376            .collect();
13377
13378        // Inline-desc flags — these always worked, keep as canary.
13379        assert!(
13380            by.get("-b").is_some_and(|d| d.contains("Recognize")),
13381            "-b should describe escape sequence recognition, got {:?}",
13382            by.get("-b"),
13383        );
13384        assert!(
13385            by.get("-m")
13386                .is_some_and(|d| d.contains("Take the first argument")),
13387            "-m should describe pattern matching, got {:?}",
13388            by.get("-m"),
13389        );
13390
13391        // Next-line-desc flags — these were silently empty before.
13392        // Spot-check a representative subset.
13393        let expectations: &[(&str, &str)] = &[
13394            ("-a", "Print arguments with the column"),
13395            ("-c", "Print the arguments in columns"),
13396            ("-D", "Treat the arguments as paths"),
13397            ("-l", "Print the arguments separated by newlines"),
13398            ("-n", "Do not add a newline"),
13399            ("-o", "Print the arguments sorted in ascending"),
13400            ("-r", "Ignore the escape conventions"),
13401            ("-s", "Place the results in the history list"),
13402            ("-z", "Push the arguments onto the editing buffer"),
13403        ];
13404        for (flag, needle) in expectations {
13405            let got = by.get(flag).copied().unwrap_or("<missing>");
13406            assert!(
13407                got.contains(needle),
13408                "flag {flag} description should contain {:?}, got {:?}",
13409                needle,
13410                got,
13411            );
13412        }
13413    }
13414
13415    #[test]
13416    fn zshrs_self_long_flag_completion_covers_zshrs_specific_plus_setopt_mirrors() {
13417        // `zshrs --<TAB>` previously returned zero long-flag items.
13418        // The new `BuiltinLongFlag` context + extract_builtin_long_flags
13419        // now surfaces every zshrs-specific long flag plus every
13420        // setopt option (transformed `AUTO_CD` → `--autocd`).
13421        assert!(super::is_known_builtin_with_long_flag_docs("zshrs"));
13422        assert!(super::is_known_builtin_with_long_flag_docs("zsh"));
13423        assert!(!super::is_known_builtin_with_long_flag_docs("print"));
13424
13425        let flags = super::extract_builtin_long_flags("zshrs");
13426        let by: std::collections::HashMap<&str, &str> = flags
13427            .iter()
13428            .map(|(f, d)| (f.as_str(), d.as_str()))
13429            .collect();
13430
13431        // Spot-check zshrs-specific long flags.
13432        for spec in [
13433            "--help",
13434            "--version",
13435            "--doctor",
13436            "--lsp",
13437            "--dap",
13438            "--dump-tokens",
13439            "--dump-ast",
13440            "--dump-wordcode",
13441            "--dump-zwc",
13442            "--dump-reflection",
13443            "--docs",
13444            "--disasm",
13445            "--zsh",
13446            "--bash",
13447            "--ksh",
13448            "--sh",
13449            "--csh",
13450            "--posix",
13451            "--emulate",
13452            "--zsh-compat",
13453            "--no-rcs",
13454            "--verbose",
13455            "--xtrace",
13456            "--login",
13457            "--interactive",
13458            "--norc",
13459            "--noprofile",
13460            "--rcfile",
13461            "--init-file",
13462        ] {
13463            assert!(
13464                by.contains_key(spec),
13465                "zshrs long-flag table missing {spec}",
13466            );
13467            let d = by.get(spec).unwrap();
13468            let letters = d.chars().filter(|c| c.is_ascii_alphabetic()).count();
13469            assert!(
13470                letters >= 10,
13471                "{spec} description should be substantive, got {:?}",
13472                d,
13473            );
13474        }
13475        // Setopt mirrors arrive from OPTION_DOCS — spot-check
13476        // both positive and inverse forms.
13477        for mirror in [
13478            "--autocd",
13479            "--errexit",
13480            "--pipefail",
13481            "--nullglob",
13482            "--extendedglob",
13483            "--no-autocd",
13484            "--no-errexit",
13485            "--no-pipefail",
13486        ] {
13487            assert!(
13488                by.contains_key(mirror),
13489                "setopt-mirror flag {mirror} missing — OPTION_DOCS not flowing through?",
13490            );
13491        }
13492        // Total: ZSHRS_SELF_LONG_FLAG_DOCS hand table (~25) +
13493        // OPTION_DOCS canonical (~197) × 2 (positive + inverse).
13494        // Should land near 420.
13495        assert!(
13496            flags.len() > 400,
13497            "expected > 400 long-flag entries (hand table + setopt mirrors × 2), got {}",
13498            flags.len(),
13499        );
13500    }
13501
13502    #[test]
13503    fn zshrs_self_flag_completion_lists_standard_short_flags() {
13504        // `zshrs -<TAB>` previously returned zero completions: the
13505        // binary itself isn't a builtin / ext-builtin / compsys fn,
13506        // so `is_known_builtin_with_flag_docs` short-circuited. The
13507        // ZSHRS_SELF_FLAG_DOCS table + the `name == "zshrs"` branch
13508        // make all 9 standard short flags surface, each with a
13509        // description.
13510        assert!(super::is_known_builtin_with_flag_docs("zshrs"));
13511        assert!(super::is_known_builtin_with_flag_docs("zsh"));
13512
13513        let flags = extract_builtin_flags("zshrs");
13514        let names: std::collections::HashSet<&str> =
13515            flags.iter().map(|(f, _)| f.as_str()).collect();
13516        for must_have in ["-b", "-c", "-f", "-i", "-l", "-s", "-o", "-v", "-x"] {
13517            assert!(
13518                names.contains(must_have),
13519                "zshrs self-flag table missing {must_have}",
13520            );
13521        }
13522        for (f, d) in &flags {
13523            let letters = d.chars().filter(|c| c.is_ascii_alphabetic()).count();
13524            assert!(
13525                letters >= 10,
13526                "zshrs {f} description should be substantive, got {:?}",
13527                d,
13528            );
13529        }
13530        // `zsh` as a name alias resolves to the same table.
13531        let zsh_flags = extract_builtin_flags("zsh");
13532        assert_eq!(zsh_flags.len(), flags.len());
13533    }
13534
13535    // Coverage audit — eprintln only, never fails. Run with
13536    //   cargo test -p zshrs --lib audit_all_builtin_flag_coverage -- --nocapture
13537    // to see which builtins still have empty / near-empty descriptions
13538    // after the bullet regex fix.
13539    #[test]
13540    fn audit_all_builtin_flag_coverage() {
13541        let mut all_names: Vec<String> = Vec::new();
13542        for b in crate::ported::builtin::BUILTINS.iter() {
13543            all_names.push(b.node.nam.to_string());
13544        }
13545        for n in crate::ext_builtins::EXT_BUILTIN_NAMES.iter() {
13546            all_names.push(n.to_string());
13547        }
13548        for n in crate::compsys::COMPSYS_FN_NAMES.iter() {
13549            all_names.push(n.to_string());
13550        }
13551        all_names.sort();
13552        all_names.dedup();
13553
13554        let mut total_flags = 0usize;
13555        let mut empty_descs = 0usize;
13556        let mut builtins_with_any_flag = 0usize;
13557        let mut builtins_with_empty: Vec<(String, usize, usize)> = Vec::new();
13558        // A description is "useful" when it has at least 3 ASCII
13559        // letters somewhere in it. Pure mojibake (`Â`, `â `), bare
13560        // em-dashes, or punctuation-only strings fail this.
13561        fn useful(d: &str) -> bool {
13562            let letters = d.chars().filter(|c| c.is_ascii_alphabetic()).count();
13563            letters >= 3
13564        }
13565        for name in &all_names {
13566            let flags = extract_builtin_flags(name);
13567            if flags.is_empty() {
13568                continue;
13569            }
13570            builtins_with_any_flag += 1;
13571            let total = flags.len();
13572            let empty = flags.iter().filter(|(_, d)| !useful(d)).count();
13573            total_flags += total;
13574            empty_descs += empty;
13575            if empty > 0 {
13576                let preview: Vec<String> = flags
13577                    .iter()
13578                    .filter(|(_, d)| !useful(d))
13579                    .take(3)
13580                    .map(|(f, d)| format!("{f}={:?}", d))
13581                    .collect();
13582                builtins_with_empty.push((
13583                    format!("{name}  [{}]", preview.join(", ")),
13584                    empty,
13585                    total,
13586                ));
13587            }
13588        }
13589        eprintln!(
13590            "audit: {} builtins with flags, {} total flag entries, {} empty desc ({:.1}%)",
13591            builtins_with_any_flag,
13592            total_flags,
13593            empty_descs,
13594            100.0 * empty_descs as f64 / total_flags.max(1) as f64,
13595        );
13596        builtins_with_empty.sort_by(|a, b| b.1.cmp(&a.1));
13597        for (name, empty, total) in builtins_with_empty.iter().take(40) {
13598            eprintln!("  {name} : {empty}/{total} empty");
13599        }
13600    }
13601}