1use serde::{Deserialize, Serialize};
24use serde_json::{json, Value};
25use std::collections::HashMap;
26use std::io::{self, BufRead, BufReader, Read, Write};
27use std::sync::Mutex;
28
29fn read_message<R: BufRead>(reader: &mut R) -> io::Result<Option<Value>> {
35 let mut content_length: Option<usize> = None;
36 loop {
37 let mut line = String::new();
38 let n = reader.read_line(&mut line)?;
39 if n == 0 {
40 return Ok(None);
41 }
42 if line == "\r\n" || line == "\n" {
43 break;
44 }
45 if let Some(rest) = line.strip_prefix("Content-Length:") {
46 content_length =
47 Some(rest.trim().parse().map_err(|_| {
48 io::Error::new(io::ErrorKind::InvalidData, "bad Content-Length")
49 })?);
50 }
51 }
52 let len = content_length
53 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing Content-Length"))?;
54 let mut buf = vec![0u8; len];
55 reader.read_exact(&mut buf)?;
56 let v: Value =
57 serde_json::from_slice(&buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
58 Ok(Some(v))
59}
60
61fn write_message<W: Write>(writer: &mut W, msg: &Value) -> io::Result<()> {
62 let body = serde_json::to_vec(msg)?;
63 write!(writer, "Content-Length: {}\r\n\r\n", body.len())?;
64 writer.write_all(&body)?;
65 writer.flush()
66}
67
68#[derive(Default)]
71struct State {
72 docs: HashMap<String, String>,
75 workspace_files: HashMap<String, String>,
82 workspace_roots: Vec<std::path::PathBuf>,
86}
87
88impl State {
89 fn all_docs(&self) -> Vec<(String, String)> {
94 let mut out: HashMap<String, String> = self.workspace_files.clone();
95 for (k, v) in &self.docs {
96 out.insert(k.clone(), v.clone());
97 }
98 let mut v: Vec<(String, String)> = out.into_iter().collect();
99 v.sort_by(|a, b| a.0.cmp(&b.0));
100 v
101 }
102}
103
104const ZSH_EXT: &[&str] = &["zsh", "sh"];
109const ZSH_BASENAMES: &[&str] = &[
110 ".zshrc",
111 ".zshenv",
112 ".zprofile",
113 ".zlogin",
114 ".zlogout",
115 ".zsh_aliases",
116 ".zsh_functions",
117 ".zshrc.local",
118 "zshrc",
119];
120const SKIP_DIRS: &[&str] = &[
124 ".git",
125 ".hg",
126 ".svn",
127 "node_modules",
128 "target",
129 "build",
130 "dist",
131 ".idea",
132 ".vscode",
133 ".cache",
134 ".direnv",
135 ".venv",
136 "venv",
137 "__pycache__",
138];
139const MAX_WORKSPACE_FILES: usize = 10_000;
143const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
146
147fn is_zsh_source_filename(name: &str) -> bool {
151 if let Some(ext) = name.rsplit('.').next() {
152 if ext != name && ZSH_EXT.contains(&ext) {
153 return true;
154 }
155 }
156 ZSH_BASENAMES.contains(&name)
157}
158
159fn path_to_file_uri(p: &std::path::Path) -> Option<String> {
162 let abs = if p.is_absolute() {
163 p.to_path_buf()
164 } else {
165 std::env::current_dir().ok()?.join(p)
166 };
167 let s = abs.to_str()?;
168 Some(format!("file://{s}"))
169}
170
171fn file_uri_to_path(uri: &str) -> Option<std::path::PathBuf> {
175 uri.strip_prefix("file://").map(std::path::PathBuf::from)
176}
177
178fn scan_workspace_root(root: &std::path::Path, out: &mut HashMap<String, String>) {
187 let mut stack: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
188 while let Some(dir) = stack.pop() {
189 if out.len() >= MAX_WORKSPACE_FILES {
190 tracing::warn!(
191 target: "zshrs::lsp::workspace",
192 cap = MAX_WORKSPACE_FILES,
193 "workspace scan capped",
194 );
195 return;
196 }
197 let entries = match std::fs::read_dir(&dir) {
198 Ok(e) => e,
199 Err(e) => {
200 tracing::trace!(target: "zshrs::lsp::workspace", path=?dir, %e, "read_dir failed");
201 continue;
202 }
203 };
204 for ent in entries.flatten() {
205 let path = ent.path();
206 let name = match path.file_name().and_then(|n| n.to_str()) {
207 Some(n) => n,
208 None => continue,
209 };
210 let ty = match ent.file_type() {
211 Ok(t) => t,
212 Err(_) => continue,
213 };
214 if ty.is_dir() {
215 if SKIP_DIRS.contains(&name)
216 || name.starts_with('.') && !ZSH_BASENAMES.iter().any(|b| b == &name)
217 {
218 continue;
219 }
220 stack.push(path);
221 continue;
222 }
223 if !ty.is_file() {
224 continue;
225 }
226 if !is_zsh_source_filename(name) {
227 continue;
228 }
229 let md = match ent.metadata() {
230 Ok(m) => m,
231 Err(_) => continue,
232 };
233 if md.len() > MAX_FILE_BYTES {
234 continue;
235 }
236 let text = match std::fs::read_to_string(&path) {
237 Ok(t) => t,
238 Err(_) => continue,
239 };
240 if let Some(uri) = path_to_file_uri(&path) {
241 out.insert(uri, text);
242 if out.len() >= MAX_WORKSPACE_FILES {
243 return;
244 }
245 }
246 }
247 }
248}
249
250fn ingest_workspace_init(state: &mut State, params: &Value) {
253 let mut roots: Vec<std::path::PathBuf> = Vec::new();
255 if let Some(uri) = params.get("rootUri").and_then(|v| v.as_str()) {
256 if let Some(p) = file_uri_to_path(uri) {
257 roots.push(p);
258 }
259 }
260 if let Some(folders) = params.get("workspaceFolders").and_then(|v| v.as_array()) {
261 for f in folders {
262 if let Some(uri) = f.get("uri").and_then(|v| v.as_str()) {
263 if let Some(p) = file_uri_to_path(uri) {
264 roots.push(p);
265 }
266 }
267 }
268 }
269 let mut seen = std::collections::HashSet::new();
271 roots.retain(|p| seen.insert(p.clone()));
272 if roots.is_empty() {
273 tracing::info!(target: "zshrs::lsp::workspace", "no roots in initialize");
274 return;
275 }
276 let mut buf: HashMap<String, String> = HashMap::new();
277 for r in &roots {
278 scan_workspace_root(r, &mut buf);
279 }
280 tracing::info!(
281 target: "zshrs::lsp::workspace",
282 roots = roots.len(),
283 files = buf.len(),
284 "scanned",
285 );
286 state.workspace_roots = roots;
287 state.workspace_files = buf;
288}
289
290fn refresh_workspace_file(state: &mut State, uri: &str) {
293 if state.workspace_roots.is_empty() {
294 return;
295 }
296 let path = match file_uri_to_path(uri) {
297 Some(p) => p,
298 None => return,
299 };
300 let inside_root = state.workspace_roots.iter().any(|r| path.starts_with(r));
301 if !inside_root {
302 return;
303 }
304 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
305 if !is_zsh_source_filename(name) {
306 return;
307 }
308 }
309 match std::fs::read_to_string(&path) {
310 Ok(t) => {
311 state.workspace_files.insert(uri.to_string(), t);
312 }
313 Err(_) => {
314 state.workspace_files.remove(uri);
315 }
316 }
317}
318
319fn spawn_orphan_guard() {
330 std::thread::spawn(|| {
331 #[cfg(target_os = "linux")]
335 unsafe {
337 libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL as libc::c_ulong, 0, 0, 0);
338 }
339 loop {
340 std::thread::sleep(std::time::Duration::from_secs(2));
341 if unsafe { libc::getppid() } == 1 {
343 std::process::exit(0);
344 }
345 }
346 });
347}
348
349pub fn run_lsp() -> i32 {
350 tracing::info!(
351 target: "zshrs::lsp",
352 pid = std::process::id(),
353 "starting --lsp",
354 );
355 spawn_orphan_guard();
356 let mut state = State::default();
357 let stdin = io::stdin();
358 let mut reader = BufReader::new(stdin.lock());
359 let stdout = io::stdout();
360 let mut writer = stdout.lock();
361
362 let log_path = std::env::var("ZSHRS_LSP_LOG").ok();
363 let mut log = log_path.as_ref().and_then(|p| {
364 std::fs::OpenOptions::new()
365 .create(true)
366 .append(true)
367 .open(p)
368 .ok()
369 });
370
371 loop {
372 let msg = match read_message(&mut reader) {
373 Ok(Some(m)) => m,
374 Ok(None) => {
375 tracing::info!(target: "zshrs::lsp", "stdin EOF, shutting down");
376 break;
377 }
378 Err(e) => {
379 if let Some(l) = log.as_mut() {
380 let _ = writeln!(l, "← read error: {}", e);
381 }
382 tracing::error!(target: "zshrs::lsp", %e, "read error, shutting down");
383 break;
384 }
385 };
386
387 if let Some(l) = log.as_mut() {
388 let _ = writeln!(l, "← {}", msg);
389 }
390
391 let method = msg
392 .get("method")
393 .and_then(|v| v.as_str())
394 .map(|s| s.to_string());
395 let id = msg.get("id").cloned();
396 let params = msg.get("params").cloned().unwrap_or(Value::Null);
397 tracing::trace!(
398 target: "zshrs::lsp::req",
399 method = method.as_deref().unwrap_or("?"),
400 id = ?id,
401 );
402
403 let response = match method.as_deref() {
404 Some("initialize") => {
405 ingest_workspace_init(&mut state, ¶ms);
406 Some(handle_initialize(id, ¶ms))
407 }
408 Some("initialized") => None,
409 Some("shutdown") => Some(reply(id, json!(null))),
410 Some("exit") => break,
411 Some("textDocument/didOpen") => {
412 if let (Some(uri), Some(text)) = (
413 params["textDocument"]["uri"].as_str(),
414 params["textDocument"]["text"].as_str(),
415 ) {
416 state.docs.insert(uri.to_string(), text.to_string());
417 publish_diagnostics(&mut writer, uri, text, &mut log);
418 }
419 None
420 }
421 Some("textDocument/didChange") => {
422 if let Some(uri) = params["textDocument"]["uri"].as_str() {
423 if let Some(changes) = params["contentChanges"].as_array() {
424 if let Some(t) = changes.last().and_then(|c| c["text"].as_str()) {
426 state.docs.insert(uri.to_string(), t.to_string());
427 publish_diagnostics(&mut writer, uri, t, &mut log);
428 }
429 }
430 }
431 None
432 }
433 Some("textDocument/didClose") => {
434 if let Some(uri) = params["textDocument"]["uri"].as_str() {
435 state.docs.remove(uri);
436 }
437 None
438 }
439 Some("textDocument/didSave") => {
440 if let Some(uri) = params["textDocument"]["uri"].as_str() {
441 if let Some(text) = state.docs.get(uri).cloned() {
442 publish_diagnostics(&mut writer, uri, &text, &mut log);
443 }
444 refresh_workspace_file(&mut state, uri);
448 }
449 None
450 }
451 Some("textDocument/completion") => Some(reply(id, completion(&state, ¶ms))),
452 Some("textDocument/hover") => Some(reply(id, hover(&state, ¶ms))),
453 Some("textDocument/documentSymbol") => {
454 Some(reply(id, document_symbols(&state, ¶ms)))
455 }
456 Some("textDocument/foldingRange") => Some(reply(id, folding_ranges(&state, ¶ms))),
457 Some("textDocument/definition") => Some(reply(id, definition(&state, ¶ms))),
458 Some("textDocument/references") => Some(reply(id, references(&state, ¶ms))),
459 Some("textDocument/documentHighlight") => {
460 Some(reply(id, document_highlights(&state, ¶ms)))
461 }
462 Some("textDocument/rename") => Some(reply(id, rename(&state, ¶ms))),
463 Some("textDocument/prepareRename") => Some(reply(id, prepare_rename(&state, ¶ms))),
464 Some("textDocument/semanticTokens/full") => {
465 Some(reply(id, semantic_tokens(&state, ¶ms)))
466 }
467 Some("textDocument/formatting") => Some(reply(id, formatting(&state, ¶ms))),
468 Some("textDocument/codeAction") => Some(reply(id, code_actions(&state, ¶ms))),
469 Some(_) if id.is_some() => Some(reply_error(id, -32601, "Method not found")),
471 _ => None,
472 };
473
474 if let Some(resp) = response {
475 if let Some(l) = log.as_mut() {
476 let _ = writeln!(l, "→ {}", resp);
477 }
478 if let Err(e) = write_message(&mut writer, &resp) {
479 if let Some(l) = log.as_mut() {
480 let _ = writeln!(l, "write error: {}", e);
481 }
482 break;
483 }
484 }
485 }
486 0
487}
488
489fn reply(id: Option<Value>, result: Value) -> Value {
490 json!({
491 "jsonrpc": "2.0",
492 "id": id.unwrap_or(Value::Null),
493 "result": result,
494 })
495}
496
497fn reply_error(id: Option<Value>, code: i32, message: &str) -> Value {
498 json!({
499 "jsonrpc": "2.0",
500 "id": id.unwrap_or(Value::Null),
501 "error": { "code": code, "message": message },
502 })
503}
504
505fn handle_initialize(id: Option<Value>, _params: &Value) -> Value {
508 reply(
509 id,
510 json!({
511 "capabilities": {
512 "textDocumentSync": { "openClose": true, "change": 1, "save": true },
513 "completionProvider": {
514 "triggerCharacters": ["$", "{", "(", "[", "#", "*", "?", "!", "-", ":"],
522 "resolveProvider": false,
523 },
524 "hoverProvider": true,
525 "definitionProvider": true,
526 "referencesProvider": true,
527 "documentHighlightProvider": true,
528 "documentSymbolProvider": true,
529 "foldingRangeProvider": true,
530 "renameProvider": { "prepareProvider": true },
531 "documentFormattingProvider": true,
532 "codeActionProvider": {
533 "codeActionKinds": [
534 "refactor.extract",
535 ],
536 },
537 "semanticTokensProvider": {
538 "legend": {
539 "tokenTypes": SEMANTIC_TOKEN_TYPES,
540 "tokenModifiers": [],
541 },
542 "full": true,
543 "range": false,
544 },
545 },
546 "serverInfo": { "name": "zshrs-lsp", "version": env!("CARGO_PKG_VERSION") },
547 }),
548 )
549}
550
551fn publish_diagnostics<W: Write>(
554 writer: &mut W,
555 uri: &str,
556 text: &str,
557 log: &mut Option<std::fs::File>,
558) {
559 let diags = diagnose(text);
560 let msg = json!({
561 "jsonrpc": "2.0",
562 "method": "textDocument/publishDiagnostics",
563 "params": { "uri": uri, "diagnostics": diags },
564 });
565 if let Some(l) = log.as_mut() {
566 let _ = writeln!(l, "→ {}", msg);
567 }
568 let _ = write_message(writer, &msg);
569}
570
571fn strip_comments_and_strings(line: &str) -> String {
579 let bytes = line.as_bytes();
580 let mut out = String::with_capacity(bytes.len());
581 let mut i = 0usize;
582 while i < bytes.len() {
583 let c = bytes[i] as char;
584 match c {
585 '\\' if i + 1 < bytes.len() => {
586 out.push(c);
587 out.push(bytes[i + 1] as char);
588 i += 2;
589 continue;
590 }
591 '"' | '\'' | '`' => {
592 let q = c;
593 out.push(q);
594 i += 1;
595 while i < bytes.len() {
596 let cc = bytes[i] as char;
597 if cc == '\\' && q != '\'' && i + 1 < bytes.len() {
598 i += 2;
599 continue;
600 }
601 if cc == q {
602 out.push(q);
603 i += 1;
604 break;
605 }
606 i += 1;
607 }
608 continue;
609 }
610 '#' => {
611 let prev = if i == 0 {
612 None
613 } else {
614 Some(bytes[i - 1] as char)
615 };
616 let is_comment_start = match prev {
617 None => true,
618 Some(p) => p.is_whitespace() || p == ';' || p == '&' || p == '|',
622 };
623 if is_comment_start {
624 break;
625 }
626 out.push(c);
627 }
628 _ => out.push(c),
629 }
630 i += 1;
631 }
632 out
633}
634
635fn diagnose(text: &str) -> Vec<Value> {
639 let mut diags = Vec::new();
640 let mut stack: Vec<(char, usize, usize)> = Vec::new(); let mut block_stack: Vec<(&str, usize, usize)> = Vec::new();
642 let mut open_quote: Option<char> = None;
647
648 for (line_no, line) in text.lines().enumerate() {
649 let mut post_quote_tail: Option<usize> = None;
653 if let Some(q) = open_quote {
654 let bytes = line.as_bytes();
655 let mut i = 0usize;
656 let mut closed_at = None;
657 while i < bytes.len() {
658 let c = bytes[i] as char;
659 if c == '\\' && q != '\'' && i + 1 < bytes.len() {
660 i += 2;
661 continue;
662 }
663 if c == q {
664 closed_at = Some(i);
665 break;
666 }
667 i += 1;
668 }
669 if let Some(close_pos) = closed_at {
670 open_quote = None;
671 post_quote_tail = Some(close_pos + 1);
680 } else {
681 continue;
684 }
685 }
686 let trimmed = line.trim_start();
687 if trimmed.starts_with('#') {
688 continue;
689 }
690 let line_code_only = strip_comments_and_strings(line);
699 let line_has_case_keyword = line_code_only.split_whitespace().any(|t| {
700 let bare = t.trim_end_matches(|c: char| matches!(c, ';' | '&' | '|'));
701 bare == "case"
702 });
703 let mut bareword_value_positions: std::collections::HashSet<usize> =
718 std::collections::HashSet::new();
719 {
720 let toks: Vec<&str> = line_code_only.split_whitespace().collect();
721 for (i, t) in toks.iter().enumerate() {
722 if i == 0 {
723 continue;
724 }
725 let prev_bare =
726 toks[i - 1].trim_end_matches(|c: char| matches!(c, ';' | '&' | '|'));
727 let prev_ends_with_separator =
728 toks[i - 1].ends_with(|c: char| matches!(c, ';' | '&' | '|'));
729 let bare = t.trim_end_matches(|c: char| matches!(c, ';' | '&' | '|'));
730 let prev_is_body_opener = matches!(
731 prev_bare,
732 "do" | "then" | "else" | "elif" | "&&" | "||" | ";" | "&" | "|"
733 );
734 if matches!(bare, "done" | "fi" | "esac")
735 && !prev_ends_with_separator
736 && !prev_is_body_opener
737 {
738 bareword_value_positions.insert(i);
739 }
740 }
741 }
742 let mut i = post_quote_tail.unwrap_or(0);
749 let bytes = line.as_bytes();
750 while i < bytes.len() {
751 let c = bytes[i] as char;
752 match c {
753 '(' => {
754 if bytes.get(i + 1) == Some(&b'(') {
757 let prev_is_dollar = i > 0 && bytes[i - 1] == b'$';
764 let next2 = bytes.get(i + 2).copied();
765 let next3 = bytes.get(i + 3).copied();
766 if prev_is_dollar && next2 == Some(b')') && next3 != Some(b')') {
767 stack.push(('(', line_no, i)); stack.push(('(', line_no, i + 1)); i += 2;
772 continue;
773 }
774 if stack.iter().any(|x| x.0 == 'A') {
782 stack.push(('(', line_no, i));
783 stack.push(('(', line_no, i + 1));
784 i += 2;
785 continue;
786 }
787 stack.push(('A', line_no, i));
788 i += 2;
789 continue;
790 }
791 stack.push(('(', line_no, i));
792 }
793 ')' => {
794 if bytes.get(i + 1) == Some(&b')') && stack.last().map(|x| x.0) == Some('A') {
796 stack.pop();
797 i += 2;
798 continue;
799 }
800 if stack.last().map(|x| x.0) == Some('(') {
801 stack.pop();
802 } else {
803 let in_case = block_stack.iter().any(|(kw, _, _)| *kw == "case")
812 || line_has_case_keyword;
813 if !in_case {
814 diags.push(diagnostic(line_no, i, 1, "unmatched `)`", 1));
815 }
816 }
817 }
818 '{' => {
819 let preceded_by_dollar = i > 0 && bytes[i - 1] == b'$';
829 if preceded_by_dollar {
830 let mut depth = 1i32;
831 let mut j = i + 1;
832 while j < bytes.len() && depth > 0 {
833 match bytes[j] {
834 b'\\' if j + 1 < bytes.len() => {
835 j += 2;
836 continue;
837 }
838 b'{' => depth += 1,
839 b'}' => depth -= 1,
840 _ => {}
841 }
842 j += 1;
843 }
844 i = j;
846 continue;
847 }
848 stack.push(('{', line_no, i))
849 }
850 '}' => {
851 if stack.last().map(|x| x.0) == Some('{') {
852 stack.pop();
853 } else {
854 diags.push(diagnostic(line_no, i, 1, "unmatched `}`", 1));
855 }
856 }
857 '[' => {
858 if bytes.get(i + 1) == Some(&b'[') {
861 stack.push(('D', line_no, i));
862 i += 2;
863 continue;
864 }
865 stack.push(('[', line_no, i));
866 }
867 ']' => {
868 if bytes.get(i + 1) == Some(&b']') && stack.last().map(|x| x.0) == Some('D') {
869 stack.pop();
870 i += 2;
871 continue;
872 }
873 if stack.last().map(|x| x.0) == Some('[') {
874 stack.pop();
875 } else {
876 diags.push(diagnostic(line_no, i, 1, "unmatched `]`", 1));
877 }
878 }
879 '\\' => {
880 i += 2;
883 continue;
884 }
885 '"' | '\'' | '`' => {
886 let q = c;
888 i += 1;
889 let mut closed = false;
890 while i < bytes.len() {
891 let cc = bytes[i] as char;
892 if cc == '\\' && q != '\'' && i + 1 < bytes.len() {
893 i += 2;
894 continue;
895 }
896 if cc == q {
897 closed = true;
898 break;
899 }
900 i += 1;
901 }
902 if !closed {
903 open_quote = Some(q);
909 break;
911 }
912 }
913 '#' => {
914 let in_arith = stack.iter().any(|x| x.0 == 'A');
925 let prev = if i == 0 {
926 None
927 } else {
928 Some(bytes[i - 1] as char)
929 };
930 let is_comment_start = !in_arith
931 && match prev {
932 None => true,
933 Some(p) => {
934 p.is_whitespace() || p == ';' || p == '&' || p == '|'
941 }
942 };
943 if is_comment_start {
944 break;
945 }
946 }
947 _ => {}
948 }
949 i += 1;
950 }
951 if post_quote_tail.is_some() {
960 continue;
961 }
962 let code_only = strip_comments_and_strings(line);
963 for (tok_idx, kw_raw) in code_only.split_whitespace().enumerate() {
964 let kw = kw_raw.trim_end_matches(|c: char| matches!(c, ';' | '&' | '|'));
972 let kw_static: &'static str = match kw {
976 "if" => "if",
977 "for" => "for",
978 "while" => "while",
979 "until" => "until",
980 "case" => "case",
981 "select" => "select",
982 "repeat" => "repeat",
983 "fi" => "fi",
984 "done" => "done",
985 "esac" => "esac",
986 _ => continue,
987 };
988 if kw_static == "repeat" && !code_only.split_whitespace().any(|t| t == "do") {
992 continue;
993 }
994 if kw_static == "select" && !code_only.split_whitespace().any(|t| t == "do") {
1001 continue;
1002 }
1003 if bareword_value_positions.contains(&tok_idx) {
1009 continue;
1010 }
1011 match kw_static {
1012 "if" | "for" | "while" | "until" | "case" | "select" | "repeat" => {
1013 block_stack.push((kw_static, line_no, 0));
1014 }
1015 "fi" => {
1016 if block_stack.last().map(|x| x.0) == Some("if") {
1017 block_stack.pop();
1018 } else {
1019 diags.push(diagnostic(line_no, 0, 2, "unmatched `fi`", 1));
1020 }
1021 }
1022 "done" => {
1023 let last = block_stack.last().map(|x| x.0);
1024 if matches!(
1025 last,
1026 Some("for")
1027 | Some("while")
1028 | Some("until")
1029 | Some("select")
1030 | Some("repeat")
1031 ) {
1032 block_stack.pop();
1033 } else {
1034 diags.push(diagnostic(line_no, 0, 4, "unmatched `done`", 1));
1035 }
1036 }
1037 "esac" => {
1038 if block_stack.last().map(|x| x.0) == Some("case") {
1039 block_stack.pop();
1040 } else {
1041 diags.push(diagnostic(line_no, 0, 4, "unmatched `esac`", 1));
1042 }
1043 }
1044 _ => {}
1045 }
1046 }
1047 }
1048 for (c, line, col) in stack {
1049 diags.push(diagnostic(line, col, 1, &format!("unclosed `{}`", c), 1));
1050 }
1051 for (kw, line, col) in block_stack {
1052 diags.push(diagnostic(
1053 line,
1054 col,
1055 kw.len(),
1056 &format!("unclosed `{}` block", kw),
1057 1,
1058 ));
1059 }
1060 diags
1061}
1062
1063fn diagnostic(line: usize, col: usize, len: usize, msg: &str, severity: u8) -> Value {
1064 json!({
1065 "range": {
1066 "start": { "line": line, "character": col },
1067 "end": { "line": line, "character": col + len },
1068 },
1069 "severity": severity,
1070 "source": "zshrs",
1071 "message": msg,
1072 })
1073}
1074
1075fn completion(state: &State, params: &Value) -> Value {
1078 let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
1079 let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
1080 let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
1081 let text = state.docs.get(uri);
1082 let line = text.and_then(|t| t.lines().nth(line_no));
1083
1084 if let Some(l) = line {
1094 if cursor_in_uninterpolated_string(l, col) {
1095 return json!({ "isIncomplete": false, "items": [] });
1096 }
1097 }
1098
1099 fn ctx_item(label: &str, detail: &str, doc_md: &str) -> Value {
1111 json!({
1112 "label": label,
1113 "kind": 14, "detail": detail,
1115 "filterText": label,
1116 "insertText": label,
1117 "sortText": format!("0_{}", label),
1118 "documentation": {
1119 "kind": "markdown",
1120 "value": doc_md,
1121 },
1122 })
1123 }
1124 fn ctx_item_chain(label: &str, detail: &str, doc_md: &str) -> Value {
1131 json!({
1132 "label": label,
1133 "kind": 14,
1134 "detail": detail,
1135 "filterText": label,
1136 "insertText": label,
1137 "sortText": format!("0_{}", label),
1138 "documentation": {
1139 "kind": "markdown",
1140 "value": doc_md,
1141 },
1142 "command": {
1143 "title": "Re-trigger completion",
1144 "command": "editor.action.triggerSuggest",
1145 },
1146 })
1147 }
1148 if let Some(l) = line {
1149 match lsp_completion_context(l, col) {
1150 LspCompletionContext::ParamFlag => {
1151 let items: Vec<Value> = PARAM_FLAG_DOCS
1154 .iter()
1155 .map(|(flag, doc)| ctx_item_chain(flag, *doc,
1156 &format!("**`(`{}`)`** — {}\n\n_zsh parameter expansion flag — `${{(FLAGS)var}}`_", flag, doc)))
1157 .collect();
1158 return json!({ "isIncomplete": false, "items": items });
1159 }
1160 LspCompletionContext::GlobQualifier => {
1161 let items: Vec<Value> = GLOB_QUALIFIER_DOCS
1163 .iter()
1164 .map(|(q, doc)| {
1165 ctx_item_chain(
1166 q,
1167 *doc,
1168 &format!(
1169 "**`(`{}`)`** — {}\n\n_zsh glob qualifier — `*(QUALIFIERS)`_",
1170 q, doc
1171 ),
1172 )
1173 })
1174 .collect();
1175 return json!({ "isIncomplete": false, "items": items });
1176 }
1177 LspCompletionContext::HistoryDesignator => {
1178 let items: Vec<Value> = HISTORY_DESIGNATOR_DOCS
1179 .iter()
1180 .map(|(d, doc)| {
1181 ctx_item(
1182 d,
1183 *doc,
1184 &format!("**`!{}`** — {}\n\n_zsh history event designator_", d, doc),
1185 )
1186 })
1187 .collect();
1188 return json!({ "isIncomplete": false, "items": items });
1189 }
1190 LspCompletionContext::ParamColonModifier => {
1191 let items: Vec<Value> = PARAM_MODIFIER_DOCS
1199 .iter()
1200 .map(|(m, doc)| {
1201 ctx_item_chain(
1202 m,
1203 *doc,
1204 &format!(
1205 "**`:{}`** — {}\n\n_zsh modifier — `${{var:MOD}}` / `!event:MOD`_",
1206 m, doc
1207 ),
1208 )
1209 })
1210 .collect();
1211 return json!({ "isIncomplete": false, "items": items });
1212 }
1213 LspCompletionContext::OptionOnly => {
1215 let mut items = Vec::new();
1216 for o in crate::ported::options::ZSH_OPTIONS_SET.iter() {
1217 items.push(json!({
1218 "label": o,
1219 "kind": 21, "detail": "zsh option (setopt / unsetopt)",
1221 }));
1222 }
1223 return json!({ "isIncomplete": false, "items": items });
1224 }
1225 LspCompletionContext::SignalName => {
1226 let items: Vec<Value> = SIGNAL_NAMES
1227 .iter()
1228 .map(|(n, doc)| {
1229 ctx_item(
1230 n,
1231 *doc,
1232 &format!(
1233 "**SIG{}** — {}\n\n_signal name — `kill -{}` / `trap … {}`_",
1234 n, doc, n, n
1235 ),
1236 )
1237 })
1238 .collect();
1239 return json!({ "isIncomplete": false, "items": items });
1240 }
1241 LspCompletionContext::ModuleName => {
1242 let items: Vec<Value> = ZSH_MODULE_NAMES
1243 .iter()
1244 .map(|(n, doc)| {
1245 ctx_item(
1246 n,
1247 *doc,
1248 &format!("**`{}`** — {}\n\n_zsh module — `zmodload {}`_", n, doc, n),
1249 )
1250 })
1251 .collect();
1252 return json!({ "isIncomplete": false, "items": items });
1253 }
1254 LspCompletionContext::KeymapName => {
1255 let items: Vec<Value> = KEYMAP_NAMES
1256 .iter()
1257 .map(|(n, doc)| ctx_item(n, *doc, &format!("**`{}`** — {}", n, doc)))
1258 .collect();
1259 return json!({ "isIncomplete": false, "items": items });
1260 }
1261 LspCompletionContext::WidgetName => {
1262 let items: Vec<Value> = ZLE_WIDGET_NAMES
1263 .iter()
1264 .map(|(n, doc)| {
1265 ctx_item(n, *doc, &format!("**`{}`** — {}\n\n_ZLE widget_", n, doc))
1266 })
1267 .collect();
1268 return json!({ "isIncomplete": false, "items": items });
1269 }
1270 LspCompletionContext::TypesetFlag => {
1271 let items: Vec<Value> = TYPESET_FLAGS
1272 .iter()
1273 .map(|(f, doc)| {
1274 ctx_item(
1275 f,
1276 *doc,
1277 &format!(
1278 "**`{}`** — {}\n\n_typeset / declare / local / readonly flag_",
1279 f, doc
1280 ),
1281 )
1282 })
1283 .collect();
1284 return json!({ "isIncomplete": false, "items": items });
1285 }
1286 LspCompletionContext::ZstyleContext => {
1287 let items: Vec<Value> = ZSTYLE_CONTEXTS
1288 .iter()
1289 .map(|(c, doc)| {
1290 ctx_item(
1291 c,
1292 *doc,
1293 &format!("**`{}`** — {}\n\n_zstyle context pattern_", c, doc),
1294 )
1295 })
1296 .collect();
1297 return json!({ "isIncomplete": false, "items": items });
1298 }
1299 LspCompletionContext::CompdefFn => {
1300 let mut items = Vec::new();
1301 for n in crate::compsys::COMPSYS_FN_NAMES {
1302 items.push(ctx_item(
1303 n,
1304 "compsys completion function",
1305 &format!("**`{}`** — compsys completion function", n),
1306 ));
1307 }
1308 return json!({ "isIncomplete": false, "items": items });
1309 }
1310 LspCompletionContext::TestOperator => {
1312 let items: Vec<Value> = TEST_OPERATORS
1313 .iter()
1314 .map(|(op, doc)| {
1315 ctx_item(
1316 op,
1317 *doc,
1318 &format!("**`{}`** — {}\n\n_inside `[[ … ]]` conditional_", op, doc),
1319 )
1320 })
1321 .collect();
1322 return json!({ "isIncomplete": false, "items": items });
1323 }
1324 LspCompletionContext::MathFunction => {
1325 let items: Vec<Value> = MATH_FUNCTIONS
1326 .iter()
1327 .map(|(n, doc)| ctx_item(n, *doc,
1328 &format!("**`{}(…)`** — {}\n\n_math function — inside `((…))` / `$((…))` (most require `zmodload zsh/mathfunc`)_", n, doc)))
1329 .collect();
1330 return json!({ "isIncomplete": false, "items": items });
1331 }
1332 LspCompletionContext::PatternModifier => {
1333 let items: Vec<Value> = PATTERN_MODIFIERS
1336 .iter()
1337 .map(|(m, doc)| ctx_item_chain(m, *doc,
1338 &format!("**`(#{})`** — {}\n\n_extended-glob pattern modifier (needs `EXTENDED_GLOB`)_", m, doc)))
1339 .collect();
1340 return json!({ "isIncomplete": false, "items": items });
1341 }
1342 LspCompletionContext::SubscriptFlag => {
1343 let items: Vec<Value> = SUBSCRIPT_FLAGS
1345 .iter()
1346 .map(|(f, doc)| ctx_item_chain(f, *doc,
1347 &format!("**`({})`** — {}\n\n_array subscript flag — `${{arr[({})pattern]}}`_", f, doc, f)))
1348 .collect();
1349 return json!({ "isIncomplete": false, "items": items });
1350 }
1351 LspCompletionContext::BuiltinFlag(ref builtin_name) => {
1352 let flags = extract_builtin_flags(builtin_name);
1355 let bname = builtin_name.clone();
1356 let cur_word: String = if let Some(l) = line {
1365 let bytes = l.as_bytes();
1366 let cap = col.min(bytes.len());
1367 let mut j = cap;
1368 while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
1369 j -= 1;
1370 }
1371 String::from_utf8_lossy(&bytes[j..cap]).to_string()
1372 } else {
1373 "-".to_string()
1374 };
1375 let stack_prefix: String = if cur_word.starts_with('-') && cur_word.len() >= 2 {
1381 cur_word.clone()
1382 } else {
1383 "-".to_string()
1384 };
1385 let cur_word_chars = cur_word.chars().count() as u64;
1396 let dash_col = (col as u64).saturating_sub(cur_word_chars);
1397 let edit_range = json!({
1398 "start": { "line": line_no, "character": dash_col },
1399 "end": { "line": line_no, "character": col },
1400 });
1401 let items: Vec<Value> = flags
1402 .into_iter()
1403 .map(|(flag, desc)| {
1404 let letter = flag.trim_start_matches('-');
1409 let label = if stack_prefix == "-" {
1410 flag.clone()
1411 } else {
1412 format!("{}{}", stack_prefix, letter)
1413 };
1414 let detail = if desc.is_empty() {
1415 format!("option flag for `{}`", bname)
1416 } else {
1417 desc.clone()
1418 };
1419 let doc_md = if desc.is_empty() {
1420 format!("**`{}`** — option flag for `{}`", flag, bname)
1421 } else {
1422 format!("**`{}`** — {}\n\n_option flag for `{}`_", flag, desc, bname)
1423 };
1424 let mut item = ctx_item(&label, &detail, &doc_md);
1425 if let Some(obj) = item.as_object_mut() {
1426 obj.remove("insertText");
1427 obj.insert(
1428 "textEdit".to_string(),
1429 json!({ "range": edit_range, "newText": label }),
1430 );
1431 }
1432 item
1433 })
1434 .collect();
1435 return json!({ "isIncomplete": false, "items": items });
1436 }
1437 LspCompletionContext::BuiltinLongFlag(ref builtin_name) => {
1438 let flags = extract_builtin_long_flags(builtin_name);
1443 let bname = builtin_name.clone();
1444 let cur_word: String = if let Some(l) = line {
1445 let bytes = l.as_bytes();
1446 let cap = col.min(bytes.len());
1447 let mut j = cap;
1448 while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
1449 j -= 1;
1450 }
1451 String::from_utf8_lossy(&bytes[j..cap]).to_string()
1452 } else {
1453 "--".to_string()
1454 };
1455 let cur_word_chars = cur_word.chars().count() as u64;
1456 let dash_col = (col as u64).saturating_sub(cur_word_chars);
1457 let edit_range = json!({
1458 "start": { "line": line_no, "character": dash_col },
1459 "end": { "line": line_no, "character": col },
1460 });
1461 let zshrs_specific_count = ZSHRS_SELF_LONG_FLAG_DOCS.len();
1467 let items: Vec<Value> = flags
1468 .into_iter()
1469 .enumerate()
1470 .map(|(idx, (flag, desc))| {
1471 let bucket = if idx < zshrs_specific_count { "0" } else { "1" };
1472 let detail = if desc.is_empty() {
1473 format!("long-form flag for `{}`", bname)
1474 } else {
1475 desc.clone()
1476 };
1477 let doc_md = if desc.is_empty() {
1478 format!("**`{}`** — long-form flag for `{}`", flag, bname)
1479 } else {
1480 format!(
1481 "**`{}`** — {}\n\n_long-form flag for `{}`_",
1482 flag, desc, bname
1483 )
1484 };
1485 json!({
1486 "label": flag,
1487 "kind": 14, "detail": detail,
1489 "filterText": flag,
1490 "sortText": format!("{}_{}", bucket, flag),
1491 "textEdit": {
1492 "range": edit_range,
1493 "newText": flag,
1494 },
1495 "documentation": {
1496 "kind": "markdown",
1497 "value": doc_md,
1498 },
1499 })
1500 })
1501 .collect();
1502 return json!({ "isIncomplete": false, "items": items });
1503 }
1504 LspCompletionContext::Normal => {}
1505 }
1506 }
1507
1508 let prefix = line
1509 .map(|line| {
1510 let upto = &line[..line.len().min(col)];
1511 let start = upto
1512 .rfind(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$' || c == '-'))
1513 .map(|i| i + 1)
1514 .unwrap_or(0);
1515 upto[start..].to_string()
1516 })
1517 .unwrap_or_default();
1518
1519 let mut items = Vec::new();
1520 let push = |items: &mut Vec<Value>, label: &str, kind: u8, detail: &str| {
1521 items.push(json!({
1530 "label": label,
1531 "kind": kind,
1532 "detail": detail,
1533 "filterText": label,
1534 "insertText": label,
1535 }));
1536 };
1537
1538 let pre = prefix.to_lowercase();
1540 let want = |s: &str| pre.is_empty() || s.to_lowercase().starts_with(&pre);
1541
1542 for k in KEYWORDS {
1544 if want(k) {
1545 push(&mut items, k, 14, "keyword");
1546 }
1547 }
1548 for (name, _token) in crate::ported::hashtable::RESWDS {
1555 if want(name) {
1556 push(&mut items, name, 14, "keyword");
1557 }
1558 }
1559 for b in BUILTINS {
1562 if want(b) {
1563 push(&mut items, b, 3, "builtin");
1564 }
1565 }
1566 for b in crate::ported::builtin::BUILTINS.iter() {
1574 if want(&b.node.nam) {
1575 push(&mut items, &b.node.nam, 3, "builtin");
1576 }
1577 }
1578 for n in crate::ext_builtins::EXT_BUILTIN_NAMES {
1585 if want(n) {
1586 push(&mut items, n, 3, "extension builtin");
1587 }
1588 }
1589 for n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES {
1590 if want(n) {
1591 push(&mut items, n, 3, "extension builtin (daemon)");
1592 }
1593 }
1594 for n in crate::compsys::COMPSYS_FN_NAMES {
1598 if want(n) {
1599 push(&mut items, n, 3, "compsys function");
1600 }
1601 }
1602 for o in OPTIONS {
1603 if want(o) {
1604 push(&mut items, o, 21, "option");
1605 }
1606 }
1607 for o in crate::ported::options::ZSH_OPTIONS_SET.iter() {
1611 if want(o) {
1612 push(&mut items, o, 21, "option");
1613 }
1614 }
1615 let prefix_has_dollar = prefix.starts_with('$');
1638 let bare_prefix: String = prefix
1639 .strip_prefix('$')
1640 .map(|s| s.to_string())
1641 .unwrap_or_default();
1642 let bare_prefix_lc = bare_prefix.to_lowercase();
1643 for (name, _doc) in crate::zsh_special_var_docs::SPECIAL_VAR_DOCS {
1644 if !name
1648 .chars()
1649 .next()
1650 .map(|c| c.is_ascii_alphabetic() || c == '_')
1651 .unwrap_or(false)
1652 {
1653 continue;
1654 }
1655 if want(name) {
1656 push(&mut items, name, 6, "special variable");
1657 } else if prefix_has_dollar
1658 && (bare_prefix_lc.is_empty() || name.to_lowercase().starts_with(&bare_prefix_lc))
1659 {
1660 let with_sigil = format!("${}", name);
1661 push(&mut items, &with_sigil, 6, "special variable");
1662 }
1663 }
1664 for (alias, _canon) in crate::zsh_special_var_docs::SPECIAL_VAR_ALIASES {
1668 if !alias
1669 .chars()
1670 .next()
1671 .map(|c| c.is_ascii_alphabetic() || c == '_')
1672 .unwrap_or(false)
1673 {
1674 continue;
1675 }
1676 if want(alias) {
1677 push(&mut items, alias, 6, "special variable");
1678 } else if prefix_has_dollar
1679 && (bare_prefix_lc.is_empty() || alias.to_lowercase().starts_with(&bare_prefix_lc))
1680 {
1681 let with_sigil = format!("${}", alias);
1682 push(&mut items, &with_sigil, 6, "special variable");
1683 }
1684 }
1685 for (prefix, body, detail) in SNIPPETS {
1690 if !want(prefix) {
1691 continue;
1692 }
1693 items.push(json!({
1694 "label": format!("{} …", prefix),
1695 "kind": 15u8,
1696 "detail": detail,
1697 "filterText": prefix,
1698 "insertText": body,
1699 "insertTextFormat": 2u8,
1700 }));
1701 }
1702
1703 if let Some(t) = text {
1705 for (name, kind, detail) in scan_symbols(t) {
1706 if want(&name) {
1707 let lsp_kind: u8 = match kind {
1708 "function" => 3,
1709 "variable" => 6,
1710 _ => 1,
1711 };
1712 push(&mut items, &name, lsp_kind, detail);
1713 }
1714 }
1715 }
1716
1717 if let Some(extra) = try_compsys_completion(state, params) {
1724 if let Some(arr) = extra["items"].as_array() {
1729 for item in arr {
1730 items.push(item.clone());
1731 }
1732 }
1733 let is_incomplete = extra["isIncomplete"].as_bool().unwrap_or(false);
1734 return json!({ "isIncomplete": is_incomplete, "items": items });
1735 }
1736
1737 json!({ "isIncomplete": false, "items": items })
1738}
1739
1740fn try_compsys_completion(state: &State, params: &Value) -> Option<Value> {
1747 let uri = params["textDocument"]["uri"].as_str()?;
1748 let pos = ¶ms["position"];
1749 let text = state.docs.get(uri)?;
1750 let line_no = pos["line"].as_u64()? as usize;
1751 let col = pos["character"].as_u64()? as usize;
1752 let line_text = text.lines().nth(line_no)?;
1753 let req = crate::compsys::in_editor::CompsysRequest::new_with_default_budget(line_text, col);
1758 let resp = crate::compsys::in_editor::complete_at(req);
1759 if resp.matches.is_empty() && !resp.is_incomplete {
1760 return None;
1761 }
1762 let items: Vec<Value> = resp
1763 .matches
1764 .iter()
1765 .map(|m| {
1766 let kind: u64 = match m.group.as_deref() {
1773 Some("options") => 5, Some("subcommands") | Some("commands") => 3, Some("values") => 12, Some("hosts") | Some("files") | Some("directories") => 17, _ => 1, };
1779 let bucket = match m.group.as_deref() {
1783 Some("subcommands") | Some("commands") => "0",
1784 Some("options") => "1",
1785 Some("values") => "2",
1786 Some("hosts") => "3",
1787 Some("files") | Some("directories") => "4",
1788 _ => "5",
1789 };
1790 let detail = m
1791 .description
1792 .clone()
1793 .unwrap_or_else(|| m.group.clone().unwrap_or_default());
1794 json!({
1795 "label": m.completion,
1796 "kind": kind,
1797 "detail": detail,
1798 "filterText": m.completion,
1799 "insertText": m.completion,
1800 "sortText": format!("{bucket}_{}", m.completion),
1801 })
1802 })
1803 .collect();
1804 Some(json!({
1805 "isIncomplete": resp.is_incomplete,
1806 "items": items,
1807 }))
1808}
1809
1810const SNIPPETS: &[(&str, &str, &str)] = &[
1831 ("if", "if ${1:cmd}; then\n ${2:body}\nfi${0}", "if/then/fi block (snippet)"),
1833 ("ifelse", "if ${1:cmd}; then\n ${2:body}\nelse\n ${3:alt}\nfi${0}", "if/else/fi block (snippet)"),
1834 ("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)"),
1835 ("elsif", "elif ${1:cmd}; then\n ${2:body}${0}", "elif branch (snippet)"),
1836 ("unless", "if ! ${1:cmd}; then\n ${2:body}\nfi${0}", "negated if (snippet)"),
1837 ("for", "for ${1:item} in ${2:list}; do\n ${3:body}\ndone${0}", "for loop (snippet)"),
1838 ("forin", "for ${1:item} in \"${2:\\${array[@]}}\"; do\n ${3:body}\ndone${0}", "for over quoted-array expansion (snippet)"),
1839 ("forarith", "for ((${1:i}=0; \\$${1:i} < ${2:n}; ${1:i}++)); do\n ${3:body}\ndone${0}", "C-style arithmetic for (snippet)"),
1840 ("foreach", "foreach ${1:item} (${2:list})\n ${3:body}\nend${0}", "zsh-alt foreach…end (snippet)"),
1841 ("while", "while ${1:cmd}; do\n ${2:body}\ndone${0}", "while loop (snippet)"),
1842 ("until", "until ${1:cmd}; do\n ${2:body}\ndone${0}", "until loop (snippet)"),
1843 ("case", "case ${1:word} in\n ${2:pattern})\n ${3:body}\n ;;\n *)\n ${4:default}\n ;;\nesac${0}", "case/esac (snippet)"),
1844 ("select", "select ${1:choice} in ${2:items}; do\n ${3:body}\n break\ndone${0}", "select interactive menu (snippet)"),
1845 ("repeat", "repeat ${1:N}; do\n ${2:body}\ndone${0}", "repeat N times (snippet)"),
1846 ("break", "break ${1:1}${0}", "break N levels (snippet)"),
1847 ("continue", "continue ${1:1}${0}", "continue N levels (snippet)"),
1848 ("return", "return ${1:0}${0}", "return status (snippet)"),
1849 ("fn", "${1:name}() {\n ${2:body}\n}${0}", "function declaration (snippet)"),
1851 ("function", "function ${1:name} {\n ${2:body}\n}${0}", "function keyword form (snippet)"),
1852 ("anonfn", "() {\n ${1:body}\n} ${2:args}${0}", "anonymous function (snippet)"),
1853 ("local", "local ${1:var}=${2:value}${0}", "local declaration (snippet)"),
1854 ("locals", "local ${1:a}=\"\\$1\" ${2:b}=\"\\$2\" ${3:c}=\"\\$3\"${0}", "local positional-arg unpack (snippet)"),
1855 ("typeset", "typeset -${1:gAi} ${2:name}${3:=value}${0}", "typeset with attributes (snippet)"),
1856 ("export", "export ${1:NAME}=\"${2:value}\"${0}", "export env var (snippet)"),
1857 ("readonly", "readonly ${1:NAME}=\"${2:value}\"${0}", "readonly var (snippet)"),
1858 ("integer", "integer ${1:name}=${2:0}${0}", "integer typeset shorthand (snippet)"),
1859 ("array", "${1:name}=(${2:a b c})${0}", "indexed array literal (snippet)"),
1860 ("assoc", "typeset -A ${1:name}\n${1:name}=(\n [${2:key1}]=${3:val1}\n [${4:key2}]=${5:val2}\n)${0}", "associative array (snippet)"),
1861 ("trap", "trap '${1:handler}' ${2:INT TERM EXIT}${0}", "signal trap (snippet)"),
1863 ("setopt", "setopt ${1:EXTENDED_GLOB NULL_GLOB PIPE_FAIL}${0}", "setopt one or more options (snippet)"),
1864 ("unsetopt", "unsetopt ${1:CASE_GLOB}${0}", "unsetopt options (snippet)"),
1865 ("autoload", "autoload -Uz ${1:funcname}${0}", "autoload function with -Uz (snippet)"),
1866 ("compdef", "compdef ${1:_completer} ${2:command}${0}", "register completion (snippet)"),
1867 ("bindkey", "bindkey '${1:^X^E}' ${2:edit-command-line}${0}", "ZLE bindkey (snippet)"),
1868 ("alias", "alias ${1:name}='${2:command}'${0}", "alias (snippet)"),
1869 ("galias", "alias -g ${1:NAME}='${2:expansion}'${0}", "global alias (snippet)"),
1870 ("salias", "alias -s ${1:ext}='${2:opener}'${0}", "suffix alias (snippet)"),
1871 ("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)"),
1873 ("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)"),
1874 ("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)"),
1875 ("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)"),
1876 ("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)"),
1877 ("shebang", "#!/usr/bin/env zshrs\n${0}", "zshrs shebang (snippet)"),
1879 ("safeshebang", "#!/usr/bin/env zsh\nemulate -L zsh\nsetopt err_return no_unset pipe_fail extended_glob\n${0}", "strict-mode shebang (snippet)"),
1880 ("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)"),
1881 ("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)"),
1882 ("strict", "emulate -L zsh\nsetopt err_return no_unset pipe_fail extended_glob${0}", "strict-mode options (snippet)"),
1883 ("while-read", "while IFS= read -r ${1:line}; do\n ${2:body}\ndone < ${3:file}${0}", "read-loop over file (snippet)"),
1885 ("for-each-line", "for ${1:line} in \"\\${(@f)\\$(cat ${2:file})}\"; do\n ${3:body}\ndone${0}", "for-each-line via process subst (snippet)"),
1886 ("cat-pipe", "${1:cmd} | while read -r ${2:line}; do\n ${3:body}\ndone${0}", "pipe-to-while (snippet)"),
1887 ("heredoc", "cat <<EOT\n${1:body}\nEOT${0}", "heredoc (snippet)"),
1888 ("heredocl", "cat <<-EOT\n\t${1:body}\nEOT${0}", "tab-stripped heredoc (snippet)"),
1889 ("herestring", "${1:cmd} <<< \"${2:input}\"${0}", "here-string (snippet)"),
1890 ("psub-in", "${1:cmd} < <(${2:producer})${0}", "process substitution (input) (snippet)"),
1891 ("psub-out", "${1:cmd} > >(${2:consumer})${0}", "process substitution (output) (snippet)"),
1892 ("subshell", "(\n ${1:body}\n)${0}", "subshell (snippet)"),
1893 ("printfmt", "printf '%s\\\\n' \"${1:args}\"${0}", "printf line-per-arg (snippet)"),
1894 ("dirtest", "[[ -d \"${1:path}\" ]] && ${2:body}${0}", "directory-test guard (snippet)"),
1896 ("filetest", "[[ -f \"${1:path}\" ]] && ${2:body}${0}", "regular-file guard (snippet)"),
1897 ("regexm", "if [[ \"${1:str}\" =~ ${2:pattern} ]]; then\n ${3:body} # \\$match[*] / \\$MATCH\nfi${0}", "regex match into \\$match (snippet)"),
1898 ("notempty", "[[ -n \"${1:var}\" ]] || ${2:return 1}${0}", "non-empty guard (snippet)"),
1899 ("async", "${1:job}=\\$(async ${2:'expensive_command'})\n${3:# … other work …}\n${4:result}=\\$(await \\$${1:job})${0}", "async + await pair (snippet)"),
1901 ("barrier", "barrier '${1:task1}' ::: '${2:task2}' ::: '${3:task3}'${0}", "barrier (parallel + join) (snippet)"),
1902 ("peach", "peach ${1:array} {\n ${2:body} # uses \\$it for each element\n}${0}", "parallel for-each on worker pool (snippet)"),
1903 ("intercept", "intercept ${1:before} ${2:command} {\n ${3:body}\n}${0}", "AOP intercept (snippet)"),
1904 ("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)"),
1906 ("argspec", "_arguments \\\\\n '(-h --help)'{-h,--help}'[show help]' \\\\\n '(-v --verbose)'{-v,--verbose}'[verbose]' \\\\\n ':${1:argname}:${2:_files}'${0}", "_arguments spec (snippet)"),
1908 ("filesspec", "_files -g '${1:*.zsh}'${0}", "_files glob spec (snippet)"),
1909 ("valspec", "_values '${1:tag}' \\\\\n '${2:one}[${3:desc}]' \\\\\n '${4:two}[${5:desc}]'${0}", "_values descriptor (snippet)"),
1910 ("describe", "_describe '${1:group}' ${2:choices_array}${0}", "_describe (snippet)"),
1911 ("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)"),
1913 ("gitcommit", "git add -A && git commit -m \"${1:message}\" && git push${0}", "git add+commit+push (snippet)"),
1914 ("curlget", "curl -fsSL ${1:https://example.com/api} | ${2:jq .}${0}", "curl GET + jq pipe (snippet)"),
1915 ("jsonget", "${1:cmd} | jq -r '${2:.field}'${0}", "extract JSON field via jq (snippet)"),
1916 ("zmodload", "zmodload zsh/${1:datetime}${0}", "load zsh module (snippet)"),
1917];
1918
1919fn hover(state: &State, params: &Value) -> Value {
1922 let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
1923 let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
1924 let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
1925 let text = match state.docs.get(uri) {
1926 Some(t) => t,
1927 None => {
1928 tracing::trace!(target: "zshrs::lsp::hover", line = line_no, col, "no_doc_for_uri");
1929 return Value::Null;
1930 }
1931 };
1932 let word = word_at(text, line_no, col).unwrap_or_default();
1933 if word.is_empty() {
1934 tracing::trace!(target: "zshrs::lsp::hover", line = line_no, col, "empty_word");
1935 return Value::Null;
1936 }
1937 let line_text = text.lines().nth(line_no).unwrap_or("");
1938 let (word_start, word_end) = word_span_at(line_text, col).unwrap_or((col, col));
1942 let gate = classify_hover_position(line_text, word_start, word_end);
1943 if let Some(module_doc) = find_module_doc_for_position(state, text, line_text, line_no) {
1948 tracing::debug!(target: "zshrs::lsp::hover", line = line_no, col, "module_hit");
1949 return json!({
1950 "contents": { "kind": "markdown", "value": module_doc }
1951 });
1952 }
1953 if gate != HoverGate::Code {
1954 tracing::debug!(
1955 target: "zshrs::lsp::hover",
1956 line = line_no, col, %word,
1957 gated = ?gate,
1958 "suppressed",
1959 );
1960 return Value::Null;
1961 }
1962 let mut doc = if let Some(user_doc) = find_user_symbol_doc(text, &word) {
1970 user_doc
1971 } else {
1972 String::new()
1973 };
1974 if doc.is_empty() {
1975 doc = lookup_doc(&word);
1976 }
1977 if doc.is_empty() {
1978 if let Some(module_doc) = find_module_doc_for_position(state, text, line_text, line_no) {
1989 doc = module_doc;
1990 }
1991 }
1992 if doc.is_empty() {
1993 tracing::trace!(target: "zshrs::lsp::hover", line = line_no, col, %word, "miss");
1994 return Value::Null;
1995 }
1996 tracing::debug!(target: "zshrs::lsp::hover", line = line_no, col, %word, "hit");
1997 json!({
1998 "contents": {
1999 "kind": "markdown",
2000 "value": doc,
2001 }
2002 })
2003}
2004
2005pub(crate) fn find_user_symbol_doc(text: &str, name: &str) -> Option<String> {
2026 let lines: Vec<&str> = text.lines().collect();
2027 if let Some(table) = crate::lsp_symbols::SymbolTable::build(text) {
2043 let mut undocumented: Option<(u32, &'static str, &str)> = None;
2047 for sym in &table.symbols {
2048 if sym.name != name {
2049 continue;
2050 }
2051 let line_idx = sym.decl_line as usize;
2052 let def_line = lines.get(line_idx).copied().unwrap_or("");
2053 let line_trim = def_line.trim_start();
2058 let kind = if line_trim.starts_with("alias ") || line_trim.starts_with("alias\t") {
2059 "alias"
2060 } else {
2061 match sym.kind {
2062 crate::lsp_symbols::SymbolKind::Func => "function",
2063 crate::lsp_symbols::SymbolKind::Global => "parameter",
2064 crate::lsp_symbols::SymbolKind::Local => "parameter",
2065 }
2066 };
2067 let doc_block = collect_doc_block_above(&lines, line_idx);
2068 if !doc_block.is_empty() {
2069 return Some(render_user_doc_card(
2070 name,
2071 kind,
2072 def_line.trim(),
2073 &doc_block,
2074 ));
2075 }
2076 if undocumented.is_none() {
2077 undocumented = Some((sym.decl_line, kind, def_line));
2078 }
2079 }
2080 if let Some((_, kind, line)) = undocumented {
2081 return Some(render_user_doc_card_no_block(name, kind, line.trim()));
2082 }
2083 }
2086 let mut undocumented: Option<(usize, &'static str, &str)> = None;
2089 for (i, line) in lines.iter().enumerate() {
2090 let kind = match symbol_decl_kind(line, name) {
2091 Some(k) => k,
2092 None => continue,
2093 };
2094 let doc_block = collect_doc_block_above(&lines, i);
2095 if !doc_block.is_empty() {
2096 return Some(render_user_doc_card(name, kind, line.trim(), &doc_block));
2097 }
2098 if undocumented.is_none() {
2099 undocumented = Some((i, kind, line));
2100 }
2101 }
2102 undocumented.map(|(_, kind, line)| render_user_doc_card_no_block(name, kind, line.trim()))
2103}
2104
2105fn render_user_doc_card_no_block(name: &str, kind: &str, def_line: &str) -> String {
2111 format!(
2112 "**user-defined {kind} `{name}`**\n\n```zsh\n{def_line}\n```\n\n\
2113 *(no `## …` doc-comment block above the definition)*"
2114 )
2115}
2116
2117fn symbol_decl_kind(line: &str, name: &str) -> Option<&'static str> {
2122 let t = line.trim_start();
2123 if t.starts_with('#') {
2124 return None;
2125 }
2126 if let Some(rest) = t
2128 .strip_prefix("function ")
2129 .or_else(|| t.strip_prefix("function\t"))
2130 {
2131 if first_ident(rest).as_deref() == Some(name) {
2132 return Some("function");
2133 }
2134 }
2135 if let Some(idx) = t.find("()") {
2139 let head = &t[..idx];
2140 if !head.contains(' ') && !head.contains('\t') && head == name {
2141 return Some("function");
2142 }
2143 }
2144 if let Some(rest) = t.strip_prefix("alias ") {
2146 if first_ident(rest).as_deref() == Some(name) {
2147 return Some("alias");
2148 }
2149 }
2150 for prefix in &[
2155 "local ",
2156 "typeset ",
2157 "declare ",
2158 "readonly ",
2159 "export ",
2160 "integer ",
2161 "float ",
2162 ] {
2163 if let Some(rest) = t.strip_prefix(prefix) {
2164 let after_flags = skip_leading_flags(rest);
2165 if first_ident(after_flags).as_deref() == Some(name) {
2166 return Some("parameter");
2167 }
2168 }
2169 }
2170 if let Some(eq) = t.find('=') {
2172 let head = &t[..eq];
2173 if head == name && !head.contains(' ') && !head.contains('\t') {
2174 return Some("parameter");
2175 }
2176 }
2177 None
2178}
2179
2180fn collect_doc_block_above(lines: &[&str], def_line: usize) -> String {
2193 let mut collected: Vec<String> = Vec::new();
2194 let mut saw_doc = false;
2195 for j in (0..def_line).rev() {
2196 let trimmed = lines[j].trim_start();
2197 if trimmed.is_empty() {
2198 if saw_doc {
2199 break;
2202 }
2203 continue;
2206 }
2207 if let Some(rest) = trimmed.strip_prefix("## ") {
2208 collected.push(rest.to_string());
2209 saw_doc = true;
2210 continue;
2211 }
2212 if trimmed == "##" {
2213 collected.push(String::new());
2214 saw_doc = true;
2215 continue;
2216 }
2217 break;
2221 }
2222 collected.reverse();
2223 while collected.last().is_some_and(|l| l.is_empty()) {
2225 collected.pop();
2226 }
2227 collected.join("\n")
2228}
2229
2230fn find_module_doc_for_position(
2240 state: &State,
2241 text: &str,
2242 line_text: &str,
2243 _line_no: usize,
2244) -> Option<String> {
2245 let trimmed = line_text.trim_start();
2246 if trimmed.starts_with("#!") {
2247 return extract_module_doc(text).map(|d| render_module_doc_card("(this file)", &d));
2248 }
2249 let path_arg: Option<&str> = if let Some(rest) = trimmed.strip_prefix("source ") {
2253 Some(rest.split_whitespace().next().unwrap_or(""))
2254 } else if let Some(rest) = trimmed.strip_prefix(". ") {
2255 Some(rest.split_whitespace().next().unwrap_or(""))
2256 } else {
2257 None
2258 };
2259 let path = path_arg.filter(|p| !p.is_empty())?;
2260 let path = path.trim_matches(|c| c == '"' || c == '\'');
2262 let needle = path.strip_prefix("./").unwrap_or(path);
2266 let body = state
2268 .docs
2269 .iter()
2270 .find_map(|(uri, body)| uri.ends_with(needle).then(|| body.clone()))
2271 .or_else(|| std::fs::read_to_string(path).ok())?;
2272 extract_module_doc(&body).map(|d| render_module_doc_card(path, &d))
2273}
2274
2275fn render_module_doc_card(label: &str, doc: &str) -> String {
2282 format!("{doc}\n\n---\n\nzsh module `{label}`")
2283}
2284
2285pub(crate) fn extract_module_doc(text: &str) -> Option<String> {
2308 let lines: Vec<&str> = text.lines().collect();
2309 let mut i = 0;
2310 if let Some(first) = lines.first() {
2312 if first.starts_with("#!") {
2313 i = 1;
2314 }
2315 }
2316 while i < lines.len() && lines[i].trim().is_empty() {
2318 i += 1;
2319 }
2320 let mut collected: Vec<String> = Vec::new();
2322 while i < lines.len() {
2323 let trimmed = lines[i].trim_start();
2324 if let Some(rest) = trimmed.strip_prefix("## ") {
2325 collected.push(rest.to_string());
2326 } else if trimmed == "##" {
2327 collected.push(String::new());
2328 } else {
2329 break;
2330 }
2331 i += 1;
2332 }
2333 while collected.last().is_some_and(|l| l.is_empty()) {
2334 collected.pop();
2335 }
2336 if collected.is_empty() {
2337 None
2338 } else {
2339 Some(collected.join("\n"))
2340 }
2341}
2342
2343fn skip_leading_flags(s: &str) -> &str {
2348 let mut rest = s.trim_start();
2349 while rest.starts_with('-') || rest.starts_with('+') {
2350 let end = rest
2352 .char_indices()
2353 .find(|(_, c)| c.is_whitespace())
2354 .map(|(i, _)| i)
2355 .unwrap_or(rest.len());
2356 rest = rest[end..].trim_start();
2357 }
2358 rest
2359}
2360
2361fn render_user_doc_card(name: &str, kind: &str, _def_line: &str, doc: &str) -> String {
2369 format!("{doc}\n\n---\n\nuser-defined {kind} `{name}`")
2370}
2371
2372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2377pub(crate) enum HoverGate {
2378 Code,
2380 Comment,
2382 StringLiteral,
2389}
2390
2391fn position_inside_string_literal(line_text: &str, start: usize, end: usize) -> bool {
2408 let bytes = line_text.as_bytes();
2409 let cap = end.min(bytes.len());
2417 if start < cap
2418 && bytes[start] == b'$'
2419 && bytes[start + 1..cap]
2420 .iter()
2421 .all(|b| b.is_ascii_alphanumeric() || *b == b'_')
2422 {
2423 return false;
2424 }
2425 if start > 0 && start < cap && bytes[start - 1] == b'$' {
2426 let span_ok = bytes[start..cap]
2427 .iter()
2428 .all(|b| b.is_ascii_alphanumeric() || *b == b'_');
2429 if span_ok {
2430 return false;
2431 }
2432 }
2433 let limit = start.min(bytes.len());
2434 let mut i = 0;
2435 let mut in_str: Option<u8> = None;
2436 let mut interp_depth: i32 = 0;
2437 while i < limit {
2438 let c = bytes[i];
2439 if interp_depth > 0 {
2442 match c {
2443 b'{' => interp_depth += 1,
2444 b'}' => interp_depth -= 1,
2445 _ => {}
2446 }
2447 i += 1;
2448 continue;
2449 }
2450 if let Some(q) = in_str {
2451 if (q == b'"' || q == b'`') && c == b'\\' && i + 1 < bytes.len() {
2452 i += 2;
2453 continue;
2454 }
2455 if (q == b'"' || q == b'`') && c == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{'
2458 {
2459 interp_depth = 1;
2460 i += 2;
2461 continue;
2462 }
2463 if c == q {
2464 in_str = None;
2465 }
2466 i += 1;
2467 continue;
2468 }
2469 match c {
2470 b'#' => return false,
2471 b'"' | b'\'' | b'`' => in_str = Some(c),
2472 _ => {}
2473 }
2474 i += 1;
2475 }
2476 if interp_depth > 0 {
2478 return false;
2479 }
2480 if in_str.is_none() {
2481 return false;
2482 }
2483 let q = in_str.unwrap();
2489 let mut j = start;
2490 while j < end.min(bytes.len()) {
2491 if (q == b'"' || q == b'`') && bytes[j] == b'\\' && j + 1 < bytes.len() {
2492 j += 2;
2493 continue;
2494 }
2495 if bytes[j] == q {
2496 return false;
2497 }
2498 j += 1;
2499 }
2500 true
2501}
2502
2503pub(crate) fn classify_hover_position(line_text: &str, start: usize, end: usize) -> HoverGate {
2507 if line_starts_comment_before(line_text, start) {
2508 return HoverGate::Comment;
2509 }
2510 if position_inside_string_literal(line_text, start, end) {
2511 return HoverGate::StringLiteral;
2512 }
2513 HoverGate::Code
2514}
2515
2516fn word_span_at(line_text: &str, col: usize) -> Option<(usize, usize)> {
2521 let bytes = line_text.as_bytes();
2522 if col > bytes.len() {
2523 return None;
2524 }
2525 let mut start = col;
2527 while start > 0 {
2528 let c = bytes[start - 1] as char;
2529 if c == '_' || c.is_alphanumeric() || c == '$' {
2530 start -= 1;
2531 } else {
2532 break;
2533 }
2534 }
2535 let mut end = col;
2536 while end < bytes.len() {
2537 let c = bytes[end] as char;
2538 if c == '_' || c.is_alphanumeric() {
2539 end += 1;
2540 } else {
2541 break;
2542 }
2543 }
2544 if start == end {
2545 return None;
2546 }
2547 let is_dollar_var = bytes[start] == b'$';
2551 let in_braced = start > 0 && bytes[start - 1] == b'{';
2552 if !is_dollar_var && !in_braced {
2553 while end < bytes.len() && bytes[end] == b'-' {
2554 let mut p = end + 1;
2555 while p < bytes.len() {
2556 let c = bytes[p] as char;
2557 if c == '_' || c.is_alphanumeric() {
2558 p += 1;
2559 } else {
2560 break;
2561 }
2562 }
2563 if p > end + 1 {
2564 end = p;
2565 } else {
2566 break;
2567 }
2568 }
2569 while start > 1 && bytes[start - 1] == b'-' {
2570 let mut p = start - 1;
2571 while p > 0 {
2572 let c = bytes[p - 1] as char;
2573 if c == '_' || c.is_alphanumeric() {
2574 p -= 1;
2575 } else {
2576 break;
2577 }
2578 }
2579 if p < start - 1 {
2580 start = p;
2581 } else {
2582 break;
2583 }
2584 }
2585 }
2586 Some((start, end))
2587}
2588
2589pub(crate) fn cursor_in_uninterpolated_string(line: &str, col: usize) -> bool {
2620 let bytes = line.as_bytes();
2621 let cap = col.min(bytes.len());
2622 let mut stack: Vec<u8> = Vec::new();
2626 let mut i = 0;
2627 while i < cap {
2628 let c = bytes[i];
2629 let top = stack.last().copied();
2630 if matches!(top, Some(b'"') | Some(b'`')) && c == b'\\' && i + 1 < cap {
2633 i += 2;
2634 continue;
2635 }
2636 match top {
2637 Some(b'\'') => {
2639 if c == b'\'' {
2640 stack.pop();
2641 }
2642 i += 1;
2643 continue;
2644 }
2645 Some(b'"') => {
2647 if c == b'"' {
2648 stack.pop();
2649 i += 1;
2650 continue;
2651 }
2652 if c == b'$' && i + 1 < cap {
2653 let nxt = bytes[i + 1];
2654 if nxt == b'(' {
2655 stack.push(b'(');
2656 i += 2;
2657 continue;
2658 }
2659 if nxt == b'{' {
2660 stack.push(b'{');
2661 i += 2;
2662 continue;
2663 }
2664 }
2665 if c == b'`' {
2666 stack.push(b'`');
2667 i += 1;
2668 continue;
2669 }
2670 i += 1;
2671 continue;
2672 }
2673 Some(b'`') => {
2675 if c == b'`' {
2676 stack.pop();
2677 i += 1;
2678 continue;
2679 }
2680 if c == b'$' && i + 1 < cap {
2681 let nxt = bytes[i + 1];
2682 if nxt == b'(' {
2683 stack.push(b'(');
2684 i += 2;
2685 continue;
2686 }
2687 if nxt == b'{' {
2688 stack.push(b'{');
2689 i += 2;
2690 continue;
2691 }
2692 }
2693 i += 1;
2694 continue;
2695 }
2696 Some(b'(') => {
2698 if c == b')' {
2699 stack.pop();
2700 i += 1;
2701 continue;
2702 }
2703 }
2706 Some(b'{') => {
2708 if c == b'}' {
2709 stack.pop();
2710 i += 1;
2711 continue;
2712 }
2713 }
2715 _ => {}
2716 }
2717 match c {
2719 b'"' => stack.push(b'"'),
2720 b'\'' => stack.push(b'\''),
2721 b'`' => stack.push(b'`'),
2722 b'$' if i + 1 < cap => {
2723 let nxt = bytes[i + 1];
2724 if nxt == b'(' {
2725 stack.push(b'(');
2726 i += 2;
2727 continue;
2728 }
2729 if nxt == b'{' {
2730 stack.push(b'{');
2731 i += 2;
2732 continue;
2733 }
2734 if nxt == b'\'' {
2735 stack.push(b'\'');
2738 i += 2;
2739 continue;
2740 }
2741 }
2742 b'#' => {
2743 let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
2751 let comment_open = matches!(
2752 prev,
2753 None | Some(b' ') | Some(b'\t') | Some(b';') | Some(b'&') | Some(b'|')
2754 );
2755 if comment_open {
2756 return true;
2757 }
2758 }
2759 _ => {}
2760 }
2761 i += 1;
2762 }
2763 matches!(stack.last().copied(), Some(b'"') | Some(b'\''))
2766}
2767
2768pub(crate) fn line_position_inside_string_or_comment(line: &str, end: usize) -> bool {
2769 let bytes = line.as_bytes();
2770 let cap = end.min(bytes.len());
2771 let mut in_dq = false;
2772 let mut in_sq = false;
2773 let mut in_bt = false;
2774 let mut i = 0;
2775 while i < cap {
2776 let c = bytes[i];
2777 if in_dq {
2778 if c == b'\\' && i + 1 < cap {
2779 i += 2;
2780 continue;
2781 }
2782 if c == b'"' {
2783 in_dq = false;
2784 }
2785 } else if in_sq {
2786 if c == b'\'' {
2787 in_sq = false;
2788 }
2789 } else if in_bt {
2790 if c == b'\\' && i + 1 < cap {
2791 i += 2;
2792 continue;
2793 }
2794 if c == b'`' {
2795 in_bt = false;
2796 }
2797 } else if c == b'#' {
2798 return true;
2799 } else if c == b'"' {
2800 in_dq = true;
2801 } else if c == b'\'' {
2802 in_sq = true;
2803 } else if c == b'`' {
2804 in_bt = true;
2805 }
2806 i += 1;
2807 }
2808 in_dq || in_sq || in_bt
2809}
2810
2811pub(crate) fn line_position_inside_uninterpolating_context(line: &str, end: usize) -> bool {
2820 let bytes = line.as_bytes();
2821 let cap = end.min(bytes.len());
2822 let mut in_dq = false;
2823 let mut in_sq = false;
2824 let mut in_bt = false;
2825 let mut i = 0;
2826 while i < cap {
2827 let c = bytes[i];
2828 if in_dq {
2829 if c == b'\\' && i + 1 < cap {
2830 i += 2;
2831 continue;
2832 }
2833 if c == b'"' {
2834 in_dq = false;
2835 }
2836 } else if in_sq {
2837 if c == b'\'' {
2838 in_sq = false;
2839 }
2840 } else if in_bt {
2841 if c == b'\\' && i + 1 < cap {
2842 i += 2;
2843 continue;
2844 }
2845 if c == b'`' {
2846 in_bt = false;
2847 }
2848 } else if c == b'#' {
2849 let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
2854 let starts_comment = match prev {
2855 None => true,
2856 Some(p) => matches!(p, b' ' | b'\t' | b';' | b'&' | b'|' | b'('),
2857 };
2858 if starts_comment {
2859 return true;
2860 }
2861 } else if c == b'"' {
2862 in_dq = true;
2863 } else if c == b'\'' {
2864 in_sq = true;
2865 } else if c == b'`' {
2866 in_bt = true;
2867 }
2868 i += 1;
2869 }
2870 in_sq
2873}
2874
2875pub(crate) fn line_starts_comment_before(line: &str, end: usize) -> bool {
2876 let bytes = line.as_bytes();
2877 let cap = end.min(bytes.len());
2878 let mut in_dq = false;
2879 let mut in_sq = false;
2880 let mut in_bt = false;
2881 let mut i = 0;
2882 while i < cap {
2883 let c = bytes[i];
2884 if in_dq {
2885 if c == b'\\' && i + 1 < cap {
2886 i += 2;
2887 continue;
2888 }
2889 if c == b'"' {
2890 in_dq = false;
2891 }
2892 } else if in_sq {
2893 if c == b'\'' {
2894 in_sq = false;
2895 }
2896 } else if in_bt {
2897 if c == b'\\' && i + 1 < cap {
2898 i += 2;
2899 continue;
2900 }
2901 if c == b'`' {
2902 in_bt = false;
2903 }
2904 } else {
2905 if c == b'#' {
2906 return true;
2907 }
2908 if c == b'"' {
2909 in_dq = true;
2910 } else if c == b'\'' {
2911 in_sq = true;
2912 } else if c == b'`' {
2913 in_bt = true;
2914 }
2915 }
2916 i += 1;
2917 }
2918 false
2919}
2920pub fn lookup_doc(name: &str) -> String {
2922 if let Some(d) = OPERATOR_DOCS.iter().find(|(k, _)| *k == name) {
2936 return format!("**{}** — _zsh operator_\n\n{}", d.0, d.1);
2937 }
2938 if let Some((canon, body)) = crate::zsh_keyword_docs::lookup_keyword_doc(name) {
2939 return format!("**{}** — _zsh keyword_\n\n{}", canon, body);
2940 }
2941 let is_keyword = crate::ported::hashtable::RESWDS
2952 .iter()
2953 .any(|(n, _)| *n == name);
2954 let is_real_builtin = crate::ported::builtin::BUILTINS
2955 .iter()
2956 .any(|b| b.node.nam == name);
2957 if is_keyword && !is_real_builtin {
2958 if let Some(d) = KEYWORD_DOCS.iter().find(|(k, _)| *k == name) {
2959 return format!("**{}** — _zsh keyword_\n\n{}", d.0, d.1);
2960 }
2961 return format!("**{}** — _zsh keyword_", name);
2964 }
2965 let is_extension = crate::ext_builtins::EXT_BUILTIN_NAMES.contains(&name)
2973 || crate::daemon::builtins::ZSHRS_BUILTIN_NAMES.contains(&name);
2974 if is_extension {
2975 if let Some(body) = crate::zsh_ext_builtin_docs::lookup_full(name) {
2976 return format!("**{}** — _zshrs extension builtin_\n\n{}", name, body);
2977 }
2978 if let Some(d) = EXT_BUILTIN_DOCS.iter().find(|(k, _)| *k == name) {
2979 return format!("**{}** — _zshrs extension builtin_\n\n{}", d.0, d.1);
2980 }
2981 }
2982 if !is_real_builtin {
2994 if let Some((canon, body)) = crate::zsh_special_var_docs::lookup_special_var_doc(name) {
2995 return format!("**${}** — _special variable_\n\n{}", canon, body);
2996 }
2997 let bare = name.strip_prefix('$').unwrap_or(name);
2998 if !bare.is_empty() && bare != name {
2999 if let Some((canon, body)) = crate::zsh_special_var_docs::lookup_special_var_doc(bare) {
3000 return format!("**${}** — _special variable_\n\n{}", canon, body);
3001 }
3002 }
3003 }
3004 if let Some((canon, body)) = crate::zsh_builtin_docs::lookup_builtin_doc(name) {
3005 return format!("**{}** — _zsh builtin_\n\n{}", canon, body);
3006 }
3007 if let Some((canon, body)) = crate::zsh_special_var_docs::lookup_special_var_doc(name) {
3014 return format!("**${}** — _special variable_\n\n{}", canon, body);
3015 }
3016 let bare = name.strip_prefix('$').unwrap_or(name);
3017 if !bare.is_empty() && bare != name {
3018 if let Some((canon, body)) = crate::zsh_special_var_docs::lookup_special_var_doc(bare) {
3019 return format!("**${}** — _special variable_\n\n{}", canon, body);
3020 }
3021 }
3022 if let Some((canon, body)) = crate::zsh_option_docs::lookup_option_doc(name) {
3023 return format!("**{}** — _zsh option_\n\n{}", canon, body);
3024 }
3025 if let Some(d) = KEYWORD_DOCS.iter().find(|(k, _)| *k == name) {
3027 return format!("**{}** — _zsh keyword_\n\n{}", d.0, d.1);
3028 }
3029 if let Some(d) = BUILTIN_DOCS.iter().find(|(k, _)| *k == name) {
3030 return format!("**{}** — _zsh builtin_\n\n{}", d.0, d.1);
3031 }
3032 if name.starts_with('$') {
3033 if let Some(d) = SPECIAL_VAR_DOCS.iter().find(|(k, _)| *k == name) {
3034 return format!("**{}** — _special variable_\n\n{}", d.0, d.1);
3035 }
3036 }
3037 if let Some(d) = OPTION_DOCS_FALLBACK
3038 .iter()
3039 .find(|(k, _)| k.eq_ignore_ascii_case(name))
3040 {
3041 return format!("**{}** — _zsh option_\n\n{}", d.0, d.1);
3042 }
3043 if let Some(body) = crate::zsh_ext_builtin_docs::lookup_full(name) {
3049 return format!("**{}** — _zshrs extension builtin_\n\n{}", name, body);
3050 }
3051 if let Some(d) = EXT_BUILTIN_DOCS.iter().find(|(k, _)| *k == name) {
3052 return format!("**{}** — _zshrs extension builtin_\n\n{}", d.0, d.1);
3053 }
3054 if let Some(d) = COMPSYS_FN_DOCS.iter().find(|(k, _)| *k == name) {
3055 return format!("**{}** — _compsys function_\n\n{}", d.0, d.1);
3056 }
3057 String::new()
3058}
3059
3060const OPERATOR_DOCS: &[(&str, &str)] = &[
3070 ("|", "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)."),
3072 ("|&", "Pipeline merging stderr. `cmd1 |& cmd2` = `cmd1 2>&1 | cmd2`. Both stdout AND stderr of `cmd1` are piped to `cmd2`."),
3073 ("&&", "Logical AND list operator. `cmd1 && cmd2` runs `cmd2` only if `cmd1` succeeded (exit status 0). Short-circuits."),
3075 ("||", "Logical OR list operator. `cmd1 || cmd2` runs `cmd2` only if `cmd1` failed (non-zero exit). Short-circuits."),
3076 (";", "Sequential list separator. `cmd1; cmd2` runs `cmd2` after `cmd1` finishes, regardless of its exit status."),
3077 ("&", "Background list operator. `cmd &` runs `cmd` asynchronously in the background; the shell does not wait. Sets `$!` to the job's PID."),
3078 (";;", "Case-branch terminator. Ends a `case` arm: `case x in pat) cmds ;; esac`. Stops case dispatch after this arm."),
3079 (";;&", "Case-branch fall-through-and-test-next. Continues to the next `case` arm and tests its pattern."),
3080 (";|", "Case-branch unconditional fall-through. Continues to the next `case` arm and runs it without testing its pattern."),
3081 ("!", "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)."),
3083 (">", "Stdout redirect. `cmd > file` writes `cmd`'s stdout to `file` (overwrite). With `NO_CLOBBER`, refuses to overwrite an existing file — use `>|` or `>!` to force."),
3085 (">>", "Stdout append. `cmd >> file` appends `cmd`'s stdout to `file` (creates if missing)."),
3086 ("<", "Stdin redirect. `cmd < file` makes `file` the source of `cmd`'s stdin."),
3087 ("<<", "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."),
3088 ("<<-", "Heredoc with tab-stripping. Like `<<` but every leading tab on body lines (and the terminator) is removed — lets you indent the heredoc for readability."),
3089 ("<<<", "Here-string. `cmd <<< 'text'` makes the literal string `text` the source of `cmd`'s stdin. Adds a trailing newline."),
3090 ("&>", "Redirect stdout + stderr together. `cmd &> file` = `cmd > file 2>&1`. Shorthand for the common combined redirect."),
3091 ("&>>", "Append stdout + stderr together. `cmd &>> file` = `cmd >> file 2>&1`."),
3092 (">&", "Redirect a file descriptor. `2>&1` sends stderr to wherever stdout currently points. `>& file` is also accepted as `&> file`."),
3093 ("<&", "Duplicate an input file descriptor. `cmd <&3` reads from fd 3. `<& -` closes stdin."),
3094 ("<>", "Read+write redirect. `cmd <> file` opens `file` for both reading and writing on stdin."),
3095 (">|", "Force-overwrite redirect. Equivalent to `>` but ignores `NO_CLOBBER`."),
3096 (">!", "Same as `>|` — force-overwrite, bypass `NO_CLOBBER`."),
3097 ("[[", "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."),
3099 ("]]", "Close zsh conditional expression. Pairs with `[[`. Must be a separate word — `[[ -n $x]]` is a syntax error; use `[[ -n $x ]]`."),
3100 ("[", "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."),
3101 ("]", "Close POSIX `test`. Pairs with `[`."),
3102 ("((", "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++ ))`."),
3103 ("))", "Close arithmetic command. Pairs with `((`."),
3104 ("$(", "Command substitution open. `$(cmd)` runs `cmd` and substitutes its trimmed-trailing-newline stdout. Nestable: `$(echo $(date))`. Preferred over backticks."),
3106 ("${", "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."),
3107 ("$((", "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."),
3108 ("<(", "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."),
3109 (">(", "Process substitution (output). `cmd >(consumer)` exposes a filename to `cmd`; anything `cmd` writes there flows to `consumer`'s stdin."),
3110 ("`", "Backtick command substitution. ``cmd`` runs `cmd` and substitutes its stdout. Legacy form — prefer `$(cmd)` for nestability and quoting clarity."),
3111 ("-e", "File-exists test. `[[ -e PATH ]]` is true if `PATH` exists (any type — file / dir / link / socket / ...)."),
3113 ("-f", "Regular-file test. `[[ -f PATH ]]` is true if `PATH` exists AND is a regular file (not a directory / symlink / device)."),
3114 ("-d", "Directory test. `[[ -d PATH ]]` is true if `PATH` exists AND is a directory."),
3115 ("-r", "Readable test. `[[ -r PATH ]]` is true if `PATH` exists AND is readable by the current process."),
3116 ("-w", "Writable test. `[[ -w PATH ]]` is true if `PATH` exists AND is writable by the current process."),
3117 ("-x", "Executable test. `[[ -x PATH ]]` is true if `PATH` exists AND has execute permission (or for directories, search permission)."),
3118 ("-s", "Non-empty test. `[[ -s PATH ]]` is true if `PATH` exists AND has size > 0."),
3119 ("-L", "Symlink test. `[[ -L PATH ]]` is true if `PATH` is a symbolic link (does NOT dereference)."),
3120 ("-h", "Same as `-L` — symlink test."),
3121 ("-z", "Empty-string test. `[[ -z $s ]]` is true if `$s` is the empty string."),
3122 ("-n", "Non-empty-string test. `[[ -n $s ]]` is true if `$s` has length > 0. Equivalent to `[[ $s ]]`."),
3123 ("-eq", "Numeric equality. `[[ a -eq b ]]` is true if integers `a` and `b` are equal. For strings use `==`."),
3125 ("-ne", "Numeric inequality. `[[ a -ne b ]]` is true if integers `a` and `b` differ."),
3126 ("-lt", "Numeric less-than. `[[ a -lt b ]]` is true if integer `a` < `b`."),
3127 ("-le", "Numeric less-or-equal. `[[ a -le b ]]` is true if integer `a` ≤ `b`."),
3128 ("-gt", "Numeric greater-than. `[[ a -gt b ]]` is true if integer `a` > `b`."),
3129 ("-ge", "Numeric greater-or-equal. `[[ a -ge b ]]` is true if integer `a` ≥ `b`."),
3130 ("-ot", "Older-than test. `[[ A -ot B ]]` is true if file `A` has an older mtime than `B`."),
3131 ("-nt", "Newer-than test. `[[ A -nt B ]]` is true if file `A` has a newer mtime than `B`."),
3132 ("-ef", "Same-file test. `[[ A -ef B ]]` is true if `A` and `B` are the same inode (hard-linked / same path)."),
3133 ("==", "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\" ]]`."),
3135 ("!=", "Pattern-mismatch (inside `[[ … ]]`). Inverse of `==`. Quote the RHS for literal comparison."),
3136 ("=~", "Regex match (inside `[[ … ]]`). `[[ $s =~ pat ]]` matches `$s` against the regex `pat`. Capture groups land in `$match` / `$MATCH` / `$BASH_REMATCH`."),
3137 ("*", "Glob: match zero or more characters of any name (excluding leading `.` unless `GLOB_DOTS` is set). Also a multiplication operator inside `(( … ))`."),
3139 ("?", "Glob: match exactly one character. Also the last-exit-status variable when used as `$?`."),
3140 ("**", "Recursive glob (zsh extended). `**/*.rs` matches `*.rs` at any depth under the current directory. Requires `EXTENDED_GLOB` for additional pattern operators."),
3141 ("~", "Pattern exclude (with `EXTENDED_GLOB`). `*~README` matches everything except `README`. Also tilde expansion: `~` = `$HOME`, `~user` = user's home, `~+` = `$PWD`, `~-` = `$OLDPWD`."),
3142 ("^", "Pattern negate first-match (with `EXTENDED_GLOB`). `^*.rs` matches everything that's NOT `*.rs`. Inside `[…]` ranges, negates: `[^abc]`."),
3143 ("{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."),
3145 ("{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}`."),
3146 ("=", "Assignment. `VAR=value`. NO whitespace around `=`. With `local` / `typeset`: `local VAR=value` declares + assigns."),
3148 ("+=", "Append assignment. `VAR+=more` appends to a scalar; for arrays `arr+=(x y)` appends elements. Numeric for `integer`: `(( count += 1 ))`."),
3149 (":=", "Conditional-assign default (inside `${…}`). `${VAR:=fallback}` assigns `fallback` to `VAR` (and substitutes it) if `VAR` is unset or empty."),
3150 ("?=", "Error-if-unset (inside `${…}`). `${VAR:?msg}` substitutes `$VAR` if set, else prints `msg` to stderr and exits."),
3151];
3152
3153const PARAM_FLAG_DOCS: &[(&str, &str)] = &[
3159 ("-", "sort decimal integers numerically (signed)"),
3160 ("@", "prevent double-quoted joining of arrays"),
3161 ("*", "enable extended globs for pattern arguments"),
3162 ("#", "interpret numeric expression as character code"),
3163 ("%", "expand prompt sequences (`%P` for prompt-only escapes)"),
3164 ("~", "treat strings in parameter flag arguments as patterns"),
3165 ("0", "split words on null bytes"),
3166 ("A", "assign as an array parameter (in `${...=...}` etc)"),
3167 ("a", "sort in array index order (with `O` to reverse)"),
3168 ("b", "backslash-quote pattern characters only"),
3169 ("B", "include index of beginning of match in `#`, `%` expressions"),
3170 ("C", "capitalize words"),
3171 ("c", "count characters in an array (with `${(c)#...}`)"),
3172 ("D", "perform directory name abbreviation"),
3173 ("E", "include index of one past end of match in `#`, `%` expressions"),
3174 ("e", "perform single-word shell expansions"),
3175 ("F", "join arrays with newlines"),
3176 ("f", "split the result on newlines"),
3177 ("g", "process echo array sequences (needs options like `gec`)"),
3178 ("I", "search Nth match in `#`, `%`, `/` expressions (`(I:N:)`)"),
3179 ("i", "sort case-insensitively"),
3180 ("j", "join arrays with specified string (`(j:STR:)`)"),
3181 ("k", "substitute keys of associative arrays"),
3182 ("l", "left-pad resulting words (`(l:N:)`, `(l:N::pad:)`)"),
3183 ("L", "lower case all letters"),
3184 ("m", "count multibyte width in padding calculation"),
3185 ("M", "include matched portion in `#`, `%` expressions"),
3186 ("N", "include length of match in `#`, `%` expressions"),
3187 ("n", "sort positive decimal integers numerically (unsigned)"),
3188 ("o", "sort in ascending order (lexically if no other sort option)"),
3189 ("O", "sort in descending order (lexically if no other sort option)"),
3190 ("p", "handle print escapes in parameter flag string arguments"),
3191 ("P", "use parameter value as name of parameter for redirected lookup"),
3192 ("q", "quote with backslashes (`q-` shell-quote, `qq` single-quote, `qqq` double-quote, `qqqq` $'...')"),
3193 ("Q", "remove one level of quoting"),
3194 ("R", "include rest (unmatched portion) in `#`, `%` expressions"),
3195 ("r", "right-pad resulting words (`(r:N:)`, `(r:N::pad:)`)"),
3196 ("S", "match non-greedy in `/`, `//`, or search substrings in `%`/`#` expressions"),
3197 ("s", "split words on specified string (`(s:STR:)`)"),
3198 ("t", "substitute type of parameter (`scalar`, `array`, `association`, `integer`, `float`, plus flags)"),
3199 ("u", "substitute first occurrence of each unique word"),
3200 ("U", "upper case all letters"),
3201 ("v", "substitute values of associative arrays (with `k`)"),
3202 ("V", "visibility enhancements for special characters"),
3203 ("w", "count words in array or string (with `${(w)#...}`)"),
3204 ("W", "count words including empty words (with `${(W)#...}`)"),
3205 ("X", "report parsing errors and exit substitution on failure"),
3206 ("z", "split words as if a zsh command line"),
3207 ("Z", "split words as if a zsh command line (with options — `(Z:cn:)`, `(Z:Cn:)`)"),
3208];
3209
3210const GLOB_QUALIFIER_DOCS: &[(&str, &str)] = &[
3215 ("/", "directories"),
3217 ("F", "non-empty directories"),
3218 (".", "plain files (regular)"),
3219 ("@", "symbolic links"),
3220 ("=", "sockets"),
3221 ("p", "named pipes (FIFOs)"),
3222 ("*", "executable plain files (mode `0111`)"),
3223 ("%", "device files (block or character)"),
3224 ("r", "owner-readable"),
3226 ("w", "owner-writable"),
3227 ("x", "owner-executable"),
3228 ("A", "group-readable"),
3229 ("I", "group-writable"),
3230 ("E", "group-executable"),
3231 ("R", "world-readable"),
3232 ("W", "world-writable"),
3233 ("X", "world-executable"),
3234 ("s", "setuid"),
3235 ("S", "setgid"),
3236 ("t", "sticky bit set"),
3237 ("U", "owned by current effective uid"),
3238 ("G", "owned by current effective gid"),
3239 ("u", "owned by specified uid (`u:LOGIN:` / `u<UID>`)"),
3240 ("g", "owned by specified gid (`g:GROUP:` / `g<GID>`)"),
3241 ("f", "exact file mode match (`f:SPEC:`, eg `f:u+w:`)"),
3242 ("a", "atime (`a-N` younger than N days, `a+N` older)"),
3244 ("m", "mtime (`m-N` / `m+N`; suffixes `M`/`w`/`h`/`m`/`s`)"),
3245 ("c", "ctime (`c-N` / `c+N`)"),
3246 ("L", "size in bytes (`L-N`, `L+N`, suffixes `k`/`m`/`p`)"),
3247 ("l", "link count (`l-N` / `l+N`)"),
3248 ("d", "files on device DEV (`d<DEV>`)"),
3249 ("o", "order ascending (`oN` name, `oL` size, `om` mtime, `oa` atime, `oc` ctime, `od` depth, `oe:cmd:` custom)"),
3251 ("O", "order descending (same suffixes as `o`)"),
3252 ("[", "slice / range (`[N]`, `[N,M]`, `[N,-1]`)"),
3253 ("^", "negate the rest of the qualifier list"),
3254 ("-", "follow symbolic links when testing subsequent qualifiers"),
3255 ("M", "mark directories with trailing `/`"),
3256 ("T", "mark types with file-type indicator (`/=@*%|`)"),
3257 ("N", "set NULL_GLOB for this glob only (no match → empty)"),
3258 ("D", "include dotfiles in matches"),
3259 ("n", "numeric sort (use with `o` / `O`)"),
3260 ("Y", "early termination after N matches (`Y<N>`)"),
3261 ("P", "prepend WORD to each result (`P:WORD:`)"),
3262 ("e", "evaluate expression on each candidate (`e:EXPR:`); `$REPLY` is the filename"),
3263 ("+", "true if `cmd FILENAME` exits 0 (`+cmd`)"),
3264];
3265
3266const HISTORY_DESIGNATOR_DOCS: &[(&str, &str)] = &[
3272 ("!", "previous command (`!!`)"),
3273 ("N", "command N from history (`!42`)"),
3274 ("-N", "N commands back (`!-3` = third-to-last)"),
3275 ("str", "most recent command starting with `str` (`!ls`)"),
3276 (
3277 "?str?",
3278 "most recent command containing `str` (`!?docker?`)",
3279 ),
3280 ("#", "current command line typed so far"),
3281 ("$", "last argument of previous command (= `!!:$`)"),
3282 ("^", "first argument of previous command (= `!!:^`)"),
3283 ("*", "all arguments of previous command (= `!!:*`)"),
3284 (
3285 ":",
3286 "introduce a word designator / modifier — `!!:1`, `!!:s/old/new/`, `!!:h`",
3287 ),
3288];
3289
3290const PARAM_MODIFIER_DOCS: &[(&str, &str)] = &[
3300 ("-", "`${var:-WORD}` — use WORD if `var` unset or empty"),
3302 (
3303 "=",
3304 "`${var:=WORD}` — assign WORD to `var` (and use it) if unset/empty",
3305 ),
3306 (
3307 "?",
3308 "`${var:?MSG}` — print MSG to stderr + exit if `var` unset/empty",
3309 ),
3310 (
3311 "+",
3312 "`${var:+WORD}` — use WORD if `var` IS set (the inverse of `:-`)",
3313 ),
3314 (
3316 "0",
3317 "`${var:OFFSET:LENGTH}` — substring (zero-based; negative offset = from end)",
3318 ),
3319 ("h", "head — strip last path component (like `dirname`)"),
3321 (
3322 "t",
3323 "tail — keep ONLY last path component (like `basename`)",
3324 ),
3325 ("r", "root — strip the final `.ext` suffix"),
3326 (
3327 "e",
3328 "extension — keep ONLY the final `.ext` (no leading dot)",
3329 ),
3330 (
3331 "a",
3332 "absolute — textually resolve `..` / `.` against `$PWD`",
3333 ),
3334 ("A", "absolute + resolve symlinks (like `realpath`)"),
3335 (
3336 "c",
3337 "PATH lookup — replace bare command with full path via `$PATH`",
3338 ),
3339 ("P", "physical path — resolve all symlinks"),
3340 (
3341 "f",
3342 "repeat `:h` until the result is no longer an existing directory",
3343 ),
3344 ("F", "`:F:N:` — repeat `:h` N times"),
3345 ("s", "`:s/OLD/NEW/` — substitute first OLD with NEW"),
3347 (
3348 "gs",
3349 "`:gs/OLD/NEW/` — global substitute (every occurrence)",
3350 ),
3351 ("&", "repeat the last `:s` substitution"),
3352 ("g&", "repeat the last `:s` substitution globally"),
3353 ("q", "quote — backslash-escape all metacharacters"),
3355 ("Q", "unquote — remove ONE level of quoting"),
3356 ("x", "quote, breaking at whitespace into separate words"),
3357 ("l", "lowercase first character"),
3359 ("u", "uppercase first character"),
3360 ("L", "lowercase ENTIRE string"),
3361 ("U", "uppercase ENTIRE string"),
3362 ("C", "capitalize each word (`Title Case`)"),
3363 ("S", "sort array elements ascending"),
3365 ("O", "sort array elements descending"),
3366 (
3367 "#",
3368 "`${var:#PATTERN}` — remove array elements matching PATTERN (with `(@)`)",
3369 ),
3370 (
3371 "|",
3372 "`${arr:|other}` — set difference (elements of `arr` not in `other`)",
3373 ),
3374 ("*", "`${arr:*other}` — set intersection"),
3375 ("^", "`${arr:^other}` — interleave (zip) two arrays"),
3376 ("^^", "`${arr:^^other}` — distributed zip (every pair)"),
3377];
3378
3379const SIGNAL_NAMES: &[(&str, &str)] = &[
3384 ("HUP", "1 — hangup (terminal closed)"),
3385 ("INT", "2 — interrupt (Ctrl-C)"),
3386 ("QUIT", "3 — quit + core dump (Ctrl-\\)"),
3387 ("ILL", "4 — illegal instruction"),
3388 ("TRAP", "5 — trace/breakpoint trap"),
3389 ("ABRT", "6 — abort (`abort()` syscall)"),
3390 ("BUS", "7 — bus error"),
3391 ("FPE", "8 — floating-point exception"),
3392 ("KILL", "9 — kill (uncatchable, unblockable)"),
3393 ("USR1", "10 — user-defined signal 1"),
3394 ("SEGV", "11 — segmentation fault"),
3395 ("USR2", "12 — user-defined signal 2"),
3396 ("PIPE", "13 — write to pipe with no readers"),
3397 ("ALRM", "14 — alarm clock (`alarm()`)"),
3398 ("TERM", "15 — termination request (default `kill`)"),
3399 ("CHLD", "17 — child process state change"),
3400 ("CONT", "18 — continue if stopped"),
3401 ("STOP", "19 — stop (uncatchable)"),
3402 ("TSTP", "20 — terminal stop (Ctrl-Z)"),
3403 ("TTIN", "21 — background process needs tty input"),
3404 ("TTOU", "22 — background process tty output"),
3405 ("URG", "23 — urgent socket data"),
3406 ("XCPU", "24 — CPU time limit exceeded"),
3407 ("XFSZ", "25 — file size limit exceeded"),
3408 ("VTALRM", "26 — virtual timer alarm"),
3409 ("PROF", "27 — profiling timer alarm"),
3410 ("WINCH", "28 — window size change"),
3411 ("IO", "29 — async I/O ready"),
3412 ("PWR", "30 — power failure"),
3413 ("SYS", "31 — bad syscall"),
3414 ("EXIT", "0 — shell exit (special — `trap ... EXIT`)"),
3416 ("ZERR", "zsh — fires on any non-zero exit status"),
3417 (
3418 "DEBUG",
3419 "zsh — fires before every command (with `DEBUG_BEFORE_CMD`)",
3420 ),
3421];
3422
3423const ZSH_MODULE_NAMES: &[(&str, &str)] = &[
3427 ("zsh/attr", "extended file attribute manipulation"),
3428 ("zsh/cap", "POSIX capability sets"),
3429 ("zsh/clone", "fork the shell to a new session"),
3430 ("zsh/compctl", "legacy `compctl` completion (deprecated)"),
3431 ("zsh/complete", "core programmable completion machinery"),
3432 (
3433 "zsh/complist",
3434 "completion list display + menuselect keymap",
3435 ),
3436 (
3437 "zsh/computil",
3438 "internal helpers used by `_arguments` / `_describe`",
3439 ),
3440 ("zsh/curses", "ncurses bindings (`zcurses`)"),
3441 (
3442 "zsh/datetime",
3443 "`strftime` builtin + `$EPOCHSECONDS` / `$EPOCHREALTIME`",
3444 ),
3445 (
3446 "zsh/db/gdbm",
3447 "GDBM key-value store as a zsh associative array",
3448 ),
3449 (
3450 "zsh/deltochar",
3451 "`delete-to-char` / `zap-to-char` ZLE widgets",
3452 ),
3453 ("zsh/example", "template module (skeleton; not useful)"),
3454 (
3455 "zsh/files",
3456 "in-shell file ops (`mkdir`, `chmod`, `mv`, `rm`, `chown`, `sync`, `ln`)",
3457 ),
3458 ("zsh/langinfo", "locale info (`$langinfo`)"),
3459 ("zsh/mapfile", "read/write a file as an assoc array"),
3460 (
3461 "zsh/mathfunc",
3462 "`sin`, `cos`, `sqrt`, `log`, `exp`, … math functions for `((…))`",
3463 ),
3464 ("zsh/nearcolor", "approximate-color terminal fallback"),
3465 ("zsh/newuser", "first-run user setup helper"),
3466 (
3467 "zsh/parameter",
3468 "reflection — `$functions`, `$aliases`, `$options`, `$commands`, `$parameters`, etc.",
3469 ),
3470 ("zsh/pcre", "Perl-compatible regex (`pcre_match` / `=~`)"),
3471 ("zsh/regex", "POSIX extended regex (`=~`)"),
3472 ("zsh/sched", "in-shell scheduler (`sched +5 cmd`)"),
3473 ("zsh/net/socket", "Unix-domain socket builtin (`zsocket`)"),
3474 ("zsh/stat", "`stat` builtin returning fields into a hash"),
3475 (
3476 "zsh/system",
3477 "low-level syscalls (`sysread`, `syswrite`, `syserror`, `sysopen`)",
3478 ),
3479 ("zsh/net/tcp", "TCP socket builtin (`ztcp`)"),
3480 ("zsh/termcap", "termcap parameter access (`$termcap`)"),
3481 ("zsh/terminfo", "terminfo parameter access (`$terminfo`)"),
3482 ("zsh/zftp", "FTP client built into the shell"),
3483 (
3484 "zsh/zle",
3485 "Zsh Line Editor — `bindkey`, `zle`, widget registration",
3486 ),
3487 (
3488 "zsh/zleparameter",
3489 "ZLE introspection — `$widgets`, `$keymaps`",
3490 ),
3491 ("zsh/zprof", "profiling — `zprof` builtin"),
3492 ("zsh/zpty", "spawn commands in a pseudo-terminal"),
3493 ("zsh/zselect", "`select(2)` on fds with a timeout"),
3494 (
3495 "zsh/zutil",
3496 "core utilities — `zparseopts`, `zformat`, `zstyle`, `zregexparse`",
3497 ),
3498];
3499
3500const KEYMAP_NAMES: &[(&str, &str)] = &[
3503 ("emacs", "GNU Readline emacs bindings (default)"),
3504 ("vicmd", "vi command-mode keymap"),
3505 ("viins", "vi insert-mode keymap"),
3506 ("viopp", "vi operator-pending keymap (for `d` / `c` / `y`)"),
3507 ("visual", "vi visual-mode keymap"),
3508 (
3509 ".safe",
3510 "minimal fallback keymap — only `self-insert` + `accept-line`",
3511 ),
3512 (
3513 "main",
3514 "alias — whichever keymap is currently the editing map",
3515 ),
3516 ("command", "vi-mode command-line input keymap"),
3517 ("menuselect", "active inside `menu-select` widget"),
3518 ("isearch", "active inside incremental-search widgets"),
3519 ("listscroll", "active when scrolling completion list"),
3520];
3521
3522const ZLE_WIDGET_NAMES: &[(&str, &str)] = &[
3527 ("backward-char", "move one character left"),
3529 ("forward-char", "move one character right"),
3530 ("backward-word", "move one word left"),
3531 ("forward-word", "move one word right"),
3532 ("beginning-of-line", "move to start of line"),
3533 ("end-of-line", "move to end of line"),
3534 (
3535 "beginning-of-buffer-or-history",
3536 "start of buffer / previous-history at top",
3537 ),
3538 (
3539 "end-of-buffer-or-history",
3540 "end of buffer / next-history at bottom",
3541 ),
3542 ("self-insert", "insert the typed character"),
3544 ("accept-line", "submit current line for execution"),
3545 ("accept-and-hold", "submit + keep line in buffer"),
3546 (
3547 "accept-and-infer-next-history",
3548 "submit + recall the line after this in history",
3549 ),
3550 ("backward-delete-char", "delete character before cursor"),
3551 ("delete-char", "delete character under cursor"),
3552 (
3553 "backward-kill-word",
3554 "delete word before cursor (saves to kill ring)",
3555 ),
3556 ("kill-word", "delete word after cursor"),
3557 ("backward-kill-line", "delete from cursor to start of line"),
3558 ("kill-line", "delete from cursor to end of line"),
3559 ("kill-whole-line", "delete entire line"),
3560 ("kill-region", "delete from mark to cursor"),
3561 ("yank", "paste last kill"),
3562 ("yank-pop", "rotate to earlier kill (after `yank`)"),
3563 ("transpose-chars", "swap two characters"),
3564 ("transpose-words", "swap two words"),
3565 ("up-case-word", "uppercase next word"),
3566 ("down-case-word", "lowercase next word"),
3567 ("capitalize-word", "capitalize next word"),
3568 (
3569 "quoted-insert",
3570 "literal-insert next key (e.g. for control chars)",
3571 ),
3572 ("overwrite-mode", "toggle insert / overwrite"),
3573 ("undo", "undo last edit"),
3574 ("redo", "redo last undone edit"),
3575 ("clear-screen", "clear terminal + redraw"),
3576 ("redisplay", "force redraw"),
3577 ("send-break", "abandon line (SIGINT-equivalent)"),
3578 (
3580 "up-line-or-history",
3581 "previous line / previous history entry",
3582 ),
3583 ("down-line-or-history", "next line / next history entry"),
3584 ("up-history", "previous history entry"),
3585 ("down-history", "next history entry"),
3586 ("beginning-of-history", "first history entry"),
3587 ("end-of-history", "last history entry (current line)"),
3588 (
3589 "history-incremental-search-backward",
3590 "Ctrl-R — incremental search backward",
3591 ),
3592 (
3593 "history-incremental-search-forward",
3594 "Ctrl-S — incremental search forward",
3595 ),
3596 (
3597 "history-search-backward",
3598 "search history matching current line prefix",
3599 ),
3600 (
3601 "history-search-forward",
3602 "forward variant of `history-search-backward`",
3603 ),
3604 (
3605 "history-beginning-search-backward",
3606 "search backward keeping cursor position",
3607 ),
3608 (
3609 "history-beginning-search-forward",
3610 "search forward keeping cursor position",
3611 ),
3612 (
3613 "infer-next-history",
3614 "infer next-history based on previous match",
3615 ),
3616 (
3617 "insert-last-word",
3618 "insert last word of previous line (`!!:$`)",
3619 ),
3620 ("complete-word", "complete the current word"),
3622 ("expand-or-complete", "expand alias / glob, else complete"),
3623 (
3624 "expand-or-complete-prefix",
3625 "as above but with prefix match",
3626 ),
3627 ("list-choices", "show completion options without inserting"),
3628 ("menu-complete", "cycle through completions"),
3629 ("menu-expand-or-complete", "expand / cycle"),
3630 ("reverse-menu-complete", "cycle backward"),
3631 (
3632 "delete-char-or-list",
3633 "delete-char if not at EOL, else list-choices",
3634 ),
3635 ("complete-prefix", "complete current prefix"),
3636 ("expand-cmd-path", "expand command to full path"),
3637 ("expand-word", "expand current word"),
3638 ("vi-cmd-mode", "switch to vi command mode"),
3640 ("vi-insert", "switch to vi insert mode"),
3641 ("vi-insert-bol", "insert at start of line"),
3642 ("vi-add-next", "append after current char (vi `a`)"),
3643 ("vi-add-eol", "append at end of line (vi `A`)"),
3644 ("vi-backward-char", "h"),
3645 ("vi-forward-char", "l"),
3646 ("vi-backward-word", "b"),
3647 ("vi-forward-word", "w"),
3648 ("vi-backward-word-end", "ge"),
3649 ("vi-forward-word-end", "e"),
3650 ("vi-backward-blank-word", "B"),
3651 ("vi-forward-blank-word", "W"),
3652 ("vi-up-line-or-history", "k — previous line / history"),
3653 ("vi-down-line-or-history", "j — next line / history"),
3654 ("vi-beginning-of-line", "0"),
3655 ("vi-end-of-line", "$"),
3656 ("vi-first-non-blank", "^"),
3657 ("vi-delete", "d"),
3658 ("vi-delete-char", "x"),
3659 ("vi-backward-delete-char", "X"),
3660 ("vi-change", "c"),
3661 ("vi-change-eol", "C"),
3662 ("vi-change-whole-line", "S"),
3663 ("vi-substitute", "s"),
3664 ("vi-yank", "y"),
3665 ("vi-yank-eol", "Y"),
3666 ("vi-yank-whole-line", "yy"),
3667 ("vi-put-after", "p"),
3668 ("vi-put-before", "P"),
3669 ("vi-replace", "R"),
3670 ("vi-replace-chars", "r"),
3671 ("vi-repeat-change", "."),
3672 ("vi-repeat-search", "n"),
3673 ("vi-rev-repeat-search", "N"),
3674 ("vi-find-next-char", "f"),
3675 ("vi-find-prev-char", "F"),
3676 ("vi-find-next-char-skip", "t"),
3677 ("vi-find-prev-char-skip", "T"),
3678 ("vi-undo-change", "u"),
3679 ("vi-join", "J — join with next line"),
3680 ("vi-quoted-insert", "Ctrl-V — literal next"),
3681 ("vi-set-buffer", "select named register"),
3682 ("vi-history-search-backward", "?"),
3683 ("vi-history-search-forward", "/"),
3684 ("vi-match-bracket", "% — jump to matching bracket"),
3685 ("which-command", "show what command would run"),
3687 ("describe-key-briefly", "show binding for next key"),
3688 ("execute-named-cmd", "M-x style command execution"),
3689 ("execute-last-named-cmd", "re-run last named command"),
3690 ("push-line", "save line + clear, runs on next prompt"),
3691 ("push-line-or-edit", "push-line or edit multiline"),
3692 ("push-input", "push to input stack"),
3693 ("get-line", "pop input from stack"),
3694 ("set-mark-command", "set the mark at cursor"),
3695 ("exchange-point-and-mark", "swap cursor + mark"),
3696 ("digit-argument", "begin numeric argument"),
3697 ("universal-argument", "begin numeric argument"),
3698 ("undefined-key", "called when binding lookup fails"),
3699];
3700
3701const TYPESET_FLAGS: &[(&str, &str)] = &[
3705 ("-a", "indexed array"),
3706 ("-A", "associative array (hash)"),
3707 ("-i", "integer (with optional base: `-i 16`)"),
3708 ("-E", "float, scientific notation"),
3709 ("-F", "float, fixed notation"),
3710 ("-l", "lowercase on assignment"),
3711 ("-u", "uppercase on assignment"),
3712 ("-L", "left-justify, width N (`-L4`)"),
3713 ("-R", "right-justify, width N (`-R8`)"),
3714 ("-Z", "zero-pad (right-justified, numeric)"),
3715 ("-r", "readonly"),
3716 ("-x", "export to environment"),
3717 ("-g", "global (skip the local scope this would create)"),
3718 ("-U", "unique — for arrays, drop duplicate elements"),
3719 ("-T", "tie scalar ↔ array (`-T PATH path :`)"),
3720 ("-t", "set the `TAGGED` flag (used by some completions)"),
3721 ("-H", "hide value in `typeset` listing"),
3722 ("-h", "hide builtin/special status"),
3723 ("-f", "operate on functions, not parameters"),
3724 ("-p", "print declarations in re-readable form"),
3725 ("-m", "treat name args as patterns (`typeset -m 'FOO*'`)"),
3726 ("-+", "operate at the next outer scope"),
3727];
3728
3729const TEST_OPERATORS: &[(&str, &str)] = &[
3733 ("-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."),
3735 ("-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."),
3736 ("-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`."),
3737 ("-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`."),
3738 ("-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."),
3739 ("-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`."),
3740 ("-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'`."),
3741 ("-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'`."),
3742 ("-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'`."),
3743 ("-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)."),
3744 ("-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; }`."),
3746 ("-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."),
3747 ("-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`."),
3748 ("-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."),
3749 ("-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`."),
3750 ("-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`."),
3751 ("-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'`."),
3752 ("-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."),
3753 ("-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`."),
3754 ("-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."),
3755 ("-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`."),
3757 ("-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."),
3758 ("=", "**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."),
3759 ("==", "**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."),
3760 ("!=", "**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 `==`."),
3761 ("<", "**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`."),
3762 (">", "**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."),
3763 ("=~", "**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."),
3764 ("-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`."),
3766 ("-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."),
3767 ("-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`."),
3768 ("-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)."),
3769 ("-gt", "**Numeric greater-than.** Integer-aware. Mirror of `-lt`.\n\nExample: `[[ $(date +%s) -gt $deadline ]] && abort 'timed out'`."),
3770 ("-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."),
3771 ("-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`."),
3773 ("-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."),
3774 ("-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."),
3775 ("!", "**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'`."),
3777 ("&&", "**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)."),
3778 ("||", "**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`."),
3779 ("-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."),
3780 ("-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."),
3781];
3782
3783const MATH_FUNCTIONS: &[(&str, &str)] = &[
3786 ("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 π."),
3788 ("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."),
3789 ("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."),
3790 ("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."),
3791 ("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."),
3792 ("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."),
3793 ("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."),
3794 ("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)."),
3795 ("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`."),
3796 ("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)`."),
3797 ("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)."),
3798 ("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)`."),
3799 ("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)."),
3800 ("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`."),
3802 ("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`."),
3803 ("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`."),
3804 ("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."),
3805 ("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)`."),
3806 ("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`."),
3807 ("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)`."),
3808 ("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."),
3809 ("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."),
3810 ("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."),
3811 ("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)."),
3813 ("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) ))`."),
3814 ("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) ))`."),
3815 ("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) ))`."),
3816 ("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`."),
3817 ("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)."),
3818 ("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."),
3820 ("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)."),
3821 ("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`."),
3822 ("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."),
3823 ("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."),
3824 ("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)²`)."),
3825 ("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)."),
3826 ("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."),
3827 ("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."),
3828 ("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."),
3829 ("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 }`."),
3831 ("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 }`."),
3832 ("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`."),
3833 ("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."),
3835 ("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."),
3836 ("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."),
3837 ("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)."),
3838 ("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) ))`."),
3839 ("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."),
3840 ("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."),
3841 ("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."),
3842 ("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`."),
3843 ("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."),
3844 ("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."),
3845 ("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."),
3846 ("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) ))`."),
3847 ("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."),
3848];
3849
3850const ZSTYLE_CONTEXTS: &[(&str, &str)] = &[
3855 (":completion:*", "all completion settings"),
3856 (":completion:*:default", "default completion"),
3857 (
3858 ":completion:*:descriptions",
3859 "tag-group descriptions in menus",
3860 ),
3861 (":completion:*:matches", "match grouping / formatting"),
3862 (":completion:*:options", "option-name completion"),
3863 (":completion:*:warnings", "no-match warning style"),
3864 (
3865 ":completion:*:messages",
3866 "info messages from completion ported",
3867 ),
3868 (":completion:*:corrections", "spell-correction style"),
3869 (
3870 ":completion:*:*:*:*:processes",
3871 "process-name completion (`kill <TAB>`)",
3872 ),
3873 (":completion:*:functions", "function-name completion"),
3874 (":completion:*:manuals", "man-page completion"),
3875 (
3876 ":completion:*:hosts",
3877 "hostname completion (ssh, scp, etc.)",
3878 ),
3879 (
3880 ":vcs_info:*",
3881 "version-control info system (`git`/`hg`/`svn` in prompt)",
3882 ),
3883 (":vcs_info:git:*", "git-specific vcs_info"),
3884 (":prompt:*", "prompt customization (themes)"),
3885 (":urlglobber", "URL-glob filtering"),
3886 (":zftp:*", "zftp module configuration"),
3887 (":grep:*", "grep widget configuration"),
3888 (":compinstall", "`compinstall` wizard state"),
3889 (":zle:*", "ZLE widget configuration"),
3890 (":bracketed-paste-magic", "bracketed-paste-magic widget"),
3891 (
3892 ":syntax-highlighting",
3893 "fast-syntax-highlighting / zsh-syntax-highlighting",
3894 ),
3895];
3896
3897const PATTERN_MODIFIERS: &[(&str, &str)] = &[
3900 ("i", "case-insensitive matching for the rest of the pattern"),
3901 ("l", "lowercase chars match upper + lower"),
3902 ("I", "case-sensitive — reset after `(#i)`"),
3903 (
3904 "b",
3905 "activate backreferences (`$match[N]` / `$mbegin` / `$mend`)",
3906 ),
3907 ("B", "deactivate backreferences"),
3908 (
3909 "m",
3910 "set `$MATCH` / `$MBEGIN` / `$MEND` even without backref",
3911 ),
3912 ("M", "deactivate `m`"),
3913 ("a", "`(#aN)` — approximate match with up to N errors"),
3914 ("s", "anchor pattern to start of string"),
3915 ("e", "anchor pattern to end of string"),
3916 (
3917 "c",
3918 "`(#cN,M)` — preceding atom matched between N and M times",
3919 ),
3920 ("u", "use Unicode character properties"),
3921 ("U", "deactivate `u`"),
3922 (
3923 "q",
3924 "treat following pattern as glob qualifier list (`(#q.,L0)`)",
3925 ),
3926];
3927
3928const SUBSCRIPT_FLAGS: &[(&str, &str)] = &[
3932 ("e", "exact match — disable globbing on subscript"),
3933 ("i", "return INDEX of first matching element"),
3934 ("I", "return INDEX of LAST matching element"),
3935 ("r", "return VALUE of first match — search reverse"),
3936 ("R", "as `r` but ranged"),
3937 ("b", "byte offset (with `i` / `I`)"),
3938 ("n", "`(nN)` — Nth match (with `i` / `I` / `r` / `R`)"),
3939 ("w", "word offset (split on `$IFS`)"),
3940 ("W", "word offset with empty fields"),
3941 ("p", "process `\\NNN` escapes in `(s::)` separator"),
3942 ("s", "`(s:STR:)` — split on STR (with `w` / `W`)"),
3943 ("f", "split scalar on newlines (= `(s.\\n.)`)"),
3944 ("k", "match against keys of an associative array"),
3945 ("v", "match against values of an associative array"),
3946];
3947
3948#[derive(Debug, Clone, PartialEq, Eq)]
3953enum LspCompletionContext {
3954 Normal,
3955 ParamFlag,
3956 GlobQualifier,
3957 HistoryDesignator,
3958 ParamColonModifier,
3959 OptionOnly, SignalName, ModuleName, KeymapName, WidgetName, TypesetFlag, ZstyleContext, CompdefFn, TestOperator, MathFunction, PatternModifier, SubscriptFlag, BuiltinFlag(String),
3980 BuiltinLongFlag(String),
3988}
3989
3990fn leading_command_at(line: &str, col: usize) -> Option<(String, usize)> {
3995 let bytes = line.as_bytes();
3996 let cap = col.min(bytes.len());
3997 let mut s: usize = 0;
4001 let mut i = cap;
4002 while i > 0 {
4003 i -= 1;
4004 let c = bytes[i];
4005 if c == b'\n' || c == b';' {
4006 s = i + 1;
4007 break;
4008 }
4009 if (c == b'|' || c == b'&') && i > 0 {
4010 s = i + 1;
4012 break;
4013 }
4014 if c == b'(' {
4020 s = i + 1;
4021 break;
4022 }
4023 }
4024 while s < cap && matches!(bytes[s], b' ' | b'\t') {
4026 s += 1;
4027 }
4028 let mut e = s;
4030 while e < cap && (bytes[e].is_ascii_alphanumeric() || matches!(bytes[e], b'_' | b'-' | b'.')) {
4031 e += 1;
4032 }
4033 if e == s {
4034 return None;
4035 }
4036 let cmd = std::str::from_utf8(&bytes[s..e]).ok()?.to_string();
4037 Some((cmd, e))
4038}
4039
4040fn count_pair(bytes: &[u8], end: usize, tok: [u8; 2]) -> i32 {
4042 let cap = end.min(bytes.len());
4043 let mut n: i32 = 0;
4044 let mut i = 0;
4045 while i + 1 < cap {
4046 if bytes[i] == tok[0] && bytes[i + 1] == tok[1] {
4047 n += 1;
4048 i += 2;
4049 } else {
4050 i += 1;
4051 }
4052 }
4053 n
4054}
4055
4056fn lsp_completion_context(line: &str, col: usize) -> LspCompletionContext {
4064 let bytes = line.as_bytes();
4065 let cap = col.min(bytes.len());
4066
4067 {
4072 let mut k = cap;
4073 while k > 0 {
4074 let c = bytes[k - 1];
4075 if c.is_ascii_alphanumeric()
4076 || matches!(c, b'?' | b'#' | b'$' | b'^' | b'*' | b'-' | b'_')
4077 {
4078 k -= 1;
4079 } else {
4080 break;
4081 }
4082 }
4083 if k > 0 && bytes[k - 1] == b'!' {
4084 let bang = k - 1;
4085 let word_bound = bang == 0
4086 || matches!(
4087 bytes[bang - 1],
4088 b' ' | b'\t' | b';' | b'&' | b'|' | b'(' | b'`' | b'\n'
4089 );
4090 let escaped = bang > 0 && bytes[bang - 1] == b'\\';
4091 let mut paren_pairs: i32 = 0;
4095 let mut j = 0;
4096 while j + 1 < bang {
4097 if bytes[j] == b'(' && bytes[j + 1] == b'(' {
4098 paren_pairs += 1;
4099 j += 2;
4100 continue;
4101 }
4102 if bytes[j] == b')' && bytes[j + 1] == b')' {
4103 paren_pairs -= 1;
4104 j += 2;
4105 continue;
4106 }
4107 j += 1;
4108 }
4109 let in_arith = paren_pairs > 0;
4110 if word_bound && !escaped && !in_arith {
4111 return LspCompletionContext::HistoryDesignator;
4112 }
4113 }
4114 }
4115
4116 {
4118 let mut depth: i32 = 0;
4119 let mut i = cap;
4120 while i > 0 {
4121 i -= 1;
4122 let c = bytes[i];
4123 if c == b')' {
4124 depth += 1;
4125 } else if c == b'(' {
4126 if depth == 0 {
4127 if i >= 2 && bytes[i - 2] == b'$' && bytes[i - 1] == b'{' {
4128 return LspCompletionContext::ParamFlag;
4129 }
4130 if i >= 1 {
4131 let prev = bytes[i - 1];
4132 if matches!(prev, b'*' | b'?' | b']' | b')') {
4133 return LspCompletionContext::GlobQualifier;
4134 }
4135 }
4136 break;
4137 }
4138 depth -= 1;
4139 }
4140 }
4141 }
4142
4143 {
4147 let mut bdepth: i32 = 0;
4148 let mut found_colon = false;
4149 let mut k = cap;
4150 while k > 0 {
4151 k -= 1;
4152 let c = bytes[k];
4153 if c == b'}' {
4154 bdepth += 1;
4155 } else if c == b'{' {
4156 if bdepth == 0 {
4157 if k >= 1 && bytes[k - 1] == b'$' && found_colon {
4158 return LspCompletionContext::ParamColonModifier;
4159 }
4160 break;
4161 }
4162 bdepth -= 1;
4163 } else if c == b':' && bdepth == 0 && !found_colon {
4164 found_colon = true;
4165 }
4166 }
4167 }
4168
4169 {
4172 let mut k = cap;
4173 while k > 0
4175 && (bytes[k - 1].is_ascii_alphabetic() || matches!(bytes[k - 1], b'&' | b'/' | b'g'))
4176 {
4177 k -= 1;
4178 }
4179 if k > 0 && bytes[k - 1] == b':' {
4180 let colon = k - 1;
4184 let mut e = colon;
4185 while e > 0
4186 && (bytes[e - 1].is_ascii_alphanumeric()
4187 || matches!(
4188 bytes[e - 1],
4189 b'?' | b'#' | b'$' | b'^' | b'*' | b'-' | b'_' | b'!'
4190 ))
4191 {
4192 e -= 1;
4193 }
4194 if e < colon && bytes[e] == b'!' {
4195 let bang = e;
4198 let word_bound = bang == 0
4199 || matches!(
4200 bytes[bang - 1],
4201 b' ' | b'\t' | b';' | b'&' | b'|' | b'(' | b'`' | b'\n'
4202 );
4203 if word_bound {
4204 return LspCompletionContext::ParamColonModifier;
4205 }
4206 }
4207 }
4208 }
4209
4210 {
4212 let bytes = line.as_bytes();
4213 let cap = col.min(bytes.len());
4214 {
4217 let mut depth: i32 = 0;
4218 let mut i = cap;
4219 while i > 0 {
4220 i -= 1;
4221 let c = bytes[i];
4222 if c == b')' {
4223 depth += 1;
4224 } else if c == b'(' {
4225 if depth == 0 {
4226 if i + 1 < bytes.len() && bytes[i + 1] == b'#' {
4227 return LspCompletionContext::PatternModifier;
4228 }
4229 break;
4230 }
4231 depth -= 1;
4232 }
4233 }
4234 }
4235 {
4239 let mut depth: i32 = 0;
4240 let mut i = cap;
4241 while i > 0 {
4242 i -= 1;
4243 let c = bytes[i];
4244 if c == b')' {
4245 depth += 1;
4246 } else if c == b'(' {
4247 if depth == 0 {
4248 if i >= 1 && bytes[i - 1] == b'[' {
4249 return LspCompletionContext::SubscriptFlag;
4250 }
4251 break;
4252 }
4253 depth -= 1;
4254 }
4255 }
4256 }
4257 let lbrack = count_pair(bytes, cap, [b'[', b'[']);
4260 let rbrack = count_pair(bytes, cap, [b']', b']']);
4261 if lbrack > rbrack {
4262 return LspCompletionContext::TestOperator;
4263 }
4264 let lparen = count_pair(bytes, cap, [b'(', b'(']);
4266 let rparen = count_pair(bytes, cap, [b')', b')']);
4267 if lparen > rparen {
4268 return LspCompletionContext::MathFunction;
4269 }
4270 }
4271
4272 if let Some((cmd, _arg_start)) = leading_command_at(line, col) {
4274 match cmd.as_str() {
4275 "setopt" | "unsetopt" => return LspCompletionContext::OptionOnly,
4276 "set" => {
4277 let bytes = line.as_bytes();
4280 let cap = col.min(bytes.len());
4281 let mut j = 0;
4282 let mut saw_o = false;
4283 while j + 1 < cap {
4284 if (bytes[j] == b'-' || bytes[j] == b'+') && bytes[j + 1] == b'o' {
4285 saw_o = true;
4286 break;
4287 }
4288 j += 1;
4289 }
4290 if saw_o {
4291 return LspCompletionContext::OptionOnly;
4292 }
4293 }
4294 "kill" => {
4295 let bytes = line.as_bytes();
4299 let cap = col.min(bytes.len());
4300 let mut has_dash = false;
4301 let mut j = 0;
4302 while j < cap {
4303 if bytes[j] == b'-' && j > 0 && matches!(bytes[j - 1], b' ' | b'\t') {
4304 has_dash = true;
4305 break;
4306 }
4307 j += 1;
4308 }
4309 if has_dash {
4310 return LspCompletionContext::SignalName;
4311 }
4312 }
4313 "trap" => return LspCompletionContext::SignalName,
4314 "zmodload" => return LspCompletionContext::ModuleName,
4315 "bindkey" => {
4316 let bytes = line.as_bytes();
4321 let cap = col.min(bytes.len());
4322 let mut last_flag: Option<u8> = None;
4323 let mut j = 0;
4324 while j < cap {
4325 if bytes[j] == b'-'
4326 && j > 0
4327 && matches!(bytes[j - 1], b' ' | b'\t')
4328 && j + 1 < cap
4329 {
4330 last_flag = Some(bytes[j + 1]);
4331 }
4332 j += 1;
4333 }
4334 if matches!(last_flag, Some(b'A') | Some(b'M') | Some(b'N')) {
4335 return LspCompletionContext::KeymapName;
4336 }
4337 return LspCompletionContext::WidgetName;
4338 }
4339 "zle" => {
4340 let bytes = line.as_bytes();
4347 let cap = col.min(bytes.len());
4348 let mut j = cap;
4349 while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
4350 j -= 1;
4351 }
4352 if j < cap && bytes[j] == b'-' {
4353 return LspCompletionContext::BuiltinFlag("zle".to_string());
4354 }
4355 return LspCompletionContext::WidgetName;
4356 }
4357 "typeset" | "declare" | "local" | "readonly" | "integer" | "float" | "export"
4358 | "private" => {
4359 let bytes = line.as_bytes();
4361 let cap = col.min(bytes.len());
4362 let mut j = cap;
4364 while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
4365 j -= 1;
4366 }
4367 if j < cap && bytes[j] == b'-' {
4368 return LspCompletionContext::TypesetFlag;
4369 }
4370 }
4371 "zstyle" => return LspCompletionContext::ZstyleContext,
4372 "compdef" => return LspCompletionContext::CompdefFn,
4373 _ => {}
4374 }
4375 let bytes = line.as_bytes();
4383 let cap = col.min(bytes.len());
4384 let mut j = cap;
4385 while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
4386 j -= 1;
4387 }
4388 let starts_with_dash = j < cap && bytes[j] == b'-';
4389 let starts_with_double_dash = j + 1 < cap && bytes[j] == b'-' && bytes[j + 1] == b'-';
4390 let just_after_builtin = j == cap;
4391 if starts_with_double_dash && is_known_builtin_with_long_flag_docs(&cmd) {
4396 return LspCompletionContext::BuiltinLongFlag(cmd);
4397 }
4398 if (starts_with_dash || just_after_builtin) && is_known_builtin_with_flag_docs(&cmd) {
4399 return LspCompletionContext::BuiltinFlag(cmd);
4400 }
4401 }
4402
4403 LspCompletionContext::Normal
4404}
4405
4406fn derive_inline_flag_desc(body: &str, flag: &str) -> Option<String> {
4419 let needle = format!("`{}`", flag);
4420 let bytes = body.as_bytes();
4421 let nbytes = needle.as_bytes();
4422 let mut best: Option<String> = None;
4424 let mut search_from = 0;
4425 while let Some(pos) = body[search_from..].find(&needle) {
4426 let abs = search_from + pos;
4427 search_from = abs + needle.len();
4428 let mut sstart = abs;
4431 while sstart > 0 {
4432 let c = bytes[sstart - 1];
4433 if c == b'\n' && sstart >= 2 && bytes[sstart - 2] == b'\n' {
4434 break;
4435 }
4436 if c == b'.' && sstart < bytes.len() && matches!(bytes[sstart], b' ' | b'\n') {
4437 sstart += 1; break;
4439 }
4440 sstart -= 1;
4441 }
4442 let mut send = abs + needle.len();
4445 let cap_end = (sstart + 400).min(bytes.len());
4446 while send < cap_end {
4447 let c = bytes[send];
4448 if c == b'.' && send + 1 < bytes.len() && matches!(bytes[send + 1], b' ' | b'\n') {
4449 send += 1; break;
4451 }
4452 if c == b'\n' && send + 1 < bytes.len() && bytes[send + 1] == b'\n' {
4453 break;
4454 }
4455 send += 1;
4456 }
4457 let raw = &body[sstart..send.min(bytes.len())];
4458 let cleaned: String = raw
4460 .split_whitespace()
4461 .collect::<Vec<_>>()
4462 .join(" ")
4463 .trim()
4464 .to_string();
4465 if cleaned.len() < 15 {
4466 continue; }
4468 if cleaned.starts_with('#') {
4470 continue;
4471 }
4472 if best
4474 .as_ref()
4475 .map(|b| b.len() > cleaned.len())
4476 .unwrap_or(true)
4477 {
4478 best = Some(cleaned);
4479 }
4480 }
4481 best.map(|s| s.chars().take(200).collect())
4482}
4483
4484fn is_known_builtin_with_flag_docs(name: &str) -> bool {
4485 let is_compat = crate::ported::builtin::BUILTINS
4486 .iter()
4487 .any(|b| b.node.nam == name);
4488 let is_ext = crate::ext_builtins::EXT_BUILTIN_NAMES.contains(&name);
4489 let is_compsys = crate::compsys::COMPSYS_FN_NAMES.contains(&name);
4495 let is_self = name == "zshrs" || name == "zsh";
4499 if !is_compat && !is_ext && !is_compsys && !is_self {
4500 return false;
4501 }
4502 !extract_builtin_flags(name).is_empty()
4503}
4504
4505pub fn extract_builtin_flags_for_test(name: &str) -> Vec<(String, String)> {
4525 extract_builtin_flags(name)
4526}
4527
4528fn extract_builtin_flags(name: &str) -> Vec<(String, String)> {
4529 use std::sync::Mutex;
4530 use std::sync::OnceLock;
4531 static CACHE: OnceLock<Mutex<std::collections::HashMap<String, Vec<(String, String)>>>> =
4532 OnceLock::new();
4533 let cache = CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
4534 if let Ok(g) = cache.lock() {
4535 if let Some(v) = g.get(name) {
4536 return v.clone();
4537 }
4538 }
4539 if name == "zshrs" || name == "zsh" {
4547 let out: Vec<(String, String)> = ZSHRS_SELF_FLAG_DOCS
4548 .iter()
4549 .map(|(f, d)| (f.to_string(), d.to_string()))
4550 .collect();
4551 if let Ok(mut g) = cache.lock() {
4552 g.insert(name.to_string(), out.clone());
4553 }
4554 return out;
4555 }
4556 if let Some(flags) = lookup_compsys_flag_docs(name) {
4563 let out: Vec<(String, String)> = flags
4564 .iter()
4565 .map(|(f, d)| (f.to_string(), d.to_string()))
4566 .collect();
4567 if let Ok(mut g) = cache.lock() {
4568 g.insert(name.to_string(), out.clone());
4569 }
4570 return out;
4571 }
4572 let body: String = match crate::zsh_builtin_docs::lookup_builtin_doc(name) {
4581 Some((_, b)) => b.to_string(),
4582 None => crate::zsh_ext_builtin_docs::lookup_full(name)
4583 .map(|b| b.to_string())
4584 .unwrap_or_default(),
4585 };
4586 let mut out: Vec<(String, String)> = Vec::new();
4587 let re_bullet = regex::Regex::new(
4611 r"(?m)^\s*-\s+\*\*`(-[A-Za-z+])(?:\s+[^`]*)?`[^*\n]*\*\*[ \t]*[^A-Za-z\n]*[ \t]*(?:\n[ \t]*)?([A-Z][^\n]+)",
4612 )
4613 .unwrap();
4614 for cap in re_bullet.captures_iter(&body) {
4615 let flag = cap.get(1).unwrap().as_str().to_string();
4616 let raw_desc = cap.get(2).map(|m| m.as_str()).unwrap_or("");
4617 let desc: String = raw_desc.trim().chars().take(200).collect();
4618 if !out.iter().any(|(f, _)| f == &flag) {
4619 out.push((flag, desc));
4620 }
4621 }
4622 if out.is_empty() {
4633 let re_inline = regex::Regex::new(r"`(-[A-Za-z+])`").unwrap();
4634 for cap in re_inline.captures_iter(&body) {
4635 let flag = cap.get(1).unwrap().as_str().to_string();
4636 if out.iter().any(|(f, _)| f == &flag) {
4637 continue;
4638 }
4639 let desc = derive_inline_flag_desc(&body, &flag).unwrap_or_default();
4642 out.push((flag, desc));
4643 }
4644 }
4645 if let Some(over) = lookup_builtin_flag_docs_override(name) {
4650 let over_keys: std::collections::HashSet<&str> = over.iter().map(|(f, _)| *f).collect();
4651 out.retain(|(f, _)| !over_keys.contains(f.as_str()));
4652 for (f, d) in over {
4653 out.push((f.to_string(), d.to_string()));
4654 }
4655 }
4656 tracing::debug!(
4657 target: "zshrs::lsp::completion",
4658 builtin = %name,
4659 flag_count = out.len(),
4660 "extract_builtin_flags",
4661 );
4662 if let Ok(mut g) = cache.lock() {
4663 g.insert(name.to_string(), out.clone());
4664 }
4665 out
4666}
4667
4668pub(crate) fn lookup_builtin_flag_docs_override(
4673 name: &str,
4674) -> Option<&'static [(&'static str, &'static str)]> {
4675 BUILTIN_FLAG_DOCS_OVERRIDE
4676 .iter()
4677 .find(|(n, _)| *n == name)
4678 .map(|(_, flags)| *flags)
4679}
4680
4681const BUILTIN_FLAG_DOCS_OVERRIDE: &[(&str, &[(&str, &str)])] = &[
4685 (
4686 "bindkey",
4687 &[(
4688 "-L",
4689 "With `-l`, format output as `bindkey -A` / `-N` replay invocations.",
4690 )],
4691 ),
4692 (
4693 "enable",
4694 &[(
4695 "-p",
4696 "Operate on patterns added with `disable -p` (custom match-pattern hooks).",
4697 )],
4698 ),
4699 (
4700 "example",
4701 &[
4702 ("-a", "Pass arg as the example builtin's first parameter."),
4703 (
4704 "-f",
4705 "Toggle the example builtin's `flag` field (test option).",
4706 ),
4707 ("-g", "Toggle the example builtin's global-state test mode."),
4708 ("-l", "Toggle the example builtin's `long` test mode."),
4709 ("-s", "Toggle the example builtin's stateful test mode."),
4710 ],
4711 ),
4712 (
4713 "fc",
4714 &[(
4715 "-s",
4716 "Substitute `old=new` on the selected line and re-execute (no editor invoked).",
4717 )],
4718 ),
4719 (
4720 "getln",
4721 &[
4722 (
4723 "-A",
4724 "Read into an array (split into words instead of one scalar).",
4725 ),
4726 ("-E", "Don't echo (default; symmetric counterpart to `-e`)."),
4727 ("-c", "Read characters one at a time."),
4728 ("-e", "Echo read text back to terminal as it arrives."),
4729 ("-l", "Read just one line (default)."),
4730 ("-n", "Don't strip trailing newline from the result."),
4731 ],
4732 ),
4733 (
4734 "kill",
4735 &[
4736 (
4737 "-g",
4738 "Send the signal to the process GROUP, not just the process. Job-spec is a pgid.",
4739 ),
4740 (
4741 "-i",
4742 "Interpret arguments as job specs rather than process ids.",
4743 ),
4744 ("-n", "`-n signum` — send numeric signal `signum`."),
4745 (
4746 "-s",
4747 "`-s signame` — send named signal (`TERM`, `HUP`, `KILL`, …).",
4748 ),
4749 ],
4750 ),
4751 (
4752 "print",
4753 &[(
4754 "-f",
4755 "`-f format` — printf-style format string (same semantics as `printf`).",
4756 )],
4757 ),
4758 (
4759 "read",
4760 &[
4761 ("-c", "Read characters one at a time (no line-buffering)."),
4762 ("-e", "Echo read input back to terminal as it arrives."),
4763 ],
4764 ),
4765 (
4766 "sched",
4767 &[
4768 (
4769 "-e",
4770 "`+sched +HH:MM:SS event...` — schedule a command at the given time.",
4771 ),
4772 (
4773 "-i",
4774 "`sched -i id` — remove the scheduled entry with the given id.",
4775 ),
4776 (
4777 "-m",
4778 "`sched -m mask` — match scheduled entries against a glob pattern.",
4779 ),
4780 ("-t", "Print scheduled entries with full timestamps."),
4781 ],
4782 ),
4783 (
4784 "type",
4785 &[
4786 (
4787 "-S",
4788 "Like `-s` but include scripts in `$PATH` as commands.",
4789 ),
4790 (
4791 "-a",
4792 "Print every match for each name (not just the first).",
4793 ),
4794 ("-f", "Skip functions when looking up `name`."),
4795 ("-m", "Treat each name as a glob pattern."),
4796 ("-p", "Print only external commands found in `$path`."),
4797 (
4798 "-s",
4799 "Suppress output; exit 0 if name resolves to a command.",
4800 ),
4801 (
4802 "-w",
4803 "Print one of `alias`/`builtin`/`command`/`function`/`hashed`/`none` per name.",
4804 ),
4805 ],
4806 ),
4807 (
4808 "ulimit",
4809 &[
4810 (
4811 "-H",
4812 "Operate on the hard limit (default with `-S` is the soft limit).",
4813 ),
4814 (
4815 "-N",
4816 "`-N n` — operate on resource number `n` (system-specific integer).",
4817 ),
4818 (
4819 "-S",
4820 "Operate on the soft limit (default if neither `-H` nor `-S` given).",
4821 ),
4822 ("-T", "Maximum number of threads per process."),
4823 (
4824 "-a",
4825 "List all of the current resource limits (default verb).",
4826 ),
4827 ("-c", "Maximum core-file size in 512-byte blocks."),
4828 ("-d", "Maximum data-segment size in kilobytes."),
4829 (
4830 "-f",
4831 "Maximum file size the shell can write in 512-byte blocks.",
4832 ),
4833 ("-i", "Maximum number of pending signals."),
4834 ("-k", "Maximum number of kqueues allocated (BSD)."),
4835 ("-l", "Maximum locked-in-memory address space in kilobytes."),
4836 ("-m", "Maximum resident-set size in kilobytes."),
4837 ("-n", "Maximum number of open file descriptors."),
4838 ("-p", "The number of pseudo-terminals (BSD)."),
4839 ("-q", "Maximum bytes in POSIX message queues."),
4840 ("-r", "Maximum real-time scheduling priority."),
4841 ("-s", "Maximum stack size in kilobytes."),
4842 ("-t", "Maximum CPU time in seconds."),
4843 ("-v", "Maximum virtual-memory address space in kilobytes."),
4844 ("-w", "Maximum kilobytes of swapped-out memory."),
4845 ("-x", "Maximum number of file-locks held."),
4846 ],
4847 ),
4848 (
4849 "where",
4850 &[
4851 (
4852 "-S",
4853 "Like `-s` but include scripts in `$PATH` as commands.",
4854 ),
4855 ("-m", "Treat each name as a glob pattern."),
4856 ("-p", "Print only external commands found in `$path`."),
4857 ("-s", "Suppress output; exit 0 if name resolves."),
4858 (
4859 "-w",
4860 "Print one of `alias`/`builtin`/`command`/`function`/`hashed`/`none` per name.",
4861 ),
4862 (
4863 "-x",
4864 "`-x num` — indent each printed body line by `num` spaces.",
4865 ),
4866 ],
4867 ),
4868 (
4869 "which",
4870 &[
4871 ("-S", "Like `-s` but include scripts in `$PATH`."),
4872 ("-a", "Print every match for each name."),
4873 ("-m", "Treat each name as a glob pattern."),
4874 ("-p", "Print only external commands found in `$path`."),
4875 ("-s", "Suppress output; exit 0 if name resolves."),
4876 (
4877 "-w",
4878 "Print one of `alias`/`builtin`/`command`/`function`/`hashed`/`none` per name.",
4879 ),
4880 (
4881 "-x",
4882 "`-x num` — indent each printed body line by `num` spaces.",
4883 ),
4884 ],
4885 ),
4886 (
4887 "zcompile",
4888 &[
4889 ("-k", "Mark each compiled function for KSH-style autoload."),
4890 ("-m", "With `-c` / `-a`, treat each name as a glob pattern."),
4891 ],
4892 ),
4893 (
4895 "chgrp",
4896 &[
4897 ("-R", "Recursively descend into directories."),
4898 ("-h", "Change group of the symlink itself, not the target."),
4899 ("-s", "Suppress error messages for inaccessible files."),
4900 ],
4901 ),
4902 (
4903 "ln",
4904 &[
4905 (
4906 "-d",
4907 "Create a hard link to a directory (requires privilege).",
4908 ),
4909 (
4910 "-f",
4911 "If `dest` exists, remove it before creating the link.",
4912 ),
4913 (
4914 "-h",
4915 "If `dest` is a symlink, operate on the symlink itself.",
4916 ),
4917 ("-i", "Prompt before overwriting `dest`."),
4918 (
4919 "-n",
4920 "If `dest` is a symlink to a directory, replace the symlink.",
4921 ),
4922 ("-s", "Create a symbolic link instead of a hard link."),
4923 ],
4924 ),
4925 (
4927 "syserror",
4928 &[
4929 (
4930 "-e",
4931 "`-e errvar` — store error string in `$errvar` instead of stderr.",
4932 ),
4933 ("-p", "`-p prefix` — prepend `prefix` to the error message."),
4934 ],
4935 ),
4936 (
4937 "sysread",
4938 &[
4939 (
4940 "-c",
4941 "`-c countvar` — store byte count read in `$countvar`.",
4942 ),
4943 (
4944 "-i",
4945 "`-i infd` — read from file descriptor `infd` instead of stdin.",
4946 ),
4947 (
4948 "-o",
4949 "`-o outfd` — relay bytes to `outfd` as well as storing them.",
4950 ),
4951 ],
4952 ),
4953 (
4954 "syswrite",
4955 &[
4956 (
4957 "-c",
4958 "`-c countvar` — store byte count actually written in `$countvar`.",
4959 ),
4960 ("-o", "`-o outfd` — write to `outfd` instead of stdout."),
4961 ],
4962 ),
4963 (
4964 "zselect",
4965 &[
4966 ("-A", "`-A arrayname` — store ready fds into `arrayname`."),
4967 (
4968 "-t",
4969 "`-t timeout` — timeout in hundredths of a second (centiseconds).",
4970 ),
4971 ],
4972 ),
4973 (
4974 "zsystem",
4975 &[(
4976 "-f",
4977 "`zsystem flock -f var file` — store lock file descriptor in `$var`.",
4978 )],
4979 ),
4980 (
4982 "zsocket",
4983 &[
4984 (
4985 "-a",
4986 "Open a server (listening) socket bound to the named path.",
4987 ),
4988 (
4989 "-d",
4990 "`-d fd` — open the socket on the specified file descriptor.",
4991 ),
4992 ("-l", "List currently-open zsocket file descriptors."),
4993 ("-t", "Set close-on-exec on the socket."),
4994 (
4995 "-v",
4996 "Verbose — print the resulting file descriptor to stdout.",
4997 ),
4998 ],
4999 ),
5000 (
5001 "ztcp",
5002 &[
5003 (
5004 "-a",
5005 "Server mode — accept the next connection on the specified listening fd.",
5006 ),
5007 ("-c", "Close the named ztcp file descriptor."),
5008 ("-d", "`-d fd` — operate on the specified file descriptor."),
5009 (
5010 "-f",
5011 "Force — don't fail if a similar connection already exists.",
5012 ),
5013 (
5014 "-l",
5015 "Listen mode — open a server socket on the given port.",
5016 ),
5017 ("-t", "Set close-on-exec on the socket."),
5018 (
5019 "-v",
5020 "Verbose — print the resulting file descriptor to stdout.",
5021 ),
5022 ],
5023 ),
5024 (
5026 "zdelattr",
5027 &[("-h", "Operate on the symlink itself, not its target.")],
5028 ),
5029 (
5030 "zgetattr",
5031 &[("-h", "Operate on the symlink itself, not its target.")],
5032 ),
5033 (
5034 "zlistattr",
5035 &[("-h", "Operate on the symlink itself, not its target.")],
5036 ),
5037 (
5038 "zsetattr",
5039 &[("-h", "Operate on the symlink itself, not its target.")],
5040 ),
5041 (
5043 "zstyle",
5044 &[
5045 (
5046 "-L",
5047 "`-L [ metapattern [ style ] ]` — list styles in `zstyle`-replay form.",
5048 ),
5049 (
5050 "-e",
5051 "`-e pattern style string ...` — value-as-shell-code (re-evaluated each lookup).",
5052 ),
5053 ],
5054 ),
5055 (
5057 "zpty",
5058 &[
5059 ("-L", "List active zpty sessions with their commands."),
5060 (
5061 "-m",
5062 "With `-r`, treat `pattern` as a match-spec (read until pattern matches).",
5063 ),
5064 (
5065 "-n",
5066 "With `-w`, don't append a newline to written strings.",
5067 ),
5068 ("-t", "Test whether the named zpty session is still alive."),
5069 ],
5070 ),
5071 (
5073 "zle",
5074 &[
5075 (
5076 "-L",
5077 "With `-l`, format output as `zle` replay invocations.",
5078 ),
5079 (
5080 "-a",
5081 "With `-N`, mark new widget as available outside the editor (script-callable).",
5082 ),
5083 ("-c", "With `-R`, clear the screen before re-display."),
5084 (
5085 "-n",
5086 "Pass `-n num` through to the widget invocation (numeric argument).",
5087 ),
5088 ("-r", "With `-T`, remove the named termcap handler."),
5089 (
5090 "-w",
5091 "With `-F`, treat the fd handler as a writeable-fd ready handler.",
5092 ),
5093 ],
5094 ),
5095];
5096
5097fn lookup_compsys_flag_docs(name: &str) -> Option<&'static [(&'static str, &'static str)]> {
5113 COMPSYS_FN_FLAG_DOCS
5114 .iter()
5115 .find(|(n, _)| *n == name)
5116 .map(|(_, flags)| *flags)
5117}
5118
5119fn is_known_builtin_with_long_flag_docs(name: &str) -> bool {
5123 matches!(name, "zshrs" | "zsh")
5124}
5125
5126fn extract_builtin_long_flags(name: &str) -> Vec<(String, String)> {
5133 use std::sync::OnceLock;
5134 if name != "zshrs" && name != "zsh" {
5135 return Vec::new();
5136 }
5137 static CACHE: OnceLock<Vec<(String, String)>> = OnceLock::new();
5138 CACHE
5139 .get_or_init(|| {
5140 let mut out: Vec<(String, String)> = ZSHRS_SELF_LONG_FLAG_DOCS
5141 .iter()
5142 .map(|(f, d)| (f.to_string(), d.to_string()))
5143 .collect();
5144 for (opt_name, opt_desc) in crate::zsh_option_docs::OPTION_DOCS {
5152 let lower = opt_name.to_ascii_lowercase().replace('_', "");
5153 let first_line = opt_desc
5154 .lines()
5155 .find(|l| !l.trim().is_empty())
5156 .unwrap_or("")
5157 .trim();
5158 let short: String = first_line.chars().take(200).collect();
5159 out.push((format!("--{}", lower), short));
5160 out.push((
5161 format!("--no-{}", lower),
5162 format!("Turn OFF `--{}`. Inverse of the setopt option.", lower),
5163 ));
5164 }
5165 out
5166 })
5167 .clone()
5168}
5169
5170const ZSHRS_SELF_FLAG_DOCS: &[(&str, &str)] = &[
5182 ("-b", "End option processing, like `--`. Subsequent arguments are positional even if they start with `-`."),
5183 ("-c", "Take the FIRST argument as a command string to execute. Example: `zshrs -c 'echo hi'`."),
5184 ("-f", "Equivalent to `--no-rcs`: skip sourcing `.zshenv` / `.zshrc` / `.zprofile` / `.zlogin`. Use for clean-environment scripting."),
5185 ("-i", "Force interactive mode even when stdin isn't a terminal (job control + prompt + line editor active)."),
5186 ("-l", "Force login-shell mode: source `.zprofile` / `.zlogin` on startup and `.zlogout` on exit. Equivalent to invoking as `-zshrs`."),
5187 ("-s", "Read commands from standard input. Combine with `-c CMD …` to run CMD first then drain stdin."),
5188 ("-o", "Set a setopt option by name. Example: `-o errexit -o pipefail -o nounset`. Inverse: `+o OPTION` or `--no-OPTION`."),
5189 ("-v", "Verbose: print each input line as it's read. Equivalent to `--verbose` / `setopt VERBOSE`."),
5190 ("-x", "xtrace: print each command and its arguments before execution. Equivalent to `--xtrace` / `setopt XTRACE` / `set -x`."),
5191];
5192
5193const ZSHRS_SELF_LONG_FLAG_DOCS: &[(&str, &str)] = &[
5199 ("--help", "Print the full usage message (every flag, dumper, parity mode) and exit 0."),
5201 ("--version", "Print the zsh version string baked into the binary and exit 0."),
5202 ("--doctor", "Full diagnostic report of shell health, caches, plugin load timings, and performance counters."),
5203 ("--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."),
5205 ("--dap", "`--dap HOST:PORT` — Debug Adapter Protocol server. Connects back to the IDE's listener at HOST:PORT and drives breakpoints / step / variables / evaluate."),
5206 ("--dump-reflection", "Emit the JSON blob the IntelliJ \"zshrs\" reflection tool window consumes: builtins / keywords / options / special_vars, each tagged by category."),
5207 ("--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."),
5208 ("--dump-tokens", "`--dump-tokens FILE|-` — one TOKNAME<tab>TOKSTR line per lexer token. Use `-` to read from stdin."),
5210 ("--dump-ast", "`--dump-ast FILE|-` — parser AST as a canonical S-expression. Use `-` to read from stdin."),
5211 ("--dump-wordcode", "`--dump-wordcode FILE|-` — wordcode emitter output: EPROG / WORDS / WC[i] / STRS sections matching zsh's binary cache format."),
5212 ("--dump-zwc", "`--dump-zwc ZWCFILE [FN]` — inspect a compiled .zwc cache. Without FN, list every function. With FN, dump only that function's wordcode."),
5213 ("--disasm", "Print fusevm opcodes for each compiled unit before VM run. Does NOT suppress execution — script still runs."),
5214 ("--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/`)."),
5216 ("--out", "`--out DIR` — destination directory for `--gen-docs` HTML output (default `docs/`)."),
5217 ("--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."),
5218 ("--names", "With `--dump-reflection` or `--gen-docs`, restrict the dump / walk to a comma-separated list of NAMES instead of the full set."),
5219 ("--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."),
5221 ("--color", "`--color WHEN` — control coloured output (`auto` / `always` / `never`). Default `auto`: respect `$TERM`, `$NO_COLOR`, and stdout TTY status."),
5222 ("--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."),
5224 ("--bash", "Identical-behaviour drop-in for `/bin/bash`. Caches / daemon OFF; every echo / source re-fires byte-for-byte against reference bash."),
5225 ("--ksh", "Identical-behaviour drop-in for `/bin/ksh` (ksh-93). Caches / daemon OFF."),
5226 ("--sh", "Identical-behaviour drop-in for `/bin/sh` / POSIX (alias of `--posix`). Caches / daemon OFF."),
5227 ("--csh", "Identical-behaviour drop-in for `/bin/csh`. Caches / daemon OFF."),
5228 ("--posix", "Identical-behaviour drop-in for `/bin/sh` / POSIX (Bourne / dash semantics). Caches / daemon OFF."),
5229 ("--emulate", "`--emulate MODE` — generic parity alias for `--MODE` (zsh-compat: matches the `emulate zsh` / `emulate bash` etc. builtin)."),
5230 ("--zsh-compat","Alias of `--zsh` (legacy spelling — kept for back-compat with older scripts / CI invocations)."),
5231 ("--no-rcs", "Skip sourcing `.zshenv` / `.zshrc` / `.zprofile` / `.zlogin`. Equivalent to the short `-f` flag."),
5233 ("--verbose", "Print each input line as it's read. Equivalent to short `-v` / `setopt VERBOSE`."),
5234 ("--xtrace", "Print each command and its arguments before executing. Equivalent to short `-x` / `setopt XTRACE` / `set -x`."),
5235 ("--login", "Force login-shell mode. Equivalent to short `-l`."),
5236 ("--interactive", "Force interactive mode. Equivalent to short `-i`."),
5237];
5238
5239const COMPSYS_FN_FLAG_DOCS: &[(&str, &[(&str, &str)])] = &[
5240 (
5241 "_all_labels",
5242 &[
5243 ("-x", "Show the description even when no matches are added."),
5244 ("-1", "Pass `-1` through to `compadd` (suppress duplicate-removal of literal matches)."),
5245 ("-2", "Pass `-2` through to `compadd` (suppress de-duplication based on display string)."),
5246 ("-V", "Pass `-V name` through to `compadd` (put matches in a named unsorted group)."),
5247 ("-J", "Pass `-J name` through to `compadd` (put matches in a named sorted group)."),
5248 ],
5249 ),
5250 (
5251 "_alternative",
5252 &[
5253 ("-O", "Pass `-O name` through to nested `_arguments` calls (preserve the option-spec array)."),
5254 ("-C", "Set the curcontext parameter to `name` while running each alternative."),
5255 ],
5256 ),
5257 (
5258 "_arguments",
5259 &[
5260 ("-n", "Set `$NORMARG` to the position of the first normal argument in the `$words` array."),
5261 ("-s", "Allow option stacking (`-abc` parsed as `-a -b -c`) — required for typical POSIX-style getopts CLIs."),
5262 ("-w", "Even with `-s`, allow stacked options to consume an argument before the next short option."),
5263 ("-W", "Even after a `--` separator, keep parsing further `-x` as options instead of treating them as positional."),
5264 ("-C", "Make `curcontext` available to action handlers in `->state` form."),
5265 ("-R", "Return status 300 instead of 0 when a `->state` action is dispatched (lets callers chain dispatch)."),
5266 ("-S", "Stop parsing options once a non-option word is seen (treat the rest as positionals)."),
5267 ("-A", "Treat any argument matching pattern `pat` as a non-option terminator (e.g. `-A '-*'`)."),
5268 ("-O", "Name an array holding extra `compadd` options to pass to every match."),
5269 ("-M", "Pass match spec `matchspec` to `compadd` (controls case-folding, partial-word matching, etc.)."),
5270 ("--", "Separator between option specs and rest-arg `[helpspec ...]` syntax."),
5271 ("-l", "Long-option style: each rest-arg `helpspec` describes one long option (e.g. `--foo=bar`)."),
5272 ("-i", "Skip option specs whose names match any of the patterns in `pats`."),
5273 ],
5274 ),
5275 (
5276 "_call_program",
5277 &[
5278 ("-l", "Read one line at a time from the external program (don't slurp all output)."),
5279 ("-p", "Treat the tag as a program-call context for the `command` style lookup."),
5280 ],
5281 ),
5282 (
5283 "_canonical_paths",
5284 &[
5285 ("-A", "Store the resolved canonical paths in array variable `var`."),
5286 ("-N", "Don't realpath-resolve — treat the input paths as already canonical."),
5287 ("-M", "Pass `-M matchspec` through to `compadd`."),
5288 ("-J", "Pass `-J name` through to `compadd` (named sorted group)."),
5289 ("-V", "Pass `-V name` through to `compadd` (named unsorted group)."),
5290 ("-1", "Pass `-1` through to `compadd`."),
5291 ("-2", "Pass `-2` through to `compadd`."),
5292 ("-n", "Pass `-n` through to `compadd` (no inserted suffix)."),
5293 ("-f", "Pass `-f` through to `compadd` (mark matches as filenames for suffix/cdpath handling)."),
5294 ("-X", "Pass `-X explanation` through to `compadd` (custom listing-line message)."),
5295 ],
5296 ),
5297 (
5298 "_combination",
5299 &[
5300 ("-s", "Use `pattern` to split each existing style value into fields (default is comma)."),
5301 ],
5302 ),
5303 (
5304 "_command_names",
5305 &[
5306 ("-e", "Complete only external commands found in `$path` (skip aliases, builtins, functions, reserved words)."),
5307 ("-", "Same as `-e`: external-only mode."),
5308 ],
5309 ),
5310 (
5311 "_completers",
5312 &[
5313 ("-p", "List only completer-function names that are valid for use in the `completer` style."),
5314 ],
5315 ),
5316 (
5317 "_describe",
5318 &[
5319 ("-1", "Pass `-1` through to `_next_label`/`compadd`."),
5320 ("-2", "Pass `-2` through to `_next_label`/`compadd`."),
5321 ("-J", "Pass `-J name` through to `_next_label` (named sorted group)."),
5322 ("-V", "Pass `-V name` through to `_next_label` (named unsorted group)."),
5323 ("-x", "Show the description even when no matches are added."),
5324 ("-o", "Treat each `name1`/`name2` array as describing options (default tag is `options`)."),
5325 ("-O", "Like `-o` but each match supports an argument-string suffix after a colon."),
5326 ("-t", "Use `tag` instead of the default `values`/`options` tag."),
5327 ],
5328 ),
5329 (
5330 "_description",
5331 &[
5332 ("-x", "Show the description even when no matches are added."),
5333 ("-1", "Pass `-1` through to `compadd`."),
5334 ("-2", "Pass `-2` through to `compadd`."),
5335 ("-V", "Pass `-V name` through to `compadd`."),
5336 ("-J", "Pass `-J name` through to `compadd` (groups matches in a named sorted group based on group-name style)."),
5337 ],
5338 ),
5339 (
5340 "_dir_list",
5341 &[
5342 ("-s", "Use `sep` instead of `:` as the directory-list separator."),
5343 ("-S", "Keep partially-typed list elements in the completion (allow trailing separator)."),
5344 ],
5345 ),
5346 (
5347 "_email_addresses",
5348 &[
5349 ("-c", "Add only the current-typed prefix as a match (don't expand to full addresses)."),
5350 ("-n", "Restrict to entries returned by the named `plugin` backend (`_email-`<plugin>)."),
5351 ],
5352 ),
5353 (
5354 "_message",
5355 &[
5356 ("-r", "Take `descr` literally as the message text (skip the `format` style lookup)."),
5357 ("-1", "Show the message even when no matches are added."),
5358 ("-2", "Show the message only when matches already exist for some other tag."),
5359 ("-V", "Display the message in a named unsorted group `group`."),
5360 ("-J", "Display the message in a named sorted group `group`."),
5361 ],
5362 ),
5363 (
5364 "_multi_parts",
5365 &[
5366 ("-i", "Insert matches immediately rather than only when uniquely determined."),
5367 ],
5368 ),
5369 (
5370 "_next_label",
5371 &[
5372 ("-x", "Show the description even when no matches are added."),
5373 ("-1", "Pass `-1` through to `compadd`."),
5374 ("-2", "Pass `-2` through to `compadd`."),
5375 ("-V", "Pass `-V name` through to `compadd`."),
5376 ("-J", "Pass `-J name` through to `compadd`."),
5377 ],
5378 ),
5379 (
5380 "_normal",
5381 &[
5382 ("-P", "Treat the current word as following a precommand (`nohup`, `time`, …) — skip to the real command."),
5383 ("-p", "Like `-P` but explicitly name the precommand for context lookup."),
5384 ],
5385 ),
5386 (
5387 "_numbers",
5388 &[
5389 ],
5394 ),
5395 (
5396 "_pick_variant",
5397 &[
5398 ("-b", "Treat `builtin-label` as the variant when the command is a shell builtin."),
5399 ("-c", "Run `command` (with its args) to obtain the version string instead of the default `--version` invocation."),
5400 ("-r", "Store the matched variant name in the parameter `name`."),
5401 ],
5402 ),
5403 (
5404 "_requested",
5405 &[
5406 ("-x", "Show the description even when no matches are added."),
5407 ("-1", "Pass `-1` through to `compadd`."),
5408 ("-2", "Pass `-2` through to `compadd`."),
5409 ("-V", "Pass `-V name` through to `compadd`."),
5410 ("-J", "Pass `-J name` through to `compadd`."),
5411 ],
5412 ),
5413 (
5414 "_sequence",
5415 &[
5416 ("-s", "Use `sep` instead of `,` as the list separator."),
5417 ("-n", "Stop accepting more matches once `max` items have been completed in the sequence."),
5418 ("-d", "Allow duplicate entries in the sequence (default rejects already-typed values)."),
5419 ],
5420 ),
5421 (
5422 "_tags",
5423 &[
5424 ("-C", "Set the curcontext parameter to `name` while iterating the tag list."),
5425 ],
5426 ),
5427 (
5428 "_values",
5429 &[
5430 ("-O", "Name an array holding extra `compadd` options to pass to every match."),
5431 ("-s", "Use `sep` as the value separator between multiple keywords (default is comma)."),
5432 ("-S", "Use `sep` as the keyword-and-argument separator (default is `=`)."),
5433 ("-w", "Examine other typed arguments as well when computing which value-specs are already used."),
5434 ("-C", "Make `curcontext` available to action handlers (must be made local by the caller)."),
5435 ],
5436 ),
5437 (
5438 "_wanted",
5439 &[
5440 ("-x", "Show the description even when no matches are added."),
5441 ("-C", "Set the curcontext parameter to `name` while invoking the inner command."),
5442 ("-1", "Pass `-1` through to `compadd`."),
5443 ("-2", "Pass `-2` through to `compadd`."),
5444 ("-V", "Pass `-V name` through to `compadd`."),
5445 ("-J", "Pass `-J name` through to `compadd`."),
5446 ],
5447 ),
5448 (
5449 "_widgets",
5450 &[
5451 ("-g", "Restrict completions to widget names matching shell pattern `pattern`."),
5452 ],
5453 ),
5454];
5455
5456const COMPSYS_FN_DOCS: &[(&str, &str)] = &[
5461 (
5462 "_main_complete",
5463 "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`.",
5464 ),
5465 (
5466 "_directories",
5467 "Complete directory names only. Equivalent to `_files -/`. Honors `path-files` zstyle and respects `GLOB_DOTS`. Rust impl in `crate::compsys::files::directories_execute`.",
5468 ),
5469 (
5470 "_cargo",
5471 "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.",
5472 ),
5473 (
5474 "_docker",
5475 "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.",
5476 ),
5477 (
5478 "_git",
5479 "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.",
5480 ),
5481 (
5482 "_kubectl",
5483 "Completion for `kubectl` — subcommands, resource kinds, resource names (queried via `kubectl get`), context/namespace names from kubeconfig.",
5484 ),
5485 (
5486 "_terraform",
5487 "Completion for `terraform` — subcommands, workspace names, state-file paths, providers, modules, variable names from the loaded HCL.",
5488 ),
5489 (
5490 "_ls",
5491 "Completion for `ls` — flags + file paths. Baseline stub that delegates path completion to `_files` and option completion to a static spec.",
5492 ),
5493 (
5494 "_cd",
5495 "Completion for `cd` — directory paths from `$PWD`, `$cdpath`, and the `dirs` stack. Honors `AUTO_CD` and `CDABLE_VARS`.",
5496 ),
5497 (
5498 "_cp",
5499 "Completion for `cp` — flags + file paths. Source paths exclude the destination; destination directory is offered as the final candidate.",
5500 ),
5501 (
5502 "_mv",
5503 "Completion for `mv` — flags + file/directory paths. Source/destination split identical to `_cp`.",
5504 ),
5505 (
5506 "_rm",
5507 "Completion for `rm` — flags + file paths. `-r` enables directory completion; without it, directories are filtered out.",
5508 ),
5509 (
5510 "_cat",
5511 "Completion for `cat` — file paths only. No subcommands; flags pass through to `_files`.",
5512 ),
5513 (
5514 "_grep",
5515 "Completion for `grep` (GNU/BSD-flavor-aware) — flags then file paths. First positional argument is the pattern (no completion offered for free-text patterns).",
5516 ),
5517];
5518
5519const EXT_BUILTIN_DOCS: &[(&str, &str)] = &[
5526 ("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."),
5527 ("arch", "Print the machine architecture (uname -m equivalent): `x86_64`, `arm64`, `aarch64`, etc."),
5528 ("async", "Spawn a background task on the persistent worker pool. `async name { body }` queues the body for parallel execution. Pair with `await name` to join."),
5529 ("await", "Block until a previously-spawned `async` task completes. `await name` returns the task's exit status; `await` with no args waits for all in-flight tasks."),
5530 ("barrier", "Synchronization point for the parallel worker pool. Waits until every running `async`/`peach` task has finished before continuing."),
5531 ("base64", "Encode / decode Base64. `-d` decodes; `-w0` no line wrap. coreutils drop-in."),
5532 ("basename", "Strip leading directories and an optional suffix. `basename /a/b.txt .txt` → `b`. coreutils drop-in."),
5533 ("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."),
5534 ("cat", "Concatenate files to stdout. `-n` numbers lines, `-A` shows tabs/EOLs. coreutils drop-in."),
5535 ("cdreplay", "Replay the directory stack into the named directory. Reverses recent `cd` history without traversing the parent chain."),
5536 ("cksum", "Print CRC32 checksum + byte count of each file. coreutils drop-in."),
5537 ("comm", "Compare two sorted files line-by-line. `-1` / `-2` / `-3` suppress columns. coreutils drop-in."),
5538 ("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.)"),
5539 ("compgen", "Bash-compatible word generator. `compgen -W 'foo bar baz' fo` → `foo`. Used by bash-completion scripts ported to zshrs."),
5540 ("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."),
5541 ("complete", "Bash-compatible `complete` command — register a completion spec for a command. zshrs bridges to compsys internally."),
5542 ("compopt", "Bash-compatible `compopt` — modify completion options at runtime."),
5543 ("cut", "Extract fields or character ranges. `-d':' -f1,3` / `-c5-10`. coreutils drop-in."),
5544 ("date", "Print or set the system date. `+%FORMAT` strftime; `-d 'rel'` parse relative; `-u` UTC. coreutils drop-in."),
5545 ("dbview", "Dump the local zshrs SQLite mirror tables (autoload bodies, completion mirror, history FTS). Mirrors only — 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."),
5546 ("dircolors", "Emit `LS_COLORS` from a `.dircolors` file. coreutils drop-in."),
5547 ("dirname", "Strip the last path component. `dirname /a/b/c` → `/a/b`. coreutils drop-in."),
5548 ("doctor", "Diagnostic report of shell health — cache stats, autoload coverage, fpath sanity, daemon presence, memory footprint, recent error summary. zshrs-only."),
5549 ("env", "Run a command in a modified environment, or print the current environment. `env -i` empties; `env VAR=val cmd` sets. coreutils drop-in."),
5550 ("expand", "Convert tabs to spaces. `-t N` sets tab width. coreutils drop-in."),
5551 ("expr", "Evaluate an arithmetic / string expression. `expr 2 + 3` → `5`. Prefer `$(( … ))` in zshrs scripts; provided for POSIX compatibility."),
5552 ("factor", "Print prime factors. `factor 60` → `60: 2 2 3 5`. coreutils drop-in."),
5553 ("find", "Walk the filesystem and print / act on matches. Supports `-name`/`-type`/`-mtime`/`-exec`. coreutils drop-in (subset)."),
5554 ("fold", "Wrap each input line to a width. `-w N` width, `-s` break at spaces. coreutils drop-in."),
5555 ("groups", "Print groups the user (or named user) belongs to. coreutils drop-in."),
5556 ("head", "Print the first N lines (`-n N`) or bytes (`-c N`) of each file. coreutils drop-in."),
5557 ("help", "Print help for a builtin. `help cd` shows the cd usage. zshrs-only."),
5558 ("hostname", "Print the system hostname. `-s` short, `-f` FQDN."),
5559 ("id", "Print user / group IDs. `-u` user only, `-g` group only, `-n` names. coreutils drop-in."),
5560 ("intercept", "Register an AOP intercept. `intercept before|after|around <cmd> { body }` runs `body` around every invocation of `<cmd>`. Bytecode-compiled at registration; no per-call interpreter overhead. zshrs-only."),
5561 ("intercept_proceed", "Inside an `around` intercept body, invoke the underlying command. Required so the intercept doesn't shadow the call permanently."),
5562 ("link", "Create a hard link. `link src dst`. coreutils drop-in."),
5563 ("logname", "Print the user's login name. coreutils drop-in."),
5564 ("mkfifo", "Create named pipes (FIFOs). `mkfifo path …`. coreutils drop-in."),
5565 ("mktemp", "Create a temp file or directory with a unique name. `-d` directory, `-p DIR` parent. coreutils drop-in."),
5566 ("nice", "Run a command with adjusted scheduling priority. `nice -n 10 cmd`. coreutils drop-in."),
5567 ("nl", "Number lines. `-b a` numbers all, `-w N` field width. coreutils drop-in."),
5568 ("nproc", "Print the number of processing units available. `--all` ignores affinity."),
5569 ("paste", "Merge corresponding lines of files. `-d DELIM` separator. coreutils drop-in."),
5570 ("peach", "Parallel-for-each — run a block once per element of an array across the worker pool. `peach arr { print $it }`. Returns when all workers finish. zshrs-only."),
5571 ("pgrep", "Print PIDs of processes matching a pattern. `-f` matches full command line."),
5572 ("pmap", "Display the memory map of one or more processes. `pmap PID`."),
5573 ("printenv", "Print the value of one or more environment variables, or all if none given. coreutils drop-in."),
5574 ("profile", "CPU / wall-time profile a command and emit a flamegraph. `profile cmd …` → SVG path printed on stdout. Backed by the same sampler as `zprof`."),
5575 ("realpath", "Resolve symlinks and `.` / `..` to a canonical absolute path. coreutils drop-in."),
5576 ("rev", "Reverse each input line character-by-character. coreutils drop-in."),
5577 ("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."),
5578 ("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`."),
5579 ("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."),
5580 ("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."),
5581 ("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."),
5582 ("zassert_false", "`zassert_false V [MSG]` — alias of `zassert_err`."),
5583 ("zassert_ge", "`zassert_ge A B [MSG]` — pass iff numeric A ≥ B. Inputs are `trim().parse::<f64>()`'d; non-numeric inputs default to 0."),
5584 ("zassert_gt", "`zassert_gt A B [MSG]` — pass iff numeric A > B."),
5585 ("zassert_le", "`zassert_le A B [MSG]` — pass iff numeric A ≤ B."),
5586 ("zassert_lt", "`zassert_lt A B [MSG]` — pass iff numeric A < B."),
5587 ("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."),
5588 ("zassert_ne", "`zassert_ne A B [MSG]` — string inequality."),
5589 ("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."),
5590 ("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."),
5591 ("zassert_true", "`zassert_true V [MSG]` — alias of `zassert_ok`."),
5592 ("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."),
5593 ("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; }`)."),
5594 ("seq", "Print a sequence of numbers. `seq 1 10` / `seq 1 2 10` / `seq -w 1 10`. coreutils drop-in."),
5595 ("sha256sum", "Print or check SHA-256 digests. `-c FILE` checks. coreutils drop-in."),
5596 ("shuf", "Shuffle input lines. `-n N` limit, `-e ITEM…` shuffle args, `-i LO-HI` shuffle range. coreutils drop-in."),
5597 ("sleep", "Pause for the given duration. `sleep 1`, `sleep 0.5`, `sleep 1m`. coreutils drop-in."),
5598 ("sort", "Sort lines. `-n` numeric, `-r` reverse, `-k N` by field, `-u` unique. coreutils drop-in."),
5599 ("sum", "BSD/sysv checksum + 1K-block count. coreutils drop-in."),
5600 ("tac", "Concatenate files in reverse line order. coreutils drop-in."),
5601 ("tail", "Print the last N lines (`-n N`) or follow appends (`-f`). coreutils drop-in."),
5602 ("tee", "Copy stdin to stdout AND to each named file. `-a` append. coreutils drop-in."),
5603 ("touch", "Create a file or update its mtime. `-d STR` set time, `-r REF` copy from REF. coreutils drop-in."),
5604 ("tput", "Terminal-capability query. `tput cols`, `tput setaf 1`. Reads `$TERM` via terminfo."),
5605 ("tr", "Translate / squeeze / delete characters. `tr a-z A-Z` uppercases. coreutils drop-in."),
5606 ("tsort", "Topological sort of partial-order pairs read from stdin. coreutils drop-in."),
5607 ("tty", "Print the controlling terminal device path, or `not a tty` if stdin isn't one."),
5608 ("uname", "Print system info. `-a` all, `-s` kernel, `-m` machine, `-r` release. coreutils drop-in."),
5609 ("unexpand", "Convert leading spaces to tabs. `-a` all spaces. coreutils drop-in."),
5610 ("uniq", "Filter adjacent matching lines. `-c` prefix count, `-d` only duplicates. coreutils drop-in."),
5611 ("unlink", "Remove a single file via the `unlink(2)` syscall (no `-r`, no prompts). coreutils drop-in."),
5612 ("users", "Print the login names of users currently logged in."),
5613 ("wc", "Count newlines, words, bytes. `-l` lines, `-w` words, `-c` bytes. coreutils drop-in."),
5614 ("whoami", "Print the effective user name. coreutils drop-in."),
5615 ("yes", "Repeatedly output a line. `yes` prints `y` forever; `yes STR` prints STR. coreutils drop-in."),
5616 ("zbuild", "Bytecode-compile a zsh source file ahead of time. `zbuild script.zsh` writes `script.zwc` next to it; subsequent `source`s skip the lexer/parser. Same on-disk format as `zcompile` but uses fusevm bytecode."),
5617 ("zask", "Send an ask-style request to the daemon and print the JSON response. Used by tools/agents that want a single synchronous query against the shared catalog."),
5619 ("zcache", "Read / write / list the per-shell cache namespace. `zcache get K` / `zcache set K V [TTL]` / `zcache del K` / `zcache list [PREFIX]`. Backed by the daemon's in-memory KV with optional SQLite persistence."),
5620 ("zcmd-result", "Push the exit status + output of a just-completed command to the daemon's command-history catalog. Used by `precmd` hooks to populate the cross-shell `zhistory` index."),
5621 ("zcomplete", "Push a completion candidate to the daemon's shared completion cache. Other shells running compinit will see it without re-walking fpath."),
5622 ("zd", "Daemon HTTP client. In-process when invoked from inside zshrs (Unix socket); same args as the standalone `zd` binary. `zd ping` / `zd ops` / `zd cache get K`. Maps 1:1 to `POST /op/<NAME>`."),
5623 ("zhistory", "Query the daemon's federated command-history catalog. Spans every shell that pushed via `zcmd-result`. SQLite FTS5-backed; `zhistory search 'pattern'`."),
5624 ("zid", "Print the current shell's federated ID — the stable `shell_id` (`bash` / `zsh` / `zshrs` / …) and the per-process `bundle_id` the daemon uses to scope state."),
5625 ("zjob", "Manage background jobs through the daemon: `zjob submit -- cmd …` queues, `zjob status ID`, `zjob output ID`, `zjob wait ID`, `zjob kill ID`. Jobs survive shell exit because the daemon owns them."),
5626 ("zlock", "Acquire / release / try a named cross-shell lock. `zlock acquire NAME [TIMEOUT]` / `zlock release NAME TOKEN` / `zlock try NAME` / `zlock do NAME -- cmd …`. PID-tagged so the daemon GCs stale entries."),
5627 ("zlog", "Append a structured log entry to the daemon's log catalog. `zlog 'message' [key=val …]`. Queryable later via `zhistory` / `dbview`."),
5628 ("zls", "List entries in the daemon's federated catalog (aliases, functions, env vars, etc.). `zls --kind alias --shell-id bash`. The cross-shell mirror of `alias`/`functions`/`typeset`."),
5629 ("znotify", "Send a desktop / system notification through the daemon. Routes to `osascript` (macOS), `notify-send` (Linux), or the in-shell UI when no platform notifier is available."),
5630 ("zping", "Round-trip latency probe against the daemon. Prints the RTT in microseconds; non-zero exit if the daemon is unreachable."),
5631 ("zpublish", "Publish a JSON event to a pubsub topic. `zpublish topic.name '{\"key\":\"val\"}'`. Subscribers receive via `zsubscribe`."),
5632 ("zsend", "Send a one-shot message to another shell (by `shell_id` or `bundle_id`). Like `znotify` but targets a specific shell, not the user's desktop."),
5633 ("zsource", "Push a sourced-file event to the daemon's federated catalog. Used by `source`/`.` hooks so the daemon knows which rc files have been loaded by which shells."),
5634 ("zsubscribe", "Subscribe to a pubsub topic and stream incoming messages to stdout as SSE-style JSON lines. `zsubscribe 'shell:*.build_done'`."),
5635 ("zsuggest", "Query the daemon's suggestion engine for the next command, given the current cwd + history. Used by ZLE's autosuggestion widget when the local history can't supply a candidate."),
5636 ("zsync", "Force a flush of the daemon's in-memory state to the SQLite catalog. Normally happens in the background; `zsync` makes it synchronous so a snapshot is consistent."),
5637 ("ztag", "Tag the current shell session with one or more labels. `ztag prod-deploy`. Other shells can filter by tag via `zls --tag prod-deploy`."),
5638 ("zunsubscribe", "Cancel a `zsubscribe` stream. `zunsubscribe TOPIC` or `zunsubscribe --all`."),
5639 ("zuntag", "Remove a tag from the current shell session. Inverse of `ztag`."),
5640 ("zwhere", "Locate which shell / bundle / cwd defined a given alias / function / env var in the federated catalog. `zwhere alias ll` → list of every shell that set `ll`."),
5641];
5642
5643const OPTION_DOCS_FALLBACK: &[(&str, &str)] = &[(
5649 "RESTRICTED",
5650 "Restricted-shell mode (equivalent to invoking zsh as `rzsh` or with `-r`).\
5651 \n\nDisables: `cd`, modifying `$PATH` / `$ENV` / `$SHELL`, `>` / `>>` redirects,\
5652 creating functions with the `function` keyword, `exec`-ing commands containing `/`,\
5653 `kill`-ing by pid, and several `setopt` toggles. Designed for sandboxed login shells\
5654 where the user must stay inside a curated command set. Once set, cannot be cleared\
5655 within the running shell.",
5656)];
5657
5658fn document_symbols(state: &State, params: &Value) -> Value {
5661 let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
5662 let text = match state.docs.get(uri) {
5663 Some(t) => t,
5664 None => return Value::Array(vec![]),
5665 };
5666 let mut syms = Vec::new();
5667 for (name, kind, _detail) in scan_symbols(text) {
5668 let mut line_no = 0usize;
5670 for (i, l) in text.lines().enumerate() {
5671 if l.contains(&name) {
5672 line_no = i;
5673 break;
5674 }
5675 }
5676 let lsp_kind: u8 = match kind {
5677 "function" => 12,
5678 "variable" => 13,
5679 _ => 1,
5680 };
5681 syms.push(json!({
5682 "name": name,
5683 "kind": lsp_kind,
5684 "range": {
5685 "start": { "line": line_no, "character": 0 },
5686 "end": { "line": line_no, "character": 0 },
5687 },
5688 "selectionRange": {
5689 "start": { "line": line_no, "character": 0 },
5690 "end": { "line": line_no, "character": 0 },
5691 },
5692 }));
5693 }
5694 Value::Array(syms)
5695}
5696
5697fn scan_symbols(text: &str) -> Vec<(String, &'static str, &'static str)> {
5701 let mut out = Vec::new();
5702 for line in text.lines() {
5703 let t = line.trim_start();
5704 if t.starts_with('#') {
5705 continue;
5706 }
5707 if let Some(rest) = t
5709 .strip_prefix("function ")
5710 .or_else(|| t.strip_prefix("function\t"))
5711 {
5712 if let Some(name) = first_ident(rest) {
5713 out.push((name, "function", "function"));
5714 continue;
5715 }
5716 }
5717 if let Some(idx) = t.find("()") {
5719 let head = &t[..idx];
5720 if let Some(name) = first_ident(head) {
5721 if !head.contains(' ') && !head.contains('\t') {
5722 out.push((name, "function", "function"));
5723 continue;
5724 }
5725 }
5726 }
5727 if let Some(rest) = t.strip_prefix("alias ") {
5729 if let Some(name) = first_ident(rest) {
5730 out.push((name, "alias", "alias"));
5731 continue;
5732 }
5733 }
5734 for prefix in &[
5736 "local ",
5737 "typeset ",
5738 "declare ",
5739 "readonly ",
5740 "export ",
5741 "integer ",
5742 "float ",
5743 ] {
5744 if let Some(rest) = t.strip_prefix(prefix) {
5745 if let Some(name) = first_ident(rest) {
5746 out.push((name, "variable", "variable"));
5747 break;
5748 }
5749 }
5750 }
5751 }
5752 out
5753}
5754
5755fn first_ident(s: &str) -> Option<String> {
5756 let s = s.trim_start();
5757 let mut end = 0;
5758 for c in s.chars() {
5759 if c == '_' || c.is_alphanumeric() {
5760 end += c.len_utf8();
5761 } else {
5762 break;
5763 }
5764 }
5765 if end == 0 {
5766 None
5767 } else {
5768 Some(s[..end].to_string())
5769 }
5770}
5771
5772fn folding_ranges(state: &State, params: &Value) -> Value {
5775 let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
5776 let text = match state.docs.get(uri) {
5777 Some(t) => t,
5778 None => return Value::Array(vec![]),
5779 };
5780 let mut out = Vec::new();
5781 let mut brace_stack: Vec<usize> = Vec::new();
5782 let mut block_stack: Vec<(usize, &str)> = Vec::new();
5783 let mut comment_run_start: Option<usize> = None;
5784 for (i, line) in text.lines().enumerate() {
5785 let t = line.trim_start();
5786 if t.starts_with('#') {
5788 if comment_run_start.is_none() {
5789 comment_run_start = Some(i);
5790 }
5791 } else {
5792 if let Some(start) = comment_run_start.take() {
5793 if i - 1 >= start + 2 {
5794 out.push(json!({
5795 "startLine": start, "endLine": i - 1, "kind": "comment"
5796 }));
5797 }
5798 }
5799 }
5800 for c in line.chars() {
5801 if c == '{' {
5802 brace_stack.push(i);
5803 } else if c == '}' {
5804 if let Some(start) = brace_stack.pop() {
5805 if i > start {
5806 out.push(json!({ "startLine": start, "endLine": i, "kind": "region" }));
5807 }
5808 }
5809 }
5810 }
5811 for tok in t.split_whitespace() {
5812 match tok {
5813 "do" | "then" => block_stack.push((i, tok)),
5814 "done" | "fi" => {
5815 if let Some((start, _)) = block_stack.pop() {
5816 if i > start {
5817 out.push(json!({ "startLine": start, "endLine": i, "kind": "region" }));
5818 }
5819 }
5820 }
5821 "case" => block_stack.push((i, "case")),
5822 "esac" => {
5823 if let Some((start, _)) = block_stack.pop() {
5824 if i > start {
5825 out.push(json!({ "startLine": start, "endLine": i, "kind": "region" }));
5826 }
5827 }
5828 }
5829 _ => {}
5830 }
5831 }
5832 }
5833 Value::Array(out)
5834}
5835
5836fn definition(state: &State, params: &Value) -> Value {
5839 let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
5840 let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
5841 let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
5842 let text = match state.docs.get(uri) {
5843 Some(t) => t,
5844 None => return Value::Null,
5845 };
5846 let word = match word_at(text, line_no, col) {
5847 Some(w) if !w.is_empty() => w,
5848 _ => return Value::Null,
5849 };
5850 let bare = word.strip_prefix('$').unwrap_or(&word);
5851 if let Some(v) = definition_via_ast(state, uri, text, &word, bare) {
5856 return v;
5857 }
5858 for (i, l) in text.lines().enumerate() {
5860 let t = l.trim_start();
5861 let is_def = t.starts_with(&format!("function {}", word))
5862 || t.starts_with(&format!("{}()", word))
5863 || t.starts_with(&format!("{} ()", word));
5864 if is_def {
5865 let start_col = l.find(&word).unwrap_or(0);
5866 return json!({
5867 "uri": uri,
5868 "range": {
5869 "start": { "line": i, "character": start_col },
5870 "end": { "line": i, "character": start_col + word.len() },
5871 },
5872 });
5873 }
5874 }
5875 Value::Null
5876}
5877
5878fn definition_via_ast(
5889 state: &State,
5890 active_uri: &str,
5891 active_text: &str,
5892 word: &str,
5893 bare: &str,
5894) -> Option<Value> {
5895 use crate::lsp_symbols::{find_ast_occurrences, SymbolKind};
5896 let kind = if word.starts_with('$') {
5900 SymbolKind::Global
5901 } else {
5902 SymbolKind::Func
5903 };
5904 let files = collect_active_and_sourced_files(state, active_uri, active_text);
5911 let mut hits: Vec<Value> = Vec::new();
5912 let scan = |kind: SymbolKind, hits: &mut Vec<Value>| {
5913 for (uri, src) in &files {
5914 let lines = find_ast_occurrences(src, bare, kind.clone());
5915 for line in lines {
5916 if !line_is_decl(src, line, bare, &kind) {
5917 continue;
5918 }
5919 if let Some((start, end)) = find_first_word_col(src, line, bare) {
5920 hits.push(json!({
5921 "uri": uri,
5922 "range": {
5923 "start": { "line": line, "character": start },
5924 "end": { "line": line, "character": end },
5925 },
5926 }));
5927 }
5928 }
5929 }
5930 };
5931 scan(kind.clone(), &mut hits);
5932 if hits.is_empty() {
5933 let alt = if matches!(kind, SymbolKind::Global) {
5936 SymbolKind::Func
5937 } else {
5938 SymbolKind::Global
5939 };
5940 scan(alt, &mut hits);
5941 }
5942 match hits.len() {
5943 0 => None,
5944 1 => Some(hits.into_iter().next().unwrap()),
5945 _ => Some(Value::Array(hits)),
5946 }
5947}
5948
5949fn collect_active_and_sourced_files(
5967 state: &State,
5968 active_uri: &str,
5969 active_text: &str,
5970) -> Vec<(String, String)> {
5971 const MAX_FILES: usize = 256;
5972
5973 let read_text = |uri: &str| -> Option<String> {
5975 if uri == active_uri {
5976 return Some(active_text.to_string());
5977 }
5978 state
5979 .docs
5980 .get(uri)
5981 .cloned()
5982 .or_else(|| state.workspace_files.get(uri).cloned())
5983 .or_else(|| file_uri_to_path(uri).and_then(|p| std::fs::read_to_string(p).ok()))
5984 };
5985
5986 let mut component: std::collections::HashMap<String, String> = std::collections::HashMap::new();
5988 component.insert(active_uri.to_string(), active_text.to_string());
5989 let mut queue: Vec<String> = vec![active_uri.to_string()];
5990 while let Some(uri) = queue.pop() {
5991 if component.len() >= MAX_FILES {
5992 break;
5993 }
5994 let parent_text = match component.get(&uri).cloned().or_else(|| read_text(&uri)) {
5995 Some(t) => t,
5996 None => continue,
5997 };
5998 let parent_dir = file_uri_to_path(&uri)
5999 .and_then(|p| p.parent().map(|d| d.to_path_buf()))
6000 .unwrap_or_else(|| std::path::PathBuf::from("."));
6001 for sourced_path in crate::lsp_symbols::collect_sourced_paths(&parent_text, &parent_dir) {
6002 let sourced_uri = format!("file://{}", sourced_path.display());
6003 if component.contains_key(&sourced_uri) {
6004 continue;
6005 }
6006 if let Some(t) = read_text(&sourced_uri) {
6007 component.insert(sourced_uri.clone(), t);
6008 queue.push(sourced_uri);
6009 }
6010 }
6011 }
6012
6013 loop {
6017 if component.len() >= MAX_FILES {
6018 tracing::warn!(
6019 target: "zshrs::lsp::source_chain",
6020 walked = component.len(),
6021 "source-graph component hit MAX_FILES cap",
6022 );
6023 break;
6024 }
6025 let before = component.len();
6026 let candidates: Vec<(String, String)> = state
6030 .all_docs()
6031 .into_iter()
6032 .filter(|(u, _)| !component.contains_key(u.as_str()))
6033 .collect();
6034 for (uri, src) in candidates {
6035 let parent_dir = file_uri_to_path(&uri)
6036 .and_then(|p| p.parent().map(|d| d.to_path_buf()))
6037 .unwrap_or_else(|| std::path::PathBuf::from("."));
6038 let mut sources_into_component = false;
6039 for sourced_path in crate::lsp_symbols::collect_sourced_paths(&src, &parent_dir) {
6040 let sourced_uri = format!("file://{}", sourced_path.display());
6041 if component.contains_key(&sourced_uri) {
6042 sources_into_component = true;
6043 break;
6044 }
6045 }
6046 if sources_into_component {
6047 component.insert(uri, src);
6048 if component.len() >= MAX_FILES {
6049 break;
6050 }
6051 }
6052 }
6053 if component.len() == before {
6054 break;
6055 }
6056 }
6057
6058 component.into_iter().collect()
6059}
6060
6061fn line_is_decl(src: &str, line: u32, name: &str, kind: &crate::lsp_symbols::SymbolKind) -> bool {
6065 let l = match src.lines().nth(line as usize) {
6066 Some(l) => l,
6067 None => return false,
6068 };
6069 let t = l.trim_start();
6070 use crate::lsp_symbols::SymbolKind;
6071 match kind {
6072 SymbolKind::Func => {
6073 t.starts_with(&format!("function {}", name))
6074 || t.starts_with(&format!("function {} ", name))
6075 || t.starts_with(&format!("{}()", name))
6076 || t.starts_with(&format!("{} ()", name))
6077 }
6078 SymbolKind::Global | SymbolKind::Local => {
6079 let prefixes = [
6083 format!("{}=", name),
6084 format!("{}+=", name),
6085 format!("local {}", name),
6086 format!("typeset {}", name),
6087 format!("declare {}", name),
6088 format!("private {}", name),
6089 format!("export {}", name),
6090 format!("readonly {}", name),
6091 format!("integer {}", name),
6092 format!("float {}", name),
6093 ];
6094 prefixes.iter().any(|p| t.starts_with(p.as_str()))
6095 }
6096 }
6097}
6098
6099fn find_first_word_col(src: &str, line: u32, name: &str) -> Option<(u32, u32)> {
6104 let l = src.lines().nth(line as usize)?;
6105 let mut start = 0;
6106 while let Some(p) = l[start..].find(name) {
6107 let abs = start + p;
6108 let before = l[..abs].chars().last();
6109 let after = l[abs + name.len()..].chars().next();
6110 let ok_b = before
6111 .map(|c| !(c.is_alphanumeric() || c == '_' || c == '-'))
6112 .unwrap_or(true);
6113 let ok_a = after
6114 .map(|c| !(c.is_alphanumeric() || c == '_' || c == '-'))
6115 .unwrap_or(true);
6116 if ok_b && ok_a && !line_position_inside_string_or_comment(l, abs) {
6117 return Some((abs as u32, (abs + name.len()) as u32));
6118 }
6119 start = abs + name.len();
6120 }
6121 None
6122}
6123
6124fn find_all_word_cols(line_text: &str, name: &str) -> Vec<(u32, u32)> {
6133 find_all_word_cols_kinded(line_text, name, false)
6134}
6135
6136fn find_all_word_cols_kinded(
6137 line_text: &str,
6138 name: &str,
6139 is_variable_ref: bool,
6140) -> Vec<(u32, u32)> {
6141 let mut out = Vec::new();
6142 let mut start = 0;
6143 while let Some(p) = line_text[start..].find(name) {
6144 let abs = start + p;
6145 let before = line_text[..abs].chars().last();
6146 let after = line_text[abs + name.len()..].chars().next();
6147 let ok_b = before
6148 .map(|c| !(c.is_alphanumeric() || c == '_' || c == '-'))
6149 .unwrap_or(true);
6150 let ok_a = after
6151 .map(|c| !(c.is_alphanumeric() || c == '_' || c == '-'))
6152 .unwrap_or(true);
6153 let masked = if is_variable_ref {
6154 line_position_inside_uninterpolating_context(line_text, abs)
6155 } else {
6156 line_position_inside_string_or_comment(line_text, abs)
6157 };
6158 if ok_b && ok_a && !masked {
6159 out.push((abs as u32, (abs + name.len()) as u32));
6160 }
6161 start = abs + name.len();
6162 }
6163 out
6164}
6165
6166fn references(state: &State, params: &Value) -> Value {
6167 let active_uri = params["textDocument"]["uri"]
6168 .as_str()
6169 .unwrap_or("")
6170 .to_string();
6171 let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
6172 let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
6173 let active_text = match state
6176 .docs
6177 .get(&active_uri)
6178 .cloned()
6179 .or_else(|| state.workspace_files.get(&active_uri).cloned())
6180 {
6181 Some(t) => t,
6182 None => return Value::Array(vec![]),
6183 };
6184 let word = match word_at(&active_text, line_no, col) {
6185 Some(w) if !w.is_empty() => w,
6186 _ => return Value::Array(vec![]),
6187 };
6188 match references_via_ast(state, &active_uri, &active_text, line_no as u32, &word) {
6200 Some(v) => v,
6201 None => {
6202 tracing::warn!(
6203 target: "zshrs::lsp::references",
6204 uri = %active_uri,
6205 %word,
6206 line = line_no,
6207 col,
6208 "AST-walk returned no resolution \
6209 (parse failure or cursor not on a declared symbol); \
6210 returning empty rather than falling back to text-search",
6211 );
6212 Value::Array(vec![])
6213 }
6214 }
6215}
6216
6217fn references_via_ast(
6234 state: &State,
6235 active_uri: &str,
6236 active_text: &str,
6237 cursor_line: u32,
6238 cursor_word: &str,
6239) -> Option<Value> {
6240 use crate::lsp_symbols::{find_ast_occurrences, SymbolKind, SymbolTable};
6241
6242 let bare = cursor_word.strip_prefix('$').unwrap_or(cursor_word);
6244
6245 let active_table = SymbolTable::build(active_text)?;
6246 let (name, kind) = match active_table
6251 .symbol_at(cursor_line, bare)
6252 .and_then(|id| active_table.symbols.iter().find(|s| s.id == id))
6253 {
6254 Some(sym) => (sym.name.clone(), sym.kind.clone()),
6255 None => {
6256 let chain_files = collect_active_and_sourced_files(state, active_uri, active_text);
6261 let mut found: Option<SymbolKind> = None;
6262 'outer: for (other_uri, src) in &chain_files {
6263 if other_uri == active_uri {
6264 continue;
6265 }
6266 let Some(t) = SymbolTable::build(src) else {
6267 continue;
6268 };
6269 for s in &t.symbols {
6270 if s.name == bare && matches!(s.kind, SymbolKind::Func | SymbolKind::Global) {
6271 found = Some(s.kind.clone());
6272 break 'outer;
6273 }
6274 }
6275 }
6276 let default_kind = if cursor_word.starts_with('$') {
6277 SymbolKind::Global
6278 } else {
6279 SymbolKind::Func
6280 };
6281 (bare.to_string(), found.unwrap_or(default_kind))
6282 }
6283 };
6284
6285 let mut out: Vec<Value> = Vec::new();
6286
6287 let is_var = matches!(kind, SymbolKind::Global | SymbolKind::Local);
6292
6293 let active_lines: Vec<&str> = active_text.lines().collect();
6297 if let Some(id) = active_table.symbol_at(cursor_line, &name) {
6298 for (line, n) in active_table.occurrences(id) {
6299 if let Some(lt) = active_lines.get(line as usize) {
6300 for (s, e) in find_all_word_cols_kinded(lt, &n, is_var) {
6301 out.push(json!({
6302 "uri": active_uri,
6303 "range": {
6304 "start": { "line": line, "character": s },
6305 "end": { "line": line, "character": e },
6306 },
6307 }));
6308 }
6309 }
6310 }
6311 } else {
6312 let lines = find_ast_occurrences(active_text, &name, kind.clone());
6313 for line in lines {
6314 if let Some(lt) = active_lines.get(line as usize) {
6315 for (s, e) in find_all_word_cols_kinded(lt, &name, is_var) {
6316 out.push(json!({
6317 "uri": active_uri,
6318 "range": {
6319 "start": { "line": line, "character": s },
6320 "end": { "line": line, "character": e },
6321 },
6322 }));
6323 }
6324 }
6325 }
6326 }
6327
6328 if !matches!(kind, SymbolKind::Local) {
6340 let component = collect_active_and_sourced_files(state, active_uri, active_text);
6346 for (uri, src) in &component {
6347 if uri == active_uri {
6348 continue; }
6350 let lines = find_ast_occurrences(src, &name, kind.clone());
6351 let src_lines: Vec<&str> = src.lines().collect();
6352 for line in lines {
6353 if let Some(lt) = src_lines.get(line as usize) {
6354 for (s, e) in find_all_word_cols_kinded(lt, &name, is_var) {
6355 out.push(json!({
6356 "uri": uri,
6357 "range": {
6358 "start": { "line": line, "character": s },
6359 "end": { "line": line, "character": e },
6360 },
6361 }));
6362 }
6363 }
6364 }
6365 }
6366 tracing::debug!(
6367 target: "zshrs::lsp::references_ast",
6368 files_in_component = component.len(),
6369 "source-graph component walked",
6370 );
6371 }
6372
6373 tracing::debug!(
6374 target: "zshrs::lsp::references_ast",
6375 %name,
6376 ?kind,
6377 n_results = out.len(),
6378 "AST-resolved",
6379 );
6380 Some(Value::Array(out))
6381}
6382
6383fn document_highlights(state: &State, params: &Value) -> Value {
6384 let refs = references(state, params);
6386 let arr = refs.as_array().cloned().unwrap_or_default();
6387 Value::Array(
6388 arr.into_iter()
6389 .map(|r| json!({ "range": r["range"], "kind": 1 }))
6390 .collect(),
6391 )
6392}
6393
6394fn prepare_rename(state: &State, params: &Value) -> Value {
6395 let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
6396 let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
6397 let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
6398 let text = match state.docs.get(uri) {
6399 Some(t) => t,
6400 None => {
6401 tracing::debug!(
6402 target: "zshrs::lsp::prepareRename",
6403 line = line_no, col,
6404 "no_doc_for_uri",
6405 );
6406 return Value::Null;
6407 }
6408 };
6409 let line_text = text.lines().nth(line_no).unwrap_or("");
6413 if line_starts_comment_before(line_text, col) {
6414 tracing::debug!(
6415 target: "zshrs::lsp::prepareRename",
6416 line = line_no, col,
6417 "gated_comment",
6418 );
6419 return Value::Null;
6420 }
6421 if let Some(word) = word_at(text, line_no, col) {
6422 if !word.is_empty() {
6423 if let Some(line) = text.lines().nth(line_no) {
6424 if let Some(s) = line.find(&word) {
6425 tracing::debug!(
6426 target: "zshrs::lsp::prepareRename",
6427 %word, line = line_no, "accepted",
6428 );
6429 return json!({
6430 "start": { "line": line_no, "character": s },
6431 "end": { "line": line_no, "character": s + word.len() },
6432 "placeholder": word,
6433 });
6434 }
6435 }
6436 }
6437 }
6438 tracing::debug!(
6439 target: "zshrs::lsp::prepareRename",
6440 line = line_no, col,
6441 "no_identifier",
6442 );
6443 Value::Null
6444}
6445
6446fn rename(state: &State, params: &Value) -> Value {
6447 let new_name_raw = params["newName"].as_str().unwrap_or("").to_string();
6448 if new_name_raw.is_empty() {
6449 tracing::warn!(target: "zshrs::lsp::rename", "rejecting empty new_name");
6450 return Value::Null;
6451 }
6452 let new_name = match new_name_raw.rfind("::") {
6467 Some(idx) => {
6468 let bare = new_name_raw[idx + 2..].to_string();
6469 tracing::warn!(
6470 target: "zshrs::lsp::rename",
6471 %new_name_raw, %bare,
6472 "stripping `::` qualifier from new_name",
6473 );
6474 bare
6475 }
6476 None => new_name_raw,
6477 };
6478 let refs = references(state, params);
6482 let arr = refs.as_array().cloned().unwrap_or_default();
6483 let mut buckets: HashMap<String, Vec<Value>> = HashMap::new();
6484 let mut total = 0usize;
6485 for r in arr {
6486 let uri = r["uri"].as_str().unwrap_or("").to_string();
6487 if uri.is_empty() {
6488 continue;
6489 }
6490 buckets
6491 .entry(uri)
6492 .or_default()
6493 .push(json!({ "range": r["range"], "newText": new_name }));
6494 total += 1;
6495 }
6496 tracing::info!(
6497 target: "zshrs::lsp::rename",
6498 %new_name,
6499 n_files = buckets.len(),
6500 n_edits = total,
6501 "applied",
6502 );
6503 let mut changes = serde_json::Map::new();
6504 for (uri, edits) in buckets {
6505 changes.insert(uri, Value::Array(edits));
6506 }
6507 json!({ "changes": Value::Object(changes) })
6508}
6509
6510const SEMANTIC_TOKEN_TYPES: &[&str] = &[
6513 "comment", "string", "number", "keyword", "operator", "function", "variable", "parameter", "type", "macro", "property", "regexp", "zshrsExtension", "zshrsCompsys", ];
6528
6529fn semantic_tokens(state: &State, params: &Value) -> Value {
6530 let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
6531 let text = match state.docs.get(uri) {
6532 Some(t) => t,
6533 None => return json!({ "data": [] }),
6534 };
6535 let mut data: Vec<u32> = Vec::new();
6537 let mut last_line: u32 = 0;
6538 let mut last_col: u32 = 0;
6539 for (i, line) in text.lines().enumerate() {
6540 let ln = i as u32;
6541 let mut col = 0usize;
6542 let bytes = line.as_bytes();
6543 while col < bytes.len() {
6544 let rest = &line[col..];
6545 if rest.starts_with('#') {
6547 push_tok(
6548 &mut data,
6549 &mut last_line,
6550 &mut last_col,
6551 ln,
6552 col as u32,
6553 rest.len() as u32,
6554 0,
6555 );
6556 break;
6557 }
6558 if rest.starts_with('"') || rest.starts_with('\'') || rest.starts_with('`') {
6571 let q = rest.as_bytes()[0] as char;
6572 let bb = rest.as_bytes();
6573 let mut close = 1;
6576 while close < bb.len() {
6577 let c = bb[close] as char;
6578 if c == '\\' && q != '\'' && close + 1 < bb.len() {
6579 close += 2;
6580 continue;
6581 }
6582 if c == q {
6583 close += 1;
6584 break;
6585 }
6586 close += 1;
6587 }
6588 if q == '\'' {
6590 push_tok(
6591 &mut data,
6592 &mut last_line,
6593 &mut last_col,
6594 ln,
6595 col as u32,
6596 close as u32,
6597 1,
6598 );
6599 col += close;
6600 continue;
6601 }
6602 let mut seg_start = 0usize;
6607 let mut p = 1usize;
6608 let inner_end =
6613 if close > 0 && close <= bb.len() && bb.get(close - 1) == Some(&(q as u8)) {
6614 close - 1
6615 } else {
6616 close
6617 };
6618 let flush_string = |data: &mut Vec<u32>,
6619 last_line: &mut u32,
6620 last_col: &mut u32,
6621 col: usize,
6622 seg_start: usize,
6623 seg_end: usize| {
6624 if seg_end > seg_start {
6625 push_tok(
6626 data,
6627 last_line,
6628 last_col,
6629 ln,
6630 (col + seg_start) as u32,
6631 (seg_end - seg_start) as u32,
6632 1, );
6634 }
6635 };
6636 while p < inner_end {
6637 let c = bb[p] as char;
6638 if c == '\\' && q != '\'' && p + 1 < inner_end {
6644 p += 2;
6645 continue;
6646 }
6647 if c == '$' {
6648 flush_string(&mut data, &mut last_line, &mut last_col, col, seg_start, p);
6650 let var_start = p;
6656 let mut q2 = p + 1;
6657 let is_arith = q2 + 1 < inner_end && bb[q2] == b'(' && bb[q2 + 1] == b'(';
6664 if is_arith {
6665 let mut depth = 2i32; let arith_start = q2 + 2;
6668 q2 = arith_start;
6669 while q2 < inner_end && depth > 0 {
6670 match bb[q2] {
6671 b'(' => depth += 1,
6672 b')' => depth -= 1,
6673 _ => {}
6674 }
6675 if depth == 0 {
6676 break;
6677 }
6678 q2 += 1;
6679 }
6680 let arith_end = if q2 > 0 && q2 < bb.len() && bb[q2] == b')' {
6686 q2.saturating_sub(0)
6690 } else {
6691 q2
6692 };
6693 push_tok(
6695 &mut data,
6696 &mut last_line,
6697 &mut last_col,
6698 ln,
6699 (col + var_start) as u32,
6700 3,
6701 4,
6702 );
6703 emit_arith_interior(
6706 &mut data,
6707 &mut last_line,
6708 &mut last_col,
6709 ln,
6710 col,
6711 bb,
6712 arith_start,
6713 arith_end,
6714 );
6715 if arith_end + 1 < bb.len()
6717 && bb[arith_end] == b')'
6718 && bb[arith_end + 1] == b')'
6719 {
6720 push_tok(
6721 &mut data,
6722 &mut last_line,
6723 &mut last_col,
6724 ln,
6725 (col + arith_end) as u32,
6726 2,
6727 4,
6728 );
6729 q2 = arith_end + 2;
6730 } else {
6731 q2 = arith_end;
6732 }
6733 seg_start = q2;
6734 p = q2;
6735 continue;
6736 }
6737 if q2 < inner_end && bb[q2] == b'{' {
6738 let mut depth = 1i32;
6742 q2 += 1;
6743 while q2 < inner_end && depth > 0 {
6744 match bb[q2] {
6745 b'{' => depth += 1,
6746 b'}' => depth -= 1,
6747 _ => {}
6748 }
6749 q2 += 1;
6750 }
6751 } else if q2 < inner_end && bb[q2] == b'(' {
6752 let mut depth = 1i32;
6754 q2 += 1;
6755 while q2 < inner_end && depth > 0 {
6756 match bb[q2] {
6757 b'(' => depth += 1,
6758 b')' => depth -= 1,
6759 _ => {}
6760 }
6761 q2 += 1;
6762 }
6763 } else {
6764 while q2 < inner_end {
6766 let cc = bb[q2] as char;
6767 if cc.is_alphanumeric() || cc == '_' {
6768 q2 += 1;
6769 } else {
6770 break;
6771 }
6772 }
6773 if q2 == p + 1 && q2 < inner_end {
6776 let cc = bb[q2] as char;
6777 if "?!$#*@-_0123456789".contains(cc) {
6778 q2 += 1;
6779 }
6780 }
6781 }
6782 if q2 > var_start + 1 {
6783 push_tok(
6785 &mut data,
6786 &mut last_line,
6787 &mut last_col,
6788 ln,
6789 (col + var_start) as u32,
6790 (q2 - var_start) as u32,
6791 6,
6792 );
6793 seg_start = q2;
6794 p = q2;
6795 continue;
6796 }
6797 p += 1;
6800 continue;
6801 }
6802 p += 1;
6803 }
6804 flush_string(
6806 &mut data,
6807 &mut last_line,
6808 &mut last_col,
6809 col,
6810 seg_start,
6811 close,
6812 );
6813 col += close;
6814 continue;
6815 }
6816 if rest.starts_with('$') {
6818 let mut end = 1;
6819 let b = rest.as_bytes();
6820 if end < b.len() && b[end] == b'{' {
6821 end += 1;
6823 while end < b.len() && b[end] != b'}' {
6824 end += 1;
6825 }
6826 if end < b.len() {
6827 end += 1;
6828 }
6829 } else {
6830 while end < b.len() {
6831 let c = b[end] as char;
6832 if c.is_alphanumeric() || c == '_' {
6833 end += 1;
6834 } else {
6835 break;
6836 }
6837 }
6838 if end == 1 {
6839 if end < b.len() {
6841 let c = b[end] as char;
6842 if "?!$#*@-_0123456789".contains(c) {
6843 end += 1;
6844 }
6845 }
6846 }
6847 }
6848 push_tok(
6849 &mut data,
6850 &mut last_line,
6851 &mut last_col,
6852 ln,
6853 col as u32,
6854 end as u32,
6855 6,
6856 );
6857 col += end;
6858 continue;
6859 }
6860 if rest.starts_with("--") && rest.len() > 2 && rest.as_bytes()[2].is_ascii_alphabetic()
6872 {
6873 let bb = rest.as_bytes();
6874 let mut end = 2;
6875 while end < bb.len() {
6876 let c = bb[end];
6877 if c.is_ascii_alphanumeric() || c == b'-' || c == b'_' {
6878 end += 1;
6879 } else {
6880 break;
6881 }
6882 }
6883 push_tok(
6884 &mut data,
6885 &mut last_line,
6886 &mut last_col,
6887 ln,
6888 col as u32,
6889 end as u32,
6890 4, );
6892 col += end;
6893 continue;
6894 }
6895 if rest.starts_with('{') {
6909 let bb = rest.as_bytes();
6910 let mut p = 1usize;
6911 let scan_run = |bb: &[u8], mut p: usize| -> usize {
6912 while p < bb.len() {
6913 let c = bb[p];
6914 if c.is_ascii_alphanumeric() || c == b'-' || c == b'_' {
6915 p += 1;
6916 } else {
6917 break;
6918 }
6919 }
6920 p
6921 };
6922 let after_a = scan_run(bb, p);
6923 let has_dotdot = after_a > p
6924 && bb.get(after_a) == Some(&b'.')
6925 && bb.get(after_a + 1) == Some(&b'.');
6926 if has_dotdot {
6927 p = after_a + 2;
6928 let after_b = scan_run(bb, p);
6929 if after_b > p {
6930 let mut close = after_b;
6932 if bb.get(close) == Some(&b'.') && bb.get(close + 1) == Some(&b'.') {
6933 let after_step = scan_run(bb, close + 2);
6934 if after_step > close + 2 {
6935 close = after_step;
6936 }
6937 }
6938 if bb.get(close) == Some(&b'}') {
6939 let span = close + 1;
6940 push_tok(
6941 &mut data,
6942 &mut last_line,
6943 &mut last_col,
6944 ln,
6945 col as u32,
6946 span as u32,
6947 4, );
6949 col += span;
6950 continue;
6951 }
6952 }
6953 }
6954 }
6955 const OPERATORS: &[&str] = &[
6965 ";;&", "<<<", "<<-", "&&", "||", "|&", "<<", ">>", "&>", ">|", ">!", ">&", "<&",
6966 "<>", "==", "!=", "=~", "+=", "-=", ":=", "?=", "[[", "]]", "((", "))", ";;", ";|",
6967 "|", "&", ">", "<",
6968 ];
6969 let mut op_len = 0usize;
6970 for op in OPERATORS {
6971 if rest.starts_with(op) {
6972 op_len = op.len();
6973 break;
6974 }
6975 }
6976 if op_len > 0 {
6977 push_tok(
6978 &mut data,
6979 &mut last_line,
6980 &mut last_col,
6981 ln,
6982 col as u32,
6983 op_len as u32,
6984 4, );
6986 col += op_len;
6987 continue;
6988 }
6989 let c0 = rest.as_bytes()[0] as char;
6991 if c0.is_ascii_digit() {
6992 let mut end = 0;
6993 let b = rest.as_bytes();
6994 while end < b.len() && (b[end] as char).is_ascii_digit() {
6995 end += 1;
6996 }
6997 push_tok(
6998 &mut data,
6999 &mut last_line,
7000 &mut last_col,
7001 ln,
7002 col as u32,
7003 end as u32,
7004 2,
7005 );
7006 col += end;
7007 continue;
7008 }
7009 use crate::ported::ztype_h::{ialnum, iident, iuser};
7025 let leading_sigil = iuser(c0 as u8)
7026 && !iident(c0 as u8) && rest.as_bytes().get(1).map_or(false, |b| iident(*b));
7028 let extra_sigil = !leading_sigil
7033 && matches!(c0, '+' | '@' | ':' | '^')
7034 && rest.as_bytes().get(1).map_or(false, |b| iident(*b));
7035 let is_sigil = leading_sigil || extra_sigil;
7036 if iident(c0 as u8) || is_sigil {
7037 let b = rest.as_bytes();
7038 let mut end = if is_sigil { 1 } else { 0 };
7039 while end < b.len() {
7040 let c = b[end];
7041 if ialnum(c) || c == b'_' {
7042 end += 1;
7043 } else if matches!(c, b'-' | b'.' | b':')
7044 && end + 1 < b.len()
7045 && (ialnum(b[end + 1]) || b[end + 1] == b'_')
7046 {
7047 end += 1;
7048 } else {
7049 break;
7050 }
7051 }
7052 let w = &rest[..end];
7053 let kind = if KEYWORDS.contains(&w) {
7063 3u32
7064 } else if crate::ext_builtins::EXT_BUILTIN_NAMES.contains(&w)
7065 || crate::daemon::builtins::ZSHRS_BUILTIN_NAMES.contains(&w)
7066 {
7067 12
7068 } else if crate::compsys::COMPSYS_FN_NAMES.contains(&w) {
7069 13
7070 } else if BUILTINS.contains(&w) {
7071 5
7072 } else {
7073 6
7074 };
7075 push_tok(
7076 &mut data,
7077 &mut last_line,
7078 &mut last_col,
7079 ln,
7080 col as u32,
7081 end as u32,
7082 kind,
7083 );
7084 col += end;
7085 continue;
7086 }
7087 col += 1;
7088 }
7089 }
7090 json!({ "data": data })
7091}
7092
7093fn emit_arith_interior(
7105 data: &mut Vec<u32>,
7106 last_line: &mut u32,
7107 last_col: &mut u32,
7108 ln: u32,
7109 col: usize,
7110 bb: &[u8],
7111 arith_start: usize,
7112 arith_end: usize,
7113) {
7114 let mut p = arith_start;
7115 while p < arith_end {
7116 let c = bb[p];
7117 if c == b' ' || c == b'\t' {
7119 p += 1;
7120 continue;
7121 }
7122 if c.is_ascii_digit() {
7124 let mut end = p + 1;
7125 while end < arith_end && (bb[end].is_ascii_digit() || bb[end] == b'.') {
7126 end += 1;
7127 }
7128 push_tok(
7129 data,
7130 last_line,
7131 last_col,
7132 ln,
7133 (col + p) as u32,
7134 (end - p) as u32,
7135 2,
7136 );
7137 p = end;
7138 continue;
7139 }
7140 if c.is_ascii_alphabetic() || c == b'_' {
7142 let mut end = p + 1;
7143 while end < arith_end && (bb[end].is_ascii_alphanumeric() || bb[end] == b'_') {
7144 end += 1;
7145 }
7146 push_tok(
7147 data,
7148 last_line,
7149 last_col,
7150 ln,
7151 (col + p) as u32,
7152 (end - p) as u32,
7153 6,
7154 );
7155 p = end;
7156 continue;
7157 }
7158 let two = if p + 1 < arith_end {
7164 Some(&bb[p..p + 2])
7165 } else {
7166 None
7167 };
7168 let span = match two {
7169 Some(b"**") | Some(b"++") | Some(b"--") | Some(b"<<") | Some(b">>") | Some(b"&&")
7170 | Some(b"||") | Some(b"==") | Some(b"!=") | Some(b"<=") | Some(b">=") | Some(b"+=")
7171 | Some(b"-=") | Some(b"*=") | Some(b"/=") | Some(b"%=") => 2,
7172 _ => 1,
7173 };
7174 push_tok(
7175 data,
7176 last_line,
7177 last_col,
7178 ln,
7179 (col + p) as u32,
7180 span as u32,
7181 4,
7182 );
7183 p += span;
7184 }
7185}
7186
7187fn push_tok(
7188 out: &mut Vec<u32>,
7189 last_line: &mut u32,
7190 last_col: &mut u32,
7191 line: u32,
7192 col: u32,
7193 len: u32,
7194 ty: u32,
7195) {
7196 let delta_line = line - *last_line;
7197 let delta_col = if delta_line == 0 {
7198 col - *last_col
7199 } else {
7200 col
7201 };
7202 out.push(delta_line);
7203 out.push(delta_col);
7204 out.push(len);
7205 out.push(ty);
7206 out.push(0);
7207 *last_line = line;
7208 *last_col = col;
7209}
7210
7211fn code_actions(state: &State, params: &Value) -> Value {
7227 let uri = params["textDocument"]["uri"]
7228 .as_str()
7229 .unwrap_or("")
7230 .to_string();
7231 let text = match state.docs.get(&uri).cloned() {
7232 Some(t) => t,
7233 None => return Value::Array(vec![]),
7234 };
7235 let r = ¶ms["range"];
7236 let start_line = r["start"]["line"].as_u64().unwrap_or(0) as u32;
7237 let start_char = r["start"]["character"].as_u64().unwrap_or(0) as u32;
7238 let end_line = r["end"]["line"].as_u64().unwrap_or(0) as u32;
7239 let end_char = r["end"]["character"].as_u64().unwrap_or(0) as u32;
7240
7241 let mut actions: Vec<Value> = Vec::new();
7242 let same_line = start_line == end_line;
7243 let nonempty = start_line != end_line || start_char != end_char;
7244
7245 if !same_line {
7249 if let Some(action) = make_extract_function_multiline(&uri, &text, start_line, end_line) {
7250 actions.push(action);
7251 }
7252 return Value::Array(actions);
7253 }
7254
7255 let line_text = match text.lines().nth(start_line as usize) {
7258 Some(l) => l,
7259 None => return Value::Array(vec![]),
7260 };
7261 let leading_ws: String = line_text
7262 .chars()
7263 .take_while(|c| c.is_whitespace())
7264 .collect();
7265
7266 let line_has_content = !line_text.trim().is_empty();
7274 let whole_line_selected =
7275 nonempty && selection_covers_whole_line(line_text, start_char, end_char);
7276 if line_has_content && (whole_line_selected || !nonempty) {
7277 let body = if whole_line_selected {
7278 utf16_slice(line_text, start_char, end_char)
7279 .map(str::trim_end)
7280 .unwrap_or_else(|| line_text.trim())
7281 } else {
7282 line_text.trim()
7283 };
7284 actions.push(make_extract_function_singleline(
7285 &uri,
7286 &leading_ws,
7287 start_line,
7288 body,
7289 ));
7290 }
7291
7292 let (eff_start_char, eff_end_char) = if !nonempty {
7296 match snap_to_word_at_cursor(line_text, start_char) {
7297 Some((s, e)) => (s, e),
7298 None => return Value::Array(actions),
7301 }
7302 } else {
7303 (start_char, end_char)
7304 };
7305
7306 if eff_end_char <= eff_start_char {
7307 return Value::Array(actions);
7308 }
7309
7310 let sel = match utf16_slice(line_text, eff_start_char, eff_end_char) {
7311 Some(s) if !s.trim().is_empty() => s,
7312 _ => return Value::Array(actions),
7313 };
7314
7315 let eff_range = json!({
7316 "start": { "line": start_line, "character": eff_start_char },
7317 "end": { "line": start_line, "character": eff_end_char },
7318 });
7319
7320 let in_string = same_line_inside_interpolating_string(line_text, eff_start_char);
7324 let rhs = if in_string && needs_string_wrap_for_extraction(sel) {
7325 format!("\"{}\"", escape_for_double_quoted(sel))
7326 } else {
7327 sel.to_string()
7328 };
7329
7330 actions.push(make_extract_action(
7331 &uri,
7332 &leading_ws,
7333 start_line,
7334 &eff_range,
7335 &rhs,
7336 "EXTRACTED",
7337 "local",
7338 "Extract to variable (`local NAME=…`)",
7339 ));
7340 actions.push(make_extract_action(
7341 &uri,
7342 &leading_ws,
7343 start_line,
7344 &eff_range,
7345 &rhs,
7346 "EXTRACTED",
7347 "readonly",
7348 "Extract to constant (`readonly NAME=…`)",
7349 ));
7350
7351 Value::Array(actions)
7352}
7353
7354fn selection_covers_whole_line(line_text: &str, start_col: u32, end_col: u32) -> bool {
7361 let mut prefix_byte = 0;
7362 let mut suffix_byte = line_text.len();
7363 let mut u16_seen = 0u32;
7364 for (i, ch) in line_text.char_indices() {
7365 if u16_seen == start_col {
7366 prefix_byte = i;
7367 }
7368 u16_seen += ch.len_utf16() as u32;
7369 if u16_seen == end_col {
7370 suffix_byte = i + ch.len_utf8();
7371 }
7372 }
7373 line_text[..prefix_byte].chars().all(char::is_whitespace)
7374 && line_text[suffix_byte..].chars().all(char::is_whitespace)
7375}
7376
7377fn make_extract_function_singleline(uri: &str, leading_ws: &str, line: u32, body: &str) -> Value {
7378 let name = "extracted_function";
7381 let decl = format!("{leading_ws}{name}() {{\n{leading_ws} {body}\n{leading_ws}}}\n");
7382 let insert_range = json!({
7383 "start": { "line": line, "character": 0 },
7384 "end": { "line": line, "character": 0 },
7385 });
7386 let replace_range = json!({
7387 "start": { "line": line, "character": 0 },
7388 "end": { "line": line + 1, "character": 0 },
7389 });
7390 let replacement = format!("{leading_ws}{name}\n");
7391 let changes = json!({
7392 uri: [
7393 { "range": insert_range, "newText": decl },
7394 { "range": replace_range, "newText": replacement },
7395 ]
7396 });
7397 json!({
7398 "title": "Extract to function (`name() { … }`)",
7399 "kind": "refactor.extract",
7400 "edit": { "changes": changes },
7401 })
7402}
7403
7404fn make_extract_function_multiline(
7405 uri: &str,
7406 text: &str,
7407 start_line: u32,
7408 end_line: u32,
7409) -> Option<Value> {
7410 let lines: Vec<&str> = text.lines().collect();
7415 if (start_line as usize) >= lines.len() {
7416 return None;
7417 }
7418 let last = (end_line as usize).min(lines.len() - 1);
7419 let block = &lines[start_line as usize..=last];
7420 if block.iter().all(|l| l.trim().is_empty()) {
7421 return None;
7422 }
7423
7424 let common_indent = block
7427 .iter()
7428 .filter(|l| !l.trim().is_empty())
7429 .map(|l| l.chars().take_while(|c| c.is_whitespace()).count())
7430 .min()
7431 .unwrap_or(0);
7432
7433 let leading_ws: String = block
7434 .iter()
7435 .find(|l| !l.trim().is_empty())
7436 .map(|l| l.chars().take(common_indent).collect())
7437 .unwrap_or_default();
7438
7439 let name = "extracted_function";
7440 let mut decl = String::new();
7441 decl.push_str(&format!("{leading_ws}{name}() {{\n"));
7442 for l in block {
7443 if l.trim().is_empty() {
7444 decl.push('\n');
7445 } else {
7446 let stripped = if l.chars().take(common_indent).all(|c| c.is_whitespace()) {
7449 &l[l.char_indices()
7450 .nth(common_indent)
7451 .map(|(i, _)| i)
7452 .unwrap_or(l.len())..]
7453 } else {
7454 l.trim_start()
7455 };
7456 decl.push_str(&format!("{leading_ws} {stripped}\n"));
7457 }
7458 }
7459 decl.push_str(&format!("{leading_ws}}}\n"));
7460
7461 let insert_range = json!({
7462 "start": { "line": start_line, "character": 0 },
7463 "end": { "line": start_line, "character": 0 },
7464 });
7465 let replace_range = json!({
7466 "start": { "line": start_line, "character": 0 },
7467 "end": { "line": last as u32 + 1, "character": 0 },
7468 });
7469 let replacement = format!("{leading_ws}{name}\n");
7470
7471 let changes = json!({
7472 uri: [
7473 { "range": insert_range, "newText": decl },
7474 { "range": replace_range, "newText": replacement },
7475 ]
7476 });
7477 Some(json!({
7478 "title": "Extract to function (`name() { … }`)",
7479 "kind": "refactor.extract",
7480 "edit": { "changes": changes },
7481 }))
7482}
7483
7484fn make_extract_action(
7485 uri: &str,
7486 leading_ws: &str,
7487 line: u32,
7488 selection_range: &Value,
7489 rhs: &str,
7490 name: &str,
7491 decl_keyword: &str,
7492 title: &str,
7493) -> Value {
7494 let decl_line = format!("{leading_ws}{decl_keyword} {name}={rhs}\n");
7495 let insert_range = json!({
7496 "start": { "line": line, "character": 0 },
7497 "end": { "line": line, "character": 0 },
7498 });
7499 let changes = json!({
7500 uri: [
7501 { "range": insert_range, "newText": decl_line },
7502 { "range": selection_range, "newText": format!("${name}") },
7503 ]
7504 });
7505 json!({
7506 "title": title,
7507 "kind": "refactor.extract",
7508 "edit": { "changes": changes },
7509 })
7510}
7511
7512fn utf16_slice(line_text: &str, start: u32, end: u32) -> Option<&str> {
7516 let mut u16_seen = 0u32;
7517 let mut s_byte: Option<usize> = None;
7518 let mut e_byte: Option<usize> = None;
7519 for (i, ch) in line_text.char_indices() {
7520 if u16_seen == start {
7521 s_byte = Some(i);
7522 }
7523 u16_seen += ch.len_utf16() as u32;
7524 if u16_seen == end {
7525 e_byte = Some(i + ch.len_utf8());
7526 break;
7527 }
7528 }
7529 let s = s_byte?;
7530 let e = e_byte.unwrap_or(line_text.len());
7531 line_text.get(s..e)
7532}
7533
7534fn same_line_inside_interpolating_string(line_text: &str, col: u32) -> bool {
7538 let mut byte_cutoff = line_text.len();
7539 let mut u16_seen = 0u32;
7540 for (i, ch) in line_text.char_indices() {
7541 if u16_seen >= col {
7542 byte_cutoff = i;
7543 break;
7544 }
7545 u16_seen += ch.len_utf16() as u32;
7546 }
7547 let mut in_dq = false;
7548 let mut in_sq = false;
7549 let mut in_bt = false;
7550 let mut chars = line_text[..byte_cutoff].chars().peekable();
7551 while let Some(c) = chars.next() {
7552 match c {
7553 '\\' => {
7554 chars.next();
7555 }
7556 '"' if !in_sq && !in_bt => in_dq = !in_dq,
7557 '\'' if !in_dq && !in_bt => in_sq = !in_sq,
7558 '`' if !in_dq && !in_sq => in_bt = !in_bt,
7559 _ => {}
7560 }
7561 }
7562 in_dq || in_bt
7563}
7564
7565fn needs_string_wrap_for_extraction(selection: &str) -> bool {
7569 let t = selection.trim();
7570 if t.is_empty() {
7571 return false;
7572 }
7573 if (t.starts_with('"') && t.ends_with('"')) || (t.starts_with('\'') && t.ends_with('\'')) {
7574 return false;
7575 }
7576 if let Some(rest) = t.strip_prefix('$') {
7578 let body = rest
7579 .strip_prefix('{')
7580 .and_then(|r| r.strip_suffix('}'))
7581 .unwrap_or(rest);
7582 if !body.is_empty() && body.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
7583 return false;
7584 }
7585 }
7586 true
7587}
7588
7589fn escape_for_double_quoted(s: &str) -> String {
7590 let mut out = String::with_capacity(s.len());
7591 for c in s.chars() {
7592 match c {
7593 '\\' => out.push_str("\\\\"),
7594 '"' => out.push_str("\\\""),
7595 _ => out.push(c),
7596 }
7597 }
7598 out
7599}
7600
7601fn snap_to_word_at_cursor(line_text: &str, cursor_col: u32) -> Option<(u32, u32)> {
7604 let mut byte_cur = line_text.len();
7605 let mut u16_seen = 0u32;
7606 for (i, ch) in line_text.char_indices() {
7607 if u16_seen >= cursor_col {
7608 byte_cur = i;
7609 break;
7610 }
7611 u16_seen += ch.len_utf16() as u32;
7612 }
7613 let is_word_char = |c: char| c.is_ascii_alphanumeric() || c == '_';
7614
7615 if same_line_inside_interpolating_string(line_text, cursor_col) {
7617 let prev_char = line_text[..byte_cur].chars().next_back();
7618 let cur_char = line_text[byte_cur..].chars().next();
7619 if matches!(prev_char, Some('$')) || matches!(cur_char, Some('$')) {
7620 let mut start_byte = byte_cur;
7622 for (i, c) in line_text[..byte_cur].char_indices().rev() {
7623 if c == '$' {
7624 start_byte = i;
7625 break;
7626 }
7627 if !is_word_char(c) {
7628 break;
7629 }
7630 start_byte = i;
7631 }
7632 if cur_char == Some('$') {
7633 start_byte = byte_cur;
7634 }
7635 let mut end_byte = start_byte;
7636 let mut iter = line_text[start_byte..].char_indices();
7637 if let Some((_, first)) = iter.next() {
7638 if first == '$' {
7639 end_byte = start_byte + first.len_utf8();
7640 for (i, c) in iter {
7641 if !is_word_char(c) {
7642 break;
7643 }
7644 end_byte = start_byte + i + c.len_utf8();
7645 }
7646 }
7647 }
7648 if end_byte > start_byte {
7649 return Some((
7650 byte_to_utf16_col(line_text, start_byte),
7651 byte_to_utf16_col(line_text, end_byte),
7652 ));
7653 }
7654 }
7655 let mut start_byte = byte_cur;
7656 for (i, c) in line_text[..byte_cur].char_indices().rev() {
7657 if !is_word_char(c) {
7658 break;
7659 }
7660 start_byte = i;
7661 }
7662 let mut end_byte = byte_cur;
7663 for (i, c) in line_text[byte_cur..].char_indices() {
7664 if !is_word_char(c) {
7665 break;
7666 }
7667 end_byte = byte_cur + i + c.len_utf8();
7668 }
7669 if end_byte > start_byte {
7670 return Some((
7671 byte_to_utf16_col(line_text, start_byte),
7672 byte_to_utf16_col(line_text, end_byte),
7673 ));
7674 }
7675 return None;
7676 }
7677
7678 let mut start_byte = byte_cur;
7680 for (i, c) in line_text[..byte_cur].char_indices().rev() {
7681 if !is_word_char(c) {
7682 break;
7683 }
7684 start_byte = i;
7685 }
7686 let mut end_byte = byte_cur;
7687 for (i, c) in line_text[byte_cur..].char_indices() {
7688 if !is_word_char(c) {
7689 break;
7690 }
7691 end_byte = byte_cur + i + c.len_utf8();
7692 }
7693 if start_byte > 0 {
7695 if let Some((idx, '$')) = line_text[..start_byte].char_indices().next_back() {
7696 let standalone = match line_text[..idx].chars().next_back() {
7697 None => true,
7698 Some(c) => !is_word_char(c),
7699 };
7700 if standalone {
7701 start_byte = idx;
7702 }
7703 }
7704 }
7705 if end_byte > start_byte {
7706 Some((
7707 byte_to_utf16_col(line_text, start_byte),
7708 byte_to_utf16_col(line_text, end_byte),
7709 ))
7710 } else {
7711 None
7712 }
7713}
7714
7715fn byte_to_utf16_col(line_text: &str, byte_idx: usize) -> u32 {
7716 line_text[..byte_idx.min(line_text.len())]
7717 .encode_utf16()
7718 .count() as u32
7719}
7720
7721fn formatting(state: &State, params: &Value) -> Value {
7722 let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
7723 let text = match state.docs.get(uri) {
7724 Some(t) => t.clone(),
7725 None => return Value::Array(vec![]),
7726 };
7727 let opts = ¶ms["options"];
7728 let tab_size = opts["tabSize"].as_u64().unwrap_or(4) as usize;
7729 let insert_spaces = opts["insertSpaces"].as_bool().unwrap_or(true);
7730 let formatted = crate::fmt::format_source(
7739 &text,
7740 &crate::fmt::FmtOptions {
7741 indent_width: tab_size,
7742 use_tabs: !insert_spaces,
7743 },
7744 );
7745 if formatted == text {
7746 return Value::Array(vec![]);
7747 }
7748
7749 let last_line = text.lines().count().saturating_sub(1);
7750 let last_col = text.lines().last().map(|l| l.len()).unwrap_or(0);
7751 Value::Array(vec![json!({
7752 "range": {
7753 "start": { "line": 0, "character": 0 },
7754 "end": { "line": last_line, "character": last_col },
7755 },
7756 "newText": formatted,
7757 })])
7758}
7759
7760fn word_at(text: &str, line_no: usize, col: usize) -> Option<String> {
7763 let line = text.lines().nth(line_no)?;
7764 if col > line.len() {
7765 return None;
7766 }
7767 let bytes = line.as_bytes();
7768 let mut start = col;
7771 while start > 0 {
7772 let c = bytes[start - 1] as char;
7773 if c == '_' || c.is_alphanumeric() || c == '$' {
7774 start -= 1;
7775 } else {
7776 break;
7777 }
7778 }
7779 let mut end = col;
7780 while end < bytes.len() {
7781 let c = bytes[end] as char;
7782 if c == '_' || c.is_alphanumeric() {
7783 end += 1;
7784 } else {
7785 break;
7786 }
7787 }
7788 if start == end {
7789 return None;
7790 }
7791 let is_dollar_var = bytes[start] == b'$';
7802 let in_braced = start > 0 && bytes[start - 1] == b'{';
7803 if !is_dollar_var && !in_braced {
7804 while end < bytes.len() && bytes[end] == b'-' {
7806 let mut p = end + 1;
7807 while p < bytes.len() {
7808 let c = bytes[p] as char;
7809 if c == '_' || c.is_alphanumeric() {
7810 p += 1;
7811 } else {
7812 break;
7813 }
7814 }
7815 if p > end + 1 {
7816 end = p;
7817 } else {
7818 break;
7819 }
7820 }
7821 while start > 1 && bytes[start - 1] == b'-' {
7823 let mut p = start - 1;
7824 while p > 0 {
7825 let c = bytes[p - 1] as char;
7826 if c == '_' || c.is_alphanumeric() {
7827 p -= 1;
7828 } else {
7829 break;
7830 }
7831 }
7832 if p < start - 1 {
7833 start = p;
7834 } else {
7835 break;
7836 }
7837 }
7838 }
7839 Some(line[start..end].to_string())
7840}
7841
7842const KEYWORDS: &[&str] = &[
7848 "[[",
7849 "]]",
7850 "always",
7851 "case",
7852 "do",
7853 "done",
7854 "elif",
7855 "else",
7856 "end",
7857 "esac",
7858 "fi",
7859 "for",
7860 "foreach",
7861 "if",
7862 "in",
7863 "repeat",
7864 "select",
7865 "then",
7866 "until",
7867 "while",
7868 "declare",
7869 "export",
7870 "float",
7871 "integer",
7872 "let",
7873 "local",
7874 "readonly",
7875 "set",
7876 "shift",
7877 "typeset",
7878 "function",
7879 "{",
7880 "}",
7881 ".",
7882 "eval",
7883 "exec",
7884 "source",
7885 "trap",
7886 "break",
7887 "continue",
7888 "exit",
7889 "logout",
7890 "return",
7891 "builtin",
7892 "command",
7893 "coproc",
7894 "nocorrect",
7895 "noglob",
7896 "time",
7897 "!",
7898];
7899const BUILTINS: &[&str] = &[
7903 ".",
7904 ":",
7905 "[",
7906 "alias",
7907 "autoload",
7908 "bg",
7909 "break",
7910 "bye",
7911 "cap",
7912 "cd",
7913 "chdir",
7914 "chgrp",
7915 "chmod",
7916 "chown",
7917 "clone",
7918 "continue",
7919 "declare",
7920 "dirs",
7921 "disable",
7922 "disown",
7923 "echo",
7924 "echotc",
7925 "echoti",
7926 "emulate",
7927 "enable",
7928 "eval",
7929 "example",
7930 "exit",
7931 "export",
7932 "false",
7933 "fc",
7934 "fg",
7935 "float",
7936 "functions",
7937 "getcap",
7938 "getln",
7939 "getopts",
7940 "hash",
7941 "hashinfo",
7942 "history",
7943 "integer",
7944 "jobs",
7945 "kill",
7946 "let",
7947 "ln",
7948 "local",
7949 "log",
7950 "logout",
7951 "mem",
7952 "mkdir",
7953 "mv",
7954 "nameref",
7955 "patdebug",
7956 "pcre_compile",
7957 "pcre_match",
7958 "pcre_study",
7959 "popd",
7960 "print",
7961 "printf",
7962 "private",
7963 "pushd",
7964 "pushln",
7965 "pwd",
7966 "r",
7967 "read",
7968 "readonly",
7969 "rehash",
7970 "return",
7971 "rm",
7972 "rmdir",
7973 "set",
7974 "setcap",
7975 "setopt",
7976 "shift",
7977 "source",
7978 "stat",
7979 "strftime",
7980 "suspend",
7981 "sync",
7982 "syserror",
7983 "sysopen",
7984 "sysread",
7985 "sysseek",
7986 "syswrite",
7987 "test",
7988 "times",
7989 "trap",
7990 "true",
7991 "ttyctl",
7992 "type",
7993 "typeset",
7994 "umask",
7995 "unalias",
7996 "unfunction",
7997 "unhash",
7998 "unset",
7999 "unsetopt",
8000 "wait",
8001 "whence",
8002 "where",
8003 "which",
8004 "zask",
8005 "zcache",
8006 "zcompdump",
8007 "zcompile",
8008 "zcomplete",
8009 "zcurses",
8010 "zd",
8011 "zdelattr",
8012 "zf_chgrp",
8013 "zf_chmod",
8014 "zf_chown",
8015 "zf_ln",
8016 "zf_mkdir",
8017 "zf_mv",
8018 "zf_rm",
8019 "zf_rmdir",
8020 "zf_sync",
8021 "zformat",
8022 "zftp",
8023 "zgdbmpath",
8024 "zgetattr",
8025 "zhistory",
8026 "zid",
8027 "zjob",
8028 "zlistattr",
8029 "zlock",
8030 "zlog",
8031 "zls",
8032 "zmodload",
8033 "znotify",
8034 "zparseopts",
8035 "zping",
8036 "zprof",
8037 "zpty",
8038 "zpublish",
8039 "zregexparse",
8040 "zselect",
8041 "zsend",
8042 "zsetattr",
8043 "zsocket",
8044 "zsource",
8045 "zstat",
8046 "zstyle",
8047 "zsubscribe",
8048 "zsuggest",
8049 "zsync",
8050 "zsystem",
8051 "ztag",
8052 "ztcp",
8053 "ztie",
8054 "zunsubscribe",
8055 "zuntag",
8056 "zuntie",
8057 "zwc",
8058 "zwhere",
8059];
8060const OPTIONS: &[&str] = &[
8064 "ALIASES",
8065 "ALIASFUNCDEF",
8066 "ALLEXPORT",
8067 "ALWAYSLASTPROMPT",
8068 "ALWAYSTOEND",
8069 "APPENDCREATE",
8070 "APPENDHISTORY",
8071 "AUTOCD",
8072 "AUTOCONTINUE",
8073 "AUTOLIST",
8074 "AUTOMENU",
8075 "AUTONAMEDIRS",
8076 "AUTOPARAMKEYS",
8077 "AUTOPARAMSLASH",
8078 "AUTOPUSHD",
8079 "AUTOREMOVESLASH",
8080 "AUTORESUME",
8081 "BADPATTERN",
8082 "BANGHIST",
8083 "BAREGLOBQUAL",
8084 "BASHAUTOLIST",
8085 "BASHREMATCH",
8086 "BEEP",
8087 "BGNICE",
8088 "BRACECCL",
8089 "BRACEEXPAND",
8090 "BSDECHO",
8091 "CASEGLOB",
8092 "CASEMATCH",
8093 "CASEPATHS",
8094 "CBASES",
8095 "CDABLEVARS",
8096 "CDSILENT",
8097 "CHASEDOTS",
8098 "CHASELINKS",
8099 "CHECKJOBS",
8100 "CHECKRUNNINGJOBS",
8101 "CLOBBER",
8102 "CLOBBEREMPTY",
8103 "COMBININGCHARS",
8104 "COMPLETEALIASES",
8105 "COMPLETEINWORD",
8106 "CONTINUEONERROR",
8107 "CORRECT",
8108 "CORRECTALL",
8109 "CPRECEDENCES",
8110 "CSHJUNKIEHISTORY",
8111 "CSHJUNKIELOOPS",
8112 "CSHJUNKIEQUOTES",
8113 "CSHNULLCMD",
8114 "CSHNULLGLOB",
8115 "DEBUGBEFORECMD",
8116 "DOTGLOB",
8117 "DVORAK",
8118 "EMACS",
8119 "EQUALS",
8120 "ERREXIT",
8121 "ERRRETURN",
8122 "EVALLINENO",
8123 "EXEC",
8124 "EXTENDEDGLOB",
8125 "EXTENDEDHISTORY",
8126 "FLOWCONTROL",
8127 "FORCEFLOAT",
8128 "FUNCTIONARGZERO",
8129 "GLOB",
8130 "GLOBALEXPORT",
8131 "GLOBALRCS",
8132 "GLOBASSIGN",
8133 "GLOBCOMPLETE",
8134 "GLOBDOTS",
8135 "GLOBSTARSHORT",
8136 "GLOBSUBST",
8137 "HASHALL",
8138 "HASHCMDS",
8139 "HASHDIRS",
8140 "HASHEXECUTABLESONLY",
8141 "HASHLISTALL",
8142 "HISTALLOWCLOBBER",
8143 "HISTAPPEND",
8144 "HISTBEEP",
8145 "HISTEXPAND",
8146 "HISTEXPIREDUPSFIRST",
8147 "HISTFCNTLLOCK",
8148 "HISTFINDNODUPS",
8149 "HISTIGNOREALLDUPS",
8150 "HISTIGNOREDUPS",
8151 "HISTIGNORESPACE",
8152 "HISTLEXWORDS",
8153 "HISTNOFUNCTIONS",
8154 "HISTNOSTORE",
8155 "HISTREDUCEBLANKS",
8156 "HISTSAVEBYCOPY",
8157 "HISTSAVENODUPS",
8158 "HISTSUBSTPATTERN",
8159 "HISTVERIFY",
8160 "HUP",
8161 "IGNOREBRACES",
8162 "IGNORECLOSEBRACES",
8163 "IGNOREEOF",
8164 "INCAPPENDHISTORY",
8165 "INCAPPENDHISTORYTIME",
8166 "INTERACTIVE",
8167 "INTERACTIVECOMMENTS",
8168 "KSHARRAYS",
8169 "KSHAUTOLOAD",
8170 "KSHGLOB",
8171 "KSHOPTIONPRINT",
8172 "KSHTYPESET",
8173 "KSHZEROSUBSCRIPT",
8174 "LISTAMBIGUOUS",
8175 "LISTBEEP",
8176 "LISTPACKED",
8177 "LISTROWSFIRST",
8178 "LISTTYPES",
8179 "LOCALLOOPS",
8180 "LOCALOPTIONS",
8181 "LOCALPATTERNS",
8182 "LOCALTRAPS",
8183 "LOG",
8184 "LOGIN",
8185 "LONGLISTJOBS",
8186 "MAGICEQUALSUBST",
8187 "MAILWARN",
8188 "MAILWARNING",
8189 "MARKDIRS",
8190 "MENUCOMPLETE",
8191 "MONITOR",
8192 "MULTIBYTE",
8193 "MULTIFUNCDEF",
8194 "MULTIOS",
8195 "NOMATCH",
8196 "NOTIFY",
8197 "NULLGLOB",
8198 "NUMERICGLOBSORT",
8199 "OCTALZEROES",
8200 "ONECMD",
8201 "OVERSTRIKE",
8202 "PATHDIRS",
8203 "PATHSCRIPT",
8204 "PHYSICAL",
8205 "PIPEFAIL",
8206 "POSIXALIASES",
8207 "POSIXARGZERO",
8208 "POSIXBUILTINS",
8209 "POSIXCD",
8210 "POSIXIDENTIFIERS",
8211 "POSIXJOBS",
8212 "POSIXSTRINGS",
8213 "POSIXTRAPS",
8214 "PRINTEIGHTBIT",
8215 "PRINTEXITVALUE",
8216 "PRIVILEGED",
8217 "PROMPTBANG",
8218 "PROMPTCR",
8219 "PROMPTPERCENT",
8220 "PROMPTSP",
8221 "PROMPTSUBST",
8222 "PROMPTVARS",
8223 "PUSHDIGNOREDUPS",
8224 "PUSHDMINUS",
8225 "PUSHDSILENT",
8226 "PUSHDTOHOME",
8227 "RCEXPANDPARAM",
8228 "RCQUOTES",
8229 "RCS",
8230 "RECEXACT",
8231 "REMATCHPCRE",
8232 "RMSTARSILENT",
8233 "RMSTARWAIT",
8234 "SHAREHISTORY",
8235 "SHFILEEXPANSION",
8236 "SHGLOB",
8237 "SHINSTDIN",
8238 "SHNULLCMD",
8239 "SHOPTIONLETTERS",
8240 "SHORTLOOPS",
8241 "SHORTREPEAT",
8242 "SHWORDSPLIT",
8243 "SINGLECOMMAND",
8244 "SINGLELINEZLE",
8245 "SOURCETRACE",
8246 "STDIN",
8247 "SUNKEYBOARDHACK",
8248 "TRACKALL",
8249 "TRANSIENTRPROMPT",
8250 "TRAPSASYNC",
8251 "TYPESETSILENT",
8252 "TYPESETTOUNSET",
8253 "UNSET",
8254 "VERBOSE",
8255 "VI",
8256 "WARNCREATEGLOBAL",
8257 "WARNNESTEDVAR",
8258 "XTRACE",
8259 "ZLE",
8260];
8261const KEYWORD_DOCS: &[(&str, &str)] = &[
8270 (
8271 "if",
8272 "Conditional. `if cmd; then …; elif cmd; then …; else …; fi`",
8273 ),
8274 (
8275 "for",
8276 "Loop. `for var in words; do …; done` or `for ((init; cond; step)); do …; done`",
8277 ),
8278 (
8279 "while",
8280 "Loop. `while cmd; do …; done` — runs the body while `cmd` succeeds.",
8281 ),
8282 (
8283 "until",
8284 "Loop. `until cmd; do …; done` — runs the body while `cmd` fails.",
8285 ),
8286 (
8287 "case",
8288 "Pattern match. `case word in pat1) …;; pat2) …;; esac`",
8289 ),
8290 (
8291 "select",
8292 "Interactive menu. `select var in items; do …; done`",
8293 ),
8294 ("repeat", "Counted loop. `repeat N; do …; done`"),
8295 ("then", "Body separator for `if`/`elif`. `if cmd; then body; fi`"),
8301 ("else", "Alternative branch for `if`. `if cmd; then a; else b; fi`"),
8302 ("elif", "Alternative test in an `if` chain. `if a; then …; elif b; then …; fi`"),
8303 ("do", "Body-introducer for `for`/`while`/`until`/`select`/`repeat`. `for v in …; do body; done`"),
8304 ("esac", "Closes a `case` statement. `case word in pat) …;; esac`"),
8305 ("in", "Word-list introducer for `for` and `case`. `for v in a b c; do …; done`"),
8306 (
8307 "{",
8308 "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.",
8309 ),
8310 (
8311 "}",
8312 "Command-group close brace. Pairs with `{ … }`. Reserved word — preceded by `;` or newline.",
8313 ),
8314 (
8315 "!",
8316 "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).",
8317 ),
8318 (
8319 "fi",
8320 "Closes an `if` block. `if cmd; then body; fi`. Required terminator — without it the parser keeps reading until EOF.",
8321 ),
8322 (
8323 "done",
8324 "Closes a `for` / `foreach` / `while` / `until` / `select` / `repeat` loop body. `for v in a b c; do echo $v; done`. Required terminator.",
8325 ),
8326 (
8327 "end",
8328 "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.",
8329 ),
8330 (
8331 "declare",
8332 "Alias for `typeset`. Set variable attributes. `-a` array, `-A` assoc, `-i` integer, `-r` readonly.",
8333 ),
8334 (
8335 "function",
8336 "Function declaration. `function foo { body }` or `foo() { body }`",
8337 ),
8338 (
8339 "local",
8340 "Declare a function-scope variable. `local var=value` or `local -i var=42`",
8341 ),
8342 (
8343 "typeset",
8344 "Set variable attributes. `-a` array, `-A` assoc, `-i` integer, `-r` readonly.",
8345 ),
8346 ("export", "Mark a variable for export to the environment."),
8347 ("readonly", "Mark a variable as read-only."),
8348 ("integer", "Shorthand for `typeset -i`."),
8349 ("float", "Shorthand for `typeset -F` (floating point)."),
8350 (
8351 "return",
8352 "Return from a function or sourced script with the given status.",
8353 ),
8354 (
8355 "break",
8356 "Exit the innermost loop, or N levels up with `break N`.",
8357 ),
8358 (
8359 "continue",
8360 "Skip to the next iteration of the innermost loop.",
8361 ),
8362 ("exit", "Exit the shell with the given status."),
8363 ("time", "Time the execution of the following pipeline."),
8364 (
8365 "coproc",
8366 "Run a command as a coprocess (background, attached I/O).",
8367 ),
8368];
8369
8370const BUILTIN_DOCS: &[(&str, &str)] = &[
8371 ("cd", "Change the working directory."),
8372 ("pwd", "Print the working directory."),
8373 (
8374 "pushd",
8375 "Push the current directory onto the stack and `cd`.",
8376 ),
8377 ("popd", "Pop a directory off the stack and `cd` to it."),
8378 ("alias", "Define a command alias. `alias name=value`"),
8379 ("setopt", "Turn on a zsh option. `setopt EXTENDED_GLOB`"),
8380 ("unsetopt", "Turn off a zsh option."),
8381 (
8382 "zstyle",
8383 "Set a context-aware style (used by compsys, prompts, etc.).",
8384 ),
8385 (
8386 "zmodload",
8387 "Load a zsh binary module (e.g. `zsh/datetime`, `zsh/stat`).",
8388 ),
8389 (
8390 "autoload",
8391 "Mark a function to be loaded from `fpath` on first call.",
8392 ),
8393 ("bindkey", "Bind a key sequence to a ZLE widget."),
8394 ("compdef", "Register a completion function for a command."),
8395 (
8396 "source",
8397 "Execute a file in the current shell context. Same as `.`.",
8398 ),
8399 ("eval", "Concatenate args and execute them as shell code."),
8400 (
8401 "exec",
8402 "Replace the current process with the given command.",
8403 ),
8404 ("trap", "Set a signal or pseudo-signal handler."),
8405 (
8406 "echo",
8407 "Print arguments separated by spaces, with a trailing newline.",
8408 ),
8409 (
8410 "print",
8411 "zsh-extended print. `-r` raw, `-n` no newline, `-l` one per line.",
8412 ),
8413 ("printf", "C-style formatted print."),
8414 ("read", "Read a line into a variable. `read -r var`"),
8415 (
8416 "test",
8417 "Evaluate a conditional. Same as `[`. Prefer `[[ … ]]` in zsh.",
8418 ),
8419 ("kill", "Send a signal to a job or pid."),
8420 ("jobs", "List background jobs."),
8421 ("fg", "Bring a job to the foreground."),
8422 ("bg", "Resume a stopped job in the background."),
8423 ("hash", "Print or modify the command hash table."),
8424 (
8425 "unhash",
8426 "Remove an entry from the hash / alias / function table.",
8427 ),
8428 ("history", "Show the command history."),
8429 ("fc", "List, edit, or re-execute history entries."),
8430 (
8431 "command",
8432 "Bypass aliases and functions to run the named command.",
8433 ),
8434 (
8435 "type",
8436 "Show how a name would be interpreted (alias / builtin / function / file).",
8437 ),
8438 ("whence", "Same as `type` but with more formatting options."),
8439 (
8440 "builtin",
8441 "Run the named builtin, bypassing any function / alias.",
8442 ),
8443 ("set", "Set positional parameters or options."),
8444 ("unset", "Remove a variable."),
8445 (
8446 "getopts",
8447 "Parse positional parameters in the style of GNU getopt.",
8448 ),
8449 ("let", "Evaluate an arithmetic expression. `let count++`"),
8450 (
8456 ":",
8457 "Null command. Returns true. Side-effects of argument expansion still happen.",
8458 ),
8459 (
8460 "[",
8461 "Alias for `test`. `[ expr ]` — POSIX conditional. Prefer `[[ expr ]]` in zsh.",
8462 ),
8463 ("bye", "Alias for `exit`. Exit the shell with the given status."),
8464 ("chdir", "Alias for `cd`. Change the working directory."),
8465 (
8466 "compctl",
8467 "Old completion control (compctl mechanism). Largely superseded by `compdef` / compsys.",
8468 ),
8469 ("declare", "Alias for `typeset`. Set variable attributes."),
8470 (
8471 "hashinfo",
8472 "Print internal hash-table statistics. Debug builtin in `zsh/parameter`-adjacent code.",
8473 ),
8474 (
8475 "mem",
8476 "Print zsh memory-allocator statistics. Debug builtin compiled only with `--enable-zsh-mem`.",
8477 ),
8478 (
8479 "noglob",
8480 "Precommand modifier. Disable filename generation for the next command. `noglob ls *.tmp`",
8481 ),
8482 (
8483 "patdebug",
8484 "Print pattern-matcher internals for a glob/regex. Debug builtin from `zsh/pattern`.",
8485 ),
8486 ("r", "Re-execute the previous command. Shorthand for `fc -e -`."),
8487 (
8488 "unfunction",
8489 "Remove a function definition. Equivalent to `unhash -f` / `unset -f name`.",
8490 ),
8491 ("zf_chgrp", "zftp: change group of remote files. Mirrors `chgrp(1)`."),
8494 ("zf_chmod", "zftp: change mode of remote files. Mirrors `chmod(1)`."),
8495 ("zf_chown", "zftp: change owner of remote files. Mirrors `chown(1)`."),
8496 ("zf_ln", "zftp: link / rename remote files. Mirrors `ln(1)`."),
8497 ("zf_mkdir", "zftp: create remote directories. Mirrors `mkdir(1)`."),
8498 ("zf_mv", "zftp: move / rename remote files. Mirrors `mv(1)`."),
8499 ("zf_rm", "zftp: remove remote files. Mirrors `rm(1)`."),
8500 ("zf_rmdir", "zftp: remove remote directories. Mirrors `rmdir(1)`."),
8501 ("zf_sync", "zftp: flush pending writes on the FTP control channel."),
8502];
8503
8504const SPECIAL_VAR_DOCS: &[(&str, &str)] = &[
8505 ("$0", "Script name."),
8506 ("$?", "Exit status of the last command."),
8507 ("$!", "PID of the most recent background command."),
8508 ("$$", "PID of the current shell."),
8509 ("$#", "Number of positional parameters."),
8510 ("$*", "All positional parameters as one word (IFS-joined)."),
8511 ("$@", "All positional parameters as separate words."),
8512 ("$-", "Currently set option flags."),
8513 ("$_", "Last argument of the previous command."),
8514 ("$PATH", "Colon-separated command lookup path."),
8515 ("$HOME", "User's home directory."),
8516 ("$USER", "Current user."),
8517 ("$PWD", "Current working directory."),
8518 ("$OLDPWD", "Previous working directory (used by `cd -`)."),
8519 ("$ZSH_VERSION", "zsh / zshrs version string."),
8520 (
8521 "$RANDOM",
8522 "Each read returns a fresh pseudo-random integer.",
8523 ),
8524 ("$LINENO", "Current line number in the script."),
8525 ("$SECONDS", "Seconds since the shell started."),
8526 ("$EPOCHSECONDS", "Unix epoch seconds (zsh/datetime)."),
8527 (
8528 "$EPOCHREALTIME",
8529 "Unix epoch with microsecond precision (zsh/datetime).",
8530 ),
8531 (
8532 "$fpath",
8533 "Array of directories searched for autoloaded functions.",
8534 ),
8535 ("$path", "Array version of $PATH."),
8536 ("$argv", "Array of positional parameters (same as $@)."),
8537 ("$pipestatus", "Exit statuses of each pipeline element."),
8538 (
8539 "$SHELL",
8540 "Pathname of the login shell. Honored by many tools as the default user shell.",
8541 ),
8542 (
8543 "$EDITOR",
8544 "Preferred editor for tools that invoke an editor (`fc`, `git`, `crontab`, …).",
8545 ),
8546 (
8547 "$VISUAL",
8548 "Preferred full-screen editor. Takes precedence over `$EDITOR` when set.",
8549 ),
8550];
8551
8552pub fn dump_reflection_json() -> String {
8568 let mut all = serde_json::Map::new();
8569
8570 let mut compat = serde_json::Map::new();
8576 for b in crate::ported::builtin::BUILTINS.iter() {
8577 compat.insert(b.node.nam.clone(), Value::String("compat".into()));
8578 all.insert(b.node.nam.clone(), Value::String("compat".into()));
8579 }
8580 let mut keywords = serde_json::Map::new();
8596 for (name, _token) in crate::ported::hashtable::RESWDS {
8597 keywords.insert(name.to_string(), Value::String("keyword".into()));
8598 all.insert(name.to_string(), Value::String("keyword".into()));
8599 }
8600 let mut options = serde_json::Map::new();
8608 for (name, _doc) in crate::zsh_option_docs::OPTION_DOCS {
8609 options.insert((*name).to_string(), Value::String("option".into()));
8610 all.insert((*name).to_string(), Value::String("option".into()));
8611 }
8612 for (alias, _canon) in crate::zsh_option_docs::OPTION_ALIASES {
8613 options.insert((*alias).to_string(), Value::String("option".into()));
8614 all.insert((*alias).to_string(), Value::String("option".into()));
8615 }
8616 let mut special_vars = serde_json::Map::new();
8623 for (name, _doc) in crate::zsh_special_var_docs::SPECIAL_VAR_DOCS {
8624 special_vars.insert((*name).to_string(), Value::String("special".into()));
8625 all.insert((*name).to_string(), Value::String("special".into()));
8626 }
8627 for (alias, _canon) in crate::zsh_special_var_docs::SPECIAL_VAR_ALIASES {
8631 special_vars.insert((*alias).to_string(), Value::String("special".into()));
8632 all.insert((*alias).to_string(), Value::String("special".into()));
8633 }
8634 let mut compsys = serde_json::Map::new();
8639 for n in crate::compsys::COMPSYS_FN_NAMES {
8640 compsys.insert((*n).to_string(), Value::String("compsys".into()));
8641 all.insert((*n).to_string(), Value::String("compsys".into()));
8642 }
8643 let mut extensions = serde_json::Map::new();
8655 for n in crate::ext_builtins::EXT_BUILTIN_NAMES {
8656 extensions.insert((*n).to_string(), Value::String("extension".into()));
8657 all.insert((*n).to_string(), Value::String("extension".into()));
8658 }
8659 for n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES {
8660 extensions.insert((*n).to_string(), Value::String("extension".into()));
8661 all.insert((*n).to_string(), Value::String("extension".into()));
8662 }
8663 let mut operators = serde_json::Map::new();
8665 for (op, _body) in OPERATOR_DOCS {
8666 operators.insert((*op).to_string(), Value::String("operator".into()));
8667 all.insert((*op).to_string(), Value::String("operator".into()));
8668 }
8669 let mut builtins = compat.clone();
8674 for (k, _) in &extensions {
8675 builtins.insert(k.clone(), Value::String("builtin".into()));
8676 }
8677 serde_json::to_string_pretty(&json!({
8678 "all": all,
8679 "builtins": builtins,
8680 "compat": compat,
8681 "keywords": keywords,
8682 "options": options,
8683 "special_vars": special_vars,
8684 "compsys": compsys,
8685 "extensions": extensions,
8686 "operators": operators,
8687 }))
8688 .unwrap_or_else(|_| "{}".into())
8689}
8690
8691pub fn all_canonical_names() -> Vec<String> {
8696 use std::collections::BTreeSet;
8697 let mut set: BTreeSet<String> = BTreeSet::new();
8698 for b in crate::ported::builtin::BUILTINS.iter() {
8699 set.insert(b.node.nam.clone());
8700 }
8701 for (n, t) in crate::ported::hashtable::RESWDS {
8702 if *t == crate::ported::zsh_h::TYPESET {
8703 continue;
8704 }
8705 set.insert((*n).to_string());
8706 }
8707 for o in crate::ported::options::ZSH_OPTIONS_SET.iter() {
8708 set.insert((*o).to_string());
8709 }
8710 for (name, _) in crate::zsh_special_var_docs::SPECIAL_VAR_DOCS {
8714 if name
8717 .chars()
8718 .next()
8719 .map(|c| c.is_ascii_alphabetic() || c == '_')
8720 .unwrap_or(false)
8721 {
8722 set.insert(format!("${}", name));
8723 } else {
8724 set.insert((*name).to_string());
8725 }
8726 }
8727 for n in crate::compsys::COMPSYS_FN_NAMES {
8728 set.insert((*n).to_string());
8729 }
8730 for n in crate::ext_builtins::EXT_BUILTIN_NAMES {
8731 set.insert((*n).to_string());
8732 }
8733 for n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES {
8734 set.insert((*n).to_string());
8735 }
8736 for (op, _) in OPERATOR_DOCS {
8737 set.insert((*op).to_string());
8738 }
8739 set.into_iter().collect()
8740}
8741
8742pub fn closest_name(query: &str) -> Option<String> {
8750 let names = all_canonical_names();
8751 let q_bare = query.strip_prefix('$').unwrap_or(query);
8752 let max_dist = std::cmp::max(2, q_bare.len() / 3);
8753 let mut best: Option<(usize, String)> = None;
8754 for n in names {
8755 let n_bare = n.strip_prefix('$').unwrap_or(&n);
8756 let d = edit_distance(q_bare, n_bare);
8757 if d > max_dist {
8758 continue;
8759 }
8760 match best {
8761 None => best = Some((d, n)),
8762 Some((bd, _)) if d < bd => best = Some((d, n)),
8763 _ => {}
8764 }
8765 }
8766 best.map(|(_, n)| n)
8767}
8768
8769fn edit_distance(a: &str, b: &str) -> usize {
8773 let av: Vec<char> = a.chars().collect();
8774 let bv: Vec<char> = b.chars().collect();
8775 let m = av.len();
8776 let n = bv.len();
8777 if m == 0 {
8778 return n;
8779 }
8780 if n == 0 {
8781 return m;
8782 }
8783 let mut prev: Vec<usize> = (0..=n).collect();
8784 let mut cur: Vec<usize> = vec![0; n + 1];
8785 for i in 1..=m {
8786 cur[0] = i;
8787 for j in 1..=n {
8788 let cost = if av[i - 1] == bv[j - 1] { 0 } else { 1 };
8789 cur[j] = (cur[j - 1] + 1).min(prev[j] + 1).min(prev[j - 1] + cost);
8790 }
8791 std::mem::swap(&mut prev, &mut cur);
8792 }
8793 prev[n]
8794}
8795
8796pub fn dump_reference_html() -> String {
8806 use std::fmt::Write;
8807
8808 let mut out = String::new();
8809
8810 let mut compat: Vec<String> = crate::ported::builtin::BUILTINS
8815 .iter()
8816 .map(|b| b.node.nam.clone())
8817 .collect();
8818 compat.sort();
8819 compat.dedup();
8820 write_chapter(
8821 &mut out,
8822 "ch-lsp-compat",
8823 "Compat Builtin Index",
8824 &format!(
8825 "{} entries · zsh-faithful ports from <code>ported::builtin::BUILTINS</code>. \
8826 Each mirrors an upstream <code>Src/Builtins/*.c</code> entry 1:1, with the \
8827 hover body extracted from <code>man zshall</code> yodl. See also: \
8828 <a href=\"#ch-lsp-extensions\">Extension Builtin Index</a> for zshrs-only \
8829 additions.",
8830 compat.len()
8831 ),
8832 &compat,
8833 "compat",
8834 );
8835
8836 let keywords: Vec<String> = crate::ported::hashtable::RESWDS
8844 .iter()
8845 .map(|(n, _)| n.to_string())
8846 .collect();
8847 write_chapter(
8848 &mut out,
8849 "ch-lsp-keywords",
8850 "Keyword Index",
8851 &format!(
8852 "{} entries · zsh reserved words from <code>Src/hashtable.c</code> \
8853 <code>reswds[]</code>. Mirrors the <code>man zshmisc</code> \
8854 \"Reserved Words\" section. Declarers (<code>declare</code>, \
8855 <code>export</code>, <code>float</code>, <code>integer</code>, \
8856 <code>local</code>, <code>readonly</code>, <code>typeset</code>) \
8857 are reserved AND also appear in the Builtin Index — they're both.",
8858 keywords.len()
8859 ),
8860 &keywords,
8861 "keyword",
8862 );
8863
8864 let mut options: Vec<String> = crate::ported::options::ZSH_OPTIONS_SET
8866 .iter()
8867 .map(|s| s.to_string())
8868 .collect();
8869 options.sort();
8870 write_chapter(
8871 &mut out,
8872 "ch-lsp-options",
8873 "Option Index",
8874 &format!(
8875 "{} entries · the canonical zsh option registry. \
8876 Set / clear via <code>setopt NAME</code> / <code>unsetopt NAME</code>.",
8877 options.len()
8878 ),
8879 &options,
8880 "option",
8881 );
8882
8883 let mut specials: Vec<String> = crate::zsh_special_var_docs::SPECIAL_VAR_DOCS
8889 .iter()
8890 .map(|(name, _)| {
8891 if name
8892 .chars()
8893 .next()
8894 .map(|c| c.is_ascii_alphabetic() || c == '_')
8895 .unwrap_or(false)
8896 {
8897 format!("${}", name)
8898 } else {
8899 (*name).to_string()
8900 }
8901 })
8902 .collect();
8903 specials.sort();
8904 specials.dedup();
8905 write_chapter(
8906 &mut out,
8907 "ch-lsp-specials",
8908 "Special Variable Index",
8909 &format!(
8910 "{} entries · zsh-defined parameters and well-known env vars. \
8911 Includes both scalar (<code>$?</code>) and array (<code>$path</code>) forms.",
8912 specials.len()
8913 ),
8914 &specials,
8915 "special",
8916 );
8917
8918 let mut compsys_names: Vec<String> = crate::compsys::COMPSYS_FN_NAMES
8920 .iter()
8921 .map(|s| s.to_string())
8922 .collect();
8923 compsys_names.sort();
8924 write_chapter(
8925 &mut out,
8926 "ch-lsp-compsys",
8927 "Compsys Function Index",
8928 &format!(
8929 "{} entries · the <code>_arguments</code> / <code>_files</code> / \
8930 <code>_describe</code> family of completion functions. Native Rust \
8931 implementations in the <code>compsys</code> crate replace the \
8932 upstream zsh shell-function versions for performance.",
8933 compsys_names.len()
8934 ),
8935 &compsys_names,
8936 "compsys",
8937 );
8938
8939 let mut ext_names: Vec<String> = crate::ext_builtins::EXT_BUILTIN_NAMES
8941 .iter()
8942 .map(|s| s.to_string())
8943 .chain(
8944 crate::daemon::builtins::ZSHRS_BUILTIN_NAMES
8945 .iter()
8946 .map(|s| s.to_string()),
8947 )
8948 .collect();
8949 ext_names.sort();
8950 ext_names.dedup();
8951 write_chapter(
8952 &mut out,
8953 "ch-lsp-extensions",
8954 "Extension Builtin Index",
8955 &format!(
8956 "{} entries · zshrs-only builtins with NO upstream zsh counterpart. \
8957 Split across in-process builtins (coreutils drop-ins, <code>async</code>/\
8958 <code>await</code>/<code>barrier</code>, <code>doctor</code>, \
8959 <code>intercept</code>, contrib autoloads) and daemon-backed <code>z*</code> \
8960 builtins (<code>zd</code>, <code>zcache</code>, <code>zls</code>, \
8961 <code>zlock</code>, <code>zpublish</code>, …) that proxy to the local \
8962 <code>zshrs-daemon</code> for cross-shell state.",
8963 ext_names.len()
8964 ),
8965 &ext_names,
8966 "extension",
8967 );
8968
8969 let op_names: Vec<String> = OPERATOR_DOCS
8971 .iter()
8972 .map(|(op, _)| (*op).to_string())
8973 .collect();
8974 write_chapter(
8975 &mut out,
8976 "ch-lsp-operators",
8977 "Operator / Punctuation Index",
8978 &format!(
8979 "{} entries · pipelines (<code>|</code>, <code>|&</code>), list ops \
8980 (<code>&&</code>, <code>||</code>, <code>;</code>, <code>&</code>, \
8981 <code>;;</code>), redirects (<code>></code>, <code>>></code>, \
8982 <code><<</code>, <code><<<</code>, <code>&></code>, …), \
8983 conditional/arithmetic openers (<code>[[</code>, <code>]]</code>, <code>((</code>, \
8984 <code>))</code>), substitution forms (<code>$(</code>, <code>${{</code>, \
8985 <code>$((</code>, <code><(</code>, <code>>(</code>), test ops \
8986 (<code>-e</code>, <code>-eq</code>, <code>=~</code>, …), pattern chars \
8987 (<code>*</code>, <code>?</code>, <code>**</code>, <code>~</code>), brace \
8988 expansion (<code>{{a,b,c}}</code>, <code>{{1..10}}</code>), and assignment \
8989 (<code>=</code>, <code>+=</code>). Sourced from <code>man zshmisc</code> \
8990 section prose — these have no per-name yodl <code>item</code> blocks so \
8991 they're hand-curated.",
8992 op_names.len()
8993 ),
8994 &op_names,
8995 "operator",
8996 );
8997
8998 out
8999}
9000
9001fn write_chapter(
9002 out: &mut String,
9003 id: &str,
9004 title: &str,
9005 meta_html: &str,
9006 names: &[String],
9007 kind: &str,
9008) {
9009 use std::fmt::Write;
9010 let _ = writeln!(
9011 out,
9012 "\n <!-- ════════════════════════════════════════════════════════════════════ -->\n\
9013 \n <section class=\"tutorial-section\" id=\"{id}\">\n\
9014 \n <h2>{title}</h2>\n\
9015 \n <p class=\"chapter-meta\">{meta_html}</p>",
9016 );
9017 for n in names {
9018 let body = lookup_doc(n);
9019 let body_only = body.split_once("\n\n").map(|(_, b)| b).unwrap_or("");
9023 let anchor = anchor_for(kind, n);
9024 let _ = writeln!(
9025 out,
9026 "\n <article class=\"doc-entry\" id=\"{anchor}\">\n\
9027 \n <h3><code>{}</code> <a class=\"doc-anchor\" href=\"#{anchor}\">¶</a></h3>\n\
9028 {} </article>",
9029 html_escape(n),
9030 md_to_html(body_only),
9031 );
9032 }
9033 out.push_str("\n </section>\n");
9034}
9035
9036fn anchor_for(kind: &str, name: &str) -> String {
9037 let mut slug = String::new();
9043 for c in name.chars() {
9044 match c {
9045 c if c.is_ascii_alphanumeric() => slug.push(c),
9046 '_' => slug.push('_'),
9047 '-' => slug.push_str("dash"),
9048 ':' => slug.push_str("colon"),
9049 '.' => slug.push_str("dot"),
9050 '[' => slug.push_str("lbracket"),
9051 ']' => slug.push_str("rbracket"),
9052 '(' => slug.push_str("lparen"),
9053 ')' => slug.push_str("rparen"),
9054 '{' => slug.push_str("lbrace"),
9055 '}' => slug.push_str("rbrace"),
9056 '?' => slug.push_str("qmark"),
9057 '!' => slug.push_str("bang"),
9058 '$' => slug.push_str("dollar"),
9059 '#' => slug.push_str("hash"),
9060 '*' => slug.push_str("star"),
9061 '@' => slug.push_str("at"),
9062 '/' => slug.push_str("slash"),
9063 '+' => slug.push_str("plus"),
9064 '=' => slug.push_str("eq"),
9065 _ => slug.push('-'),
9066 }
9067 }
9068 let slug = slug.trim_matches('-').to_string();
9069 if slug.is_empty() {
9070 format!("doc-lsp-{}-unnamed", kind)
9071 } else {
9072 format!("doc-lsp-{}-{}", kind, slug)
9073 }
9074}
9075
9076fn html_escape(s: &str) -> String {
9077 let mut out = String::with_capacity(s.len());
9078 for c in s.chars() {
9079 match c {
9080 '&' => out.push_str("&"),
9081 '<' => out.push_str("<"),
9082 '>' => out.push_str(">"),
9083 '"' => out.push_str("""),
9084 _ => out.push(c),
9085 }
9086 }
9087 out
9088}
9089
9090fn md_to_html(s: &str) -> String {
9097 use std::fmt::Write;
9098 let mut out = String::new();
9099 for para in s.split("\n\n") {
9100 let para = para.trim_matches('\n');
9101 if para.is_empty() {
9102 continue;
9103 }
9104 let joined: String = para
9107 .split('\n')
9108 .map(str::trim_end)
9109 .collect::<Vec<_>>()
9110 .join(" ");
9111 let _ = writeln!(out, " <p>{}</p>", inline_md(&joined));
9112 }
9113 out
9114}
9115
9116fn inline_md(s: &str) -> String {
9117 let bytes = s.as_bytes();
9121 let mut out = String::with_capacity(s.len() + 16);
9122 let mut i = 0;
9123 while i < bytes.len() {
9124 let c = bytes[i] as char;
9125 if c == '`' {
9127 out.push_str("<code>");
9128 i += 1;
9129 while i < bytes.len() && bytes[i] != b'`' {
9130 i = push_html_escaped(&mut out, bytes, i);
9131 }
9132 out.push_str("</code>");
9133 if i < bytes.len() {
9134 i += 1; }
9136 continue;
9137 }
9138 if c == '*' && i + 1 < bytes.len() && bytes[i + 1] as char == '*' {
9140 if let Some(end) = find_close(bytes, i + 2, b"**") {
9141 out.push_str("<strong>");
9142 out.push_str(&inline_md(
9143 std::str::from_utf8(&bytes[i + 2..end]).unwrap_or(""),
9144 ));
9145 out.push_str("</strong>");
9146 i = end + 2;
9147 continue;
9148 }
9149 }
9150 if c == '_'
9153 && (i == 0 || !(bytes[i - 1] as char).is_alphanumeric())
9154 && i + 1 < bytes.len()
9155 && !(bytes[i + 1] as char).is_whitespace()
9156 {
9157 if let Some(end) = find_close(bytes, i + 1, b"_") {
9158 let after_ok =
9159 end + 1 >= bytes.len() || !(bytes[end + 1] as char).is_alphanumeric();
9160 if after_ok {
9161 out.push_str("<em>");
9162 out.push_str(&inline_md(
9163 std::str::from_utf8(&bytes[i + 1..end]).unwrap_or(""),
9164 ));
9165 out.push_str("</em>");
9166 i = end + 1;
9167 continue;
9168 }
9169 }
9170 }
9171 i = push_html_escaped(&mut out, bytes, i);
9175 }
9176 out
9177}
9178
9179fn push_html_escaped(out: &mut String, bytes: &[u8], i: usize) -> usize {
9185 let b = bytes[i];
9186 if b < 0x80 {
9187 match b {
9188 b'&' => out.push_str("&"),
9189 b'<' => out.push_str("<"),
9190 b'>' => out.push_str(">"),
9191 _ => out.push(b as char),
9192 }
9193 i + 1
9194 } else {
9195 let len = match b {
9196 0xF0..=0xF4 => 4,
9197 0xE0..=0xEF => 3,
9198 0xC0..=0xDF => 2,
9199 _ => 1,
9200 };
9201 let end = (i + len).min(bytes.len());
9202 out.push_str(std::str::from_utf8(&bytes[i..end]).unwrap_or("\u{fffd}"));
9203 end
9204 }
9205}
9206
9207fn find_close(bytes: &[u8], start: usize, needle: &[u8]) -> Option<usize> {
9208 let mut i = start;
9209 while i + needle.len() <= bytes.len() {
9210 if &bytes[i..i + needle.len()] == needle {
9211 return Some(i);
9212 }
9213 i += 1;
9214 }
9215 None
9216}
9217
9218#[allow(dead_code)]
9220fn _hush() {
9221 let _ = std::mem::size_of::<Mutex<()>>();
9222}
9223
9224#[derive(Serialize, Deserialize, Default, Debug)]
9227struct _Placeholder {
9228 _x: Option<u32>,
9229}
9230
9231#[cfg(test)]
9232mod tests {
9233 use super::*;
9234
9235 #[test]
9238 fn compsys_flag_table_covers_wanted_with_all_six_flags() {
9239 let flags = extract_builtin_flags("_wanted");
9244 let names: Vec<&str> = flags.iter().map(|(f, _)| f.as_str()).collect();
9245 assert_eq!(
9246 names,
9247 vec!["-x", "-C", "-1", "-2", "-V", "-J"],
9248 "_wanted flag set drifted from man zshcompsys signature",
9249 );
9250 for (f, d) in &flags {
9251 assert!(!d.is_empty(), "flag {f} has no description");
9252 }
9253 }
9254
9255 #[test]
9256 fn compsys_flag_table_overrides_bullet_scraper_for_arguments() {
9257 let flags = extract_builtin_flags("_arguments");
9262 assert!(
9263 flags.len() >= 12,
9264 "expected _arguments to surface >=12 flags from man-derived table, got {}",
9265 flags.len()
9266 );
9267 let names: std::collections::HashSet<&str> =
9270 flags.iter().map(|(f, _)| f.as_str()).collect();
9271 for must_have in ["-n", "-s", "-w", "-W", "-C", "-R", "-S", "-A", "-O", "-M"] {
9272 assert!(
9273 names.contains(must_have),
9274 "_arguments missing canonical flag {must_have}"
9275 );
9276 }
9277 }
9278
9279 #[test]
9280 fn every_compsys_fn_in_man_is_in_inventory() {
9281 for name in [
9289 "_call_program",
9290 "_canonical_paths",
9291 "_combination",
9292 "_command_names",
9293 "_completers",
9294 "_dir_list",
9295 "_email_addresses",
9296 "_multi_parts",
9297 "_numbers",
9298 "_pick_variant",
9299 "_sequence",
9300 "_tags",
9301 "_widgets",
9302 ] {
9303 assert!(
9304 crate::compsys::COMPSYS_FN_NAMES.contains(&name),
9305 "{name}: missing from COMPSYS_FN_NAMES inventory",
9306 );
9307 }
9308 assert!(is_known_builtin_with_flag_docs("_canonical_paths"));
9309 assert!(is_known_builtin_with_flag_docs("_widgets"));
9310 assert!(is_known_builtin_with_flag_docs("_call_program"));
9311 }
9312
9313 #[test]
9316 fn word_at_middle_of_identifier() {
9317 let _g = crate::test_util::global_state_lock();
9318 let src = "cd /tmp\nlocal x=1\n";
9319 assert_eq!(word_at(src, 0, 1), Some("cd".into()));
9320 assert_eq!(word_at(src, 0, 2), Some("cd".into()));
9322 }
9323
9324 #[test]
9325 fn word_at_includes_dollar_prefix() {
9326 let _g = crate::test_util::global_state_lock();
9327 let src = "echo $HOME\n";
9328 assert_eq!(word_at(src, 0, 6), Some("$HOME".into()));
9329 }
9330
9331 #[test]
9339 fn word_at_extends_through_hyphen_for_function_name() {
9340 let _g = crate::test_util::global_state_lock();
9341 let src = "daemon-ping arg\n";
9342 assert_eq!(word_at(src, 0, 0), Some("daemon-ping".into()));
9344 assert_eq!(word_at(src, 0, 3), Some("daemon-ping".into()));
9345 assert_eq!(word_at(src, 0, 7), Some("daemon-ping".into()));
9347 assert_eq!(word_at(src, 0, 10), Some("daemon-ping".into()));
9348 }
9349
9350 #[test]
9351 fn word_at_extends_through_multiple_hyphens() {
9352 let _g = crate::test_util::global_state_lock();
9353 let src = "daemon-job-submit -- cmd\n";
9354 assert_eq!(word_at(src, 0, 8), Some("daemon-job-submit".into()));
9355 assert_eq!(word_at(src, 0, 13), Some("daemon-job-submit".into()));
9356 }
9357
9358 #[test]
9359 fn word_at_dollar_var_does_not_extend_through_hyphen() {
9360 let _g = crate::test_util::global_state_lock();
9361 let src = "echo $x-y suffix\n";
9362 assert_eq!(word_at(src, 0, 6), Some("$x".into()));
9365 }
9366
9367 #[test]
9368 fn word_at_braced_var_does_not_extend_through_hyphen() {
9369 let _g = crate::test_util::global_state_lock();
9370 let src = "echo ${x-default}\n";
9371 assert_eq!(word_at(src, 0, 7), Some("x".into()));
9374 }
9375
9376 #[test]
9377 fn word_at_returns_none_off_word() {
9378 let _g = crate::test_util::global_state_lock();
9379 let src = "echo hi\n";
9380 assert!(matches!(word_at(src, 0, 5), None | Some(_)));
9382 assert_eq!(word_at(src, 0, 999), None);
9384 }
9385
9386 #[test]
9389 fn user_doc_attaches_to_function_with_keyword_form() {
9390 let src = "## Print a hello banner with the user's name.\n\
9394 ## Used by the README demo.\n\
9395 function greet {\n print hi\n}\n";
9396 let doc = super::find_user_symbol_doc(src, "greet").expect("doc");
9397 assert!(doc.contains("Print a hello banner"), "got {doc:?}");
9398 assert!(doc.contains("Used by the README demo"), "got {doc:?}");
9399 assert!(doc.contains("\n\n---\n\n"), "missing divider: {doc:?}");
9400 assert!(doc.contains("user-defined function `greet`"), "got {doc:?}");
9401 }
9402
9403 #[test]
9404 fn user_doc_attaches_to_posix_function_form() {
9405 let src = "## Sum two integers.\n\
9406 add() {\n print $(( $1 + $2 ))\n}\n";
9407 let doc = super::find_user_symbol_doc(src, "add").expect("doc");
9408 assert!(doc.contains("Sum two integers"), "got {doc:?}");
9409 assert!(doc.contains("user-defined function"), "got {doc:?}");
9410 }
9411
9412 #[test]
9413 fn user_doc_attaches_to_alias() {
9414 let src = "## Short for `ls --color=auto -lAh`.\nalias ll='ls --color=auto -lAh'\n";
9415 let doc = super::find_user_symbol_doc(src, "ll").expect("doc");
9416 assert!(doc.contains("Short for"), "got {doc:?}");
9417 assert!(doc.contains("user-defined alias"), "got {doc:?}");
9418 }
9419
9420 #[test]
9421 fn user_doc_attaches_to_typeset_parameter() {
9422 let src = "## Maximum retries before giving up.\ntypeset -i MAX_RETRIES=5\n";
9423 let doc = super::find_user_symbol_doc(src, "MAX_RETRIES").expect("doc");
9424 assert!(doc.contains("Maximum retries"), "got {doc:?}");
9425 assert!(doc.contains("user-defined parameter"), "got {doc:?}");
9426 }
9427
9428 #[test]
9429 fn user_doc_multi_paragraph_with_blank_double_hash() {
9430 let src = "## First paragraph describing what the function does.\n\
9432 ##\n\
9433 ## Second paragraph about edge cases.\n\
9434 function foo() {}\n";
9435 let doc = super::find_user_symbol_doc(src, "foo").expect("doc");
9436 assert!(doc.contains("First paragraph"), "got {doc:?}");
9437 assert!(doc.contains("Second paragraph"), "got {doc:?}");
9438 assert!(doc.contains("\n\n"), "expected paragraph break: {doc:?}");
9440 }
9441
9442 #[test]
9443 fn user_doc_skips_blank_lines_between_block_and_def() {
9444 let src = "## Doc for the function.\n\nfunction f() {}\n";
9446 let doc = super::find_user_symbol_doc(src, "f").expect("doc");
9447 assert!(doc.contains("Doc for the function"), "got {doc:?}");
9448 }
9449
9450 #[test]
9451 fn user_doc_ignores_single_hash_comments() {
9452 let src = "# This is just a code comment, not a docstring.\n\
9459 function f() {}\n";
9460 let card = super::find_user_symbol_doc(src, "f").expect("minimal card");
9461 assert!(
9462 !card.contains("just a code comment"),
9463 "plain `#` comments must not leak into doc card: {card:?}"
9464 );
9465 }
9466
9467 #[test]
9468 fn user_doc_returns_none_when_symbol_absent() {
9469 let src = "## Doc for greet.\nfunction greet() {}\n";
9470 assert!(super::find_user_symbol_doc(src, "nonexistent").is_none());
9471 }
9472
9473 #[test]
9474 fn user_doc_returns_none_when_no_doc_block() {
9475 let src = "function greet() {}\n";
9479 let card = super::find_user_symbol_doc(src, "greet").expect("minimal card");
9480 assert!(
9481 card.contains("greet"),
9482 "card must name the symbol: {card:?}"
9483 );
9484 }
9485
9486 #[test]
9487 fn user_doc_stops_at_intervening_single_hash_line() {
9488 let src = "## Real doc here.\n\
9494 # Inline comment unrelated to the doc.\n\
9495 function f() {}\n";
9496 let card = super::find_user_symbol_doc(src, "f").expect("minimal card");
9497 assert!(
9498 !card.contains("Real doc here"),
9499 "intervening `#` must break doc collection: {card:?}"
9500 );
9501 }
9502
9503 #[test]
9506 fn module_doc_collects_top_block_after_shebang() {
9507 let src = "#!/usr/bin/env zsh\n\
9508 ## foo.zsh — short summary.\n\
9509 ## Provides foo, bar, baz helpers.\n\
9510 \n\
9511 function foo() {}\n";
9512 let doc = super::extract_module_doc(src).expect("doc");
9513 assert!(doc.contains("foo.zsh"), "got {doc:?}");
9514 assert!(doc.contains("Provides foo"), "got {doc:?}");
9515 }
9516
9517 #[test]
9518 fn module_doc_collects_top_block_without_shebang() {
9519 let src = "## bar.zsh — utility library.\n\
9520 function bar() {}\n";
9521 let doc = super::extract_module_doc(src).expect("doc");
9522 assert!(doc.contains("bar.zsh"), "got {doc:?}");
9523 }
9524
9525 #[test]
9526 fn module_doc_supports_double_hash_paragraph_breaks() {
9527 let src = "## First paragraph of the module summary.\n\
9528 ##\n\
9529 ## Second paragraph with details.\n\
9530 function foo() {}\n";
9531 let doc = super::extract_module_doc(src).expect("doc");
9532 assert!(doc.contains("First paragraph"), "got {doc:?}");
9533 assert!(doc.contains("Second paragraph"), "got {doc:?}");
9534 assert!(doc.contains("\n\n"), "expected paragraph break: {doc:?}");
9535 }
9536
9537 #[test]
9538 fn module_doc_skips_blank_lines_between_shebang_and_block() {
9539 let src = "#!/usr/bin/env zsh\n\
9540 \n\
9541 \n\
9542 ## Module doc starts here.\n\
9543 function foo() {}\n";
9544 let doc = super::extract_module_doc(src).expect("doc");
9545 assert!(doc.contains("Module doc starts here"), "got {doc:?}");
9546 }
9547
9548 #[test]
9549 fn module_doc_returns_none_when_no_block() {
9550 let src = "#!/usr/bin/env zsh\nfunction foo() {}\n";
9551 assert!(super::extract_module_doc(src).is_none());
9552 }
9553
9554 #[test]
9555 fn module_doc_returns_none_for_plain_single_hash_comments() {
9556 let src = "#!/usr/bin/env zsh\n\
9558 # This is a regular code comment, not a docstring.\n\
9559 function foo() {}\n";
9560 assert!(super::extract_module_doc(src).is_none());
9561 }
9562
9563 #[test]
9564 fn module_doc_stops_at_first_real_code_line() {
9565 let src = "## Module doc line 1.\n\
9566 function foo() {}\n\
9567 ## This should NOT be collected — below the def.\n";
9568 let doc = super::extract_module_doc(src).expect("doc");
9569 assert_eq!(doc, "Module doc line 1.");
9570 }
9571
9572 #[test]
9575 fn scan_symbols_finds_function_keyword_form() {
9576 let _g = crate::test_util::global_state_lock();
9577 let src = "function greet {\n print hi\n}\n";
9578 let s = scan_symbols(src);
9579 assert!(s.iter().any(|(n, k, _)| n == "greet" && *k == "function"));
9580 }
9581
9582 #[test]
9583 fn scan_symbols_finds_paren_form() {
9584 let _g = crate::test_util::global_state_lock();
9585 let src = "foo() {\n :\n}\n";
9586 let s = scan_symbols(src);
9587 assert!(s.iter().any(|(n, k, _)| n == "foo" && *k == "function"));
9588 }
9589
9590 #[test]
9591 fn scan_symbols_finds_locals_and_aliases() {
9592 let _g = crate::test_util::global_state_lock();
9593 let src = "local x=1\nalias ll='ls -la'\nexport PATH=/bin\n";
9594 let s = scan_symbols(src);
9595 assert!(s.iter().any(|(n, k, _)| n == "x" && *k == "variable"));
9596 assert!(s.iter().any(|(n, k, _)| n == "ll" && *k == "alias"));
9597 assert!(s.iter().any(|(n, k, _)| n == "PATH" && *k == "variable"));
9598 }
9599
9600 #[test]
9601 fn scan_symbols_ignores_comments() {
9602 let _g = crate::test_util::global_state_lock();
9603 let src = "# function fake { }\n# alias evil=rm\n: real\n";
9604 let s = scan_symbols(src);
9605 assert!(s.is_empty(), "scan_symbols leaked comment content: {:?}", s);
9606 }
9607
9608 #[test]
9611 fn lookup_doc_returns_markdown_for_known_builtin() {
9612 let _g = crate::test_util::global_state_lock();
9613 let doc = lookup_doc("cd");
9614 assert!(doc.starts_with("**cd**"), "got: {}", doc);
9615 assert!(
9617 doc.contains("Change the current directory"),
9618 "expected upstream cd prose; got: {}",
9619 doc
9620 );
9621 }
9622
9623 #[test]
9624 fn lookup_doc_handles_keywords_and_special_vars() {
9625 let _g = crate::test_util::global_state_lock();
9626 assert!(
9628 lookup_doc("if").contains("zero exit status"),
9629 "expected upstream if prose; got: {}",
9630 lookup_doc("if")
9631 );
9632 assert!(
9634 lookup_doc("$?").contains("exit status"),
9635 "expected $? doc; got: {}",
9636 lookup_doc("$?")
9637 );
9638 }
9639
9640 #[test]
9641 fn lookup_doc_handles_pure_symbolic_specials() {
9642 let _g = crate::test_util::global_state_lock();
9647 let s = lookup_doc("$");
9649 assert!(
9650 !s.is_empty() && s.contains("process ID"),
9651 "lookup_doc('$') should return the PID doc; got: {:?}",
9652 s,
9653 );
9654 for sym in &["$?", "$*", "$#", "$@", "$-", "$_", "$!"] {
9657 let card = lookup_doc(sym);
9658 assert!(
9659 !card.is_empty(),
9660 "lookup_doc({:?}) returned empty; pure-symbolic specials must resolve",
9661 sym,
9662 );
9663 }
9664 }
9665
9666 #[test]
9667 fn lookup_doc_special_var_wins_over_module_builtin_entry() {
9668 let _g = crate::test_util::global_state_lock();
9678 for name in &[
9683 "prompt",
9684 "path",
9685 "aliases",
9686 "jobdirs",
9687 "jobstates",
9688 "commands",
9689 "modules",
9690 "widgets",
9691 ] {
9692 let card = lookup_doc(name);
9693 assert!(
9694 card.contains("special variable"),
9695 "lookup_doc({:?}) classified as: {:?} — expected 'special variable'",
9696 name,
9697 card.lines().take(3).collect::<Vec<_>>(),
9698 );
9699 }
9700 for name in &[
9706 "cd",
9707 "echo",
9708 "set",
9709 "shift",
9710 "unset",
9711 "functions",
9712 "history",
9713 ] {
9714 let card = lookup_doc(name);
9715 assert!(
9716 card.contains("zsh builtin") || card.contains("zshrs"),
9717 "lookup_doc({:?}) lost its builtin classification: {:?}",
9718 name,
9719 card.lines().take(3).collect::<Vec<_>>(),
9720 );
9721 }
9722 }
9723
9724 #[test]
9725 fn lookup_doc_empty_for_unknown() {
9726 let _g = crate::test_util::global_state_lock();
9727 assert_eq!(lookup_doc("definitely_not_a_zsh_thing_xx"), "");
9728 }
9729
9730 #[test]
9733 fn diagnose_clean_file_returns_no_diagnostics() {
9734 let _g = crate::test_util::global_state_lock();
9735 let src = "if [[ -d /tmp ]]; then\n echo ok\nfi\n";
9736 let d = diagnose(src);
9737 assert!(d.is_empty(), "diagnose flagged clean file: {:?}", d);
9738 }
9739
9740 #[test]
9741 fn diagnose_flags_unmatched_brace() {
9742 let _g = crate::test_util::global_state_lock();
9743 let src = "function broken {\n echo missing close\n";
9744 let d = diagnose(src);
9745 assert!(
9746 d.iter()
9747 .any(|v| v["message"].as_str().unwrap_or("").contains("unclosed `{`")),
9748 "expected unclosed-brace diagnostic, got: {:?}",
9749 d
9750 );
9751 }
9752
9753 #[test]
9754 fn diagnose_flags_unclosed_if_block() {
9755 let _g = crate::test_util::global_state_lock();
9756 let src = "if true\nthen\necho\n";
9757 let d = diagnose(src);
9758 assert!(
9759 d.iter().any(|v| v["message"]
9760 .as_str()
9761 .unwrap_or("")
9762 .contains("unclosed `if`")),
9763 "expected unclosed-if diagnostic, got: {:?}",
9764 d
9765 );
9766 }
9767
9768 #[test]
9769 fn diagnose_ignores_braces_inside_strings() {
9770 let _g = crate::test_util::global_state_lock();
9771 let src = "echo \"a } b\" '{ }' \n";
9772 let d = diagnose(src);
9773 assert!(
9774 d.is_empty(),
9775 "string-internal braces tripped diagnose: {:?}",
9776 d
9777 );
9778 }
9779
9780 #[test]
9786 fn diagnose_ignores_block_keywords_inside_comments() {
9787 let _g = crate::test_util::global_state_lock();
9788 let src = "# operations — length, case, slice, concat, search.\nx=1\n";
9789 let d = diagnose(src);
9790 assert!(
9791 d.is_empty(),
9792 "block keyword `case` inside comment flagged: {:?}",
9793 d
9794 );
9795 }
9796
9797 #[test]
9800 fn diagnose_ignores_all_block_keywords_in_comments_and_strings() {
9801 let _g = crate::test_util::global_state_lock();
9802 let src = "# if for while until case select repeat\n\
9803 echo \"if for while until case select repeat\"\n\
9804 x=1\n";
9805 let d = diagnose(src);
9806 assert!(
9807 d.is_empty(),
9808 "block keywords in comments/strings flagged: {:?}",
9809 d
9810 );
9811 }
9812
9813 #[test]
9817 fn diagnose_ignores_terminators_inside_comments() {
9818 let _g = crate::test_util::global_state_lock();
9819 let src = "# fi done esac comment\nx=1\n";
9820 let d = diagnose(src);
9821 assert!(
9822 d.is_empty(),
9823 "terminator keywords in comments flagged: {:?}",
9824 d
9825 );
9826 }
9827
9828 #[test]
9835 fn diagnose_done_with_trailing_semicolon_pops_for() {
9836 let _g = crate::test_util::global_state_lock();
9837 let src = "for i in 1 2 3; do echo $i; done;\n";
9838 let d = diagnose(src);
9839 assert!(d.is_empty(), "`done;` must pop for: {:?}", d);
9840 }
9841
9842 #[test]
9847 fn diagnose_three_done_with_trailing_semicolon_pops_three_fors() {
9848 let _g = crate::test_util::global_state_lock();
9849 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";
9850 let d = diagnose(src);
9851 assert!(d.is_empty(), "three `done;` must pop three `for`: {:?}", d);
9852 }
9853
9854 #[test]
9857 fn diagnose_fi_with_trailing_semicolon_pops_if() {
9858 let _g = crate::test_util::global_state_lock();
9859 let src = "if true; then echo hi; fi;\n";
9860 let d = diagnose(src);
9861 assert!(d.is_empty(), "`fi;` must pop if: {:?}", d);
9862 }
9863
9864 #[test]
9869 fn diagnose_oneliner_case_arm_paren_not_flagged() {
9870 let _g = crate::test_util::global_state_lock();
9871 let src = "case foo in foo|bar) echo yes ;; *) echo no ;; esac\n";
9872 let d = diagnose(src);
9873 assert!(d.is_empty(), "one-liner case-arm `)` flagged: {:?}", d);
9874 }
9875
9876 #[test]
9883 fn diagnose_param_subst_with_escaped_brackets_no_flag() {
9884 let _g = crate::test_util::global_state_lock();
9885 let src = "level=${log_line#\\[}; date=${log_line#*\\][}\n";
9886 let d = diagnose(src);
9887 assert!(
9888 d.is_empty(),
9889 "param-subst with escaped brackets flagged: {:?}",
9890 d
9891 );
9892 }
9893
9894 #[test]
9902 fn diagnose_select_as_argument_not_block_opener() {
9903 let _g = crate::test_util::global_state_lock();
9904 let src = "zstyle ':completion:*' menu select\n";
9905 let d = diagnose(src);
9906 assert!(d.is_empty(), "`select` as builtin arg flagged: {:?}", d);
9907 }
9908
9909 #[test]
9912 fn diagnose_select_block_form_balances() {
9913 let _g = crate::test_util::global_state_lock();
9914 let src = "select x in a b c; do echo $x; break; done\n";
9915 let d = diagnose(src);
9916 assert!(
9917 d.is_empty(),
9918 "block-form `select … do … done` flagged: {:?}",
9919 d
9920 );
9921 }
9922
9923 #[test]
9930 fn find_user_symbol_doc_returns_minimal_card_when_no_docblock() {
9931 let _g = crate::test_util::global_state_lock();
9932 let src = "greet() {\n echo hello\n}\n";
9933 let r = find_user_symbol_doc(src, "greet").expect("UDF must hover");
9934 assert!(
9935 r.contains("user-defined function"),
9936 "card must label kind, got {:?}",
9937 r
9938 );
9939 assert!(
9940 r.contains("greet"),
9941 "card must include the symbol name, got {:?}",
9942 r
9943 );
9944 }
9945
9946 #[test]
9949 fn find_user_symbol_doc_minimal_card_for_user_variable() {
9950 let _g = crate::test_util::global_state_lock();
9951 let src = "fn() {\n local start=$EPOCHREALTIME\n}\n";
9952 let r = find_user_symbol_doc(src, "start").expect("user var must hover");
9953 assert!(
9954 r.contains("user-defined parameter") || r.contains("user-defined variable"),
9955 "card must label parameter/variable kind, got {:?}",
9956 r
9957 );
9958 assert!(
9959 r.contains("start"),
9960 "card must include the var name, got {:?}",
9961 r
9962 );
9963 }
9964
9965 #[test]
9968 fn find_user_symbol_doc_prefers_documented_definition() {
9969 let _g = crate::test_util::global_state_lock();
9970 let src = "## says hi\ngreet() {\n echo hi\n}\n";
9971 let r = find_user_symbol_doc(src, "greet").expect("UDF must hover");
9972 assert!(
9973 r.contains("says hi"),
9974 "documented form must include the doc block, got {:?}",
9975 r
9976 );
9977 }
9978
9979 #[test]
9986 fn semantic_tokens_brace_range_emits_single_operator_span() {
9987 let _g = crate::test_util::global_state_lock();
9988 let mut state = State::default();
9989 let uri = "file:///t.zsh";
9990 state
9991 .docs
9992 .insert(uri.to_string(), "echo {a..e}\n".to_string());
9993 let r = semantic_tokens(&state, &json!({ "textDocument": { "uri": uri } }));
9994 let data = r["data"].as_array().expect("data array");
9995 let mut found = false;
9998 for chunk in data.chunks(5) {
9999 if chunk.len() == 5 && chunk[2].as_u64() == Some(6) && chunk[3].as_u64() == Some(4) {
10000 found = true;
10001 break;
10002 }
10003 }
10004 assert!(
10005 found,
10006 "brace range `{{a..e}}` must emit a single 6-byte operator token, got data={:?}",
10007 data
10008 );
10009 }
10010
10011 #[test]
10015 fn semantic_tokens_brace_range_uppercase_endpoints_emit_operator() {
10016 let _g = crate::test_util::global_state_lock();
10017 let mut state = State::default();
10018 let uri = "file:///t.zsh";
10019 state
10020 .docs
10021 .insert(uri.to_string(), "echo {A..E}\n".to_string());
10022 let r = semantic_tokens(&state, &json!({ "textDocument": { "uri": uri } }));
10023 let data = r["data"].as_array().expect("data array");
10024 let mut found = false;
10025 for chunk in data.chunks(5) {
10026 if chunk.len() == 5 && chunk[2].as_u64() == Some(6) && chunk[3].as_u64() == Some(4) {
10027 found = true;
10028 break;
10029 }
10030 }
10031 assert!(
10032 found,
10033 "brace range `{{A..E}}` must emit a single 6-byte operator token, got data={:?}",
10034 data
10035 );
10036 }
10037
10038 #[test]
10047 fn semantic_tokens_arith_in_string_breaks_interior_into_atoms() {
10048 let _g = crate::test_util::global_state_lock();
10049 let mut state = State::default();
10050 let uri = "file:///t.zsh";
10051 state
10052 .docs
10053 .insert(uri.to_string(), "echo \"$(( -7 % 3 ))\"\n".to_string());
10054 let r = semantic_tokens(&state, &json!({ "textDocument": { "uri": uri } }));
10055 let data = r["data"].as_array().expect("data array");
10056 let mut numbers = 0usize;
10063 let mut operators = 0usize;
10064 for chunk in data.chunks(5) {
10065 if chunk.len() == 5 {
10066 match chunk[3].as_u64() {
10067 Some(2) => numbers += 1,
10068 Some(4) => operators += 1,
10069 _ => {}
10070 }
10071 }
10072 }
10073 assert!(
10074 numbers >= 2,
10075 "expected ≥2 number tokens inside $((…)), got {numbers}; data={:?}",
10076 data
10077 );
10078 assert!(
10079 operators >= 3,
10080 "expected ≥3 operator tokens ($((, %, )) ) inside $((…)), got {operators}; data={:?}",
10081 data
10082 );
10083 }
10084
10085 #[test]
10091 fn semantic_tokens_long_cli_flag_emits_operator_span() {
10092 let _g = crate::test_util::global_state_lock();
10093 let mut state = State::default();
10094 let uri = "file:///t.zsh";
10095 state.docs.insert(
10096 uri.to_string(),
10097 "parse_demo --verbose --debug arg1\n".to_string(),
10098 );
10099 let r = semantic_tokens(&state, &json!({ "textDocument": { "uri": uri } }));
10100 let data = r["data"].as_array().expect("data array");
10101 let mut saw_9_op = false;
10104 let mut saw_7_op = false;
10105 for chunk in data.chunks(5) {
10106 if chunk.len() == 5 && chunk[3].as_u64() == Some(4) {
10107 match chunk[2].as_u64() {
10108 Some(9) => saw_9_op = true,
10109 Some(7) => saw_7_op = true,
10110 _ => {}
10111 }
10112 }
10113 }
10114 assert!(
10115 saw_9_op,
10116 "`--verbose` (9 bytes) must emit an operator token, got {:?}",
10117 data
10118 );
10119 assert!(
10120 saw_7_op,
10121 "`--debug` (7 bytes) must emit an operator token, got {:?}",
10122 data
10123 );
10124 }
10125
10126 #[test]
10129 fn semantic_tokens_brace_range_with_step_emits_operator() {
10130 let _g = crate::test_util::global_state_lock();
10131 let mut state = State::default();
10132 let uri = "file:///t.zsh";
10133 state
10134 .docs
10135 .insert(uri.to_string(), "echo {1..10..2}\n".to_string());
10136 let r = semantic_tokens(&state, &json!({ "textDocument": { "uri": uri } }));
10137 let data = r["data"].as_array().expect("data array");
10138 let mut found = false;
10139 for chunk in data.chunks(5) {
10140 if chunk.len() == 5 && chunk[2].as_u64() == Some(10) && chunk[3].as_u64() == Some(4) {
10141 found = true;
10142 break;
10143 }
10144 }
10145 assert!(
10146 found,
10147 "brace range `{{1..10..2}}` must emit a single 10-byte operator token, got data={:?}",
10148 data
10149 );
10150 }
10151
10152 #[test]
10157 fn find_user_symbol_doc_documented_wins_over_earlier_undocumented() {
10158 let _g = crate::test_util::global_state_lock();
10159 let src = "\
10160fn foo() { echo first }\n\
10161\n\
10162## second def with docs\n\
10163foo() { echo second }\n\
10164";
10165 let r = find_user_symbol_doc(src, "foo").expect("UDF must hover");
10166 assert!(
10167 r.contains("second def with docs"),
10168 "documented later-def must win, got {:?}",
10169 r
10170 );
10171 }
10172
10173 #[test]
10178 fn diagnose_accepts_every_examples_demo_zsh_file() {
10179 let _g = crate::test_util::global_state_lock();
10180 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
10181 let demos_dir = manifest_dir.join("examples").join("demos");
10182 let mut failures: Vec<(String, Vec<Value>)> = Vec::new();
10183 let entries = std::fs::read_dir(&demos_dir).expect("examples/demos must exist");
10184 let mut total = 0usize;
10185 for entry in entries {
10186 let path = entry.unwrap().path();
10187 if path.extension().and_then(|s| s.to_str()) != Some("zsh") {
10188 continue;
10189 }
10190 total += 1;
10191 let text = std::fs::read_to_string(&path).unwrap();
10192 let d = diagnose(&text);
10193 if !d.is_empty() {
10194 failures.push((path.file_name().unwrap().to_string_lossy().into_owned(), d));
10195 }
10196 }
10197 assert!(total > 0, "no demos found under {}", demos_dir.display());
10198 assert!(
10199 failures.is_empty(),
10200 "{}/{} demos flagged by LSP diagnose():\n{:#?}",
10201 failures.len(),
10202 total,
10203 failures,
10204 );
10205 }
10206
10207 #[test]
10215 fn diagnose_does_not_flag_dollar_hash_as_comment() {
10216 let _g = crate::test_util::global_state_lock();
10217 let src = "[[ $# -gt 0 ]] && echo args\n";
10221 let d = diagnose(src);
10222 assert!(d.is_empty(), "`$#` mis-handled as comment marker: {:?}", d);
10223 }
10224
10225 #[test]
10226 fn diagnose_does_not_flag_param_length_as_comment() {
10227 let _g = crate::test_util::global_state_lock();
10228 let src = "echo ${#args}\nif [[ ${#arr} -gt 0 ]]; then echo nonempty; fi\n";
10230 let d = diagnose(src);
10231 assert!(
10232 d.is_empty(),
10233 "`${{#var}}` mis-handled as comment marker: {:?}",
10234 d
10235 );
10236 }
10237
10238 #[test]
10239 fn diagnose_handles_double_bracket_as_pair() {
10240 let _g = crate::test_util::global_state_lock();
10241 let src = "[[ -n \"$x\" ]]\n";
10244 let d = diagnose(src);
10245 assert!(d.is_empty(), "`[[ ]]` mis-handled as two `[`s: {:?}", d);
10246 }
10247
10248 #[test]
10249 fn diagnose_handles_arithmetic_double_paren_as_pair() {
10250 let _g = crate::test_util::global_state_lock();
10251 let src = "(( i++ ))\n";
10253 let d = diagnose(src);
10254 assert!(d.is_empty(), "`(( ))` mis-handled as two `(`s: {:?}", d);
10255 }
10256
10257 #[test]
10258 fn diagnose_does_not_flag_case_arm_paren_as_unmatched() {
10259 let _g = crate::test_util::global_state_lock();
10260 let src = "case \"$x\" in\n -h|--help) echo usage ;;\n *) echo other ;;\nesac\n";
10263 let d = diagnose(src);
10264 assert!(d.is_empty(), "case-arm `)` flagged as unmatched: {:?}", d);
10265 }
10266
10267 #[test]
10268 fn diagnose_still_flags_unmatched_paren_outside_case() {
10269 let _g = crate::test_util::global_state_lock();
10270 let src = "echo bare )\n";
10273 let d = diagnose(src);
10274 assert!(
10275 d.iter().any(|v| v["message"]
10276 .as_str()
10277 .unwrap_or("")
10278 .contains("unmatched `)`")),
10279 "real unmatched `)` was not flagged: {:?}",
10280 d
10281 );
10282 }
10283
10284 #[test]
10292 fn format_strips_trailing_whitespace() {
10293 let _g = crate::test_util::global_state_lock();
10294 let src = "echo hi \n echo bye\t\n";
10295 let out = crate::fmt::format_source(src, &crate::fmt::FmtOptions::default());
10296 assert_eq!(out, "echo hi\necho bye\n");
10297 }
10298
10299 #[test]
10300 fn format_ensures_trailing_newline() {
10301 let _g = crate::test_util::global_state_lock();
10302 let src = "echo hi";
10303 let out = crate::fmt::format_source(src, &crate::fmt::FmtOptions::default());
10304 assert!(out.ends_with('\n'));
10305 }
10306
10307 #[test]
10310 fn dump_reflection_json_is_valid_and_has_builtins() {
10311 let _g = crate::test_util::global_state_lock();
10312 let s = dump_reflection_json();
10313 let v: Value = serde_json::from_str(&s).expect("valid JSON");
10314 assert!(v["builtins"].is_object());
10315 assert!(v["keywords"].is_object());
10316 assert!(v["options"].is_object());
10317 assert!(v["special_vars"].is_object());
10318 assert!(v["builtins"]["cd"].is_string());
10323 assert!(v["keywords"]["if"].is_string());
10324 assert!(v["options"]["EXTENDED_GLOB"].is_string());
10325 for want in ["PS1", "PS2", "PS3", "PS4", "psvar", "PROMPT2"] {
10327 assert!(
10328 v["special_vars"][want].is_string(),
10329 "special_vars missing `{}` — reflection should source from SPECIAL_VAR_DOCS",
10330 want,
10331 );
10332 }
10333 assert!(
10336 v["options"].as_object().unwrap().len() >= 700,
10337 "dump_reflection options regressed to {}; expected full OPTION_DOCS + aliases (~750)",
10338 v["options"].as_object().unwrap().len(),
10339 );
10340 assert!(
10341 v["special_vars"].as_object().unwrap().len() >= 250,
10342 "dump_reflection special_vars regressed to {}; expected full SPECIAL_VAR_DOCS (~280)",
10343 v["special_vars"].as_object().unwrap().len(),
10344 );
10345 }
10346
10347 #[test]
10350 fn completion_offers_builtins_for_short_prefix() {
10351 let _g = crate::test_util::global_state_lock();
10352 let mut state = State::default();
10353 state.docs.insert("file:///t.zsh".into(), "cd".into());
10354 let params = json!({
10355 "textDocument": { "uri": "file:///t.zsh" },
10356 "position": { "line": 0, "character": 2 },
10357 });
10358 let result = completion(&state, ¶ms);
10359 let items = result["items"].as_array().unwrap();
10360 assert!(
10361 items.iter().any(|i| i["label"] == "cd"),
10362 "items: {:?}",
10363 items
10364 );
10365 }
10366
10367 #[test]
10368 fn completion_offers_daemon_z_star_builtins() {
10369 let _g = crate::test_util::global_state_lock();
10376 let mut state = State::default();
10377 state.docs.insert("file:///t.zsh".into(), "zwh".into());
10378 let params = json!({
10379 "textDocument": { "uri": "file:///t.zsh" },
10380 "position": { "line": 0, "character": 3 },
10381 });
10382 let result = completion(&state, ¶ms);
10383 let items = result["items"].as_array().unwrap();
10384 assert!(
10385 items.iter().any(|i| i["label"] == "zwhere"),
10386 "no `zwhere` in completion items for `zwh` prefix: {:?}",
10387 items
10388 .iter()
10389 .map(|i| i["label"].as_str().unwrap_or("?"))
10390 .collect::<Vec<_>>(),
10391 );
10392 }
10393
10394 #[test]
10395 fn completion_offers_ext_builtins_and_compsys_fns() {
10396 let _g = crate::test_util::global_state_lock();
10402 for (input, want) in &[("dat", "date"), ("_argu", "_arguments"), ("vare", "vared")] {
10403 let mut state = State::default();
10404 state.docs.insert("file:///t.zsh".into(), (*input).into());
10405 let params = json!({
10406 "textDocument": { "uri": "file:///t.zsh" },
10407 "position": { "line": 0, "character": input.len() },
10408 });
10409 let result = completion(&state, ¶ms);
10410 let items = result["items"].as_array().unwrap();
10411 assert!(
10412 items.iter().any(|i| i["label"] == *want),
10413 "no `{}` in completion for `{}` prefix: {:?}",
10414 want,
10415 input,
10416 items
10417 .iter()
10418 .map(|i| i["label"].as_str().unwrap_or("?"))
10419 .collect::<Vec<_>>(),
10420 );
10421 }
10422 }
10423
10424 #[test]
10425 fn completion_offers_snippet_templates() {
10426 let _g = crate::test_util::global_state_lock();
10432 let mut state = State::default();
10433 state.docs.insert("file:///t.zsh".into(), "if".into());
10434 let params = json!({
10435 "textDocument": { "uri": "file:///t.zsh" },
10436 "position": { "line": 0, "character": 2 },
10437 });
10438 let result = completion(&state, ¶ms);
10439 let items = result["items"].as_array().unwrap();
10440 let snippet = items
10441 .iter()
10442 .find(|i| i["label"].as_str() == Some("if …"))
10443 .unwrap_or_else(|| panic!("no `if …` snippet in items: {:?}", items));
10444 assert_eq!(snippet["kind"], 15, "snippet kind must be 15 (Snippet)");
10445 assert_eq!(
10446 snippet["insertTextFormat"], 2,
10447 "snippet insertTextFormat must be 2 (Snippet — placeholders honored)"
10448 );
10449 let body = snippet["insertText"].as_str().unwrap();
10450 assert!(
10451 body.contains("then") && body.contains("fi"),
10452 "snippet body wrong: {}",
10453 body
10454 );
10455 }
10456
10457 #[test]
10458 fn completion_suppressed_inside_double_quoted_literal() {
10459 let _g = crate::test_util::global_state_lock();
10464 let mut state = State::default();
10465 state
10466 .docs
10467 .insert("file:///t.zsh".into(), r#"echo "hello if"#.into());
10468 let params = json!({
10469 "textDocument": { "uri": "file:///t.zsh" },
10470 "position": { "line": 0, "character": 14 },
10472 });
10473 let result = completion(&state, ¶ms);
10474 let items = result["items"].as_array().unwrap();
10475 assert!(
10476 items.is_empty(),
10477 "expected 0 items inside dq literal, got {}: {:?}",
10478 items.len(),
10479 items
10480 .iter()
10481 .take(5)
10482 .map(|i| i["label"].as_str().unwrap_or("?"))
10483 .collect::<Vec<_>>(),
10484 );
10485 }
10486
10487 #[test]
10488 fn completion_active_inside_command_substitution_inside_dq() {
10489 let _g = crate::test_util::global_state_lock();
10493 let mut state = State::default();
10494 state
10495 .docs
10496 .insert("file:///t.zsh".into(), r#"echo "x $(cd"#.into());
10497 let params = json!({
10498 "textDocument": { "uri": "file:///t.zsh" },
10499 "position": { "line": 0, "character": 12 },
10501 });
10502 let result = completion(&state, ¶ms);
10503 let items = result["items"].as_array().unwrap();
10504 assert!(
10505 items.iter().any(|i| i["label"] == "cd"),
10506 "expected `cd` to surface inside $() within dq: {:?}",
10507 items
10508 .iter()
10509 .take(5)
10510 .map(|i| i["label"].as_str().unwrap_or("?"))
10511 .collect::<Vec<_>>(),
10512 );
10513 }
10514
10515 #[test]
10516 fn completion_active_inside_backticks_inside_dq() {
10517 let _g = crate::test_util::global_state_lock();
10518 let mut state = State::default();
10519 state
10520 .docs
10521 .insert("file:///t.zsh".into(), "echo \"x `cd".into());
10522 let params = json!({
10523 "textDocument": { "uri": "file:///t.zsh" },
10524 "position": { "line": 0, "character": 11 },
10525 });
10526 let result = completion(&state, ¶ms);
10527 let items = result["items"].as_array().unwrap();
10528 assert!(
10529 items.iter().any(|i| i["label"] == "cd"),
10530 "expected `cd` to surface inside backticks within dq",
10531 );
10532 }
10533
10534 #[test]
10535 fn completion_active_inside_param_expansion_inside_dq() {
10536 let _g = crate::test_util::global_state_lock();
10539 let mut state = State::default();
10540 state
10541 .docs
10542 .insert("file:///t.zsh".into(), r#"echo "x ${P"#.into());
10543 let params = json!({
10544 "textDocument": { "uri": "file:///t.zsh" },
10545 "position": { "line": 0, "character": 11 },
10546 });
10547 let result = completion(&state, ¶ms);
10548 let items = result["items"].as_array().unwrap();
10549 assert!(
10550 !items.is_empty(),
10551 "expected non-empty items inside ${{...}} within dq",
10552 );
10553 }
10554
10555 #[test]
10556 fn completion_suppressed_inside_single_quoted_literal() {
10557 let _g = crate::test_util::global_state_lock();
10560 let mut state = State::default();
10561 state
10562 .docs
10563 .insert("file:///t.zsh".into(), "echo 'hello if".into());
10564 let params = json!({
10565 "textDocument": { "uri": "file:///t.zsh" },
10566 "position": { "line": 0, "character": 14 },
10567 });
10568 let result = completion(&state, ¶ms);
10569 let items = result["items"].as_array().unwrap();
10570 assert!(items.is_empty(), "expected 0 items inside sq literal");
10571 }
10572
10573 #[test]
10574 fn completion_suppressed_inside_comment() {
10575 let _g = crate::test_util::global_state_lock();
10577 let mut state = State::default();
10578 state
10579 .docs
10580 .insert("file:///t.zsh".into(), "# todo: if".into());
10581 let params = json!({
10582 "textDocument": { "uri": "file:///t.zsh" },
10583 "position": { "line": 0, "character": 10 },
10584 });
10585 let result = completion(&state, ¶ms);
10586 let items = result["items"].as_array().unwrap();
10587 assert!(items.is_empty(), "expected 0 items inside comment");
10588 }
10589
10590 #[test]
10591 fn completion_active_after_closing_double_quote() {
10592 let _g = crate::test_util::global_state_lock();
10596 let mut state = State::default();
10597 state
10598 .docs
10599 .insert("file:///t.zsh".into(), r#"echo "x" if"#.into());
10600 let params = json!({
10601 "textDocument": { "uri": "file:///t.zsh" },
10602 "position": { "line": 0, "character": 11 },
10603 });
10604 let result = completion(&state, ¶ms);
10605 let items = result["items"].as_array().unwrap();
10606 assert!(
10607 items.iter().any(|i| i["label"] == "if"),
10608 "expected `if` to surface after closed dq string"
10609 );
10610 }
10611
10612 #[test]
10615 fn completion_param_flags_inside_dollar_brace_paren() {
10616 let _g = crate::test_util::global_state_lock();
10622 let mut state = State::default();
10623 state.docs.insert("file:///t.zsh".into(), "echo ${(".into());
10624 let params = json!({
10625 "textDocument": { "uri": "file:///t.zsh" },
10626 "position": { "line": 0, "character": 8 },
10627 });
10628 let result = completion(&state, ¶ms);
10629 let items = result["items"].as_array().unwrap();
10630 for want in &["L", "U", "@", "#", "f", "F", "j", "s", "q"] {
10631 assert!(
10632 items.iter().any(|i| i["label"] == *want),
10633 "missing param flag `{}` in completion; got {:?}",
10634 want,
10635 items
10636 .iter()
10637 .map(|i| i["label"].as_str().unwrap_or("?"))
10638 .collect::<Vec<_>>(),
10639 );
10640 }
10641 assert!(
10643 !items
10644 .iter()
10645 .any(|i| i["label"] == "cd" || i["label"] == "if"),
10646 "param-flag context leaked normal completion: {:?}",
10647 items
10648 .iter()
10649 .take(20)
10650 .map(|i| i["label"].as_str().unwrap_or("?"))
10651 .collect::<Vec<_>>(),
10652 );
10653 }
10654
10655 #[test]
10656 fn completion_param_flags_after_partial_flag() {
10657 let _g = crate::test_util::global_state_lock();
10660 let mut state = State::default();
10661 state
10662 .docs
10663 .insert("file:///t.zsh".into(), "echo ${(b".into());
10664 let params = json!({
10665 "textDocument": { "uri": "file:///t.zsh" },
10666 "position": { "line": 0, "character": 9 },
10667 });
10668 let result = completion(&state, ¶ms);
10669 let items = result["items"].as_array().unwrap();
10670 assert!(
10671 items.len() >= 40,
10672 "expected full flag table (40+), got {}",
10673 items.len(),
10674 );
10675 }
10676
10677 #[test]
10678 fn completion_param_flags_inside_nested_dollar_brace() {
10679 let _g = crate::test_util::global_state_lock();
10683 let mut state = State::default();
10684 state
10685 .docs
10686 .insert("file:///t.zsh".into(), "echo ${${(".into());
10687 let params = json!({
10688 "textDocument": { "uri": "file:///t.zsh" },
10689 "position": { "line": 0, "character": 10 },
10690 });
10691 let result = completion(&state, ¶ms);
10692 let items = result["items"].as_array().unwrap();
10693 assert!(items.iter().any(|i| i["label"] == "L"), "missing `L`");
10694 }
10695
10696 #[test]
10697 fn completion_no_param_flag_when_paren_already_closed() {
10698 let _g = crate::test_util::global_state_lock();
10701 let mut state = State::default();
10702 state
10703 .docs
10704 .insert("file:///t.zsh".into(), "echo ${(b)var".into());
10705 let params = json!({
10706 "textDocument": { "uri": "file:///t.zsh" },
10707 "position": { "line": 0, "character": 13 },
10708 });
10709 let result = completion(&state, ¶ms);
10710 let items = result["items"].as_array().unwrap();
10711 let single_char_only = !items.is_empty()
10717 && items
10718 .iter()
10719 .all(|i| i["label"].as_str().unwrap_or("").chars().count() == 1);
10720 assert!(
10721 !single_char_only,
10722 "param-flag table leaked past closing `)`"
10723 );
10724 }
10725
10726 #[test]
10727 fn completion_glob_qualifier_after_star_paren() {
10728 let _g = crate::test_util::global_state_lock();
10731 let mut state = State::default();
10732 state.docs.insert("file:///t.zsh".into(), "ls *(".into());
10733 let params = json!({
10734 "textDocument": { "uri": "file:///t.zsh" },
10735 "position": { "line": 0, "character": 5 },
10736 });
10737 let result = completion(&state, ¶ms);
10738 let items = result["items"].as_array().unwrap();
10739 for want in &["/", ".", "@", "*", "r", "w", "x", "U", "G"] {
10740 assert!(
10741 items.iter().any(|i| i["label"] == *want),
10742 "missing glob qualifier `{}`; got {:?}",
10743 want,
10744 items
10745 .iter()
10746 .take(20)
10747 .map(|i| i["label"].as_str().unwrap_or("?"))
10748 .collect::<Vec<_>>(),
10749 );
10750 }
10751 assert!(
10752 !items
10753 .iter()
10754 .any(|i| i["label"] == "cd" || i["label"] == "if"),
10755 "glob-qualifier context leaked normal completion",
10756 );
10757 }
10758
10759 #[test]
10760 fn completion_glob_qualifier_after_question_mark() {
10761 let _g = crate::test_util::global_state_lock();
10764 let mut state = State::default();
10765 state.docs.insert("file:///t.zsh".into(), "ls ?(".into());
10766 let params = json!({
10767 "textDocument": { "uri": "file:///t.zsh" },
10768 "position": { "line": 0, "character": 5 },
10769 });
10770 let result = completion(&state, ¶ms);
10771 let items = result["items"].as_array().unwrap();
10772 assert!(items.iter().any(|i| i["label"] == "."));
10773 }
10774
10775 #[test]
10776 fn completion_no_glob_qualifier_for_plain_subshell() {
10777 let _g = crate::test_util::global_state_lock();
10781 let mut state = State::default();
10782 state.docs.insert("file:///t.zsh".into(), "echo (".into());
10783 let params = json!({
10784 "textDocument": { "uri": "file:///t.zsh" },
10785 "position": { "line": 0, "character": 6 },
10786 });
10787 let result = completion(&state, ¶ms);
10788 let items = result["items"].as_array().unwrap();
10789 let has_normal = items
10791 .iter()
10792 .any(|i| i["label"] == "cd" || i["label"] == "if");
10793 let single_char_only = !items.is_empty()
10794 && items
10795 .iter()
10796 .all(|i| i["label"].as_str().unwrap_or("").chars().count() == 1);
10797 assert!(
10798 has_normal || !single_char_only,
10799 "subshell `(` mis-triggered glob qualifier table"
10800 );
10801 }
10802
10803 #[test]
10804 fn completion_param_flag_table_has_50_entries() {
10805 assert!(
10809 PARAM_FLAG_DOCS.len() >= 49,
10810 "PARAM_FLAG_DOCS dropped below 49 entries: {}",
10811 PARAM_FLAG_DOCS.len()
10812 );
10813 }
10814
10815 #[test]
10818 fn completion_history_designator_after_bang_at_word_start() {
10819 let _g = crate::test_util::global_state_lock();
10820 let mut state = State::default();
10821 state.docs.insert("file:///t.zsh".into(), "!".into());
10822 let params = json!({
10823 "textDocument": { "uri": "file:///t.zsh" },
10824 "position": { "line": 0, "character": 1 },
10825 });
10826 let result = completion(&state, ¶ms);
10827 let items = result["items"].as_array().unwrap();
10828 for want in &["!", "$", "^", "*", "#"] {
10829 assert!(
10830 items.iter().any(|i| i["label"] == *want),
10831 "missing history designator `{}`; got {:?}",
10832 want,
10833 items
10834 .iter()
10835 .map(|i| i["label"].as_str().unwrap_or("?"))
10836 .collect::<Vec<_>>(),
10837 );
10838 }
10839 assert!(!items
10841 .iter()
10842 .any(|i| i["label"] == "cd" || i["label"] == "if"));
10843 }
10844
10845 #[test]
10846 fn completion_history_designator_after_bang_midline() {
10847 let _g = crate::test_util::global_state_lock();
10849 let mut state = State::default();
10850 state.docs.insert("file:///t.zsh".into(), "vim !".into());
10851 let params = json!({
10852 "textDocument": { "uri": "file:///t.zsh" },
10853 "position": { "line": 0, "character": 5 },
10854 });
10855 let result = completion(&state, ¶ms);
10856 let items = result["items"].as_array().unwrap();
10857 assert!(items.iter().any(|i| i["label"] == "$"));
10858 }
10859
10860 #[test]
10861 fn completion_no_history_designator_inside_arithmetic() {
10862 let _g = crate::test_util::global_state_lock();
10865 let mut state = State::default();
10866 state.docs.insert("file:///t.zsh".into(), "(( a !".into());
10867 let params = json!({
10868 "textDocument": { "uri": "file:///t.zsh" },
10869 "position": { "line": 0, "character": 6 },
10870 });
10871 let result = completion(&state, ¶ms);
10872 let items = result["items"].as_array().unwrap();
10873 assert!(
10877 !items.iter().any(|i| i["label"] == "?str?"),
10878 "history table leaked into `((…))` arithmetic context",
10879 );
10880 }
10881
10882 #[test]
10883 fn completion_no_history_designator_after_alnum() {
10884 let _g = crate::test_util::global_state_lock();
10886 let mut state = State::default();
10887 state.docs.insert("file:///t.zsh".into(), "foo!".into());
10888 let params = json!({
10889 "textDocument": { "uri": "file:///t.zsh" },
10890 "position": { "line": 0, "character": 4 },
10891 });
10892 let result = completion(&state, ¶ms);
10893 let items = result["items"].as_array().unwrap();
10894 assert!(
10895 !items.iter().any(|i| i["label"] == "?str?"),
10896 "history table fired after alnum-preceded `!`",
10897 );
10898 }
10899
10900 #[test]
10901 fn completion_param_modifier_after_colon_in_dollar_brace() {
10902 let _g = crate::test_util::global_state_lock();
10904 let mut state = State::default();
10905 state
10906 .docs
10907 .insert("file:///t.zsh".into(), "echo ${var:".into());
10908 let params = json!({
10909 "textDocument": { "uri": "file:///t.zsh" },
10910 "position": { "line": 0, "character": 11 },
10911 });
10912 let result = completion(&state, ¶ms);
10913 let items = result["items"].as_array().unwrap();
10914 for want in &["h", "t", "r", "e", "-", "=", "+", "?", "s", "gs", "q", "Q"] {
10915 assert!(
10916 items.iter().any(|i| i["label"] == *want),
10917 "missing modifier `{}`; got {:?}",
10918 want,
10919 items
10920 .iter()
10921 .map(|i| i["label"].as_str().unwrap_or("?"))
10922 .collect::<Vec<_>>(),
10923 );
10924 }
10925 }
10926
10927 #[test]
10928 fn completion_param_modifier_after_partial_modifier() {
10929 let _g = crate::test_util::global_state_lock();
10932 let mut state = State::default();
10933 state
10934 .docs
10935 .insert("file:///t.zsh".into(), "echo ${var:h".into());
10936 let params = json!({
10937 "textDocument": { "uri": "file:///t.zsh" },
10938 "position": { "line": 0, "character": 12 },
10939 });
10940 let result = completion(&state, ¶ms);
10941 let items = result["items"].as_array().unwrap();
10942 assert!(
10943 items.len() >= 25,
10944 "expected full modifier table; got {}",
10945 items.len()
10946 );
10947 }
10948
10949 #[test]
10950 fn completion_param_modifier_after_history_bang_colon() {
10951 let _g = crate::test_util::global_state_lock();
10953 let mut state = State::default();
10954 state.docs.insert("file:///t.zsh".into(), "vim !!:".into());
10955 let params = json!({
10956 "textDocument": { "uri": "file:///t.zsh" },
10957 "position": { "line": 0, "character": 7 },
10958 });
10959 let result = completion(&state, ¶ms);
10960 let items = result["items"].as_array().unwrap();
10961 assert!(items.iter().any(|i| i["label"] == "h"));
10962 assert!(items.iter().any(|i| i["label"] == "t"));
10963 }
10964
10965 #[test]
10966 fn completion_no_param_modifier_outside_dollar_brace() {
10967 let _g = crate::test_util::global_state_lock();
10970 let mut state = State::default();
10971 state.docs.insert("file:///t.zsh".into(), "foo:".into());
10972 let params = json!({
10973 "textDocument": { "uri": "file:///t.zsh" },
10974 "position": { "line": 0, "character": 4 },
10975 });
10976 let result = completion(&state, ¶ms);
10977 let items = result["items"].as_array().unwrap();
10978 let has_normal = items
10980 .iter()
10981 .any(|i| i["label"] == "cd" || i["label"] == "if");
10982 let modifier_only = !items.is_empty()
10983 && items.iter().all(|i| {
10984 let l = i["label"].as_str().unwrap_or("");
10985 l.chars().count() <= 2
10986 });
10987 assert!(
10988 has_normal || !modifier_only,
10989 "bare `:` mis-triggered modifier table",
10990 );
10991 }
10992
10993 #[test]
10994 fn completion_history_designator_table_has_9_entries() {
10995 assert!(
10996 HISTORY_DESIGNATOR_DOCS.len() >= 9,
10997 "HISTORY_DESIGNATOR_DOCS dropped below 9: {}",
10998 HISTORY_DESIGNATOR_DOCS.len()
10999 );
11000 }
11001
11002 fn complete_at(input: &str, col: usize) -> Vec<Value> {
11005 let _g = crate::test_util::global_state_lock();
11006 let mut state = State::default();
11007 state.docs.insert("file:///t.zsh".into(), input.into());
11008 let params = json!({
11009 "textDocument": { "uri": "file:///t.zsh" },
11010 "position": { "line": 0, "character": col },
11011 });
11012 completion(&state, ¶ms)["items"]
11013 .as_array()
11014 .cloned()
11015 .unwrap_or_default()
11016 }
11017
11018 #[test]
11019 fn completion_setopt_surfaces_options_only() {
11020 let items = complete_at("setopt extend", 13);
11021 assert!(
11023 items.iter().any(|i| i["label"] == "extended_glob"
11024 || i["label"] == "extendedglob"
11025 || i["label"] == "EXTENDED_GLOB"),
11026 "no extended_glob variant"
11027 );
11028 assert!(!items
11029 .iter()
11030 .any(|i| i["label"] == "cd" || i["label"] == "if"));
11031 }
11032
11033 #[test]
11034 fn completion_unsetopt_surfaces_options_only() {
11035 let items = complete_at("unsetopt nul", 12);
11036 assert!(items.iter().any(|i| i["label"]
11037 .as_str()
11038 .unwrap_or("")
11039 .to_lowercase()
11040 .contains("null")));
11041 assert!(!items.iter().any(|i| i["label"] == "if"));
11042 }
11043
11044 #[test]
11045 fn completion_set_dash_o_surfaces_options() {
11046 let items = complete_at("set -o errex", 12);
11047 assert!(items.iter().any(|i| i["label"]
11048 .as_str()
11049 .unwrap_or("")
11050 .to_lowercase()
11051 .contains("err")));
11052 }
11053
11054 #[test]
11055 fn completion_kill_dash_surfaces_signals() {
11056 let items = complete_at("kill -", 6);
11057 for want in &["HUP", "INT", "TERM", "KILL", "USR1"] {
11058 assert!(
11059 items.iter().any(|i| i["label"] == *want),
11060 "missing signal `{}`",
11061 want
11062 );
11063 }
11064 }
11065
11066 #[test]
11067 fn completion_trap_surfaces_signals() {
11068 let items = complete_at("trap 'cmd' ", 11);
11069 for want in &["INT", "TERM", "EXIT", "ZERR", "DEBUG"] {
11070 assert!(
11071 items.iter().any(|i| i["label"] == *want),
11072 "missing signal `{}`",
11073 want
11074 );
11075 }
11076 }
11077
11078 #[test]
11079 fn completion_zmodload_surfaces_modules() {
11080 let items = complete_at("zmodload ", 9);
11081 for want in &[
11082 "zsh/zle",
11083 "zsh/datetime",
11084 "zsh/system",
11085 "zsh/parameter",
11086 "zsh/mathfunc",
11087 ] {
11088 assert!(
11089 items.iter().any(|i| i["label"] == *want),
11090 "missing module `{}`",
11091 want
11092 );
11093 }
11094 }
11095
11096 #[test]
11097 fn completion_bindkey_dash_M_surfaces_keymaps() {
11098 let items = complete_at("bindkey -M ", 11);
11099 for want in &["emacs", "vicmd", "viins", "viopp"] {
11100 assert!(
11101 items.iter().any(|i| i["label"] == *want),
11102 "missing keymap `{}`",
11103 want
11104 );
11105 }
11106 }
11107
11108 #[test]
11109 fn completion_bindkey_bare_surfaces_widgets() {
11110 let items = complete_at("bindkey '^A' acce", 17);
11113 assert!(items.iter().any(|i| i["label"] == "accept-line"));
11114 assert!(items.iter().any(|i| i["label"] == "accept-and-hold"));
11115 }
11116
11117 #[test]
11118 fn completion_zle_surfaces_widgets() {
11119 let items = complete_at("zle for", 7);
11120 assert!(items.iter().any(|i| i["label"] == "forward-char"));
11121 assert!(items.iter().any(|i| i["label"] == "forward-word"));
11122 }
11123
11124 #[test]
11125 fn completion_typeset_dash_surfaces_flags() {
11126 let items = complete_at("typeset -", 9);
11127 for want in &["-a", "-A", "-i", "-g", "-r", "-x", "-U", "-f"] {
11128 assert!(
11129 items.iter().any(|i| i["label"] == *want),
11130 "missing flag `{}`",
11131 want
11132 );
11133 }
11134 }
11135
11136 #[test]
11137 fn completion_typeset_no_flag_falls_through() {
11138 let items = complete_at("typeset FOO", 11);
11140 assert!(!items.iter().any(|i| i["label"] == "-a"));
11142 }
11143
11144 #[test]
11145 fn completion_zstyle_surfaces_contexts() {
11146 let items = complete_at("zstyle ", 7);
11147 assert!(items.iter().any(|i| i["label"] == ":completion:*"));
11148 assert!(items.iter().any(|i| i["label"] == ":vcs_info:*"));
11149 }
11150
11151 #[test]
11152 fn completion_compdef_surfaces_compsys_fns() {
11153 let items = complete_at("compdef ", 8);
11154 assert!(items
11156 .iter()
11157 .any(|i| i["label"].as_str().unwrap_or("").starts_with('_')));
11158 }
11159
11160 #[test]
11161 fn completion_inside_double_bracket_surfaces_test_ops() {
11162 let items = complete_at("[[ -f /tmp/x && -", 17);
11163 for want in &["-f", "-d", "-e", "-z", "-n", "=~"] {
11164 assert!(
11165 items.iter().any(|i| i["label"] == *want),
11166 "missing test op `{}`",
11167 want
11168 );
11169 }
11170 }
11171
11172 #[test]
11173 fn completion_inside_double_paren_surfaces_math_fns() {
11174 let items = complete_at("(( x = sq", 9);
11175 for want in &["sqrt", "sin", "cos", "log", "exp"] {
11176 assert!(
11177 items.iter().any(|i| i["label"] == *want),
11178 "missing math fn `{}`",
11179 want
11180 );
11181 }
11182 }
11183
11184 #[test]
11185 fn completion_inside_dollar_double_paren_surfaces_math_fns() {
11186 let items = complete_at("echo $(( ab", 11);
11187 assert!(items.iter().any(|i| i["label"] == "abs"));
11188 }
11189
11190 #[test]
11191 fn completion_inside_pattern_modifier_paren() {
11192 let items = complete_at("ls *.(#", 7);
11193 for want in &["i", "l", "b", "m", "a", "s", "e"] {
11194 assert!(
11195 items.iter().any(|i| i["label"] == *want),
11196 "missing pattern mod `{}` — got {:?}",
11197 want,
11198 items
11199 .iter()
11200 .take(20)
11201 .map(|i| i["label"].as_str().unwrap_or("?"))
11202 .collect::<Vec<_>>(),
11203 );
11204 }
11205 }
11206
11207 #[test]
11208 fn completion_inside_subscript_flag_paren() {
11209 let items = complete_at("echo ${arr[(", 12);
11210 for want in &["i", "I", "r", "R", "e", "n"] {
11211 assert!(
11212 items.iter().any(|i| i["label"] == *want),
11213 "missing subscript flag `{}`",
11214 want
11215 );
11216 }
11217 }
11218
11219 #[test]
11222 fn completion_signal_table_has_30_entries() {
11223 assert!(
11224 SIGNAL_NAMES.len() >= 30,
11225 "SIGNAL_NAMES: {}",
11226 SIGNAL_NAMES.len()
11227 );
11228 }
11229
11230 #[test]
11231 fn completion_module_table_has_30_entries() {
11232 assert!(
11233 ZSH_MODULE_NAMES.len() >= 30,
11234 "ZSH_MODULE_NAMES: {}",
11235 ZSH_MODULE_NAMES.len()
11236 );
11237 }
11238
11239 #[test]
11240 fn completion_keymap_table_has_10_entries() {
11241 assert!(
11242 KEYMAP_NAMES.len() >= 10,
11243 "KEYMAP_NAMES: {}",
11244 KEYMAP_NAMES.len()
11245 );
11246 }
11247
11248 #[test]
11249 fn completion_widget_table_has_100_entries() {
11250 assert!(
11251 ZLE_WIDGET_NAMES.len() >= 100,
11252 "ZLE_WIDGET_NAMES: {}",
11253 ZLE_WIDGET_NAMES.len()
11254 );
11255 }
11256
11257 #[test]
11258 fn completion_typeset_flag_table_has_20_entries() {
11259 assert!(
11260 TYPESET_FLAGS.len() >= 20,
11261 "TYPESET_FLAGS: {}",
11262 TYPESET_FLAGS.len()
11263 );
11264 }
11265
11266 #[test]
11267 fn completion_test_op_table_has_30_entries() {
11268 assert!(
11269 TEST_OPERATORS.len() >= 30,
11270 "TEST_OPERATORS: {}",
11271 TEST_OPERATORS.len()
11272 );
11273 }
11274
11275 #[test]
11276 fn completion_math_fn_table_has_40_entries() {
11277 assert!(
11278 MATH_FUNCTIONS.len() >= 40,
11279 "MATH_FUNCTIONS: {}",
11280 MATH_FUNCTIONS.len()
11281 );
11282 }
11283
11284 #[test]
11285 fn completion_zstyle_context_table_has_15_entries() {
11286 assert!(
11287 ZSTYLE_CONTEXTS.len() >= 15,
11288 "ZSTYLE_CONTEXTS: {}",
11289 ZSTYLE_CONTEXTS.len()
11290 );
11291 }
11292
11293 #[test]
11294 fn completion_pattern_modifier_table_has_10_entries() {
11295 assert!(
11296 PATTERN_MODIFIERS.len() >= 10,
11297 "PATTERN_MODIFIERS: {}",
11298 PATTERN_MODIFIERS.len()
11299 );
11300 }
11301
11302 #[test]
11303 fn completion_subscript_flag_table_has_10_entries() {
11304 assert!(
11305 SUBSCRIPT_FLAGS.len() >= 10,
11306 "SUBSCRIPT_FLAGS: {}",
11307 SUBSCRIPT_FLAGS.len()
11308 );
11309 }
11310
11311 #[test]
11312 fn completion_keywords_includes_canonical_reswds() {
11313 let _g = crate::test_util::global_state_lock();
11324 let mut state = State::default();
11327 state.docs.insert("file:///t.zsh".into(), "e".into());
11328 let params = json!({
11329 "textDocument": { "uri": "file:///t.zsh" },
11330 "position": { "line": 0, "character": 1 },
11331 });
11332 let items = completion(&state, ¶ms)["items"]
11333 .as_array()
11334 .unwrap()
11335 .clone();
11336 let labels: Vec<&str> = items
11337 .iter()
11338 .map(|i| i["label"].as_str().unwrap_or(""))
11339 .collect();
11340 assert!(
11341 labels.iter().any(|l| *l == "end"),
11342 "RESWDS `end` not surfaced — labels: {:?}",
11343 labels
11344 .iter()
11345 .filter(|l| l.starts_with('e'))
11346 .take(10)
11347 .collect::<Vec<_>>(),
11348 );
11349 }
11350
11351 #[test]
11352 fn completion_builtin_flag_print_dash_tab() {
11353 let _g = crate::test_util::global_state_lock();
11358 let mut state = State::default();
11359 state.docs.insert("file:///t.zsh".into(), "print -".into());
11360 let params = json!({
11361 "textDocument": { "uri": "file:///t.zsh" },
11362 "position": { "line": 0, "character": 7 },
11363 });
11364 let items = completion(&state, ¶ms)["items"]
11365 .as_array()
11366 .unwrap()
11367 .clone();
11368 let labels: Vec<&str> = items
11369 .iter()
11370 .map(|i| i["label"].as_str().unwrap_or(""))
11371 .collect();
11372 for want in &[
11373 "-a", "-b", "-c", "-n", "-N", "-o", "-O", "-P", "-r", "-R", "-z",
11374 ] {
11375 assert!(
11376 labels.iter().any(|l| l == want),
11377 "missing `print -` flag `{}` — got {:?}",
11378 want,
11379 labels.iter().take(20).collect::<Vec<_>>(),
11380 );
11381 }
11382 assert!(
11383 items.len() >= 15,
11384 "expected ≥15 print flags, got {}",
11385 items.len(),
11386 );
11387 }
11388
11389 #[test]
11390 fn completion_dollar_prefix_matches_canonical_specials() {
11391 let _g = crate::test_util::global_state_lock();
11399 let mut state = State::default();
11400 state.docs.insert("file:///t.zsh".into(), "$HIST".into());
11401 let params = json!({
11402 "textDocument": { "uri": "file:///t.zsh" },
11403 "position": { "line": 0, "character": 5 },
11404 });
11405 let result = completion(&state, ¶ms);
11406 let items = result["items"].as_array().unwrap();
11407 let labels: Vec<&str> = items
11408 .iter()
11409 .map(|i| i["label"].as_str().unwrap_or(""))
11410 .collect();
11411 for want in &["$HISTCMD", "$HISTNO", "$HISTCHARS"] {
11412 assert!(
11413 labels.iter().any(|l| l == want),
11414 "missing `{}` for `$HIST` prefix",
11415 want,
11416 );
11417 }
11418 }
11419
11420 #[test]
11421 fn completion_special_vars_includes_full_canonical_set() {
11422 let _g = crate::test_util::global_state_lock();
11428 for prefix in &["PS", "PROMPT", "psvar"] {
11429 let mut state = State::default();
11430 state.docs.insert("file:///t.zsh".into(), (*prefix).into());
11431 let params = json!({
11432 "textDocument": { "uri": "file:///t.zsh" },
11433 "position": { "line": 0, "character": prefix.len() },
11434 });
11435 let result = completion(&state, ¶ms);
11436 let items = result["items"].as_array().unwrap();
11437 let labels: Vec<&str> = items
11438 .iter()
11439 .map(|i| i["label"].as_str().unwrap_or(""))
11440 .collect();
11441 match *prefix {
11442 "PS" => {
11443 for want in &["PS1", "PS2", "PS3", "PS4"] {
11444 assert!(
11445 labels.iter().any(|l| l == want),
11446 "missing `{}` for prefix `{}`",
11447 want,
11448 prefix,
11449 );
11450 }
11451 }
11452 "PROMPT" => {
11453 for want in &["PROMPT", "PROMPT2", "PROMPT3", "PROMPT4"] {
11454 assert!(
11455 labels.iter().any(|l| l == want),
11456 "missing `{}` for prefix `{}`",
11457 want,
11458 prefix,
11459 );
11460 }
11461 }
11462 "psvar" => {
11463 assert!(labels.iter().any(|l| *l == "psvar"), "missing `psvar`",);
11464 }
11465 _ => {}
11466 }
11467 }
11468 }
11469
11470 #[test]
11471 fn completion_param_modifier_table_has_30_entries() {
11472 assert!(
11473 PARAM_MODIFIER_DOCS.len() >= 30,
11474 "PARAM_MODIFIER_DOCS dropped below 30: {}",
11475 PARAM_MODIFIER_DOCS.len()
11476 );
11477 }
11478
11479 #[test]
11480 fn completion_glob_qualifier_table_has_30_entries() {
11481 assert!(
11485 GLOB_QUALIFIER_DOCS.len() >= 30,
11486 "GLOB_QUALIFIER_DOCS dropped below 30 entries: {}",
11487 GLOB_QUALIFIER_DOCS.len()
11488 );
11489 }
11490
11491 #[test]
11492 fn completion_snippet_table_has_60_plus_entries() {
11493 assert!(
11498 SNIPPETS.len() >= 60,
11499 "snippet table dropped below 60 entries: {}",
11500 SNIPPETS.len()
11501 );
11502 }
11503
11504 #[test]
11507 fn folding_ranges_finds_brace_and_do_blocks() {
11508 let _g = crate::test_util::global_state_lock();
11509 let mut state = State::default();
11510 state.docs.insert(
11511 "file:///t.zsh".into(),
11512 "function f {\n echo\n}\nfor x in 1 2 3; do\n print $x\ndone\n".into(),
11513 );
11514 let params = json!({ "textDocument": { "uri": "file:///t.zsh" } });
11515 let result = folding_ranges(&state, ¶ms);
11516 let arr = result.as_array().unwrap();
11517 assert!(
11519 arr.iter().any(|r| r["startLine"] == 0 && r["endLine"] == 2),
11520 "missing brace fold: {:?}",
11521 arr
11522 );
11523 assert!(
11524 arr.iter().any(|r| r["startLine"] == 3 && r["endLine"] == 5),
11525 "missing for/do fold: {:?}",
11526 arr
11527 );
11528 }
11529
11530 #[test]
11533 fn references_returns_call_sites() {
11534 let _g = crate::test_util::global_state_lock();
11535 let mut state = State::default();
11536 state.docs.insert(
11537 "file:///t.zsh".into(),
11538 "function greet { echo hi }\ngreet\ngreet world\n".into(),
11539 );
11540 let params = json!({
11541 "textDocument": { "uri": "file:///t.zsh" },
11542 "position": { "line": 0, "character": 9 }, "context": { "includeDeclaration": true },
11544 });
11545 let refs = references(&state, ¶ms);
11546 let arr = refs.as_array().unwrap();
11547 assert_eq!(arr.len(), 3, "expected 3 refs, got: {:?}", arr);
11549 }
11550
11551 #[test]
11552 fn references_follows_source_chain_outside_workspace() {
11553 let _g = crate::test_util::global_state_lock();
11558 let mut state = State::default();
11559 let tmp = std::env::temp_dir().join(format!(
11562 "zshrs-ref-source-chain-{}",
11563 std::time::SystemTime::now()
11564 .duration_since(std::time::UNIX_EPOCH)
11565 .map(|d| d.as_nanos())
11566 .unwrap_or(0),
11567 ));
11568 std::fs::create_dir_all(&tmp).unwrap();
11569 let sourced = tmp.join("helpers.zsh");
11570 std::fs::write(&sourced, "greet world\ngreet again\n").unwrap();
11571 let active_text = format!(
11572 "function greet {{ echo hi }}\nsource {}\n",
11573 sourced.display()
11574 );
11575 state.docs.insert("file:///t.zsh".into(), active_text);
11576 let params = json!({
11577 "textDocument": { "uri": "file:///t.zsh" },
11578 "position": { "line": 0, "character": 9 }, "context": { "includeDeclaration": true },
11580 });
11581 let refs = references(&state, ¶ms);
11582 let arr = refs.as_array().unwrap();
11583 assert!(
11585 arr.len() >= 3,
11586 "source-chain following missed refs, got {}: {:?}",
11587 arr.len(),
11588 arr,
11589 );
11590 let sourced_uri = format!("file://{}", sourced.canonicalize().unwrap().display());
11592 assert!(
11593 arr.iter()
11594 .any(|r| r["uri"].as_str() == Some(sourced_uri.as_str())),
11595 "no ref pointing at sourced file `{}`: {:?}",
11596 sourced_uri,
11597 arr,
11598 );
11599 let _ = std::fs::remove_dir_all(&tmp);
11600 }
11601
11602 #[test]
11605 fn line_starts_comment_before_shebang() {
11606 let line = "#!/usr/bin/env zsh";
11609 let pos = line.find("env").unwrap();
11610 assert!(line_starts_comment_before(line, pos));
11611 }
11612
11613 #[test]
11614 fn line_starts_comment_before_inline() {
11615 let line = "echo hi; # call cd later";
11617 let pos = line.find("cd").unwrap();
11618 assert!(line_starts_comment_before(line, pos));
11619 }
11620
11621 #[test]
11622 fn line_starts_comment_before_string_with_hash_is_not_a_comment() {
11623 let line = r#"echo "x #y"; cd"#;
11626 let pos = line.rfind("cd").unwrap();
11627 assert!(
11628 !line_starts_comment_before(line, pos),
11629 "code after a string containing `#` must still be code"
11630 );
11631 }
11632
11633 #[test]
11634 fn line_starts_comment_before_single_quote_with_hash() {
11635 let line = "echo 'x #y'; cd";
11637 let pos = line.rfind("cd").unwrap();
11638 assert!(!line_starts_comment_before(line, pos));
11639 }
11640
11641 #[test]
11642 fn line_starts_comment_before_backtick_with_hash() {
11643 let line = "`echo #foo`; cd";
11649 let pos = line.rfind("cd").unwrap();
11650 assert!(!line_starts_comment_before(line, pos));
11651 }
11652
11653 #[test]
11654 fn line_starts_comment_negative_at_start() {
11655 let line = "cd /tmp";
11656 assert!(!line_starts_comment_before(line, 0));
11657 }
11658
11659 #[test]
11662 fn hover_on_shebang_env_is_suppressed() {
11663 let _g = crate::test_util::global_state_lock();
11664 let mut state = State::default();
11665 state.docs.insert(
11666 "file:///t.zsh".into(),
11667 "#!/usr/bin/env zsh\necho hi\n".into(),
11668 );
11669 let params = json!({
11672 "textDocument": { "uri": "file:///t.zsh" },
11673 "position": { "line": 0, "character": 12 },
11674 });
11675 let h = hover(&state, ¶ms);
11676 assert!(h.is_null(), "hover on shebang `env` must be null, got: {h}");
11677 }
11678
11679 #[test]
11680 fn hover_on_builtin_inside_comment_is_suppressed() {
11681 let _g = crate::test_util::global_state_lock();
11682 let mut state = State::default();
11683 state
11684 .docs
11685 .insert("file:///t.zsh".into(), "echo hi # call cd later\n".into());
11686 let cd_pos = "echo hi # call ".len();
11689 let params = json!({
11690 "textDocument": { "uri": "file:///t.zsh" },
11691 "position": { "line": 0, "character": cd_pos },
11692 });
11693 let h = hover(&state, ¶ms);
11694 assert!(h.is_null(), "comment-text hover must be null, got: {h}");
11695 }
11696
11697 #[test]
11698 fn hover_on_shebang_with_module_doc_returns_module_card() {
11699 let _g = crate::test_util::global_state_lock();
11704 let mut state = State::default();
11705 state.docs.insert(
11706 "file:///lib.zsh".into(),
11707 "#!/usr/bin/env zsh\n\
11708 ## libfoo.zsh — utility helpers.\n\
11709 ## Provides foo / bar / baz.\n\
11710 function foo() {}\n"
11711 .into(),
11712 );
11713 let params = json!({
11716 "textDocument": { "uri": "file:///lib.zsh" },
11717 "position": { "line": 0, "character": 12 },
11718 });
11719 let h = hover(&state, ¶ms);
11720 assert!(!h.is_null(), "shebang+##block hover should return card");
11721 let body = h["contents"]["value"].as_str().unwrap_or("");
11722 assert!(body.contains("libfoo.zsh"), "got {body:?}");
11723 assert!(body.contains("zsh module"), "got {body:?}");
11724 }
11725
11726 #[test]
11727 fn hover_on_source_path_uri_cached_returns_target_module_doc() {
11728 let _g = crate::test_util::global_state_lock();
11732 let mut state = State::default();
11733 state.docs.insert(
11734 "file:///proj/main.zsh".into(),
11735 "source ./helpers.zsh\nhelpers_init\n".into(),
11736 );
11737 state.docs.insert(
11738 "file:///proj/helpers.zsh".into(),
11739 "## helpers.zsh — shared bootstrap.\n\
11740 function helpers_init() {}\n"
11741 .into(),
11742 );
11743 let pos = "source ".len() + 2; let params = json!({
11746 "textDocument": { "uri": "file:///proj/main.zsh" },
11747 "position": { "line": 0, "character": pos },
11748 });
11749 let h = hover(&state, ¶ms);
11750 assert!(
11751 !h.is_null(),
11752 "source-path hover should return target's module doc"
11753 );
11754 let body = h["contents"]["value"].as_str().unwrap_or("");
11755 assert!(body.contains("helpers.zsh"), "got {body:?}");
11756 assert!(body.contains("shared bootstrap"), "got {body:?}");
11757 }
11758
11759 #[test]
11760 fn hover_on_dot_source_path_resolves_same_way() {
11761 let _g = crate::test_util::global_state_lock();
11763 let mut state = State::default();
11764 state
11765 .docs
11766 .insert("file:///proj/main.zsh".into(), ". ./helpers.zsh\n".into());
11767 state.docs.insert(
11768 "file:///proj/helpers.zsh".into(),
11769 "## dot-sourced helpers.\nfunction h() {}\n".into(),
11770 );
11771 let pos = ". ".len() + 2;
11772 let params = json!({
11773 "textDocument": { "uri": "file:///proj/main.zsh" },
11774 "position": { "line": 0, "character": pos },
11775 });
11776 let h = hover(&state, ¶ms);
11777 assert!(!h.is_null(), ". PATH hover should resolve too");
11778 let body = h["contents"]["value"].as_str().unwrap_or("");
11779 assert!(body.contains("dot-sourced helpers"), "got {body:?}");
11780 }
11781
11782 #[test]
11783 fn hover_on_source_path_without_target_doc_returns_null() {
11784 let _g = crate::test_util::global_state_lock();
11787 let mut state = State::default();
11788 state.docs.insert(
11789 "file:///t.zsh".into(),
11790 "source /nonexistent/path/that/does/not/exist.zsh\n".into(),
11791 );
11792 let pos = "source ".len() + 5;
11793 let params = json!({
11794 "textDocument": { "uri": "file:///t.zsh" },
11795 "position": { "line": 0, "character": pos },
11796 });
11797 let h = hover(&state, ¶ms);
11798 assert!(h.is_null(), "missing source target must not fabricate doc");
11799 }
11800
11801 #[test]
11802 fn hover_on_user_function_with_doc_returns_user_card() {
11803 let _g = crate::test_util::global_state_lock();
11808 let mut state = State::default();
11809 state.docs.insert(
11810 "file:///t.zsh".into(),
11811 "## Sum two numbers and print the result.\n\
11812 function my_sum_helper() { print $(( $1 + $2 )) }\n\
11813 my_sum_helper 1 2\n"
11814 .into(),
11815 );
11816 let params = json!({
11818 "textDocument": { "uri": "file:///t.zsh" },
11819 "position": { "line": 2, "character": 0 },
11820 });
11821 let h = hover(&state, ¶ms);
11822 assert!(!h.is_null(), "user-fn hover should fire");
11823 let body = h["contents"]["value"].as_str().unwrap_or("");
11824 assert!(body.contains("Sum two numbers"), "got {body:?}");
11825 assert!(body.contains("user-defined function"), "got {body:?}");
11826 }
11827
11828 #[test]
11829 fn hover_on_real_builtin_outside_comment_still_works() {
11830 let _g = crate::test_util::global_state_lock();
11831 let mut state = State::default();
11832 state
11833 .docs
11834 .insert("file:///t.zsh".into(), "cd /tmp\n".into());
11835 let params = json!({
11836 "textDocument": { "uri": "file:///t.zsh" },
11837 "position": { "line": 0, "character": 0 },
11838 });
11839 let h = hover(&state, ¶ms);
11840 assert!(!h.is_null(), "real builtin must still hover");
11841 }
11842
11843 #[test]
11848 fn position_inside_double_quoted_string_detected() {
11849 let line = "echo \"cd to dir\"";
11853 let cd_start = line.find("cd").unwrap();
11854 let cd_end = cd_start + 2;
11855 assert!(position_inside_string_literal(line, cd_start, cd_end));
11856 }
11857
11858 #[test]
11862 fn position_inside_single_quoted_string_detected() {
11863 let line = "echo 'cd to dir'";
11864 let cd_start = line.find("cd").unwrap();
11865 let cd_end = cd_start + 2;
11866 assert!(position_inside_string_literal(line, cd_start, cd_end));
11867 }
11868
11869 #[test]
11874 fn position_inside_backtick_string_detected() {
11875 let line = "echo `cd to dir`";
11876 let cd_start = line.find("cd").unwrap();
11877 let cd_end = cd_start + 2;
11878 assert!(position_inside_string_literal(line, cd_start, cd_end));
11879 }
11880
11881 #[test]
11885 fn position_inside_parameter_expansion_is_code() {
11886 let line = "echo \"${HOME}/x\"";
11888 let home_start = line.find("HOME").unwrap();
11889 let home_end = home_start + 4;
11890 assert!(
11891 !position_inside_string_literal(line, home_start, home_end),
11892 "`${{HOME}}` inside double-quotes is code, not string text"
11893 );
11894 }
11895
11896 #[test]
11898 fn position_outside_string_is_code() {
11899 let line = "cd /tmp";
11900 assert!(!position_inside_string_literal(line, 0, 2));
11901 }
11902
11903 #[test]
11905 fn position_after_closing_quote_is_code() {
11906 let line = "echo \"foo\" cd";
11908 let cd_start = line.find(" cd").unwrap() + 1;
11909 let cd_end = cd_start + 2;
11910 assert!(!position_inside_string_literal(line, cd_start, cd_end));
11911 }
11912
11913 #[test]
11915 fn classify_comment_outranks_string() {
11916 let line = "# echo \"cd\"";
11919 let cd_start = line.find("cd").unwrap();
11920 let cd_end = cd_start + 2;
11921 assert_eq!(
11922 classify_hover_position(line, cd_start, cd_end),
11923 HoverGate::Comment
11924 );
11925 }
11926
11927 #[test]
11929 fn classify_string_literal() {
11930 let line = "echo \"cd to dir\"";
11931 let cd_start = line.find("cd").unwrap();
11932 let cd_end = cd_start + 2;
11933 assert_eq!(
11934 classify_hover_position(line, cd_start, cd_end),
11935 HoverGate::StringLiteral
11936 );
11937 }
11938
11939 #[test]
11941 fn classify_bare_code() {
11942 let line = "cd /tmp";
11943 assert_eq!(classify_hover_position(line, 0, 2), HoverGate::Code);
11944 }
11945
11946 #[test]
11953 fn rename_strips_colon_colon_qualifier() {
11954 let _g = crate::test_util::global_state_lock();
11955 let mut state = State::default();
11956 state.docs.insert(
11957 "file:///t.zsh".into(),
11958 "function handle { echo hi }\nhandle\nhandle x\n".into(),
11959 );
11960 let params = json!({
11961 "textDocument": { "uri": "file:///t.zsh" },
11962 "position": { "line": 0, "character": 9 }, "newName": "Demo::handle2",
11964 });
11965 let r = rename(&state, ¶ms);
11966 let changes = r["changes"].as_object().expect("changes");
11967 let edits = changes["file:///t.zsh"].as_array().expect("edits");
11968 assert!(
11969 !edits.is_empty(),
11970 "expected at least 1 edit, got: {edits:?}"
11971 );
11972 for e in edits {
11973 assert_eq!(
11974 e["newText"],
11975 json!("handle2"),
11976 "qualifier must be stripped; got: {e:?}"
11977 );
11978 }
11979 }
11980
11981 #[test]
11984 fn rename_passes_through_bare_new_name() {
11985 let _g = crate::test_util::global_state_lock();
11986 let mut state = State::default();
11987 state.docs.insert(
11988 "file:///t.zsh".into(),
11989 "function handle { echo hi }\nhandle\n".into(),
11990 );
11991 let params = json!({
11992 "textDocument": { "uri": "file:///t.zsh" },
11993 "position": { "line": 0, "character": 9 },
11994 "newName": "handle2",
11995 });
11996 let r = rename(&state, ¶ms);
11997 let edits = r["changes"]["file:///t.zsh"].as_array().expect("edits");
11998 for e in edits {
11999 assert_eq!(e["newText"], json!("handle2"));
12000 }
12001 }
12002
12003 #[test]
12006 fn rename_function_crosses_files() {
12007 let _g = crate::test_util::global_state_lock();
12010 let mut state = State::default();
12011 state.docs.insert(
12012 "file:///lib.zsh".into(),
12013 "function greet { echo hi }\n".into(),
12014 );
12015 state.docs.insert(
12016 "file:///rc.zsh".into(),
12017 "source lib.zsh\ngreet\ngreet world\n".into(),
12018 );
12019 let params = json!({
12020 "textDocument": { "uri": "file:///lib.zsh" },
12021 "position": { "line": 0, "character": 9 }, "context": { "includeDeclaration": true },
12023 "newName": "salute",
12024 });
12025 let r = rename(&state, ¶ms);
12026 let changes = r["changes"].as_object().expect("rename has changes map");
12027 assert!(
12028 changes.contains_key("file:///lib.zsh"),
12029 "lib.zsh edited: {changes:?}"
12030 );
12031 assert!(
12032 changes.contains_key("file:///rc.zsh"),
12033 "rc.zsh edited: {changes:?}"
12034 );
12035 let lib_edits = changes["file:///lib.zsh"].as_array().unwrap();
12037 let rc_edits = changes["file:///rc.zsh"].as_array().unwrap();
12038 assert_eq!(lib_edits.len(), 1);
12039 assert_eq!(rc_edits.len(), 2);
12040 for e in lib_edits.iter().chain(rc_edits.iter()) {
12041 assert_eq!(e["newText"], "salute");
12042 }
12043 }
12044
12045 #[test]
12046 fn rename_rejects_empty_new_name() {
12047 let _g = crate::test_util::global_state_lock();
12048 let mut state = State::default();
12049 state.docs.insert(
12050 "file:///t.zsh".into(),
12051 "function greet { echo hi }\n".into(),
12052 );
12053 let params = json!({
12054 "textDocument": { "uri": "file:///t.zsh" },
12055 "position": { "line": 0, "character": 9 },
12056 "context": { "includeDeclaration": true },
12057 "newName": "",
12058 });
12059 let r = rename(&state, ¶ms);
12060 assert!(r.is_null(), "empty new_name must be rejected");
12061 }
12062
12063 #[test]
12064 fn workspace_walk_picks_up_unopened_zsh_files() {
12065 let _g = crate::test_util::global_state_lock();
12069 let tmp = std::env::temp_dir().join(format!(
12070 "zshrs-workspace-test-{}-{}",
12071 std::process::id(),
12072 std::time::SystemTime::now()
12073 .duration_since(std::time::UNIX_EPOCH)
12074 .map(|d| d.as_nanos())
12075 .unwrap_or(0)
12076 ));
12077 std::fs::create_dir_all(&tmp).unwrap();
12078 let lib_path = tmp.join("lib.zsh");
12079 let rc_path = tmp.join("rc.zsh");
12080 std::fs::write(&lib_path, "function greet { echo hi }\n").unwrap();
12081 let rc_text = format!("source {}\ngreet\ngreet world\n", lib_path.display());
12086 std::fs::write(&rc_path, &rc_text).unwrap();
12087 let rc_uri = format!("file://{}", rc_path.display());
12088
12089 let mut state = State::default();
12090 state.docs.insert(rc_uri.clone(), rc_text.clone());
12092 let init = json!({ "rootUri": format!("file://{}", tmp.display()) });
12094 ingest_workspace_init(&mut state, &init);
12095 let lib_uri = format!("file://{}", lib_path.display());
12097 assert!(
12098 state.workspace_files.contains_key(&lib_uri),
12099 "workspace walk picked up lib.zsh: keys={:?}",
12100 state.workspace_files.keys().collect::<Vec<_>>(),
12101 );
12102 let canon_lib_path = std::fs::canonicalize(&lib_path).unwrap_or(lib_path.clone());
12108 let canon_lib_uri = format!("file://{}", canon_lib_path.display());
12109 let params = json!({
12114 "textDocument": { "uri": rc_uri },
12115 "position": { "line": 1, "character": 0 },
12116 "context": { "includeDeclaration": true },
12117 "newName": "salute",
12118 });
12119 let r = rename(&state, ¶ms);
12120 let changes = r["changes"].as_object().expect("changes map");
12121 assert!(
12122 changes.contains_key(&canon_lib_uri),
12123 "lib.zsh (workspace) edited: keys={:?}",
12124 changes.keys().collect::<Vec<_>>(),
12125 );
12126 assert!(
12127 changes.contains_key(&rc_uri),
12128 "rc.zsh (open) edited: keys={:?}",
12129 changes.keys().collect::<Vec<_>>(),
12130 );
12131 assert_eq!(changes[&canon_lib_uri].as_array().unwrap().len(), 1);
12133 assert_eq!(changes[&rc_uri].as_array().unwrap().len(), 2);
12134
12135 let _ = std::fs::remove_dir_all(&tmp);
12137 }
12138
12139 #[test]
12140 fn workspace_walk_skips_node_modules_and_git() {
12141 let _g = crate::test_util::global_state_lock();
12142 let tmp = std::env::temp_dir().join(format!(
12143 "zshrs-skip-test-{}-{}",
12144 std::process::id(),
12145 std::time::SystemTime::now()
12146 .duration_since(std::time::UNIX_EPOCH)
12147 .map(|d| d.as_nanos())
12148 .unwrap_or(0)
12149 ));
12150 std::fs::create_dir_all(tmp.join(".git")).unwrap();
12151 std::fs::create_dir_all(tmp.join("node_modules")).unwrap();
12152 std::fs::write(tmp.join(".git").join("hooks.zsh"), "should_skip=1\n").unwrap();
12153 std::fs::write(tmp.join("node_modules").join("util.zsh"), "should_skip=1\n").unwrap();
12154 std::fs::write(tmp.join("real.zsh"), "should_pick_up=1\n").unwrap();
12155
12156 let mut state = State::default();
12157 let init = json!({ "rootUri": format!("file://{}", tmp.display()) });
12158 ingest_workspace_init(&mut state, &init);
12159 assert_eq!(
12160 state.workspace_files.len(),
12161 1,
12162 "only real.zsh picked up: keys={:?}",
12163 state.workspace_files.keys().collect::<Vec<_>>(),
12164 );
12165 let _ = std::fs::remove_dir_all(&tmp);
12166 }
12167
12168 #[test]
12169 fn is_zsh_source_filename_accepts_dotfiles_and_extensions() {
12170 assert!(is_zsh_source_filename("foo.zsh"));
12171 assert!(is_zsh_source_filename("foo.sh"));
12172 assert!(is_zsh_source_filename(".zshrc"));
12173 assert!(is_zsh_source_filename(".zshenv"));
12174 assert!(is_zsh_source_filename(".zsh_aliases"));
12175 assert!(!is_zsh_source_filename("foo.py"));
12176 assert!(!is_zsh_source_filename(".gitignore"));
12177 assert!(!is_zsh_source_filename("README.md"));
12178 }
12179
12180 fn run_code_actions(text: &str, sl: u32, sc: u32, el: u32, ec: u32) -> Vec<Value> {
12183 let _g = crate::test_util::global_state_lock();
12184 let mut state = State::default();
12185 state.docs.insert("file:///t.zsh".into(), text.to_string());
12186 let params = json!({
12187 "textDocument": { "uri": "file:///t.zsh" },
12188 "range": {
12189 "start": { "line": sl, "character": sc },
12190 "end": { "line": el, "character": ec },
12191 },
12192 });
12193 match code_actions(&state, ¶ms) {
12194 Value::Array(v) => v,
12195 _ => Vec::new(),
12196 }
12197 }
12198
12199 #[test]
12200 fn code_actions_single_line_offers_var_const_and_function() {
12201 let acts = run_code_actions(" echo hello\n", 0, 4, 0, 14);
12202 let titles: Vec<&str> = acts
12203 .iter()
12204 .map(|a| a["title"].as_str().unwrap_or(""))
12205 .collect();
12206 assert!(
12208 titles.iter().any(|t| t.contains("variable")),
12209 "missing Extract Variable: {:?}",
12210 titles,
12211 );
12212 assert!(
12213 titles.iter().any(|t| t.contains("constant")),
12214 "missing Extract Constant: {:?}",
12215 titles,
12216 );
12217 assert!(
12218 titles.iter().any(|t| t.contains("function")),
12219 "missing Extract Function: {:?}",
12220 titles,
12221 );
12222 }
12223
12224 #[test]
12225 fn code_actions_subexpression_skips_function_extract() {
12226 let acts = run_code_actions("echo hello world\n", 0, 5, 0, 10);
12232 let titles: Vec<&str> = acts
12233 .iter()
12234 .map(|a| a["title"].as_str().unwrap_or(""))
12235 .collect();
12236 assert!(titles.iter().any(|t| t.contains("variable")));
12237 assert!(
12238 !titles.iter().any(|t| t.contains("function")),
12239 "function extract leaked on sub-expression: {:?}",
12240 titles,
12241 );
12242 }
12243
12244 #[test]
12245 fn code_actions_multiline_only_offers_function_extract() {
12246 let text = "if true; then\n echo a\n echo b\nfi\n";
12249 let acts = run_code_actions(text, 1, 0, 3, 0);
12250 let titles: Vec<&str> = acts
12251 .iter()
12252 .map(|a| a["title"].as_str().unwrap_or(""))
12253 .collect();
12254 assert_eq!(acts.len(), 1, "expected exactly one action: {:?}", titles);
12255 assert!(titles[0].contains("function"));
12256 let changes = &acts[0]["edit"]["changes"]["file:///t.zsh"];
12259 let edits = changes.as_array().expect("edits array");
12260 assert_eq!(edits.len(), 2);
12261 let decl = edits[0]["newText"].as_str().unwrap_or("");
12262 assert!(
12263 decl.contains("extracted_function() {")
12264 && decl.contains("echo a")
12265 && decl.contains("echo b"),
12266 "decl missing body lines: {:?}",
12267 decl,
12268 );
12269 let call = edits[1]["newText"].as_str().unwrap_or("");
12270 assert!(
12271 call.trim() == "extracted_function",
12272 "call must be bare: {:?}",
12273 call
12274 );
12275 }
12276
12277 #[test]
12278 fn code_actions_multiline_preserves_relative_indent() {
12279 let text = "if outer; then\n if inner; then\n echo nested\n fi\nfi\n";
12283 let acts = run_code_actions(text, 1, 0, 3, 0);
12284 assert_eq!(acts.len(), 1);
12285 let decl = acts[0]["edit"]["changes"]["file:///t.zsh"][0]["newText"]
12286 .as_str()
12287 .unwrap_or("");
12288 assert!(
12292 decl.contains(" echo nested"),
12293 "relative indent lost: {:?}",
12294 decl,
12295 );
12296 }
12297
12298 #[test]
12299 fn code_actions_caret_only_snaps_to_word() {
12300 let acts = run_code_actions("echo greeting\n", 0, 8, 0, 8);
12303 assert!(
12304 acts.iter()
12305 .any(|a| a["title"].as_str().unwrap_or("").contains("variable")),
12306 "caret-only didn't snap to a word: {:?}",
12307 acts.iter().map(|a| a["title"].clone()).collect::<Vec<_>>(),
12308 );
12309 }
12310
12311 #[test]
12312 fn code_actions_caret_only_offers_extract_function() {
12313 let acts = run_code_actions("echo greeting\n", 0, 8, 0, 8);
12321 let titles: Vec<&str> = acts
12322 .iter()
12323 .map(|a| a["title"].as_str().unwrap_or(""))
12324 .collect();
12325 assert!(
12326 titles.iter().any(|t| t.contains("function")),
12327 "caret-only must include Extract Function for Cmd-Opt-M: {:?}",
12328 titles,
12329 );
12330 let fn_act = acts
12333 .iter()
12334 .find(|a| a["title"].as_str().unwrap_or("").contains("function"))
12335 .expect("function action present");
12336 let decl = fn_act["edit"]["changes"]["file:///t.zsh"][0]["newText"]
12337 .as_str()
12338 .unwrap_or("");
12339 assert!(
12340 decl.contains("echo greeting"),
12341 "caret-only function extract should wrap the whole line, not just the word: {:?}",
12342 decl,
12343 );
12344 }
12345
12346 #[test]
12347 fn code_actions_caret_on_whitespace_still_offers_function() {
12348 let acts = run_code_actions(" echo hello\n", 0, 2, 0, 2);
12353 let titles: Vec<&str> = acts
12354 .iter()
12355 .map(|a| a["title"].as_str().unwrap_or(""))
12356 .collect();
12357 assert!(
12358 titles.iter().any(|t| t.contains("function")),
12359 "cursor on whitespace must still emit Extract Function: {:?}",
12360 titles,
12361 );
12362 }
12363
12364 #[test]
12365 fn code_actions_caret_on_blank_line_returns_empty() {
12366 let acts = run_code_actions("foo\n\nbar\n", 1, 0, 1, 0);
12370 assert!(
12371 acts.is_empty(),
12372 "blank line should produce no actions: {:?}",
12373 acts.iter().map(|a| a["title"].clone()).collect::<Vec<_>>(),
12374 );
12375 }
12376
12377 #[test]
12378 fn prepare_rename_rejects_in_comment() {
12379 let _g = crate::test_util::global_state_lock();
12380 let mut state = State::default();
12381 state
12382 .docs
12383 .insert("file:///t.zsh".into(), "echo hi # rename me\n".into());
12384 let pos = "echo hi # rename ".len();
12385 let params = json!({
12386 "textDocument": { "uri": "file:///t.zsh" },
12387 "position": { "line": 0, "character": pos },
12388 });
12389 let r = prepare_rename(&state, ¶ms);
12390 assert!(r.is_null(), "prepareRename in comment must reject");
12391 }
12392
12393 #[test]
12394 fn long_flag_completion_inside_command_substitution() {
12395 let line = "x=$(zshrs --";
12401 let ctx = super::lsp_completion_context(line, line.len());
12402 assert!(
12403 matches!(ctx, super::LspCompletionContext::BuiltinLongFlag(ref n) if n == "zshrs"),
12404 "expected BuiltinLongFlag(zshrs) inside `$(`, got {ctx:?}",
12405 );
12406 let line = "diff <(zshrs --";
12408 let ctx = super::lsp_completion_context(line, line.len());
12409 assert!(
12410 matches!(ctx, super::LspCompletionContext::BuiltinLongFlag(ref n) if n == "zshrs"),
12411 "expected BuiltinLongFlag(zshrs) inside `<(`, got {ctx:?}",
12412 );
12413 let line = "(zshrs --";
12415 let ctx = super::lsp_completion_context(line, line.len());
12416 assert!(
12417 matches!(ctx, super::LspCompletionContext::BuiltinLongFlag(ref n) if n == "zshrs"),
12418 "expected BuiltinLongFlag(zshrs) inside `(`, got {ctx:?}",
12419 );
12420 }
12421
12422 #[test]
12423 fn zshrs_long_flag_table_includes_gen_docs() {
12424 let flags = super::extract_builtin_long_flags("zshrs");
12429 let names: std::collections::HashSet<&str> =
12430 flags.iter().map(|(f, _)| f.as_str()).collect();
12431 for must_have in [
12432 "--gen-docs",
12433 "--out",
12434 "--dump-reference-html",
12435 "--names",
12436 "--daemon",
12437 "--color",
12438 ] {
12439 assert!(
12440 names.contains(must_have),
12441 "ZSHRS_SELF_LONG_FLAG_DOCS missing {must_have}",
12442 );
12443 }
12444 }
12445
12446 #[test]
12447 fn zle_dash_dispatches_to_builtin_flag_not_widget_name() {
12448 let line = "zle -";
12456 let ctx = super::lsp_completion_context(line, line.len());
12457 assert!(
12458 matches!(ctx, super::LspCompletionContext::BuiltinFlag(ref n) if n == "zle"),
12459 "expected BuiltinFlag(zle) for `zle -`, got {ctx:?}",
12460 );
12461 let bare = "zle ";
12463 let ctx2 = super::lsp_completion_context(bare, bare.len());
12464 assert!(
12465 matches!(ctx2, super::LspCompletionContext::WidgetName),
12466 "expected WidgetName for `zle `, got {ctx2:?}",
12467 );
12468 let flags = extract_builtin_flags("zle");
12471 let names: std::collections::HashSet<&str> =
12472 flags.iter().map(|(f, _)| f.as_str()).collect();
12473 for must_have in ["-l", "-L", "-N", "-K", "-D", "-A", "-R", "-M"] {
12474 assert!(
12475 names.contains(must_have),
12476 "zle missing flag {must_have} from BUILTIN_FLAG_DOCS_OVERRIDE",
12477 );
12478 }
12479 }
12480
12481 #[test]
12482 fn print_flag_descriptions_present_for_next_line_desc_pattern() {
12483 let flags = extract_builtin_flags("print");
12491 let by: std::collections::HashMap<&str, &str> = flags
12492 .iter()
12493 .map(|(f, d)| (f.as_str(), d.as_str()))
12494 .collect();
12495
12496 assert!(
12498 by.get("-b").is_some_and(|d| d.contains("Recognize")),
12499 "-b should describe escape sequence recognition, got {:?}",
12500 by.get("-b"),
12501 );
12502 assert!(
12503 by.get("-m")
12504 .is_some_and(|d| d.contains("Take the first argument")),
12505 "-m should describe pattern matching, got {:?}",
12506 by.get("-m"),
12507 );
12508
12509 let expectations: &[(&str, &str)] = &[
12512 ("-a", "Print arguments with the column"),
12513 ("-c", "Print the arguments in columns"),
12514 ("-D", "Treat the arguments as paths"),
12515 ("-l", "Print the arguments separated by newlines"),
12516 ("-n", "Do not add a newline"),
12517 ("-o", "Print the arguments sorted in ascending"),
12518 ("-r", "Ignore the escape conventions"),
12519 ("-s", "Place the results in the history list"),
12520 ("-z", "Push the arguments onto the editing buffer"),
12521 ];
12522 for (flag, needle) in expectations {
12523 let got = by.get(flag).copied().unwrap_or("<missing>");
12524 assert!(
12525 got.contains(needle),
12526 "flag {flag} description should contain {:?}, got {:?}",
12527 needle,
12528 got,
12529 );
12530 }
12531 }
12532
12533 #[test]
12534 fn zshrs_self_long_flag_completion_covers_zshrs_specific_plus_setopt_mirrors() {
12535 assert!(super::is_known_builtin_with_long_flag_docs("zshrs"));
12540 assert!(super::is_known_builtin_with_long_flag_docs("zsh"));
12541 assert!(!super::is_known_builtin_with_long_flag_docs("print"));
12542
12543 let flags = super::extract_builtin_long_flags("zshrs");
12544 let by: std::collections::HashMap<&str, &str> = flags
12545 .iter()
12546 .map(|(f, d)| (f.as_str(), d.as_str()))
12547 .collect();
12548
12549 for spec in [
12551 "--help",
12552 "--version",
12553 "--doctor",
12554 "--lsp",
12555 "--dap",
12556 "--dump-tokens",
12557 "--dump-ast",
12558 "--dump-wordcode",
12559 "--dump-zwc",
12560 "--dump-reflection",
12561 "--docs",
12562 "--disasm",
12563 "--zsh",
12564 "--bash",
12565 "--ksh",
12566 "--sh",
12567 "--csh",
12568 "--posix",
12569 "--emulate",
12570 "--zsh-compat",
12571 "--no-rcs",
12572 "--verbose",
12573 "--xtrace",
12574 "--login",
12575 "--interactive",
12576 ] {
12577 assert!(
12578 by.contains_key(spec),
12579 "zshrs long-flag table missing {spec}",
12580 );
12581 let d = by.get(spec).unwrap();
12582 let letters = d.chars().filter(|c| c.is_ascii_alphabetic()).count();
12583 assert!(
12584 letters >= 10,
12585 "{spec} description should be substantive, got {:?}",
12586 d,
12587 );
12588 }
12589 for mirror in [
12592 "--autocd",
12593 "--errexit",
12594 "--pipefail",
12595 "--nullglob",
12596 "--extendedglob",
12597 "--no-autocd",
12598 "--no-errexit",
12599 "--no-pipefail",
12600 ] {
12601 assert!(
12602 by.contains_key(mirror),
12603 "setopt-mirror flag {mirror} missing — OPTION_DOCS not flowing through?",
12604 );
12605 }
12606 assert!(
12610 flags.len() > 400,
12611 "expected > 400 long-flag entries (hand table + setopt mirrors × 2), got {}",
12612 flags.len(),
12613 );
12614 }
12615
12616 #[test]
12617 fn zshrs_self_flag_completion_lists_standard_short_flags() {
12618 assert!(super::is_known_builtin_with_flag_docs("zshrs"));
12625 assert!(super::is_known_builtin_with_flag_docs("zsh"));
12626
12627 let flags = extract_builtin_flags("zshrs");
12628 let names: std::collections::HashSet<&str> =
12629 flags.iter().map(|(f, _)| f.as_str()).collect();
12630 for must_have in ["-b", "-c", "-f", "-i", "-l", "-s", "-o", "-v", "-x"] {
12631 assert!(
12632 names.contains(must_have),
12633 "zshrs self-flag table missing {must_have}",
12634 );
12635 }
12636 for (f, d) in &flags {
12637 let letters = d.chars().filter(|c| c.is_ascii_alphabetic()).count();
12638 assert!(
12639 letters >= 10,
12640 "zshrs {f} description should be substantive, got {:?}",
12641 d,
12642 );
12643 }
12644 let zsh_flags = extract_builtin_flags("zsh");
12646 assert_eq!(zsh_flags.len(), flags.len());
12647 }
12648
12649 #[test]
12654 fn audit_all_builtin_flag_coverage() {
12655 let mut all_names: Vec<String> = Vec::new();
12656 for b in crate::ported::builtin::BUILTINS.iter() {
12657 all_names.push(b.node.nam.to_string());
12658 }
12659 for n in crate::ext_builtins::EXT_BUILTIN_NAMES.iter() {
12660 all_names.push(n.to_string());
12661 }
12662 for n in crate::compsys::COMPSYS_FN_NAMES.iter() {
12663 all_names.push(n.to_string());
12664 }
12665 all_names.sort();
12666 all_names.dedup();
12667
12668 let mut total_flags = 0usize;
12669 let mut empty_descs = 0usize;
12670 let mut builtins_with_any_flag = 0usize;
12671 let mut builtins_with_empty: Vec<(String, usize, usize)> = Vec::new();
12672 fn useful(d: &str) -> bool {
12676 let letters = d.chars().filter(|c| c.is_ascii_alphabetic()).count();
12677 letters >= 3
12678 }
12679 for name in &all_names {
12680 let flags = extract_builtin_flags(name);
12681 if flags.is_empty() {
12682 continue;
12683 }
12684 builtins_with_any_flag += 1;
12685 let total = flags.len();
12686 let empty = flags.iter().filter(|(_, d)| !useful(d)).count();
12687 total_flags += total;
12688 empty_descs += empty;
12689 if empty > 0 {
12690 let preview: Vec<String> = flags
12691 .iter()
12692 .filter(|(_, d)| !useful(d))
12693 .take(3)
12694 .map(|(f, d)| format!("{f}={:?}", d))
12695 .collect();
12696 builtins_with_empty.push((
12697 format!("{name} [{}]", preview.join(", ")),
12698 empty,
12699 total,
12700 ));
12701 }
12702 }
12703 eprintln!(
12704 "audit: {} builtins with flags, {} total flag entries, {} empty desc ({:.1}%)",
12705 builtins_with_any_flag,
12706 total_flags,
12707 empty_descs,
12708 100.0 * empty_descs as f64 / total_flags.max(1) as f64,
12709 );
12710 builtins_with_empty.sort_by(|a, b| b.1.cmp(&a.1));
12711 for (name, empty, total) in builtins_with_empty.iter().take(40) {
12712 eprintln!(" {name} : {empty}/{total} empty");
12713 }
12714 }
12715}