1use serde_json::{json, Value};
24use std::collections::HashMap;
25use std::io::{self, BufRead, BufReader, Read, Write};
26
27fn read_message<R: BufRead>(reader: &mut R) -> io::Result<Option<Value>> {
33 let mut content_length: Option<usize> = None;
34 loop {
35 let mut line = String::new();
36 let n = reader.read_line(&mut line)?;
37 if n == 0 {
38 return Ok(None);
39 }
40 if line == "\r\n" || line == "\n" {
41 break;
42 }
43 if let Some(rest) = line.strip_prefix("Content-Length:") {
44 content_length =
45 Some(rest.trim().parse().map_err(|_| {
46 io::Error::new(io::ErrorKind::InvalidData, "bad Content-Length")
47 })?);
48 }
49 }
50 let len = content_length
51 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing Content-Length"))?;
52 let mut buf = vec![0u8; len];
53 reader.read_exact(&mut buf)?;
54 let v: Value =
55 serde_json::from_slice(&buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
56 Ok(Some(v))
57}
58
59fn write_message<W: Write>(writer: &mut W, msg: &Value) -> io::Result<()> {
60 let body = serde_json::to_vec(msg)?;
61 write!(writer, "Content-Length: {}\r\n\r\n", body.len())?;
62 writer.write_all(&body)?;
63 writer.flush()
64}
65
66#[derive(Default)]
69struct State {
70 docs: HashMap<String, String>,
73 workspace_files: HashMap<String, String>,
80 workspace_roots: Vec<std::path::PathBuf>,
84}
85
86impl State {
87 fn all_docs(&self) -> Vec<(String, String)> {
92 let mut out: HashMap<String, String> = self.workspace_files.clone();
93 for (k, v) in &self.docs {
94 out.insert(k.clone(), v.clone());
95 }
96 let mut v: Vec<(String, String)> = out.into_iter().collect();
97 v.sort_by(|a, b| a.0.cmp(&b.0));
98 v
99 }
100}
101
102const ZSH_EXT: &[&str] = &["zsh", "sh"];
107const ZSH_BASENAMES: &[&str] = &[
108 ".zshrc",
109 ".zshenv",
110 ".zprofile",
111 ".zlogin",
112 ".zlogout",
113 ".zsh_aliases",
114 ".zsh_functions",
115 ".zshrc.local",
116 "zshrc",
117];
118const SKIP_DIRS: &[&str] = &[
122 ".git",
123 ".hg",
124 ".svn",
125 "node_modules",
126 "target",
127 "build",
128 "dist",
129 ".idea",
130 ".vscode",
131 ".cache",
132 ".direnv",
133 ".venv",
134 "venv",
135 "__pycache__",
136];
137const MAX_WORKSPACE_FILES: usize = 10_000;
141const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
144
145fn is_zsh_source_filename(name: &str) -> bool {
149 if let Some(ext) = name.rsplit('.').next() {
150 if ext != name && ZSH_EXT.contains(&ext) {
151 return true;
152 }
153 }
154 ZSH_BASENAMES.contains(&name)
155}
156
157fn path_to_file_uri(p: &std::path::Path) -> Option<String> {
160 let abs = if p.is_absolute() {
161 p.to_path_buf()
162 } else {
163 std::env::current_dir().ok()?.join(p)
164 };
165 let s = abs.to_str()?;
166 Some(format!("file://{s}"))
167}
168
169fn file_uri_to_path(uri: &str) -> Option<std::path::PathBuf> {
173 uri.strip_prefix("file://").map(std::path::PathBuf::from)
174}
175
176fn scan_workspace_root(root: &std::path::Path, out: &mut HashMap<String, String>) {
185 let mut stack: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
186 while let Some(dir) = stack.pop() {
187 if out.len() >= MAX_WORKSPACE_FILES {
188 tracing::warn!(
189 target: "zshrs::lsp::workspace",
190 cap = MAX_WORKSPACE_FILES,
191 "workspace scan capped",
192 );
193 return;
194 }
195 let entries = match std::fs::read_dir(&dir) {
196 Ok(e) => e,
197 Err(e) => {
198 tracing::trace!(target: "zshrs::lsp::workspace", path=?dir, %e, "read_dir failed");
199 continue;
200 }
201 };
202 for ent in entries.flatten() {
203 let path = ent.path();
204 let name = match path.file_name().and_then(|n| n.to_str()) {
205 Some(n) => n,
206 None => continue,
207 };
208 let ty = match ent.file_type() {
209 Ok(t) => t,
210 Err(_) => continue,
211 };
212 if ty.is_dir() {
213 if SKIP_DIRS.contains(&name)
214 || name.starts_with('.') && !ZSH_BASENAMES.iter().any(|b| b == &name)
215 {
216 continue;
217 }
218 stack.push(path);
219 continue;
220 }
221 if !ty.is_file() {
222 continue;
223 }
224 if !is_zsh_source_filename(name) {
225 continue;
226 }
227 let md = match ent.metadata() {
228 Ok(m) => m,
229 Err(_) => continue,
230 };
231 if md.len() > MAX_FILE_BYTES {
232 continue;
233 }
234 let text = match std::fs::read_to_string(&path) {
235 Ok(t) => t,
236 Err(_) => continue,
237 };
238 if let Some(uri) = path_to_file_uri(&path) {
239 out.insert(uri, text);
240 if out.len() >= MAX_WORKSPACE_FILES {
241 return;
242 }
243 }
244 }
245 }
246}
247
248fn ingest_workspace_init(state: &mut State, params: &Value) {
251 let mut roots: Vec<std::path::PathBuf> = Vec::new();
253 if let Some(uri) = params.get("rootUri").and_then(|v| v.as_str()) {
254 if let Some(p) = file_uri_to_path(uri) {
255 roots.push(p);
256 }
257 }
258 if let Some(folders) = params.get("workspaceFolders").and_then(|v| v.as_array()) {
259 for f in folders {
260 if let Some(uri) = f.get("uri").and_then(|v| v.as_str()) {
261 if let Some(p) = file_uri_to_path(uri) {
262 roots.push(p);
263 }
264 }
265 }
266 }
267 let mut seen = std::collections::HashSet::new();
269 roots.retain(|p| seen.insert(p.clone()));
270 if roots.is_empty() {
271 tracing::info!(target: "zshrs::lsp::workspace", "no roots in initialize");
272 return;
273 }
274 let mut buf: HashMap<String, String> = HashMap::new();
275 for r in &roots {
276 scan_workspace_root(r, &mut buf);
277 }
278 tracing::info!(
279 target: "zshrs::lsp::workspace",
280 roots = roots.len(),
281 files = buf.len(),
282 "scanned",
283 );
284 state.workspace_roots = roots;
285 state.workspace_files = buf;
286}
287
288fn refresh_workspace_file(state: &mut State, uri: &str) {
291 if state.workspace_roots.is_empty() {
292 return;
293 }
294 let path = match file_uri_to_path(uri) {
295 Some(p) => p,
296 None => return,
297 };
298 let inside_root = state.workspace_roots.iter().any(|r| path.starts_with(r));
299 if !inside_root {
300 return;
301 }
302 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
303 if !is_zsh_source_filename(name) {
304 return;
305 }
306 }
307 match std::fs::read_to_string(&path) {
308 Ok(t) => {
309 state.workspace_files.insert(uri.to_string(), t);
310 }
311 Err(_) => {
312 state.workspace_files.remove(uri);
313 }
314 }
315}
316
317fn spawn_orphan_guard() {
328 std::thread::spawn(|| {
329 #[cfg(target_os = "linux")]
333 unsafe {
335 libc::prctl(
336 libc::PR_SET_PDEATHSIG,
337 libc::SIGKILL as libc::c_ulong,
338 0,
339 0,
340 0,
341 );
342 }
343 loop {
344 std::thread::sleep(std::time::Duration::from_secs(2));
345 if unsafe { libc::getppid() } == 1 {
347 std::process::exit(0);
348 }
349 }
350 });
351}
352
353fn claim_protocol_fds() -> (BufReader<Box<dyn io::Read + Send>>, Box<dyn Write + Send>) {
374 #[cfg(unix)]
375 {
376 use std::os::fd::FromRawFd;
377 unsafe {
381 let in_fd = libc::dup(0);
382 let out_fd = libc::dup(1);
383 if in_fd >= 0 && out_fd >= 0 {
384 let devnull = std::ffi::CString::new("/dev/null").expect("static cstr");
385 let null_fd = libc::open(devnull.as_ptr(), libc::O_RDWR);
386 if null_fd >= 0 {
387 libc::dup2(null_fd, 0);
388 libc::dup2(null_fd, 1);
389 if null_fd > 2 {
390 libc::close(null_fd);
391 }
392 tracing::info!(
393 target: "zshrs::lsp",
394 in_fd,
395 out_fd,
396 "protocol fds moved off 0/1; children see /dev/null",
397 );
398 let r: Box<dyn io::Read + Send> = Box::new(std::fs::File::from_raw_fd(in_fd));
399 let w: Box<dyn Write + Send> = Box::new(std::fs::File::from_raw_fd(out_fd));
400 return (BufReader::new(r), w);
401 }
402 libc::close(in_fd);
403 libc::close(out_fd);
404 }
405 tracing::warn!(
406 target: "zshrs::lsp",
407 "could not move protocol fds off 0/1 — a completer subprocess could corrupt the stream",
408 );
409 }
410 }
411 let r: Box<dyn io::Read + Send> = Box::new(io::stdin());
412 let w: Box<dyn Write + Send> = Box::new(io::stdout());
413 (BufReader::new(r), w)
414}
415
416pub fn run_lsp() -> i32 {
417 tracing::info!(
418 target: "zshrs::lsp",
419 pid = std::process::id(),
420 "starting --lsp",
421 );
422 spawn_orphan_guard();
423 let (mut reader, mut writer) = claim_protocol_fds();
426 let mut state = State::default();
427
428 let log_path = std::env::var("ZSHRS_LSP_LOG").ok();
429 let mut log = log_path.as_ref().and_then(|p| {
430 std::fs::OpenOptions::new()
431 .create(true)
432 .append(true)
433 .open(p)
434 .ok()
435 });
436
437 loop {
438 let msg = match read_message(&mut reader) {
439 Ok(Some(m)) => m,
440 Ok(None) => {
441 tracing::info!(target: "zshrs::lsp", "stdin EOF, shutting down");
442 break;
443 }
444 Err(e) => {
445 if let Some(l) = log.as_mut() {
446 let _ = writeln!(l, "← read error: {}", e);
447 }
448 tracing::error!(target: "zshrs::lsp", %e, "read error, shutting down");
449 break;
450 }
451 };
452
453 if let Some(l) = log.as_mut() {
454 let _ = writeln!(l, "← {}", msg);
455 }
456
457 let method = msg
458 .get("method")
459 .and_then(|v| v.as_str())
460 .map(|s| s.to_string());
461 let id = msg.get("id").cloned();
462 let params = msg.get("params").cloned().unwrap_or(Value::Null);
463 tracing::trace!(
464 target: "zshrs::lsp::req",
465 method = method.as_deref().unwrap_or("?"),
466 id = ?id,
467 );
468
469 let response = match method.as_deref() {
470 Some("initialize") => {
471 ingest_workspace_init(&mut state, ¶ms);
472 crate::compsys::in_editor::bootstrap();
479 Some(handle_initialize(id, ¶ms))
480 }
481 Some("initialized") => None,
482 Some("shutdown") => Some(reply(id, json!(null))),
483 Some("exit") => break,
484 Some("textDocument/didOpen") => {
485 if let (Some(uri), Some(text)) = (
486 params["textDocument"]["uri"].as_str(),
487 params["textDocument"]["text"].as_str(),
488 ) {
489 state.docs.insert(uri.to_string(), text.to_string());
490 publish_diagnostics(&mut writer, uri, text, &mut log);
491 }
492 None
493 }
494 Some("textDocument/didChange") => {
495 if let Some(uri) = params["textDocument"]["uri"].as_str() {
496 if let Some(changes) = params["contentChanges"].as_array() {
497 if let Some(t) = changes.last().and_then(|c| c["text"].as_str()) {
499 state.docs.insert(uri.to_string(), t.to_string());
500 publish_diagnostics(&mut writer, uri, t, &mut log);
501 }
502 }
503 }
504 None
505 }
506 Some("textDocument/didClose") => {
507 if let Some(uri) = params["textDocument"]["uri"].as_str() {
508 state.docs.remove(uri);
509 }
510 None
511 }
512 Some("textDocument/didSave") => {
513 if let Some(uri) = params["textDocument"]["uri"].as_str() {
514 if let Some(text) = state.docs.get(uri).cloned() {
515 publish_diagnostics(&mut writer, uri, &text, &mut log);
516 }
517 refresh_workspace_file(&mut state, uri);
521 }
522 None
523 }
524 Some("textDocument/completion") => Some(reply(id, completion(&state, ¶ms))),
525 Some("textDocument/hover") => Some(reply(id, hover(&state, ¶ms))),
526 Some("textDocument/documentSymbol") => {
527 Some(reply(id, document_symbols(&state, ¶ms)))
528 }
529 Some("textDocument/foldingRange") => Some(reply(id, folding_ranges(&state, ¶ms))),
530 Some("textDocument/definition") => Some(reply(id, definition(&state, ¶ms))),
531 Some("textDocument/references") => Some(reply(id, references(&state, ¶ms))),
532 Some("textDocument/documentHighlight") => {
533 Some(reply(id, document_highlights(&state, ¶ms)))
534 }
535 Some("textDocument/rename") => Some(reply(id, rename(&state, ¶ms))),
536 Some("textDocument/prepareRename") => Some(reply(id, prepare_rename(&state, ¶ms))),
537 Some("textDocument/semanticTokens/full") => {
538 Some(reply(id, semantic_tokens(&state, ¶ms)))
539 }
540 Some("textDocument/formatting") => Some(reply(id, formatting(&state, ¶ms))),
541 Some("textDocument/codeAction") => Some(reply(id, code_actions(&state, ¶ms))),
542 Some(_) if id.is_some() => Some(reply_error(id, -32601, "Method not found")),
544 _ => None,
545 };
546
547 if let Some(resp) = response {
548 if let Some(l) = log.as_mut() {
549 let _ = writeln!(l, "→ {}", resp);
550 }
551 if let Err(e) = write_message(&mut writer, &resp) {
552 if let Some(l) = log.as_mut() {
553 let _ = writeln!(l, "write error: {}", e);
554 }
555 break;
556 }
557 }
558 }
559 0
560}
561
562fn reply(id: Option<Value>, result: Value) -> Value {
563 json!({
564 "jsonrpc": "2.0",
565 "id": id.unwrap_or(Value::Null),
566 "result": result,
567 })
568}
569
570fn reply_error(id: Option<Value>, code: i32, message: &str) -> Value {
571 json!({
572 "jsonrpc": "2.0",
573 "id": id.unwrap_or(Value::Null),
574 "error": { "code": code, "message": message },
575 })
576}
577
578fn handle_initialize(id: Option<Value>, _params: &Value) -> Value {
581 reply(
582 id,
583 json!({
584 "capabilities": {
585 "textDocumentSync": { "openClose": true, "change": 1, "save": true },
586 "completionProvider": {
587 "triggerCharacters": ["$", "{", "(", "[", "#", "*", "?", "!", "-", ":"],
595 "resolveProvider": false,
596 },
597 "hoverProvider": true,
598 "definitionProvider": true,
599 "referencesProvider": true,
600 "documentHighlightProvider": true,
601 "documentSymbolProvider": true,
602 "foldingRangeProvider": true,
603 "renameProvider": { "prepareProvider": true },
604 "documentFormattingProvider": true,
605 "codeActionProvider": {
606 "codeActionKinds": [
607 "refactor.extract",
608 ],
609 },
610 "semanticTokensProvider": {
611 "legend": {
612 "tokenTypes": SEMANTIC_TOKEN_TYPES,
613 "tokenModifiers": [],
614 },
615 "full": true,
616 "range": false,
617 },
618 },
619 "serverInfo": { "name": "zshrs-lsp", "version": env!("CARGO_PKG_VERSION") },
620 }),
621 )
622}
623
624fn publish_diagnostics<W: Write>(
627 writer: &mut W,
628 uri: &str,
629 text: &str,
630 log: &mut Option<std::fs::File>,
631) {
632 let diags = diagnose(text);
633 let msg = json!({
634 "jsonrpc": "2.0",
635 "method": "textDocument/publishDiagnostics",
636 "params": { "uri": uri, "diagnostics": diags },
637 });
638 if let Some(l) = log.as_mut() {
639 let _ = writeln!(l, "→ {}", msg);
640 }
641 let _ = write_message(writer, &msg);
642}
643
644fn strip_comments_and_strings(line: &str) -> String {
652 let bytes = line.as_bytes();
653 let mut out = String::with_capacity(bytes.len());
654 let mut i = 0usize;
655 while i < bytes.len() {
656 let c = bytes[i] as char;
657 match c {
658 '\\' if i + 1 < bytes.len() => {
659 out.push(c);
660 out.push(bytes[i + 1] as char);
661 i += 2;
662 continue;
663 }
664 '"' | '\'' | '`' => {
665 let q = c;
666 out.push(q);
667 i += 1;
668 while i < bytes.len() {
669 let cc = bytes[i] as char;
670 if cc == '\\' && q != '\'' && i + 1 < bytes.len() {
671 i += 2;
672 continue;
673 }
674 if cc == q {
675 out.push(q);
676 i += 1;
677 break;
678 }
679 i += 1;
680 }
681 continue;
682 }
683 '#' => {
684 let prev = if i == 0 {
685 None
686 } else {
687 Some(bytes[i - 1] as char)
688 };
689 let is_comment_start = match prev {
690 None => true,
691 Some(p) => p.is_whitespace() || p == ';' || p == '&' || p == '|',
695 };
696 if is_comment_start {
697 break;
698 }
699 out.push(c);
700 }
701 _ => out.push(c),
702 }
703 i += 1;
704 }
705 out
706}
707
708fn diagnose(text: &str) -> Vec<Value> {
712 let mut diags = Vec::new();
713 let mut stack: Vec<(char, usize, usize)> = Vec::new(); let mut block_stack: Vec<(&str, usize, usize)> = Vec::new();
715 let mut open_quote: Option<char> = None;
720
721 for (line_no, line) in text.lines().enumerate() {
722 let mut post_quote_tail: Option<usize> = None;
726 if let Some(q) = open_quote {
727 let bytes = line.as_bytes();
728 let mut i = 0usize;
729 let mut closed_at = None;
730 while i < bytes.len() {
731 let c = bytes[i] as char;
732 if c == '\\' && q != '\'' && i + 1 < bytes.len() {
733 i += 2;
734 continue;
735 }
736 if c == q {
737 closed_at = Some(i);
738 break;
739 }
740 i += 1;
741 }
742 if let Some(close_pos) = closed_at {
743 open_quote = None;
744 post_quote_tail = Some(close_pos + 1);
753 } else {
754 continue;
757 }
758 }
759 let trimmed = line.trim_start();
760 if trimmed.starts_with('#') {
761 continue;
762 }
763 let line_code_only = strip_comments_and_strings(line);
772 let line_has_case_keyword = line_code_only.split_whitespace().any(|t| {
773 let bare = t.trim_end_matches(|c: char| matches!(c, ';' | '&' | '|'));
774 bare == "case"
775 });
776 let mut bareword_value_positions: std::collections::HashSet<usize> =
791 std::collections::HashSet::new();
792 {
793 let toks: Vec<&str> = line_code_only.split_whitespace().collect();
794 for (i, t) in toks.iter().enumerate() {
795 if i == 0 {
796 continue;
797 }
798 let prev_bare =
799 toks[i - 1].trim_end_matches(|c: char| matches!(c, ';' | '&' | '|'));
800 let prev_ends_with_separator =
801 toks[i - 1].ends_with(|c: char| matches!(c, ';' | '&' | '|'));
802 let bare = t.trim_end_matches(|c: char| matches!(c, ';' | '&' | '|'));
803 let prev_is_body_opener = matches!(
804 prev_bare,
805 "do" | "then" | "else" | "elif" | "&&" | "||" | ";" | "&" | "|"
806 );
807 if matches!(bare, "done" | "fi" | "esac")
808 && !prev_ends_with_separator
809 && !prev_is_body_opener
810 {
811 bareword_value_positions.insert(i);
812 }
813 }
814 }
815 let mut i = post_quote_tail.unwrap_or(0);
822 let bytes = line.as_bytes();
823 while i < bytes.len() {
824 let c = bytes[i] as char;
825 match c {
826 '(' => {
827 if bytes.get(i + 1) == Some(&b'(') {
830 let prev_is_dollar = i > 0 && bytes[i - 1] == b'$';
837 let next2 = bytes.get(i + 2).copied();
838 let next3 = bytes.get(i + 3).copied();
839 if prev_is_dollar && next2 == Some(b')') && next3 != Some(b')') {
840 stack.push(('(', line_no, i)); stack.push(('(', line_no, i + 1)); i += 2;
845 continue;
846 }
847 if stack.iter().any(|x| x.0 == 'A') {
855 stack.push(('(', line_no, i));
856 stack.push(('(', line_no, i + 1));
857 i += 2;
858 continue;
859 }
860 stack.push(('A', line_no, i));
861 i += 2;
862 continue;
863 }
864 stack.push(('(', line_no, i));
865 }
866 ')' => {
867 if bytes.get(i + 1) == Some(&b')') && stack.last().map(|x| x.0) == Some('A') {
869 stack.pop();
870 i += 2;
871 continue;
872 }
873 if stack.last().map(|x| x.0) == Some('(') {
874 stack.pop();
875 } else {
876 let in_case = block_stack.iter().any(|(kw, _, _)| *kw == "case")
885 || line_has_case_keyword;
886 if !in_case {
887 diags.push(diagnostic(line_no, i, 1, "unmatched `)`", 1));
888 }
889 }
890 }
891 '{' => {
892 let preceded_by_dollar = i > 0 && bytes[i - 1] == b'$';
902 if preceded_by_dollar {
903 let mut depth = 1i32;
904 let mut j = i + 1;
905 while j < bytes.len() && depth > 0 {
906 match bytes[j] {
907 b'\\' if j + 1 < bytes.len() => {
908 j += 2;
909 continue;
910 }
911 b'{' => depth += 1,
912 b'}' => depth -= 1,
913 _ => {}
914 }
915 j += 1;
916 }
917 i = j;
919 continue;
920 }
921 stack.push(('{', line_no, i))
922 }
923 '}' => {
924 if stack.last().map(|x| x.0) == Some('{') {
925 stack.pop();
926 } else {
927 diags.push(diagnostic(line_no, i, 1, "unmatched `}`", 1));
928 }
929 }
930 '[' => {
931 if bytes.get(i + 1) == Some(&b'[') {
934 stack.push(('D', line_no, i));
935 i += 2;
936 continue;
937 }
938 stack.push(('[', line_no, i));
939 }
940 ']' => {
941 if bytes.get(i + 1) == Some(&b']') && stack.last().map(|x| x.0) == Some('D') {
942 stack.pop();
943 i += 2;
944 continue;
945 }
946 if stack.last().map(|x| x.0) == Some('[') {
947 stack.pop();
948 } else {
949 diags.push(diagnostic(line_no, i, 1, "unmatched `]`", 1));
950 }
951 }
952 '\\' => {
953 i += 2;
956 continue;
957 }
958 '"' | '\'' | '`' => {
959 let q = c;
961 i += 1;
962 let mut closed = false;
963 while i < bytes.len() {
964 let cc = bytes[i] as char;
965 if cc == '\\' && q != '\'' && i + 1 < bytes.len() {
966 i += 2;
967 continue;
968 }
969 if cc == q {
970 closed = true;
971 break;
972 }
973 i += 1;
974 }
975 if !closed {
976 open_quote = Some(q);
982 break;
984 }
985 }
986 '#' => {
987 let in_arith = stack.iter().any(|x| x.0 == 'A');
998 let prev = if i == 0 {
999 None
1000 } else {
1001 Some(bytes[i - 1] as char)
1002 };
1003 let is_comment_start = !in_arith
1004 && match prev {
1005 None => true,
1006 Some(p) => {
1007 p.is_whitespace() || p == ';' || p == '&' || p == '|'
1014 }
1015 };
1016 if is_comment_start {
1017 break;
1018 }
1019 }
1020 _ => {}
1021 }
1022 i += 1;
1023 }
1024 if post_quote_tail.is_some() {
1033 continue;
1034 }
1035 let code_only = strip_comments_and_strings(line);
1036 for (tok_idx, kw_raw) in code_only.split_whitespace().enumerate() {
1037 let kw = kw_raw.trim_end_matches(|c: char| matches!(c, ';' | '&' | '|'));
1045 let kw_static: &'static str = match kw {
1049 "if" => "if",
1050 "for" => "for",
1051 "while" => "while",
1052 "until" => "until",
1053 "case" => "case",
1054 "select" => "select",
1055 "repeat" => "repeat",
1056 "fi" => "fi",
1057 "done" => "done",
1058 "esac" => "esac",
1059 _ => continue,
1060 };
1061 if kw_static == "repeat" && !code_only.split_whitespace().any(|t| t == "do") {
1065 continue;
1066 }
1067 if kw_static == "select" && !code_only.split_whitespace().any(|t| t == "do") {
1074 continue;
1075 }
1076 if bareword_value_positions.contains(&tok_idx) {
1082 continue;
1083 }
1084 match kw_static {
1085 "if" | "for" | "while" | "until" | "case" | "select" | "repeat" => {
1086 block_stack.push((kw_static, line_no, 0));
1087 }
1088 "fi" => {
1089 if block_stack.last().map(|x| x.0) == Some("if") {
1090 block_stack.pop();
1091 } else {
1092 diags.push(diagnostic(line_no, 0, 2, "unmatched `fi`", 1));
1093 }
1094 }
1095 "done" => {
1096 let last = block_stack.last().map(|x| x.0);
1097 if matches!(
1098 last,
1099 Some("for")
1100 | Some("while")
1101 | Some("until")
1102 | Some("select")
1103 | Some("repeat")
1104 ) {
1105 block_stack.pop();
1106 } else {
1107 diags.push(diagnostic(line_no, 0, 4, "unmatched `done`", 1));
1108 }
1109 }
1110 "esac" => {
1111 if block_stack.last().map(|x| x.0) == Some("case") {
1112 block_stack.pop();
1113 } else {
1114 diags.push(diagnostic(line_no, 0, 4, "unmatched `esac`", 1));
1115 }
1116 }
1117 _ => {}
1118 }
1119 }
1120 }
1121 for (c, line, col) in stack {
1122 diags.push(diagnostic(line, col, 1, &format!("unclosed `{}`", c), 1));
1123 }
1124 for (kw, line, col) in block_stack {
1125 diags.push(diagnostic(
1126 line,
1127 col,
1128 kw.len(),
1129 &format!("unclosed `{}` block", kw),
1130 1,
1131 ));
1132 }
1133 diags
1134}
1135
1136fn diagnostic(line: usize, col: usize, len: usize, msg: &str, severity: u8) -> Value {
1137 json!({
1138 "range": {
1139 "start": { "line": line, "character": col },
1140 "end": { "line": line, "character": col + len },
1141 },
1142 "severity": severity,
1143 "source": "zshrs",
1144 "message": msg,
1145 })
1146}
1147
1148fn completion(state: &State, params: &Value) -> Value {
1151 let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
1152 let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
1153 let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
1154 let text = state.docs.get(uri);
1155 let line = text.and_then(|t| t.lines().nth(line_no));
1156 let (logical_line, logical_col, _logical_first) = match (text, line) {
1163 (Some(t), _) => logical_line_at(t, line_no, col),
1164 (None, Some(l)) => (l.to_string(), col, line_no),
1165 (None, None) => (String::new(), col, line_no),
1166 };
1167
1168 if let Some(l) = line {
1182 match arg_spec_part_at(&logical_line, logical_col) {
1186 Some(ArgSpecPart::Action { word_start }) => {
1187 let phys_start = word_start.saturating_sub(logical_col - col.min(logical_col));
1192 return arg_spec_action_items(phys_start, col, line_no, l);
1193 }
1194 Some(_) => return json!({ "isIncomplete": false, "items": [] }),
1199 None => {}
1200 }
1201 if cursor_in_uninterpolated_string(l, col) {
1202 return json!({ "isIncomplete": false, "items": [] });
1203 }
1204 }
1205
1206 fn ctx_item(label: &str, detail: &str, doc_md: &str) -> Value {
1218 json!({
1219 "label": label,
1220 "kind": 14, "detail": detail,
1222 "filterText": label,
1223 "insertText": label,
1224 "sortText": format!("0_{}", label),
1225 "documentation": {
1226 "kind": "markdown",
1227 "value": doc_md,
1228 },
1229 })
1230 }
1231 fn ctx_item_chain(label: &str, detail: &str, doc_md: &str) -> Value {
1238 json!({
1239 "label": label,
1240 "kind": 14,
1241 "detail": detail,
1242 "filterText": label,
1243 "insertText": label,
1244 "sortText": format!("0_{}", label),
1245 "documentation": {
1246 "kind": "markdown",
1247 "value": doc_md,
1248 },
1249 "command": {
1250 "title": "Re-trigger completion",
1251 "command": "editor.action.triggerSuggest",
1252 },
1253 })
1254 }
1255 if let Some(l) = line {
1256 match lsp_completion_context(&logical_line, logical_col) {
1257 LspCompletionContext::ParamFlag => {
1258 let items: Vec<Value> = PARAM_FLAG_DOCS
1261 .iter()
1262 .map(|(flag, doc)| ctx_item_chain(flag, *doc,
1263 &format!("**`(`{}`)`** — {}\n\n_zsh parameter expansion flag — `${{(FLAGS)var}}`_", flag, doc)))
1264 .collect();
1265 return json!({ "isIncomplete": false, "items": items });
1266 }
1267 LspCompletionContext::GlobQualifier => {
1268 let items: Vec<Value> = GLOB_QUALIFIER_DOCS
1270 .iter()
1271 .map(|(q, doc)| {
1272 ctx_item_chain(
1273 q,
1274 *doc,
1275 &format!(
1276 "**`(`{}`)`** — {}\n\n_zsh glob qualifier — `*(QUALIFIERS)`_",
1277 q, doc
1278 ),
1279 )
1280 })
1281 .collect();
1282 return json!({ "isIncomplete": false, "items": items });
1283 }
1284 LspCompletionContext::HistoryDesignator => {
1285 let items: Vec<Value> = HISTORY_DESIGNATOR_DOCS
1286 .iter()
1287 .map(|(d, doc)| {
1288 ctx_item(
1289 d,
1290 *doc,
1291 &format!("**`!{}`** — {}\n\n_zsh history event designator_", d, doc),
1292 )
1293 })
1294 .collect();
1295 return json!({ "isIncomplete": false, "items": items });
1296 }
1297 LspCompletionContext::ParamColonModifier => {
1298 let items: Vec<Value> = PARAM_MODIFIER_DOCS
1306 .iter()
1307 .map(|(m, doc)| {
1308 ctx_item_chain(
1309 m,
1310 *doc,
1311 &format!(
1312 "**`:{}`** — {}\n\n_zsh modifier — `${{var:MOD}}` / `!event:MOD`_",
1313 m, doc
1314 ),
1315 )
1316 })
1317 .collect();
1318 return json!({ "isIncomplete": false, "items": items });
1319 }
1320 LspCompletionContext::OptionOnly => {
1322 let mut items = Vec::new();
1323 for (name, _doc) in crate::zsh_option_docs::OPTION_DOCS {
1335 items.push(json!({
1336 "label": name,
1337 "kind": 21, "detail": "zsh option (setopt / unsetopt)",
1339 }));
1340 }
1341 for (alias, _canon) in crate::zsh_option_docs::OPTION_ALIASES {
1342 items.push(json!({
1343 "label": alias,
1344 "kind": 21, "detail": "zsh option (setopt / unsetopt)",
1346 }));
1347 }
1348 for o in crate::ported::options::ZSH_OPTIONS_SET.iter() {
1349 items.push(json!({
1350 "label": o,
1351 "kind": 21, "detail": "zsh option (setopt / unsetopt)",
1353 }));
1354 }
1355 return json!({ "isIncomplete": false, "items": items });
1356 }
1357 LspCompletionContext::SignalName => {
1358 let items: Vec<Value> = SIGNAL_NAMES
1359 .iter()
1360 .map(|(n, doc)| {
1361 ctx_item(
1362 n,
1363 *doc,
1364 &format!(
1365 "**SIG{}** — {}\n\n_signal name — `kill -{}` / `trap … {}`_",
1366 n, doc, n, n
1367 ),
1368 )
1369 })
1370 .collect();
1371 return json!({ "isIncomplete": false, "items": items });
1372 }
1373 LspCompletionContext::ModuleName => {
1374 let items: Vec<Value> = ZSH_MODULE_NAMES
1375 .iter()
1376 .map(|(n, doc)| {
1377 ctx_item(
1378 n,
1379 *doc,
1380 &format!("**`{}`** — {}\n\n_zsh module — `zmodload {}`_", n, doc, n),
1381 )
1382 })
1383 .collect();
1384 return json!({ "isIncomplete": false, "items": items });
1385 }
1386 LspCompletionContext::KeymapName => {
1387 let items: Vec<Value> = KEYMAP_NAMES
1388 .iter()
1389 .map(|(n, doc)| ctx_item(n, *doc, &format!("**`{}`** — {}", n, doc)))
1390 .collect();
1391 return json!({ "isIncomplete": false, "items": items });
1392 }
1393 LspCompletionContext::WidgetName => {
1394 let items: Vec<Value> = ZLE_WIDGET_NAMES
1395 .iter()
1396 .map(|(n, doc)| {
1397 ctx_item(n, *doc, &format!("**`{}`** — {}\n\n_ZLE widget_", n, doc))
1398 })
1399 .collect();
1400 return json!({ "isIncomplete": false, "items": items });
1401 }
1402 LspCompletionContext::TypesetFlag => {
1403 let items: Vec<Value> = TYPESET_FLAGS
1404 .iter()
1405 .map(|(f, doc)| {
1406 ctx_item(
1407 f,
1408 *doc,
1409 &format!(
1410 "**`{}`** — {}\n\n_typeset / declare / local / readonly flag_",
1411 f, doc
1412 ),
1413 )
1414 })
1415 .collect();
1416 return json!({ "isIncomplete": false, "items": items });
1417 }
1418 LspCompletionContext::ZstyleContext => {
1419 let items: Vec<Value> = ZSTYLE_CONTEXTS
1420 .iter()
1421 .map(|(c, doc)| {
1422 ctx_item(
1423 c,
1424 *doc,
1425 &format!("**`{}`** — {}\n\n_zstyle context pattern_", c, doc),
1426 )
1427 })
1428 .collect();
1429 return json!({ "isIncomplete": false, "items": items });
1430 }
1431 LspCompletionContext::CompdefFn => {
1432 let mut items = Vec::new();
1433 for n in crate::compsys::COMPSYS_FN_NAMES {
1434 items.push(ctx_item(
1435 n,
1436 "compsys completion function",
1437 &format!("**`{}`** — compsys completion function", n),
1438 ));
1439 }
1440 return json!({ "isIncomplete": false, "items": items });
1441 }
1442 LspCompletionContext::TestOperator => {
1444 let items: Vec<Value> = TEST_OPERATORS
1445 .iter()
1446 .map(|(op, doc)| {
1447 ctx_item(
1448 op,
1449 *doc,
1450 &format!("**`{}`** — {}\n\n_inside `[[ … ]]` conditional_", op, doc),
1451 )
1452 })
1453 .collect();
1454 return json!({ "isIncomplete": false, "items": items });
1455 }
1456 LspCompletionContext::MathFunction => {
1457 let items: Vec<Value> = MATH_FUNCTIONS
1458 .iter()
1459 .map(|(n, doc)| ctx_item(n, *doc,
1460 &format!("**`{}(…)`** — {}\n\n_math function — inside `((…))` / `$((…))` (most require `zmodload zsh/mathfunc`)_", n, doc)))
1461 .collect();
1462 return json!({ "isIncomplete": false, "items": items });
1463 }
1464 LspCompletionContext::PatternModifier => {
1465 let items: Vec<Value> = PATTERN_MODIFIERS
1468 .iter()
1469 .map(|(m, doc)| ctx_item_chain(m, *doc,
1470 &format!("**`(#{})`** — {}\n\n_extended-glob pattern modifier (needs `EXTENDED_GLOB`)_", m, doc)))
1471 .collect();
1472 return json!({ "isIncomplete": false, "items": items });
1473 }
1474 LspCompletionContext::SubscriptFlag => {
1475 let items: Vec<Value> = SUBSCRIPT_FLAGS
1477 .iter()
1478 .map(|(f, doc)| ctx_item_chain(f, *doc,
1479 &format!("**`({})`** — {}\n\n_array subscript flag — `${{arr[({})pattern]}}`_", f, doc, f)))
1480 .collect();
1481 return json!({ "isIncomplete": false, "items": items });
1482 }
1483 LspCompletionContext::BuiltinFlag(ref builtin_name) => {
1484 let flags = extract_builtin_flags(builtin_name);
1487 let bname = builtin_name.clone();
1488 let cur_word: String = if let Some(l) = line {
1497 let bytes = l.as_bytes();
1498 let cap = col.min(bytes.len());
1499 let mut j = cap;
1500 while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
1501 j -= 1;
1502 }
1503 String::from_utf8_lossy(&bytes[j..cap]).to_string()
1504 } else {
1505 "-".to_string()
1506 };
1507 let stack_prefix: String = if cur_word.starts_with('-') && cur_word.len() >= 2 {
1513 cur_word.clone()
1514 } else {
1515 "-".to_string()
1516 };
1517 let cur_word_chars = cur_word.chars().count() as u64;
1528 let dash_col = (col as u64).saturating_sub(cur_word_chars);
1529 let edit_range = json!({
1530 "start": { "line": line_no, "character": dash_col },
1531 "end": { "line": line_no, "character": col },
1532 });
1533 let items: Vec<Value> = flags
1534 .into_iter()
1535 .map(|(flag, desc)| {
1536 let letter = flag.trim_start_matches('-');
1541 let label = if stack_prefix == "-" {
1542 flag.clone()
1543 } else {
1544 format!("{}{}", stack_prefix, letter)
1545 };
1546 let detail = if desc.is_empty() {
1547 format!("option flag for `{}`", bname)
1548 } else {
1549 desc.clone()
1550 };
1551 let doc_md = if desc.is_empty() {
1552 format!("**`{}`** — option flag for `{}`", flag, bname)
1553 } else {
1554 format!("**`{}`** — {}\n\n_option flag for `{}`_", flag, desc, bname)
1555 };
1556 let mut item = ctx_item(&label, &detail, &doc_md);
1557 if let Some(obj) = item.as_object_mut() {
1558 obj.remove("insertText");
1559 obj.insert(
1560 "textEdit".to_string(),
1561 json!({ "range": edit_range, "newText": label }),
1562 );
1563 }
1564 item
1565 })
1566 .collect();
1567 return json!({ "isIncomplete": false, "items": items });
1568 }
1569 LspCompletionContext::BuiltinLongFlag(ref builtin_name) => {
1570 let flags = extract_builtin_long_flags(builtin_name);
1575 let bname = builtin_name.clone();
1576 let cur_word: String = if let Some(l) = line {
1577 let bytes = l.as_bytes();
1578 let cap = col.min(bytes.len());
1579 let mut j = cap;
1580 while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
1581 j -= 1;
1582 }
1583 String::from_utf8_lossy(&bytes[j..cap]).to_string()
1584 } else {
1585 "--".to_string()
1586 };
1587 let cur_word_chars = cur_word.chars().count() as u64;
1588 let dash_col = (col as u64).saturating_sub(cur_word_chars);
1589 let edit_range = json!({
1590 "start": { "line": line_no, "character": dash_col },
1591 "end": { "line": line_no, "character": col },
1592 });
1593 let zshrs_specific_count = ZSHRS_SELF_LONG_FLAG_DOCS.len();
1599 let items: Vec<Value> = flags
1600 .into_iter()
1601 .enumerate()
1602 .map(|(idx, (flag, desc))| {
1603 let bucket = if idx < zshrs_specific_count { "0" } else { "1" };
1604 let detail = if desc.is_empty() {
1605 format!("long-form flag for `{}`", bname)
1606 } else {
1607 desc.clone()
1608 };
1609 let doc_md = if desc.is_empty() {
1610 format!("**`{}`** — long-form flag for `{}`", flag, bname)
1611 } else {
1612 format!(
1613 "**`{}`** — {}\n\n_long-form flag for `{}`_",
1614 flag, desc, bname
1615 )
1616 };
1617 json!({
1618 "label": flag,
1619 "kind": 14, "detail": detail,
1621 "filterText": flag,
1622 "sortText": format!("{}_{}", bucket, flag),
1623 "textEdit": {
1624 "range": edit_range,
1625 "newText": flag,
1626 },
1627 "documentation": {
1628 "kind": "markdown",
1629 "value": doc_md,
1630 },
1631 })
1632 })
1633 .collect();
1634 return json!({ "isIncomplete": false, "items": items });
1635 }
1636 LspCompletionContext::Normal => {}
1637 }
1638 }
1639
1640 let prefix = line
1641 .map(|line| {
1642 let upto = &line[..line.len().min(col)];
1643 let start = upto
1644 .rfind(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$' || c == '-'))
1645 .map(|i| i + 1)
1646 .unwrap_or(0);
1647 upto[start..].to_string()
1648 })
1649 .unwrap_or_default();
1650
1651 let mut items = Vec::new();
1652 let push = |items: &mut Vec<Value>, label: &str, kind: u8, detail: &str| {
1653 items.push(json!({
1662 "label": label,
1663 "kind": kind,
1664 "detail": detail,
1665 "filterText": label,
1666 "insertText": label,
1667 }));
1668 };
1669
1670 let pre = prefix.to_lowercase();
1672 let want = |s: &str| pre.is_empty() || s.to_lowercase().starts_with(&pre);
1673
1674 for k in KEYWORDS {
1676 if want(k) {
1677 push(&mut items, k, 14, "keyword");
1678 }
1679 }
1680 for (name, _token) in crate::ported::hashtable::RESWDS {
1687 if want(name) {
1688 push(&mut items, name, 14, "keyword");
1689 }
1690 }
1691 for b in BUILTINS {
1694 if want(b) {
1695 push(&mut items, b, 3, "builtin");
1696 }
1697 }
1698 for b in crate::ported::builtin::BUILTINS.iter() {
1706 if want(&b.node.nam) {
1707 push(&mut items, &b.node.nam, 3, "builtin");
1708 }
1709 }
1710 for n in crate::ext_builtins::EXT_BUILTIN_NAMES {
1717 if want(n) {
1718 push(&mut items, n, 3, "extension builtin");
1719 }
1720 }
1721 for n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES {
1722 if want(n) {
1723 push(&mut items, n, 3, "extension builtin (daemon)");
1724 }
1725 }
1726 for n in crate::compsys::COMPSYS_FN_NAMES {
1730 if want(n) {
1731 push(&mut items, n, 3, "compsys function");
1732 }
1733 }
1734 for o in OPTIONS {
1735 if want(o) {
1736 push(&mut items, o, 21, "option");
1737 }
1738 }
1739 for o in crate::ported::options::ZSH_OPTIONS_SET.iter() {
1743 if want(o) {
1744 push(&mut items, o, 21, "option");
1745 }
1746 }
1747 for (name, _doc) in crate::zsh_option_docs::OPTION_DOCS {
1755 if want(name) {
1756 push(&mut items, name, 21, "option");
1757 }
1758 }
1759 for (alias, _canon) in crate::zsh_option_docs::OPTION_ALIASES {
1760 if want(alias) {
1761 push(&mut items, alias, 21, "option");
1762 }
1763 }
1764 let prefix_has_dollar = prefix.starts_with('$');
1787 let bare_prefix: String = prefix
1788 .strip_prefix('$')
1789 .map(|s| s.to_string())
1790 .unwrap_or_default();
1791 let bare_prefix_lc = bare_prefix.to_lowercase();
1792 for (name, _doc) in crate::zsh_special_var_docs::SPECIAL_VAR_DOCS {
1793 if !name
1797 .chars()
1798 .next()
1799 .map(|c| c.is_ascii_alphabetic() || c == '_')
1800 .unwrap_or(false)
1801 {
1802 continue;
1803 }
1804 if want(name) {
1805 push(&mut items, name, 6, "special variable");
1806 } else if prefix_has_dollar
1807 && (bare_prefix_lc.is_empty() || name.to_lowercase().starts_with(&bare_prefix_lc))
1808 {
1809 let with_sigil = format!("${}", name);
1810 push(&mut items, &with_sigil, 6, "special variable");
1811 }
1812 }
1813 for (alias, _canon) in crate::zsh_special_var_docs::SPECIAL_VAR_ALIASES {
1817 if !alias
1818 .chars()
1819 .next()
1820 .map(|c| c.is_ascii_alphabetic() || c == '_')
1821 .unwrap_or(false)
1822 {
1823 continue;
1824 }
1825 if want(alias) {
1826 push(&mut items, alias, 6, "special variable");
1827 } else if prefix_has_dollar
1828 && (bare_prefix_lc.is_empty() || alias.to_lowercase().starts_with(&bare_prefix_lc))
1829 {
1830 let with_sigil = format!("${}", alias);
1831 push(&mut items, &with_sigil, 6, "special variable");
1832 }
1833 }
1834 for (prefix, body, detail) in SNIPPETS {
1839 if !want(prefix) {
1840 continue;
1841 }
1842 items.push(json!({
1843 "label": format!("{} …", prefix),
1844 "kind": 15u8,
1845 "detail": detail,
1846 "filterText": prefix,
1847 "insertText": body,
1848 "insertTextFormat": 2u8,
1849 }));
1850 }
1851
1852 if let Some(t) = text {
1854 for (name, kind, detail) in scan_symbols(t) {
1855 if want(&name) {
1856 let lsp_kind: u8 = match kind {
1857 "function" => 3,
1858 "variable" => 6,
1859 _ => 1,
1860 };
1861 push(&mut items, &name, lsp_kind, detail);
1862 }
1863 }
1864 }
1865
1866 if let Some(extra) = try_compsys_completion(state, params) {
1871 if let Some(arr) = extra["items"].as_array() {
1876 for item in arr {
1877 items.push(item.clone());
1878 }
1879 }
1880 let is_incomplete = extra["isIncomplete"].as_bool().unwrap_or(false);
1881 return json!({ "isIncomplete": is_incomplete, "items": items });
1882 }
1883
1884 json!({ "isIncomplete": false, "items": items })
1885}
1886
1887pub(crate) fn logical_line_at(text: &str, line_no: usize, col: usize) -> (String, usize, usize) {
1914 let lines: Vec<&str> = text.lines().collect();
1915 if lines.is_empty() {
1916 return (String::new(), 0, line_no);
1917 }
1918 let line_no = line_no.min(lines.len() - 1);
1919 let is_comment = |l: &str| l.trim_start().starts_with('#');
1920 let continues = |l: &str| {
1923 if is_comment(l) {
1924 return false;
1925 }
1926 let trailing = l.len() - l.trim_end_matches('\\').len();
1927 trailing % 2 == 1
1928 };
1929 let mut first = line_no;
1930 while first > 0 && continues(lines[first - 1]) {
1931 first -= 1;
1932 }
1933 let mut last = line_no;
1934 while last + 1 < lines.len() && continues(lines[last]) {
1935 last += 1;
1936 }
1937 if first == last {
1938 return (
1939 lines[line_no].to_string(),
1940 col.min(lines[line_no].len()),
1941 line_no,
1942 );
1943 }
1944 let mut joined = String::new();
1945 let mut cursor = col;
1946 for (i, l) in lines[first..=last].iter().enumerate() {
1947 let idx = first + i;
1948 let piece = if idx < last { &l[..l.len() - 1] } else { l };
1949 if idx == line_no {
1950 cursor = joined.len() + col.min(l.len());
1951 }
1952 joined.push_str(piece);
1953 }
1954 (joined, cursor, first)
1955}
1956
1957#[derive(Debug, PartialEq)]
1965pub(crate) enum ArgSpecPart {
1966 Head,
1970 Message,
1973 Action { word_start: usize },
1978 ActionOpaque,
1985}
1986
1987const ARG_SPEC_COMMANDS: &[&str] = &["_arguments", "_values", "_regex_arguments", "_alternative"];
1989
1990pub(crate) fn arg_spec_part_at(line: &str, col: usize) -> Option<ArgSpecPart> {
2000 let bytes = line.as_bytes();
2001 let cap = col.min(bytes.len());
2002 let mut quote: Option<u8> = None;
2012 let mut spec_start: Option<usize> = None;
2013 let mut cmd_start: Option<usize> = None;
2014 let mut cmd_end: Option<usize> = None;
2015 let mut i = 0usize;
2016 while i < cap {
2017 let c = bytes[i];
2018 match quote {
2019 None => match c {
2020 b'\'' | b'"' => {
2021 quote = Some(c);
2022 spec_start = Some(i + 1);
2023 if cmd_start.is_some() && cmd_end.is_none() {
2024 cmd_end = Some(i);
2025 }
2026 }
2027 b';' | b'|' | b'&' | b'\n' => {
2029 cmd_start = None;
2030 cmd_end = None;
2031 }
2032 b' ' | b'\t' => {
2033 if cmd_start.is_some() && cmd_end.is_none() {
2034 cmd_end = Some(i);
2035 }
2036 }
2037 _ => {
2038 if cmd_start.is_none() {
2039 cmd_start = Some(i);
2040 cmd_end = None;
2041 }
2042 }
2043 },
2044 Some(q) => {
2045 if q == b'"' && c == b'\\' {
2047 i += 2;
2048 continue;
2049 }
2050 if c == q {
2051 quote = None;
2052 spec_start = None;
2053 }
2054 }
2055 }
2056 i += 1;
2057 }
2058 let cmd = &line[cmd_start?..cmd_end.unwrap_or(cap)];
2059 if !ARG_SPEC_COMMANDS.contains(&cmd) {
2060 return None;
2061 }
2062 let start = spec_start?;
2063 quote?; let spec = &line[start..cap];
2065 let mut colons = 0usize;
2068 let mut last_colon = None;
2069 let mut brackets = 0i32;
2070 let mut parens = 0i32;
2071 let mut braces = 0i32;
2072 let sb = spec.as_bytes();
2073 let mut j = 0usize;
2074 while j < sb.len() {
2075 match sb[j] {
2076 b'\\' => {
2077 j += 2;
2078 continue;
2079 }
2080 b'[' => brackets += 1,
2081 b']' => brackets = (brackets - 1).max(0),
2082 b'(' => parens += 1,
2083 b')' => parens = (parens - 1).max(0),
2084 b'{' => braces += 1,
2085 b'}' => braces = (braces - 1).max(0),
2086 b':' if brackets == 0 && parens == 0 && braces == 0 => {
2087 colons += 1;
2088 last_colon = Some(j);
2089 }
2090 _ => {}
2091 }
2092 j += 1;
2093 }
2094 match colons {
2095 0 => Some(ArgSpecPart::Head),
2096 1 => Some(ArgSpecPart::Message),
2097 _ => {
2098 if parens > 0 || braces > 0 || brackets > 0 {
2101 return Some(ArgSpecPart::ActionOpaque);
2102 }
2103 let action_start = start + last_colon.unwrap_or(0) + 1;
2104 match line[action_start..cap].find(|c: char| c == ' ' || c == '\t') {
2107 Some(_) => Some(ArgSpecPart::ActionOpaque),
2108 None => Some(ArgSpecPart::Action {
2109 word_start: action_start,
2110 }),
2111 }
2112 }
2113 }
2114}
2115
2116fn arg_spec_action_items(word_start: usize, col: usize, line_no: usize, line: &str) -> Value {
2123 let typed = &line[word_start.min(line.len())..col.min(line.len())];
2124 if typed.starts_with('-') {
2128 return json!({ "isIncomplete": false, "items": [] });
2129 }
2130 let range_start = word_start;
2131 let mk = |label: &str, detail: &str, insert: &str, doc: &str, sort: &str| -> Value {
2132 json!({
2133 "label": label,
2134 "kind": if insert.contains("${") { 15 } else { 3 },
2135 "detail": detail,
2136 "documentation": { "kind": "markdown", "value": doc },
2137 "filterText": label,
2138 "insertTextFormat": if insert.contains("${") { 2 } else { 1 },
2139 "sortText": format!("{sort}_{label}"),
2140 "textEdit": {
2141 "range": {
2142 "start": { "line": line_no, "character": range_start },
2143 "end": { "line": line_no, "character": col },
2144 },
2145 "newText": insert,
2146 },
2147 })
2148 };
2149 let mut items = vec![
2153 mk(
2154 "(list)",
2155 "literal value list",
2156 "(${1:one} ${2:two})",
2157 "**`(word ...)`** — complete these literal words.\n\n\
2158 `'1:command:(build test)'`",
2159 "0",
2160 ),
2161 mk(
2162 "((val:desc))",
2163 "described value list",
2164 "((${1:val}\\:${2:description}))",
2165 concat!(
2166 "**`((word\\:desc ...))`** — literal words WITH ",
2167 "descriptions, shown like `_describe` output.\n\n",
2168 "`'1:cmd:((build\\:compile test\\:check))'`",
2169 ),
2170 "0",
2171 ),
2172 mk(
2173 "->state",
2174 "dispatch to $state",
2175 "->${1:state}",
2176 "**`->string`** — set `$state` to _string_ and return, so \
2177 the caller handles this argument in a `case $state in` \
2178 block. Needs `_arguments -C` for `$curcontext` to be \
2179 writable.",
2180 "0",
2181 ),
2182 mk(
2183 "{eval}",
2184 "shell-command action",
2185 "{${1:_command}}",
2186 "**`{eval-string}`** — run the shell code to generate \
2187 matches. Anything more than a call belongs in a function.",
2188 "0",
2189 ),
2190 mk(
2191 " ",
2192 "message-only (no completion)",
2193 " ",
2194 "**` `** (a single space) — accept the argument but offer \
2195 nothing; the message half is still shown via `_message`.",
2196 "0",
2197 ),
2198 ];
2199 const PREFERRED: &[&str] = &[
2203 "_files",
2204 "_directories",
2205 "_normal",
2206 "_command_names",
2207 "_hosts",
2208 "_users",
2209 "_parameters",
2210 "_options",
2211 "_signals",
2212 "_pids",
2213 "_message",
2214 "_nothing",
2215 "_guard",
2216 "_values",
2217 "_describe",
2218 "_alternative",
2219 ];
2220 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
2221 for name in crate::compsys::COMPSYS_FN_NAMES {
2222 if !seen.insert(name) {
2223 continue;
2224 }
2225 let rank = if PREFERRED.contains(name) { "1" } else { "2" };
2226 items.push(mk(
2227 name,
2228 "compsys function",
2229 name,
2230 &format!("**`{name}`** — compsys completion function used as an `_arguments` action."),
2231 rank,
2232 ));
2233 }
2234 let extra: Vec<String> = crate::ported::hashtable::shfunctab_lock()
2241 .read()
2242 .ok()
2243 .map(|tab| {
2244 tab.iter()
2245 .map(|(name, _)| name)
2246 .filter(|k| k.starts_with('_'))
2247 .cloned()
2248 .collect()
2249 })
2250 .unwrap_or_default();
2251 for name in &extra {
2252 if !seen.insert(name.as_str()) {
2253 continue;
2254 }
2255 items.push(mk(
2256 name,
2257 "completion function (fpath)",
2258 name,
2259 &format!("**`{name}`** — completion function known to this shell, usable as an `_arguments` action."),
2260 "3",
2261 ));
2262 }
2263 let _ = typed;
2264 json!({ "isIncomplete": false, "items": items })
2265}
2266
2267fn utf16_col_to_byte(line: &str, col_u16: usize) -> usize {
2273 let mut units = 0usize;
2274 for (byte_idx, ch) in line.char_indices() {
2275 if units >= col_u16 {
2276 return byte_idx;
2277 }
2278 units += ch.len_utf16();
2279 }
2280 line.len()
2281}
2282
2283fn try_compsys_completion(state: &State, params: &Value) -> Option<Value> {
2295 let uri = params["textDocument"]["uri"].as_str()?;
2296 let pos = ¶ms["position"];
2297 let text = state.docs.get(uri)?;
2298 let line_no = pos["line"].as_u64()? as usize;
2299 let col_u16 = pos["character"].as_u64()? as usize;
2300 let line_text = text.lines().nth(line_no)?;
2301 let phys_col = utf16_col_to_byte(line_text, col_u16);
2305 let (line_text, col, _) = logical_line_at(text, line_no, phys_col);
2311 let line_text = line_text.as_str();
2312 static WARM: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
2317 let budget = if WARM.load(std::sync::atomic::Ordering::Relaxed) {
2325 std::time::Duration::from_millis(350)
2326 } else {
2327 std::time::Duration::from_millis(2000)
2328 };
2329 let req = crate::compsys::in_editor::CompsysRequest::new_with_budget(line_text, col, budget);
2330 let resp = crate::compsys::in_editor::complete_at(req);
2331 if !resp.matches.is_empty() {
2332 WARM.store(true, std::sync::atomic::Ordering::Relaxed);
2333 }
2334 if resp.matches.is_empty() && !resp.is_incomplete {
2335 return None;
2336 }
2337 let mut order: Vec<String> = Vec::with_capacity(resp.matches.len());
2350 let mut best: std::collections::HashMap<String, crate::compsys::in_editor::CompsysMatch> =
2351 std::collections::HashMap::new();
2352 for m in &resp.matches {
2353 match best.get_mut(&m.completion) {
2354 None => {
2355 order.push(m.completion.clone());
2356 best.insert(m.completion.clone(), m.clone());
2357 }
2358 Some(prev) => {
2359 let prev_empty = prev.description.as_deref().unwrap_or("").is_empty();
2360 let now_has = !m.description.as_deref().unwrap_or("").is_empty();
2361 if prev_empty && now_has {
2362 *prev = m.clone();
2363 }
2364 }
2365 }
2366 }
2367 let deduped: Vec<crate::compsys::in_editor::CompsysMatch> =
2368 order.iter().filter_map(|k| best.remove(k)).collect();
2369 let items: Vec<Value> = deduped
2370 .iter()
2371 .map(|m| {
2372 let kind: u64 = match m.group.as_deref() {
2379 Some("options") => 5, Some("subcommands") | Some("commands") => 3, Some("values") => 12, Some("hosts") | Some("files") | Some("directories") => 17, _ => 1, };
2385 let bucket = match m.group.as_deref() {
2389 Some("subcommands") | Some("commands") => "0",
2390 Some("options") => "1",
2391 Some("values") => "2",
2392 Some("hosts") => "3",
2393 Some("files") | Some("directories") => "4",
2394 _ => "5",
2395 };
2396 let detail = m
2401 .description
2402 .clone()
2403 .map(|d| match d.split_once(" -- ") {
2404 Some((_, prose)) => prose.trim().to_string(),
2405 None => d.trim().to_string(),
2406 })
2407 .filter(|d| !d.is_empty())
2408 .or_else(|| m.group.clone().filter(|g| !g.starts_with('-')))
2413 .unwrap_or_default();
2414 json!({
2415 "label": m.completion,
2416 "kind": kind,
2417 "detail": detail,
2418 "filterText": m.completion,
2419 "insertText": m.completion,
2420 "sortText": format!("{bucket}_{}", m.completion),
2421 })
2422 })
2423 .collect();
2424 Some(json!({
2425 "isIncomplete": resp.is_incomplete,
2426 "items": items,
2427 }))
2428}
2429
2430const SNIPPETS: &[(&str, &str, &str)] = &[
2451 ("if", "if ${1:cmd}; then\n ${2:body}\nfi${0}", "if/then/fi block (snippet)"),
2453 ("ifelse", "if ${1:cmd}; then\n ${2:body}\nelse\n ${3:alt}\nfi${0}", "if/else/fi block (snippet)"),
2454 ("ifelsif", "if ${1:cmd1}; then\n ${2:body}\nelif ${3:cmd2}; then\n ${4:alt}\nelse\n ${5:fallback}\nfi${0}", "if/elif/else chain (snippet)"),
2455 ("elsif", "elif ${1:cmd}; then\n ${2:body}${0}", "elif branch (snippet)"),
2456 ("unless", "if ! ${1:cmd}; then\n ${2:body}\nfi${0}", "negated if (snippet)"),
2457 ("for", "for ${1:item} in ${2:list}; do\n ${3:body}\ndone${0}", "for loop (snippet)"),
2458 ("forin", "for ${1:item} in \"${2:\\${array[@]}}\"; do\n ${3:body}\ndone${0}", "for over quoted-array expansion (snippet)"),
2459 ("forarith", "for ((${1:i}=0; \\$${1:i} < ${2:n}; ${1:i}++)); do\n ${3:body}\ndone${0}", "C-style arithmetic for (snippet)"),
2460 ("foreach", "foreach ${1:item} (${2:list})\n ${3:body}\nend${0}", "zsh-alt foreach…end (snippet)"),
2461 ("while", "while ${1:cmd}; do\n ${2:body}\ndone${0}", "while loop (snippet)"),
2462 ("until", "until ${1:cmd}; do\n ${2:body}\ndone${0}", "until loop (snippet)"),
2463 ("case", "case ${1:word} in\n ${2:pattern})\n ${3:body}\n ;;\n *)\n ${4:default}\n ;;\nesac${0}", "case/esac (snippet)"),
2464 ("select", "select ${1:choice} in ${2:items}; do\n ${3:body}\n break\ndone${0}", "select interactive menu (snippet)"),
2465 ("repeat", "repeat ${1:N}; do\n ${2:body}\ndone${0}", "repeat N times (snippet)"),
2466 ("break", "break ${1:1}${0}", "break N levels (snippet)"),
2467 ("continue", "continue ${1:1}${0}", "continue N levels (snippet)"),
2468 ("return", "return ${1:0}${0}", "return status (snippet)"),
2469 ("fn", "${1:name}() {\n ${2:body}\n}${0}", "function declaration (snippet)"),
2471 ("function", "function ${1:name} {\n ${2:body}\n}${0}", "function keyword form (snippet)"),
2472 ("anonfn", "() {\n ${1:body}\n} ${2:args}${0}", "anonymous function (snippet)"),
2473 ("local", "local ${1:var}=${2:value}${0}", "local declaration (snippet)"),
2474 ("locals", "local ${1:a}=\"\\$1\" ${2:b}=\"\\$2\" ${3:c}=\"\\$3\"${0}", "local positional-arg unpack (snippet)"),
2475 ("typeset", "typeset -${1:gAi} ${2:name}${3:=value}${0}", "typeset with attributes (snippet)"),
2476 ("export", "export ${1:NAME}=\"${2:value}\"${0}", "export env var (snippet)"),
2477 ("readonly", "readonly ${1:NAME}=\"${2:value}\"${0}", "readonly var (snippet)"),
2478 ("integer", "integer ${1:name}=${2:0}${0}", "integer typeset shorthand (snippet)"),
2479 ("array", "${1:name}=(${2:a b c})${0}", "indexed array literal (snippet)"),
2480 ("assoc", "typeset -A ${1:name}\n${1:name}=(\n [${2:key1}]=${3:val1}\n [${4:key2}]=${5:val2}\n)${0}", "associative array (snippet)"),
2481 ("trap", "trap '${1:handler}' ${2:INT TERM EXIT}${0}", "signal trap (snippet)"),
2483 ("setopt", "setopt ${1:EXTENDED_GLOB NULL_GLOB PIPE_FAIL}${0}", "setopt one or more options (snippet)"),
2484 ("unsetopt", "unsetopt ${1:CASE_GLOB}${0}", "unsetopt options (snippet)"),
2485 ("autoload", "autoload -Uz ${1:funcname}${0}", "autoload function with -Uz (snippet)"),
2486 ("compdef", "compdef ${1:_completer} ${2:command}${0}", "register completion (snippet)"),
2487 ("bindkey", "bindkey '${1:^X^E}' ${2:edit-command-line}${0}", "ZLE bindkey (snippet)"),
2488 ("alias", "alias ${1:name}='${2:command}'${0}", "alias (snippet)"),
2489 ("galias", "alias -g ${1:NAME}='${2:expansion}'${0}", "global alias (snippet)"),
2490 ("salias", "alias -s ${1:ext}='${2:opener}'${0}", "suffix alias (snippet)"),
2491 ("precmd", "autoload -Uz add-zsh-hook\n${1:my_precmd}() {\n ${2:body}\n}\nadd-zsh-hook precmd ${1:my_precmd}${0}", "precmd hook (snippet)"),
2493 ("preexec", "autoload -Uz add-zsh-hook\n${1:my_preexec}() {\n ${2:body} # \\$1 = command line\n}\nadd-zsh-hook preexec ${1:my_preexec}${0}", "preexec hook (snippet)"),
2494 ("chpwd", "autoload -Uz add-zsh-hook\n${1:my_chpwd}() {\n ${2:body}\n}\nadd-zsh-hook chpwd ${1:my_chpwd}${0}", "chpwd hook (snippet)"),
2495 ("periodic", "autoload -Uz add-zsh-hook\nPERIOD=${1:60}\n${2:my_periodic}() {\n ${3:body}\n}\nadd-zsh-hook periodic ${2:my_periodic}${0}", "periodic hook (snippet)"),
2496 ("zshexit", "autoload -Uz add-zsh-hook\n${1:my_zshexit}() {\n ${2:cleanup}\n}\nadd-zsh-hook zshexit ${1:my_zshexit}${0}", "zshexit hook (snippet)"),
2497 ("shebang", "#!/usr/bin/env zshrs\n${0}", "zshrs shebang (snippet)"),
2499 ("safeshebang", "#!/usr/bin/env zsh\nemulate -L zsh\nsetopt err_return no_unset pipe_fail extended_glob\n${0}", "strict-mode shebang (snippet)"),
2500 ("main", "#!/usr/bin/env zshrs\nemulate -L zsh\nsetopt err_return no_unset pipe_fail\n\n${1:main}() {\n ${2:body}\n}\n\n${1:main} \"\\$@\"${0}", "main() scaffold (snippet)"),
2501 ("usage", "${1:usage}() {\n cat <<'EOT'\nUsage: ${2:command} [-h] [-v] ARG...\n\n -h show this help\n -v verbose\nEOT\n}${0}", "usage() helper (snippet)"),
2502 ("strict", "emulate -L zsh\nsetopt err_return no_unset pipe_fail extended_glob${0}", "strict-mode options (snippet)"),
2503 ("while-read", "while IFS= read -r ${1:line}; do\n ${2:body}\ndone < ${3:file}${0}", "read-loop over file (snippet)"),
2505 ("for-each-line", "for ${1:line} in \"\\${(@f)\\$(cat ${2:file})}\"; do\n ${3:body}\ndone${0}", "for-each-line via process subst (snippet)"),
2506 ("cat-pipe", "${1:cmd} | while read -r ${2:line}; do\n ${3:body}\ndone${0}", "pipe-to-while (snippet)"),
2507 ("heredoc", "cat <<EOT\n${1:body}\nEOT${0}", "heredoc (snippet)"),
2508 ("heredocl", "cat <<-EOT\n\t${1:body}\nEOT${0}", "tab-stripped heredoc (snippet)"),
2509 ("herestring", "${1:cmd} <<< \"${2:input}\"${0}", "here-string (snippet)"),
2510 ("psub-in", "${1:cmd} < <(${2:producer})${0}", "process substitution (input) (snippet)"),
2511 ("psub-out", "${1:cmd} > >(${2:consumer})${0}", "process substitution (output) (snippet)"),
2512 ("subshell", "(\n ${1:body}\n)${0}", "subshell (snippet)"),
2513 ("printfmt", "printf '%s\\\\n' \"${1:args}\"${0}", "printf line-per-arg (snippet)"),
2514 ("dirtest", "[[ -d \"${1:path}\" ]] && ${2:body}${0}", "directory-test guard (snippet)"),
2516 ("filetest", "[[ -f \"${1:path}\" ]] && ${2:body}${0}", "regular-file guard (snippet)"),
2517 ("regexm", "if [[ \"${1:str}\" =~ ${2:pattern} ]]; then\n ${3:body} # \\$match[*] / \\$MATCH\nfi${0}", "regex match into \\$match (snippet)"),
2518 ("notempty", "[[ -n \"${1:var}\" ]] || ${2:return 1}${0}", "non-empty guard (snippet)"),
2519 ("async", "${1:job}=\\$(async ${2:'expensive_command'})\n${3:# … other work …}\n${4:result}=\\$(await \\$${1:job})${0}", "async + await pair (snippet)"),
2521 ("barrier", "barrier '${1:task1}' ::: '${2:task2}' ::: '${3:task3}'${0}", "barrier (parallel + join) (snippet)"),
2522 ("peach", "peach '${1:cmd {\\}}' ${2:items}${0}", "parallel for-each on worker pool (snippet)"),
2523 ("intercept", "intercept ${1:before} ${2:command} {\n ${3:body}\n}${0}", "AOP intercept (snippet)"),
2524 ("zle-widget", "${1:my-widget}() {\n ${2:zle .accept-line}\n}\nzle -N ${1:my-widget}\nbindkey '${3:^X^E}' ${1:my-widget}${0}", "ZLE widget + bindkey (snippet)"),
2526 ("argspec", "_arguments \\\\\n '(-h --help)'{-h,--help}'[show help]' \\\\\n '(-v --verbose)'{-v,--verbose}'[verbose]' \\\\\n ':${1:argname}:${2:_files}'${0}", "_arguments spec (snippet)"),
2528 ("filesspec", "_files -g '${1:*.zsh}'${0}", "_files glob spec (snippet)"),
2529 ("valspec", "_values '${1:tag}' \\\\\n '${2:one}[${3:desc}]' \\\\\n '${4:two}[${5:desc}]'${0}", "_values descriptor (snippet)"),
2530 ("describe", "_describe '${1:group}' ${2:choices_array}${0}", "_describe (snippet)"),
2531 ("test", "#!/usr/bin/env zshrs\nemulate -L zsh\nsetopt err_return no_unset\n\n${1:test_name}() {\n [[ \"${2:got}\" == \"${3:want}\" ]] && echo PASS || { echo FAIL; return 1; }\n}\n\n${1:test_name}${0}", "test scaffold (snippet)"),
2533 ("gitcommit", "git add -A && git commit -m \"${1:message}\" && git push${0}", "git add+commit+push (snippet)"),
2534 ("curlget", "curl -fsSL ${1:https://example.com/api} | ${2:jq .}${0}", "curl GET + jq pipe (snippet)"),
2535 ("jsonget", "${1:cmd} | jq -r '${2:.field}'${0}", "extract JSON field via jq (snippet)"),
2536 ("zmodload", "zmodload zsh/${1:datetime}${0}", "load zsh module (snippet)"),
2537];
2538
2539fn hover(state: &State, params: &Value) -> Value {
2542 let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
2543 let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
2544 let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
2545 let text = match state.docs.get(uri) {
2546 Some(t) => t,
2547 None => {
2548 tracing::trace!(target: "zshrs::lsp::hover", line = line_no, col, "no_doc_for_uri");
2549 return Value::Null;
2550 }
2551 };
2552 let word = word_at(text, line_no, col).unwrap_or_default();
2553 if word.is_empty() {
2554 tracing::trace!(target: "zshrs::lsp::hover", line = line_no, col, "empty_word");
2555 return Value::Null;
2556 }
2557 let line_text = text.lines().nth(line_no).unwrap_or("");
2558 let (word_start, word_end) = word_span_at(line_text, col).unwrap_or((col, col));
2562 let gate = classify_hover_position(line_text, word_start, word_end);
2563 if let Some(module_doc) = find_module_doc_for_position(state, text, line_text, line_no) {
2568 tracing::debug!(target: "zshrs::lsp::hover", line = line_no, col, "module_hit");
2569 return json!({
2570 "contents": { "kind": "markdown", "value": module_doc }
2571 });
2572 }
2573 let (logical_line, logical_col, _) = logical_line_at(text, line_no, col);
2578 let in_spec_action = matches!(
2579 arg_spec_part_at(&logical_line, logical_col),
2580 Some(ArgSpecPart::Action { .. }) | Some(ArgSpecPart::ActionOpaque)
2581 ) && word.starts_with('_');
2582 if gate != HoverGate::Code && !in_spec_action {
2583 tracing::debug!(
2584 target: "zshrs::lsp::hover",
2585 line = line_no, col, %word,
2586 gated = ?gate,
2587 "suppressed",
2588 );
2589 return Value::Null;
2590 }
2591 let mut doc = if let Some(user_doc) = find_user_symbol_doc(text, &word) {
2599 user_doc
2600 } else {
2601 String::new()
2602 };
2603 if doc.is_empty() {
2604 doc = lookup_doc(&word);
2605 }
2606 if doc.is_empty() {
2607 if let Some(module_doc) = find_module_doc_for_position(state, text, line_text, line_no) {
2618 doc = module_doc;
2619 }
2620 }
2621 if doc.is_empty() {
2622 tracing::trace!(target: "zshrs::lsp::hover", line = line_no, col, %word, "miss");
2623 return Value::Null;
2624 }
2625 tracing::debug!(target: "zshrs::lsp::hover", line = line_no, col, %word, "hit");
2626 json!({
2627 "contents": {
2628 "kind": "markdown",
2629 "value": doc,
2630 }
2631 })
2632}
2633
2634pub(crate) fn find_user_symbol_doc(text: &str, name: &str) -> Option<String> {
2655 let lines: Vec<&str> = text.lines().collect();
2656 if let Some(table) = crate::lsp_symbols::SymbolTable::build(text) {
2672 let mut undocumented: Option<(u32, &'static str, &str)> = None;
2676 for sym in &table.symbols {
2677 if sym.name != name {
2678 continue;
2679 }
2680 let line_idx = sym.decl_line as usize;
2681 let def_line = lines.get(line_idx).copied().unwrap_or("");
2682 let line_trim = def_line.trim_start();
2687 let kind = if line_trim.starts_with("alias ") || line_trim.starts_with("alias\t") {
2688 "alias"
2689 } else {
2690 match sym.kind {
2691 crate::lsp_symbols::SymbolKind::Func => "function",
2692 crate::lsp_symbols::SymbolKind::Global => "parameter",
2693 crate::lsp_symbols::SymbolKind::Local => "parameter",
2694 }
2695 };
2696 let doc_block = collect_doc_block_above(&lines, line_idx);
2697 if !doc_block.is_empty() {
2698 return Some(render_user_doc_card(
2699 name,
2700 kind,
2701 def_line.trim(),
2702 &doc_block,
2703 ));
2704 }
2705 if undocumented.is_none() {
2706 undocumented = Some((sym.decl_line, kind, def_line));
2707 }
2708 }
2709 if let Some((_, kind, line)) = undocumented {
2710 return Some(render_user_doc_card_no_block(name, kind, line.trim()));
2711 }
2712 }
2715 let mut undocumented: Option<(usize, &'static str, &str)> = None;
2718 for (i, line) in lines.iter().enumerate() {
2719 let kind = match symbol_decl_kind(line, name) {
2720 Some(k) => k,
2721 None => continue,
2722 };
2723 let doc_block = collect_doc_block_above(&lines, i);
2724 if !doc_block.is_empty() {
2725 return Some(render_user_doc_card(name, kind, line.trim(), &doc_block));
2726 }
2727 if undocumented.is_none() {
2728 undocumented = Some((i, kind, line));
2729 }
2730 }
2731 undocumented.map(|(_, kind, line)| render_user_doc_card_no_block(name, kind, line.trim()))
2732}
2733
2734fn render_user_doc_card_no_block(name: &str, kind: &str, def_line: &str) -> String {
2740 format!(
2741 "**user-defined {kind} `{name}`**\n\n```zsh\n{def_line}\n```\n\n\
2742 *(no `## …` doc-comment block above the definition)*"
2743 )
2744}
2745
2746fn symbol_decl_kind(line: &str, name: &str) -> Option<&'static str> {
2751 let t = line.trim_start();
2752 if t.starts_with('#') {
2753 return None;
2754 }
2755 if let Some(rest) = t
2757 .strip_prefix("function ")
2758 .or_else(|| t.strip_prefix("function\t"))
2759 {
2760 if first_ident(rest).as_deref() == Some(name) {
2761 return Some("function");
2762 }
2763 }
2764 if let Some(idx) = t.find("()") {
2768 let head = &t[..idx];
2769 if !head.contains(' ') && !head.contains('\t') && head == name {
2770 return Some("function");
2771 }
2772 }
2773 if let Some(rest) = t.strip_prefix("alias ") {
2775 if first_ident(rest).as_deref() == Some(name) {
2776 return Some("alias");
2777 }
2778 }
2779 for prefix in &[
2784 "local ",
2785 "typeset ",
2786 "declare ",
2787 "readonly ",
2788 "export ",
2789 "integer ",
2790 "float ",
2791 ] {
2792 if let Some(rest) = t.strip_prefix(prefix) {
2793 let after_flags = skip_leading_flags(rest);
2794 if first_ident(after_flags).as_deref() == Some(name) {
2795 return Some("parameter");
2796 }
2797 }
2798 }
2799 if let Some(eq) = t.find('=') {
2801 let head = &t[..eq];
2802 if head == name && !head.contains(' ') && !head.contains('\t') {
2803 return Some("parameter");
2804 }
2805 }
2806 None
2807}
2808
2809fn collect_doc_block_above(lines: &[&str], def_line: usize) -> String {
2822 let mut collected: Vec<String> = Vec::new();
2823 let mut saw_doc = false;
2824 for j in (0..def_line).rev() {
2825 let trimmed = lines[j].trim_start();
2826 if trimmed.is_empty() {
2827 if saw_doc {
2828 break;
2831 }
2832 continue;
2835 }
2836 if let Some(rest) = trimmed.strip_prefix("## ") {
2837 collected.push(rest.to_string());
2838 saw_doc = true;
2839 continue;
2840 }
2841 if trimmed == "##" {
2842 collected.push(String::new());
2843 saw_doc = true;
2844 continue;
2845 }
2846 break;
2850 }
2851 collected.reverse();
2852 while collected.last().is_some_and(|l| l.is_empty()) {
2854 collected.pop();
2855 }
2856 collected.join("\n")
2857}
2858
2859fn find_module_doc_for_position(
2869 state: &State,
2870 text: &str,
2871 line_text: &str,
2872 _line_no: usize,
2873) -> Option<String> {
2874 let trimmed = line_text.trim_start();
2875 if trimmed.starts_with("#!") {
2876 return extract_module_doc(text).map(|d| render_module_doc_card("(this file)", &d));
2877 }
2878 let path_arg: Option<&str> = if let Some(rest) = trimmed.strip_prefix("source ") {
2882 Some(rest.split_whitespace().next().unwrap_or(""))
2883 } else if let Some(rest) = trimmed.strip_prefix(". ") {
2884 Some(rest.split_whitespace().next().unwrap_or(""))
2885 } else {
2886 None
2887 };
2888 let path = path_arg.filter(|p| !p.is_empty())?;
2889 let path = path.trim_matches(|c| c == '"' || c == '\'');
2891 let needle = path.strip_prefix("./").unwrap_or(path);
2895 let body = state
2897 .docs
2898 .iter()
2899 .find_map(|(uri, body)| uri.ends_with(needle).then(|| body.clone()))
2900 .or_else(|| std::fs::read_to_string(path).ok())?;
2901 extract_module_doc(&body).map(|d| render_module_doc_card(path, &d))
2902}
2903
2904fn render_module_doc_card(label: &str, doc: &str) -> String {
2911 format!("{doc}\n\n---\n\nzsh module `{label}`")
2912}
2913
2914pub(crate) fn extract_module_doc(text: &str) -> Option<String> {
2937 let lines: Vec<&str> = text.lines().collect();
2938 let mut i = 0;
2939 if let Some(first) = lines.first() {
2941 if first.starts_with("#!") {
2942 i = 1;
2943 }
2944 }
2945 while i < lines.len() && lines[i].trim().is_empty() {
2947 i += 1;
2948 }
2949 let mut collected: Vec<String> = Vec::new();
2951 while i < lines.len() {
2952 let trimmed = lines[i].trim_start();
2953 if let Some(rest) = trimmed.strip_prefix("## ") {
2954 collected.push(rest.to_string());
2955 } else if trimmed == "##" {
2956 collected.push(String::new());
2957 } else {
2958 break;
2959 }
2960 i += 1;
2961 }
2962 while collected.last().is_some_and(|l| l.is_empty()) {
2963 collected.pop();
2964 }
2965 if collected.is_empty() {
2966 None
2967 } else {
2968 Some(collected.join("\n"))
2969 }
2970}
2971
2972fn skip_leading_flags(s: &str) -> &str {
2977 let mut rest = s.trim_start();
2978 while rest.starts_with('-') || rest.starts_with('+') {
2979 let end = rest
2981 .char_indices()
2982 .find(|(_, c)| c.is_whitespace())
2983 .map(|(i, _)| i)
2984 .unwrap_or(rest.len());
2985 rest = rest[end..].trim_start();
2986 }
2987 rest
2988}
2989
2990fn render_user_doc_card(name: &str, kind: &str, _def_line: &str, doc: &str) -> String {
2998 format!("{doc}\n\n---\n\nuser-defined {kind} `{name}`")
2999}
3000
3001#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3006pub(crate) enum HoverGate {
3007 Code,
3009 Comment,
3011 StringLiteral,
3018}
3019
3020fn position_inside_string_literal(line_text: &str, start: usize, end: usize) -> bool {
3037 let bytes = line_text.as_bytes();
3038 let cap = end.min(bytes.len());
3046 if start < cap
3047 && bytes[start] == b'$'
3048 && bytes[start + 1..cap]
3049 .iter()
3050 .all(|b| b.is_ascii_alphanumeric() || *b == b'_')
3051 {
3052 return false;
3053 }
3054 if start > 0 && start < cap && bytes[start - 1] == b'$' {
3055 let span_ok = bytes[start..cap]
3056 .iter()
3057 .all(|b| b.is_ascii_alphanumeric() || *b == b'_');
3058 if span_ok {
3059 return false;
3060 }
3061 }
3062 let limit = start.min(bytes.len());
3063 let mut i = 0;
3064 let mut in_str: Option<u8> = None;
3065 let mut interp_depth: i32 = 0;
3066 while i < limit {
3067 let c = bytes[i];
3068 if interp_depth > 0 {
3071 match c {
3072 b'{' => interp_depth += 1,
3073 b'}' => interp_depth -= 1,
3074 _ => {}
3075 }
3076 i += 1;
3077 continue;
3078 }
3079 if let Some(q) = in_str {
3080 if (q == b'"' || q == b'`') && c == b'\\' && i + 1 < bytes.len() {
3081 i += 2;
3082 continue;
3083 }
3084 if (q == b'"' || q == b'`') && c == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{'
3087 {
3088 interp_depth = 1;
3089 i += 2;
3090 continue;
3091 }
3092 if c == q {
3093 in_str = None;
3094 }
3095 i += 1;
3096 continue;
3097 }
3098 match c {
3099 b'#' => return false,
3100 b'"' | b'\'' | b'`' => in_str = Some(c),
3101 _ => {}
3102 }
3103 i += 1;
3104 }
3105 if interp_depth > 0 {
3107 return false;
3108 }
3109 if in_str.is_none() {
3110 return false;
3111 }
3112 let q = in_str.unwrap();
3118 let mut j = start;
3119 while j < end.min(bytes.len()) {
3120 if (q == b'"' || q == b'`') && bytes[j] == b'\\' && j + 1 < bytes.len() {
3121 j += 2;
3122 continue;
3123 }
3124 if bytes[j] == q {
3125 return false;
3126 }
3127 j += 1;
3128 }
3129 true
3130}
3131
3132pub(crate) fn classify_hover_position(line_text: &str, start: usize, end: usize) -> HoverGate {
3136 if line_starts_comment_before(line_text, start) {
3137 return HoverGate::Comment;
3138 }
3139 if position_inside_string_literal(line_text, start, end) {
3140 return HoverGate::StringLiteral;
3141 }
3142 HoverGate::Code
3143}
3144
3145fn word_span_at(line_text: &str, col: usize) -> Option<(usize, usize)> {
3150 let bytes = line_text.as_bytes();
3151 if col > bytes.len() {
3152 return None;
3153 }
3154 let mut start = col;
3156 while start > 0 {
3157 let c = bytes[start - 1] as char;
3158 if c == '_' || c.is_alphanumeric() || c == '$' {
3159 start -= 1;
3160 } else {
3161 break;
3162 }
3163 }
3164 let mut end = col;
3165 while end < bytes.len() {
3166 let c = bytes[end] as char;
3167 if c == '_' || c.is_alphanumeric() {
3168 end += 1;
3169 } else {
3170 break;
3171 }
3172 }
3173 if start == end {
3174 return None;
3175 }
3176 let is_dollar_var = bytes[start] == b'$';
3180 let in_braced = start > 0 && bytes[start - 1] == b'{';
3181 if !is_dollar_var && !in_braced {
3182 while end < bytes.len() && bytes[end] == b'-' {
3183 let mut p = end + 1;
3184 while p < bytes.len() {
3185 let c = bytes[p] as char;
3186 if c == '_' || c.is_alphanumeric() {
3187 p += 1;
3188 } else {
3189 break;
3190 }
3191 }
3192 if p > end + 1 {
3193 end = p;
3194 } else {
3195 break;
3196 }
3197 }
3198 while start > 1 && bytes[start - 1] == b'-' {
3199 let mut p = start - 1;
3200 while p > 0 {
3201 let c = bytes[p - 1] as char;
3202 if c == '_' || c.is_alphanumeric() {
3203 p -= 1;
3204 } else {
3205 break;
3206 }
3207 }
3208 if p < start - 1 {
3209 start = p;
3210 } else {
3211 break;
3212 }
3213 }
3214 }
3215 Some((start, end))
3216}
3217
3218pub(crate) fn cursor_in_uninterpolated_string(line: &str, col: usize) -> bool {
3249 let bytes = line.as_bytes();
3250 let cap = col.min(bytes.len());
3251 let mut stack: Vec<u8> = Vec::new();
3255 let mut i = 0;
3256 while i < cap {
3257 let c = bytes[i];
3258 let top = stack.last().copied();
3259 if matches!(top, Some(b'"') | Some(b'`')) && c == b'\\' && i + 1 < cap {
3262 i += 2;
3263 continue;
3264 }
3265 match top {
3266 Some(b'\'') => {
3268 if c == b'\'' {
3269 stack.pop();
3270 }
3271 i += 1;
3272 continue;
3273 }
3274 Some(b'"') => {
3276 if c == b'"' {
3277 stack.pop();
3278 i += 1;
3279 continue;
3280 }
3281 if c == b'$' && i + 1 < cap {
3282 let nxt = bytes[i + 1];
3283 if nxt == b'(' {
3284 stack.push(b'(');
3285 i += 2;
3286 continue;
3287 }
3288 if nxt == b'{' {
3289 stack.push(b'{');
3290 i += 2;
3291 continue;
3292 }
3293 }
3294 if c == b'`' {
3295 stack.push(b'`');
3296 i += 1;
3297 continue;
3298 }
3299 i += 1;
3300 continue;
3301 }
3302 Some(b'`') => {
3304 if c == b'`' {
3305 stack.pop();
3306 i += 1;
3307 continue;
3308 }
3309 if c == b'$' && i + 1 < cap {
3310 let nxt = bytes[i + 1];
3311 if nxt == b'(' {
3312 stack.push(b'(');
3313 i += 2;
3314 continue;
3315 }
3316 if nxt == b'{' {
3317 stack.push(b'{');
3318 i += 2;
3319 continue;
3320 }
3321 }
3322 i += 1;
3323 continue;
3324 }
3325 Some(b'(') => {
3327 if c == b')' {
3328 stack.pop();
3329 i += 1;
3330 continue;
3331 }
3332 }
3335 Some(b'{') => {
3337 if c == b'}' {
3338 stack.pop();
3339 i += 1;
3340 continue;
3341 }
3342 }
3344 _ => {}
3345 }
3346 match c {
3348 b'"' => stack.push(b'"'),
3349 b'\'' => stack.push(b'\''),
3350 b'`' => stack.push(b'`'),
3351 b'$' if i + 1 < cap => {
3352 let nxt = bytes[i + 1];
3353 if nxt == b'(' {
3354 stack.push(b'(');
3355 i += 2;
3356 continue;
3357 }
3358 if nxt == b'{' {
3359 stack.push(b'{');
3360 i += 2;
3361 continue;
3362 }
3363 if nxt == b'\'' {
3364 stack.push(b'\'');
3367 i += 2;
3368 continue;
3369 }
3370 }
3371 b'#' => {
3372 let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
3380 let comment_open = matches!(
3381 prev,
3382 None | Some(b' ') | Some(b'\t') | Some(b';') | Some(b'&') | Some(b'|')
3383 );
3384 if comment_open {
3385 return true;
3386 }
3387 }
3388 _ => {}
3389 }
3390 i += 1;
3391 }
3392 matches!(stack.last().copied(), Some(b'"') | Some(b'\''))
3395}
3396
3397pub(crate) fn line_position_inside_string_or_comment(line: &str, end: usize) -> bool {
3398 let bytes = line.as_bytes();
3399 let cap = end.min(bytes.len());
3400 let mut in_dq = false;
3401 let mut in_sq = false;
3402 let mut in_bt = false;
3403 let mut i = 0;
3404 while i < cap {
3405 let c = bytes[i];
3406 if in_dq {
3407 if c == b'\\' && i + 1 < cap {
3408 i += 2;
3409 continue;
3410 }
3411 if c == b'"' {
3412 in_dq = false;
3413 }
3414 } else if in_sq {
3415 if c == b'\'' {
3416 in_sq = false;
3417 }
3418 } else if in_bt {
3419 if c == b'\\' && i + 1 < cap {
3420 i += 2;
3421 continue;
3422 }
3423 if c == b'`' {
3424 in_bt = false;
3425 }
3426 } else if c == b'#' {
3427 return true;
3428 } else if c == b'"' {
3429 in_dq = true;
3430 } else if c == b'\'' {
3431 in_sq = true;
3432 } else if c == b'`' {
3433 in_bt = true;
3434 }
3435 i += 1;
3436 }
3437 in_dq || in_sq || in_bt
3438}
3439
3440pub(crate) fn line_position_inside_uninterpolating_context(line: &str, end: usize) -> bool {
3449 let bytes = line.as_bytes();
3450 let cap = end.min(bytes.len());
3451 let mut in_dq = false;
3452 let mut in_sq = false;
3453 let mut in_bt = false;
3454 let mut i = 0;
3455 while i < cap {
3456 let c = bytes[i];
3457 if in_dq {
3458 if c == b'\\' && i + 1 < cap {
3459 i += 2;
3460 continue;
3461 }
3462 if c == b'"' {
3463 in_dq = false;
3464 }
3465 } else if in_sq {
3466 if c == b'\'' {
3467 in_sq = false;
3468 }
3469 } else if in_bt {
3470 if c == b'\\' && i + 1 < cap {
3471 i += 2;
3472 continue;
3473 }
3474 if c == b'`' {
3475 in_bt = false;
3476 }
3477 } else if c == b'#' {
3478 let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
3484 let starts_comment = match prev {
3485 None => true,
3486 Some(p) => matches!(p, b' ' | b'\t' | b';' | b'&' | b'|' | b'('),
3487 };
3488 if starts_comment {
3489 return true;
3490 }
3491 } else if c == b'"' {
3492 in_dq = true;
3493 } else if c == b'\'' {
3494 in_sq = true;
3495 } else if c == b'`' {
3496 in_bt = true;
3497 }
3498 i += 1;
3499 }
3500 in_sq
3503}
3504
3505pub(crate) fn line_starts_comment_before(line: &str, end: usize) -> bool {
3506 let bytes = line.as_bytes();
3507 let cap = end.min(bytes.len());
3508 let mut in_dq = false;
3509 let mut in_sq = false;
3510 let mut in_bt = false;
3511 let mut i = 0;
3512 while i < cap {
3513 let c = bytes[i];
3514 if in_dq {
3515 if c == b'\\' && i + 1 < cap {
3516 i += 2;
3517 continue;
3518 }
3519 if c == b'"' {
3520 in_dq = false;
3521 }
3522 } else if in_sq {
3523 if c == b'\'' {
3524 in_sq = false;
3525 }
3526 } else if in_bt {
3527 if c == b'\\' && i + 1 < cap {
3528 i += 2;
3529 continue;
3530 }
3531 if c == b'`' {
3532 in_bt = false;
3533 }
3534 } else {
3535 if c == b'#' {
3536 return true;
3537 }
3538 if c == b'"' {
3539 in_dq = true;
3540 } else if c == b'\'' {
3541 in_sq = true;
3542 } else if c == b'`' {
3543 in_bt = true;
3544 }
3545 }
3546 i += 1;
3547 }
3548 false
3549}
3550pub fn lookup_doc(name: &str) -> String {
3552 if let Some(d) = OPERATOR_DOCS.iter().find(|(k, _)| *k == name) {
3566 return format!("**{}** — _zsh operator_\n\n{}", d.0, d.1);
3567 }
3568 if let Some((canon, body)) = crate::zsh_keyword_docs::lookup_keyword_doc(name) {
3569 return format!("**{}** — _zsh keyword_\n\n{}", canon, body);
3570 }
3571 let is_keyword = crate::ported::hashtable::RESWDS
3582 .iter()
3583 .any(|(n, _)| *n == name);
3584 let is_real_builtin = crate::ported::builtin::BUILTINS
3585 .iter()
3586 .any(|b| b.node.nam == name);
3587 if is_keyword && !is_real_builtin {
3588 if let Some(d) = KEYWORD_DOCS.iter().find(|(k, _)| *k == name) {
3589 return format!("**{}** — _zsh keyword_\n\n{}", d.0, d.1);
3590 }
3591 return format!("**{}** — _zsh keyword_", name);
3594 }
3595 let is_extension = crate::ext_builtins::EXT_BUILTIN_NAMES.contains(&name)
3603 || crate::daemon::builtins::ZSHRS_BUILTIN_NAMES.contains(&name);
3604 if is_extension {
3605 if let Some(body) = crate::zsh_ext_builtin_docs::lookup_full(name) {
3606 return format!("**{}** — _zshrs extension builtin_\n\n{}", name, body);
3607 }
3608 if let Some(d) = EXT_BUILTIN_DOCS.iter().find(|(k, _)| *k == name) {
3609 return format!("**{}** — _zshrs extension builtin_\n\n{}", d.0, d.1);
3610 }
3611 }
3612 if !is_real_builtin {
3624 if let Some((canon, body)) = crate::zsh_special_var_docs::lookup_special_var_doc(name) {
3625 return format!("**${}** — _special variable_\n\n{}", canon, body);
3626 }
3627 let bare = name.strip_prefix('$').unwrap_or(name);
3628 if !bare.is_empty() && bare != name {
3629 if let Some((canon, body)) = crate::zsh_special_var_docs::lookup_special_var_doc(bare) {
3630 return format!("**${}** — _special variable_\n\n{}", canon, body);
3631 }
3632 }
3633 }
3634 if let Some((canon, body)) = crate::zsh_builtin_docs::lookup_builtin_doc(name) {
3635 let kind = if crate::compsys::COMPSYS_FN_NAMES.contains(&canon) {
3641 "_compsys function_"
3642 } else {
3643 "_zsh builtin_"
3644 };
3645 return format!("**{}** — {}\n\n{}", canon, kind, body);
3646 }
3647 if let Some((canon, body)) = crate::zsh_special_var_docs::lookup_special_var_doc(name) {
3654 return format!("**${}** — _special variable_\n\n{}", canon, body);
3655 }
3656 let bare = name.strip_prefix('$').unwrap_or(name);
3657 if !bare.is_empty() && bare != name {
3658 if let Some((canon, body)) = crate::zsh_special_var_docs::lookup_special_var_doc(bare) {
3659 return format!("**${}** — _special variable_\n\n{}", canon, body);
3660 }
3661 }
3662 if let Some((canon, body)) = crate::zsh_option_docs::lookup_option_doc(name) {
3663 return format!("**{}** — _zsh option_\n\n{}", canon, body);
3664 }
3665 if let Some(d) = KEYWORD_DOCS.iter().find(|(k, _)| *k == name) {
3667 return format!("**{}** — _zsh keyword_\n\n{}", d.0, d.1);
3668 }
3669 if let Some(d) = BUILTIN_DOCS.iter().find(|(k, _)| *k == name) {
3670 return format!("**{}** — _zsh builtin_\n\n{}", d.0, d.1);
3671 }
3672 if name.starts_with('$') {
3673 if let Some(d) = SPECIAL_VAR_DOCS.iter().find(|(k, _)| *k == name) {
3674 return format!("**{}** — _special variable_\n\n{}", d.0, d.1);
3675 }
3676 }
3677 if let Some(body) = crate::zsh_ext_builtin_docs::lookup_full(name) {
3683 return format!("**{}** — _zshrs extension builtin_\n\n{}", name, body);
3684 }
3685 if let Some(d) = EXT_BUILTIN_DOCS.iter().find(|(k, _)| *k == name) {
3686 return format!("**{}** — _zshrs extension builtin_\n\n{}", d.0, d.1);
3687 }
3688 if let Some(d) = COMPSYS_FN_DOCS.iter().find(|(k, _)| *k == name) {
3689 return format!("**{}** — _compsys function_\n\n{}", d.0, d.1);
3690 }
3691 String::new()
3692}
3693
3694const OPERATOR_DOCS: &[(&str, &str)] = &[
3704 ("|", "Pipeline. `cmd1 | cmd2` connects `cmd1`'s stdout to `cmd2`'s stdin. Each stage runs in a separate process; exit status is the last stage's (unless `PIPE_FAIL` is set, in which case the first non-zero in the chain wins)."),
3706 ("|&", "Pipeline merging stderr. `cmd1 |& cmd2` = `cmd1 2>&1 | cmd2`. Both stdout AND stderr of `cmd1` are piped to `cmd2`."),
3707 ("&&", "Logical AND list operator. `cmd1 && cmd2` runs `cmd2` only if `cmd1` succeeded (exit status 0). Short-circuits."),
3709 ("||", "Logical OR list operator. `cmd1 || cmd2` runs `cmd2` only if `cmd1` failed (non-zero exit). Short-circuits."),
3710 (";", "Sequential list separator. `cmd1; cmd2` runs `cmd2` after `cmd1` finishes, regardless of its exit status."),
3711 ("&", "Background list operator. `cmd &` runs `cmd` asynchronously in the background; the shell does not wait. Sets `$!` to the job's PID."),
3712 (";;", "Case-branch terminator. Ends a `case` arm: `case x in pat) cmds ;; esac`. Stops case dispatch after this arm."),
3713 (";;&", "Case-branch fall-through-and-test-next. Continues to the next `case` arm and tests its pattern."),
3714 (";|", "Case-branch unconditional fall-through. Continues to the next `case` arm and runs it without testing its pattern."),
3715 ("!", "Pipeline negation (also a reserved word). `! cmd` inverts `cmd`'s exit status — zero becomes 1, non-zero becomes 0. Distinct from `!` history expansion (lexer-stage)."),
3717 (">", "Stdout redirect. `cmd > file` writes `cmd`'s stdout to `file` (overwrite). With `NO_CLOBBER`, refuses to overwrite an existing file — use `>|` or `>!` to force."),
3719 (">>", "Stdout append. `cmd >> file` appends `cmd`'s stdout to `file` (creates if missing)."),
3720 ("<", "Stdin redirect. `cmd < file` makes `file` the source of `cmd`'s stdin."),
3721 ("<<", "Heredoc start. `cmd <<MARKER` reads the following lines as `cmd`'s stdin until a line containing only `MARKER`. Variants: `<<-` strips leading tabs; `<<'MARKER'` disables expansion in the body."),
3722 ("<<-", "Heredoc with tab-stripping. Like `<<` but every leading tab on body lines (and the terminator) is removed — lets you indent the heredoc for readability."),
3723 ("<<<", "Here-string. `cmd <<< 'text'` makes the literal string `text` the source of `cmd`'s stdin. Adds a trailing newline."),
3724 ("&>", "Redirect stdout + stderr together. `cmd &> file` = `cmd > file 2>&1`. Shorthand for the common combined redirect."),
3725 ("&>>", "Append stdout + stderr together. `cmd &>> file` = `cmd >> file 2>&1`."),
3726 (">&", "Redirect a file descriptor. `2>&1` sends stderr to wherever stdout currently points. `>& file` is also accepted as `&> file`."),
3727 ("<&", "Duplicate an input file descriptor. `cmd <&3` reads from fd 3. `<& -` closes stdin."),
3728 ("<>", "Read+write redirect. `cmd <> file` opens `file` for both reading and writing on stdin."),
3729 (">|", "Force-overwrite redirect. Equivalent to `>` but ignores `NO_CLOBBER`."),
3730 (">!", "Same as `>|` — force-overwrite, bypass `NO_CLOBBER`."),
3731 ("[[", "Open zsh conditional expression. `[[ EXPR ]]` evaluates a boolean. No word splitting / glob inside; supports `&&`, `||`, `!`, `==`, `!=`, `=~`, `-e`, `-f`, `-d`, `-z`, `-n`, etc. Prefer this over `[ ]` in zsh."),
3733 ("]]", "Close zsh conditional expression. Pairs with `[[`. Must be a separate word — `[[ -n $x]]` is a syntax error; use `[[ -n $x ]]`."),
3734 ("[", "POSIX `test` command (also spelled `test`). Same conditional semantics as POSIX `test`. Prefer `[[ … ]]` in zsh — it's safer (no word splitting) and supports more operators."),
3735 ("]", "Close POSIX `test`. Pairs with `[`."),
3736 ("((", "Open arithmetic command. `(( EXPR ))` evaluates `EXPR` as C-style integer arithmetic; exit 0 if the result is non-zero, 1 otherwise. Inside, `$` on var names is optional: `(( i++ ))`."),
3737 ("))", "Close arithmetic command. Pairs with `((`."),
3738 ("$(", "Command substitution open. `$(cmd)` runs `cmd` and substitutes its trimmed-trailing-newline stdout. Nestable: `$(echo $(date))`. Preferred over backticks."),
3740 ("${", "Parameter expansion open. `${VAR}` is the value of `VAR`. Rich modifier set: `${VAR:-default}`, `${VAR:=assign}`, `${VAR:+alt}`, `${#VAR}` length, `${VAR/p/r}` replace, `${VAR%suffix}` / `${VAR#prefix}` strip, `${(flags)VAR}` zsh flags."),
3741 ("$((", "Arithmetic expansion open. `$(( EXPR ))` evaluates `EXPR` as integer arithmetic and substitutes the result as a string. Distinct from `(( … ))` which is a command, not an expansion."),
3742 ("<(", "Process substitution (input). `cmd <(producer)` exposes `producer`'s stdout as a filename (`/dev/fd/N`) to `cmd`. Lets commands that take filenames consume pipe output."),
3743 (">(", "Process substitution (output). `cmd >(consumer)` exposes a filename to `cmd`; anything `cmd` writes there flows to `consumer`'s stdin."),
3744 ("`", "Backtick command substitution. ``cmd`` runs `cmd` and substitutes its stdout. Legacy form — prefer `$(cmd)` for nestability and quoting clarity."),
3745 ("-e", "File-exists test. `[[ -e PATH ]]` is true if `PATH` exists (any type — file / dir / link / socket / ...)."),
3747 ("-f", "Regular-file test. `[[ -f PATH ]]` is true if `PATH` exists AND is a regular file (not a directory / symlink / device)."),
3748 ("-d", "Directory test. `[[ -d PATH ]]` is true if `PATH` exists AND is a directory."),
3749 ("-r", "Readable test. `[[ -r PATH ]]` is true if `PATH` exists AND is readable by the current process."),
3750 ("-w", "Writable test. `[[ -w PATH ]]` is true if `PATH` exists AND is writable by the current process."),
3751 ("-x", "Executable test. `[[ -x PATH ]]` is true if `PATH` exists AND has execute permission (or for directories, search permission)."),
3752 ("-s", "Non-empty test. `[[ -s PATH ]]` is true if `PATH` exists AND has size > 0."),
3753 ("-L", "Symlink test. `[[ -L PATH ]]` is true if `PATH` is a symbolic link (does NOT dereference)."),
3754 ("-h", "Same as `-L` — symlink test."),
3755 ("-z", "Empty-string test. `[[ -z $s ]]` is true if `$s` is the empty string."),
3756 ("-n", "Non-empty-string test. `[[ -n $s ]]` is true if `$s` has length > 0. Equivalent to `[[ $s ]]`."),
3757 ("-eq", "Numeric equality. `[[ a -eq b ]]` is true if integers `a` and `b` are equal. For strings use `==`."),
3759 ("-ne", "Numeric inequality. `[[ a -ne b ]]` is true if integers `a` and `b` differ."),
3760 ("-lt", "Numeric less-than. `[[ a -lt b ]]` is true if integer `a` < `b`."),
3761 ("-le", "Numeric less-or-equal. `[[ a -le b ]]` is true if integer `a` ≤ `b`."),
3762 ("-gt", "Numeric greater-than. `[[ a -gt b ]]` is true if integer `a` > `b`."),
3763 ("-ge", "Numeric greater-or-equal. `[[ a -ge b ]]` is true if integer `a` ≥ `b`."),
3764 ("-ot", "Older-than test. `[[ A -ot B ]]` is true if file `A` has an older mtime than `B`."),
3765 ("-nt", "Newer-than test. `[[ A -nt B ]]` is true if file `A` has a newer mtime than `B`."),
3766 ("-ef", "Same-file test. `[[ A -ef B ]]` is true if `A` and `B` are the same inode (hard-linked / same path)."),
3767 ("==", "Pattern-match equality (inside `[[ … ]]`). `[[ $s == pat* ]]` matches `$s` against the glob pattern `pat*`. RHS is a pattern unless quoted. For literal equality, quote: `[[ $s == \"literal\" ]]`."),
3769 ("!=", "Pattern-mismatch (inside `[[ … ]]`). Inverse of `==`. Quote the RHS for literal comparison."),
3770 ("=~", "Regex match (inside `[[ … ]]`). `[[ $s =~ pat ]]` matches `$s` against the regex `pat`. Capture groups land in `$match` / `$MATCH` / `$BASH_REMATCH`."),
3771 ("*", "Glob: match zero or more characters of any name (excluding leading `.` unless `GLOB_DOTS` is set). Also a multiplication operator inside `(( … ))`."),
3773 ("?", "Glob: match exactly one character. Also the last-exit-status variable when used as `$?`."),
3774 ("**", "Recursive glob (zsh extended). `**/*.rs` matches `*.rs` at any depth under the current directory. Requires `EXTENDED_GLOB` for additional pattern operators."),
3775 ("~", "Pattern exclude (with `EXTENDED_GLOB`). `*~README` matches everything except `README`. Also tilde expansion: `~` = `$HOME`, `~user` = user's home, `~+` = `$PWD`, `~-` = `$OLDPWD`."),
3776 ("^", "Pattern negate first-match (with `EXTENDED_GLOB`). `^*.rs` matches everything that's NOT `*.rs`. Inside `[…]` ranges, negates: `[^abc]`."),
3777 ("{a,b,c}", "Brace expansion (literal list). Expands to multiple words: `cp file.{txt,bak}` becomes `cp file.txt file.bak`. No whitespace before commas."),
3779 ("{1..10}", "Brace range expansion. `{1..10}` expands to `1 2 3 4 5 6 7 8 9 10`. Supports step: `{1..10..2}` → `1 3 5 7 9`. Letters work too: `{a..z}`."),
3780 ("=", "Assignment. `VAR=value`. NO whitespace around `=`. With `local` / `typeset`: `local VAR=value` declares + assigns."),
3782 ("+=", "Append assignment. `VAR+=more` appends to a scalar; for arrays `arr+=(x y)` appends elements. Numeric for `integer`: `(( count += 1 ))`."),
3783 (":=", "Conditional-assign default (inside `${…}`). `${VAR:=fallback}` assigns `fallback` to `VAR` (and substitutes it) if `VAR` is unset or empty."),
3784 ("?=", "Error-if-unset (inside `${…}`). `${VAR:?msg}` substitutes `$VAR` if set, else prints `msg` to stderr and exits."),
3785];
3786
3787const PARAM_FLAG_DOCS: &[(&str, &str)] = &[
3793 ("-", "sort decimal integers numerically (signed)"),
3794 ("@", "prevent double-quoted joining of arrays"),
3795 ("*", "enable extended globs for pattern arguments"),
3796 ("#", "interpret numeric expression as character code"),
3797 ("%", "expand prompt sequences (`%P` for prompt-only escapes)"),
3798 ("~", "treat strings in parameter flag arguments as patterns"),
3799 ("0", "split words on null bytes"),
3800 ("A", "assign as an array parameter (in `${...=...}` etc)"),
3801 ("a", "sort in array index order (with `O` to reverse)"),
3802 ("b", "backslash-quote pattern characters only"),
3803 ("B", "include index of beginning of match in `#`, `%` expressions"),
3804 ("C", "capitalize words"),
3805 ("c", "count characters in an array (with `${(c)#...}`)"),
3806 ("D", "perform directory name abbreviation"),
3807 ("E", "include index of one past end of match in `#`, `%` expressions"),
3808 ("e", "perform single-word shell expansions"),
3809 ("F", "join arrays with newlines"),
3810 ("f", "split the result on newlines"),
3811 ("g", "process echo array sequences (needs options like `gec`)"),
3812 ("I", "search Nth match in `#`, `%`, `/` expressions (`(I:N:)`)"),
3813 ("i", "sort case-insensitively"),
3814 ("j", "join arrays with specified string (`(j:STR:)`)"),
3815 ("k", "substitute keys of associative arrays"),
3816 ("l", "left-pad resulting words (`(l:N:)`, `(l:N::pad:)`)"),
3817 ("L", "lower case all letters"),
3818 ("m", "count multibyte width in padding calculation"),
3819 ("M", "include matched portion in `#`, `%` expressions"),
3820 ("N", "include length of match in `#`, `%` expressions"),
3821 ("n", "sort positive decimal integers numerically (unsigned)"),
3822 ("o", "sort in ascending order (lexically if no other sort option)"),
3823 ("O", "sort in descending order (lexically if no other sort option)"),
3824 ("p", "handle print escapes in parameter flag string arguments"),
3825 ("P", "use parameter value as name of parameter for redirected lookup"),
3826 ("q", "quote with backslashes (`q-` shell-quote, `qq` single-quote, `qqq` double-quote, `qqqq` $'...')"),
3827 ("Q", "remove one level of quoting"),
3828 ("R", "include rest (unmatched portion) in `#`, `%` expressions"),
3829 ("r", "right-pad resulting words (`(r:N:)`, `(r:N::pad:)`)"),
3830 ("S", "match non-greedy in `/`, `//`, or search substrings in `%`/`#` expressions"),
3831 ("s", "split words on specified string (`(s:STR:)`)"),
3832 ("t", "substitute type of parameter (`scalar`, `array`, `association`, `integer`, `float`, plus flags)"),
3833 ("u", "substitute first occurrence of each unique word"),
3834 ("U", "upper case all letters"),
3835 ("v", "substitute values of associative arrays (with `k`)"),
3836 ("V", "visibility enhancements for special characters"),
3837 ("w", "count words in array or string (with `${(w)#...}`)"),
3838 ("W", "count words including empty words (with `${(W)#...}`)"),
3839 ("X", "report parsing errors and exit substitution on failure"),
3840 ("z", "split words as if a zsh command line"),
3841 ("Z", "split words as if a zsh command line (with options — `(Z:cn:)`, `(Z:Cn:)`)"),
3842];
3843
3844const GLOB_QUALIFIER_DOCS: &[(&str, &str)] = &[
3849 ("/", "directories"),
3851 ("F", "non-empty directories"),
3852 (".", "plain files (regular)"),
3853 ("@", "symbolic links"),
3854 ("=", "sockets"),
3855 ("p", "named pipes (FIFOs)"),
3856 ("*", "executable plain files (mode `0111`)"),
3857 ("%", "device files (block or character)"),
3858 ("r", "owner-readable"),
3860 ("w", "owner-writable"),
3861 ("x", "owner-executable"),
3862 ("A", "group-readable"),
3863 ("I", "group-writable"),
3864 ("E", "group-executable"),
3865 ("R", "world-readable"),
3866 ("W", "world-writable"),
3867 ("X", "world-executable"),
3868 ("s", "setuid"),
3869 ("S", "setgid"),
3870 ("t", "sticky bit set"),
3871 ("U", "owned by current effective uid"),
3872 ("G", "owned by current effective gid"),
3873 ("u", "owned by specified uid (`u:LOGIN:` / `u<UID>`)"),
3874 ("g", "owned by specified gid (`g:GROUP:` / `g<GID>`)"),
3875 ("f", "exact file mode match (`f:SPEC:`, eg `f:u+w:`)"),
3876 ("a", "atime (`a-N` younger than N days, `a+N` older)"),
3878 ("m", "mtime (`m-N` / `m+N`; suffixes `M`/`w`/`h`/`m`/`s`)"),
3879 ("c", "ctime (`c-N` / `c+N`)"),
3880 ("L", "size in bytes (`L-N`, `L+N`, suffixes `k`/`m`/`p`)"),
3881 ("l", "link count (`l-N` / `l+N`)"),
3882 ("d", "files on device DEV (`d<DEV>`)"),
3883 ("o", "order ascending (`oN` name, `oL` size, `om` mtime, `oa` atime, `oc` ctime, `od` depth, `oe:cmd:` custom)"),
3885 ("O", "order descending (same suffixes as `o`)"),
3886 ("[", "slice / range (`[N]`, `[N,M]`, `[N,-1]`)"),
3887 ("^", "negate the rest of the qualifier list"),
3888 ("-", "follow symbolic links when testing subsequent qualifiers"),
3889 ("M", "mark directories with trailing `/`"),
3890 ("T", "mark types with file-type indicator (`/=@*%|`)"),
3891 ("N", "set NULL_GLOB for this glob only (no match → empty)"),
3892 ("D", "include dotfiles in matches"),
3893 ("n", "numeric sort (use with `o` / `O`)"),
3894 ("Y", "early termination after N matches (`Y<N>`)"),
3895 ("P", "prepend WORD to each result (`P:WORD:`)"),
3896 ("e", "evaluate expression on each candidate (`e:EXPR:`); `$REPLY` is the filename"),
3897 ("+", "true if `cmd FILENAME` exits 0 (`+cmd`)"),
3898];
3899
3900const HISTORY_DESIGNATOR_DOCS: &[(&str, &str)] = &[
3906 ("!", "previous command (`!!`)"),
3907 ("N", "command N from history (`!42`)"),
3908 ("-N", "N commands back (`!-3` = third-to-last)"),
3909 ("str", "most recent command starting with `str` (`!ls`)"),
3910 (
3911 "?str?",
3912 "most recent command containing `str` (`!?docker?`)",
3913 ),
3914 ("#", "current command line typed so far"),
3915 ("$", "last argument of previous command (= `!!:$`)"),
3916 ("^", "first argument of previous command (= `!!:^`)"),
3917 ("*", "all arguments of previous command (= `!!:*`)"),
3918 (
3919 ":",
3920 "introduce a word designator / modifier — `!!:1`, `!!:s/old/new/`, `!!:h`",
3921 ),
3922];
3923
3924const PARAM_MODIFIER_DOCS: &[(&str, &str)] = &[
3934 ("-", "`${var:-WORD}` — use WORD if `var` unset or empty"),
3936 (
3937 "=",
3938 "`${var:=WORD}` — assign WORD to `var` (and use it) if unset/empty",
3939 ),
3940 (
3941 "?",
3942 "`${var:?MSG}` — print MSG to stderr + exit if `var` unset/empty",
3943 ),
3944 (
3945 "+",
3946 "`${var:+WORD}` — use WORD if `var` IS set (the inverse of `:-`)",
3947 ),
3948 (
3950 "0",
3951 "`${var:OFFSET:LENGTH}` — substring (zero-based; negative offset = from end)",
3952 ),
3953 ("h", "head — strip last path component (like `dirname`)"),
3955 (
3956 "t",
3957 "tail — keep ONLY last path component (like `basename`)",
3958 ),
3959 ("r", "root — strip the final `.ext` suffix"),
3960 (
3961 "e",
3962 "extension — keep ONLY the final `.ext` (no leading dot)",
3963 ),
3964 (
3965 "a",
3966 "absolute — textually resolve `..` / `.` against `$PWD`",
3967 ),
3968 ("A", "absolute + resolve symlinks (like `realpath`)"),
3969 (
3970 "c",
3971 "PATH lookup — replace bare command with full path via `$PATH`",
3972 ),
3973 ("P", "physical path — resolve all symlinks"),
3974 (
3975 "f",
3976 "repeat `:h` until the result is no longer an existing directory",
3977 ),
3978 ("F", "`:F:N:` — repeat `:h` N times"),
3979 ("s", "`:s/OLD/NEW/` — substitute first OLD with NEW"),
3981 (
3982 "gs",
3983 "`:gs/OLD/NEW/` — global substitute (every occurrence)",
3984 ),
3985 ("&", "repeat the last `:s` substitution"),
3986 ("g&", "repeat the last `:s` substitution globally"),
3987 ("q", "quote — backslash-escape all metacharacters"),
3989 ("Q", "unquote — remove ONE level of quoting"),
3990 ("x", "quote, breaking at whitespace into separate words"),
3991 ("l", "lowercase first character"),
3993 ("u", "uppercase first character"),
3994 ("L", "lowercase ENTIRE string"),
3995 ("U", "uppercase ENTIRE string"),
3996 ("C", "capitalize each word (`Title Case`)"),
3997 ("S", "sort array elements ascending"),
3999 ("O", "sort array elements descending"),
4000 (
4001 "#",
4002 "`${var:#PATTERN}` — remove array elements matching PATTERN (with `(@)`)",
4003 ),
4004 (
4005 "|",
4006 "`${arr:|other}` — set difference (elements of `arr` not in `other`)",
4007 ),
4008 ("*", "`${arr:*other}` — set intersection"),
4009 ("^", "`${arr:^other}` — interleave (zip) two arrays"),
4010 ("^^", "`${arr:^^other}` — distributed zip (every pair)"),
4011];
4012
4013const SIGNAL_NAMES: &[(&str, &str)] = &[
4018 ("HUP", "1 — hangup (terminal closed)"),
4019 ("INT", "2 — interrupt (Ctrl-C)"),
4020 ("QUIT", "3 — quit + core dump (Ctrl-\\)"),
4021 ("ILL", "4 — illegal instruction"),
4022 ("TRAP", "5 — trace/breakpoint trap"),
4023 ("ABRT", "6 — abort (`abort()` syscall)"),
4024 ("BUS", "7 — bus error"),
4025 ("FPE", "8 — floating-point exception"),
4026 ("KILL", "9 — kill (uncatchable, unblockable)"),
4027 ("USR1", "10 — user-defined signal 1"),
4028 ("SEGV", "11 — segmentation fault"),
4029 ("USR2", "12 — user-defined signal 2"),
4030 ("PIPE", "13 — write to pipe with no readers"),
4031 ("ALRM", "14 — alarm clock (`alarm()`)"),
4032 ("TERM", "15 — termination request (default `kill`)"),
4033 ("CHLD", "17 — child process state change"),
4034 ("CONT", "18 — continue if stopped"),
4035 ("STOP", "19 — stop (uncatchable)"),
4036 ("TSTP", "20 — terminal stop (Ctrl-Z)"),
4037 ("TTIN", "21 — background process needs tty input"),
4038 ("TTOU", "22 — background process tty output"),
4039 ("URG", "23 — urgent socket data"),
4040 ("XCPU", "24 — CPU time limit exceeded"),
4041 ("XFSZ", "25 — file size limit exceeded"),
4042 ("VTALRM", "26 — virtual timer alarm"),
4043 ("PROF", "27 — profiling timer alarm"),
4044 ("WINCH", "28 — window size change"),
4045 ("IO", "29 — async I/O ready"),
4046 ("PWR", "30 — power failure"),
4047 ("SYS", "31 — bad syscall"),
4048 ("EXIT", "0 — shell exit (special — `trap ... EXIT`)"),
4050 ("ZERR", "zsh — fires on any non-zero exit status"),
4051 (
4052 "DEBUG",
4053 "zsh — fires before every command (with `DEBUG_BEFORE_CMD`)",
4054 ),
4055];
4056
4057const ZSH_MODULE_NAMES: &[(&str, &str)] = &[
4061 ("zsh/attr", "extended file attribute manipulation"),
4062 ("zsh/cap", "POSIX capability sets"),
4063 ("zsh/clone", "fork the shell to a new session"),
4064 ("zsh/compctl", "legacy `compctl` completion (deprecated)"),
4065 ("zsh/complete", "core programmable completion machinery"),
4066 (
4067 "zsh/complist",
4068 "completion list display + menuselect keymap",
4069 ),
4070 (
4071 "zsh/computil",
4072 "internal helpers used by `_arguments` / `_describe`",
4073 ),
4074 ("zsh/curses", "ncurses bindings (`zcurses`)"),
4075 (
4076 "zsh/datetime",
4077 "`strftime` builtin + `$EPOCHSECONDS` / `$EPOCHREALTIME`",
4078 ),
4079 (
4080 "zsh/db/gdbm",
4081 "GDBM key-value store as a zsh associative array",
4082 ),
4083 (
4084 "zsh/deltochar",
4085 "`delete-to-char` / `zap-to-char` ZLE widgets",
4086 ),
4087 ("zsh/example", "template module (skeleton; not useful)"),
4088 (
4089 "zsh/files",
4090 "in-shell file ops (`mkdir`, `chmod`, `mv`, `rm`, `chown`, `sync`, `ln`)",
4091 ),
4092 ("zsh/langinfo", "locale info (`$langinfo`)"),
4093 ("zsh/mapfile", "read/write a file as an assoc array"),
4094 (
4095 "zsh/mathfunc",
4096 "`sin`, `cos`, `sqrt`, `log`, `exp`, … math functions for `((…))`",
4097 ),
4098 ("zsh/nearcolor", "approximate-color terminal fallback"),
4099 ("zsh/newuser", "first-run user setup helper"),
4100 (
4101 "zsh/parameter",
4102 "reflection — `$functions`, `$aliases`, `$options`, `$commands`, `$parameters`, etc.",
4103 ),
4104 ("zsh/pcre", "Perl-compatible regex (`pcre_match` / `=~`)"),
4105 ("zsh/regex", "POSIX extended regex (`=~`)"),
4106 ("zsh/sched", "in-shell scheduler (`sched +5 cmd`)"),
4107 ("zsh/net/socket", "Unix-domain socket builtin (`zsocket`)"),
4108 ("zsh/stat", "`stat` builtin returning fields into a hash"),
4109 (
4110 "zsh/system",
4111 "low-level syscalls (`sysread`, `syswrite`, `syserror`, `sysopen`)",
4112 ),
4113 ("zsh/net/tcp", "TCP socket builtin (`ztcp`)"),
4114 ("zsh/termcap", "termcap parameter access (`$termcap`)"),
4115 ("zsh/terminfo", "terminfo parameter access (`$terminfo`)"),
4116 ("zsh/zftp", "FTP client built into the shell"),
4117 (
4118 "zsh/zle",
4119 "Zsh Line Editor — `bindkey`, `zle`, widget registration",
4120 ),
4121 (
4122 "zsh/zleparameter",
4123 "ZLE introspection — `$widgets`, `$keymaps`",
4124 ),
4125 ("zsh/zprof", "profiling — `zprof` builtin"),
4126 ("zsh/zpty", "spawn commands in a pseudo-terminal"),
4127 ("zsh/zselect", "`select(2)` on fds with a timeout"),
4128 (
4129 "zsh/zutil",
4130 "core utilities — `zparseopts`, `zformat`, `zstyle`, `zregexparse`",
4131 ),
4132];
4133
4134const KEYMAP_NAMES: &[(&str, &str)] = &[
4137 ("emacs", "GNU Readline emacs bindings (default)"),
4138 ("vicmd", "vi command-mode keymap"),
4139 ("viins", "vi insert-mode keymap"),
4140 ("viopp", "vi operator-pending keymap (for `d` / `c` / `y`)"),
4141 ("visual", "vi visual-mode keymap"),
4142 (
4143 ".safe",
4144 "fallback keymap holding only `self-insert` + `accept-line`",
4145 ),
4146 (
4147 "main",
4148 "alias — whichever keymap is currently the editing map",
4149 ),
4150 ("command", "vi-mode command-line input keymap"),
4151 ("menuselect", "active inside `menu-select` widget"),
4152 ("isearch", "active inside incremental-search widgets"),
4153 ("listscroll", "active when scrolling completion list"),
4154];
4155
4156const ZLE_WIDGET_NAMES: &[(&str, &str)] = &[
4161 ("backward-char", "move one character left"),
4163 ("forward-char", "move one character right"),
4164 ("backward-word", "move one word left"),
4165 ("forward-word", "move one word right"),
4166 ("beginning-of-line", "move to start of line"),
4167 ("end-of-line", "move to end of line"),
4168 (
4169 "beginning-of-buffer-or-history",
4170 "start of buffer / previous-history at top",
4171 ),
4172 (
4173 "end-of-buffer-or-history",
4174 "end of buffer / next-history at bottom",
4175 ),
4176 ("self-insert", "insert the typed character"),
4178 ("accept-line", "submit current line for execution"),
4179 ("accept-and-hold", "submit + keep line in buffer"),
4180 (
4181 "accept-and-infer-next-history",
4182 "submit + recall the line after this in history",
4183 ),
4184 ("backward-delete-char", "delete character before cursor"),
4185 ("delete-char", "delete character under cursor"),
4186 (
4187 "backward-kill-word",
4188 "delete word before cursor (saves to kill ring)",
4189 ),
4190 ("kill-word", "delete word after cursor"),
4191 ("backward-kill-line", "delete from cursor to start of line"),
4192 ("kill-line", "delete from cursor to end of line"),
4193 ("kill-whole-line", "delete entire line"),
4194 ("kill-region", "delete from mark to cursor"),
4195 ("yank", "paste last kill"),
4196 ("yank-pop", "rotate to earlier kill (after `yank`)"),
4197 ("transpose-chars", "swap two characters"),
4198 ("transpose-words", "swap two words"),
4199 ("up-case-word", "uppercase next word"),
4200 ("down-case-word", "lowercase next word"),
4201 ("capitalize-word", "capitalize next word"),
4202 (
4203 "quoted-insert",
4204 "literal-insert next key (e.g. for control chars)",
4205 ),
4206 ("overwrite-mode", "toggle insert / overwrite"),
4207 ("undo", "undo last edit"),
4208 ("redo", "redo last undone edit"),
4209 ("clear-screen", "clear terminal + redraw"),
4210 ("redisplay", "force redraw"),
4211 ("send-break", "abandon line (SIGINT-equivalent)"),
4212 (
4214 "up-line-or-history",
4215 "previous line / previous history entry",
4216 ),
4217 ("down-line-or-history", "next line / next history entry"),
4218 ("up-history", "previous history entry"),
4219 ("down-history", "next history entry"),
4220 ("beginning-of-history", "first history entry"),
4221 ("end-of-history", "last history entry (current line)"),
4222 (
4223 "history-incremental-search-backward",
4224 "Ctrl-R — incremental search backward",
4225 ),
4226 (
4227 "history-incremental-search-forward",
4228 "Ctrl-S — incremental search forward",
4229 ),
4230 (
4231 "history-search-backward",
4232 "search history matching current line prefix",
4233 ),
4234 (
4235 "history-search-forward",
4236 "forward variant of `history-search-backward`",
4237 ),
4238 (
4239 "history-beginning-search-backward",
4240 "search backward keeping cursor position",
4241 ),
4242 (
4243 "history-beginning-search-forward",
4244 "search forward keeping cursor position",
4245 ),
4246 (
4247 "infer-next-history",
4248 "infer next-history based on previous match",
4249 ),
4250 (
4251 "insert-last-word",
4252 "insert last word of previous line (`!!:$`)",
4253 ),
4254 ("complete-word", "complete the current word"),
4256 ("expand-or-complete", "expand alias / glob, else complete"),
4257 (
4258 "expand-or-complete-prefix",
4259 "as above but with prefix match",
4260 ),
4261 ("list-choices", "show completion options without inserting"),
4262 ("menu-complete", "cycle through completions"),
4263 ("menu-expand-or-complete", "expand / cycle"),
4264 ("reverse-menu-complete", "cycle backward"),
4265 (
4266 "delete-char-or-list",
4267 "delete-char if not at EOL, else list-choices",
4268 ),
4269 ("complete-prefix", "complete current prefix"),
4270 ("expand-cmd-path", "expand command to full path"),
4271 ("expand-word", "expand current word"),
4272 ("vi-cmd-mode", "switch to vi command mode"),
4274 ("vi-insert", "switch to vi insert mode"),
4275 ("vi-insert-bol", "insert at start of line"),
4276 ("vi-add-next", "append after current char (vi `a`)"),
4277 ("vi-add-eol", "append at end of line (vi `A`)"),
4278 ("vi-backward-char", "h"),
4279 ("vi-forward-char", "l"),
4280 ("vi-backward-word", "b"),
4281 ("vi-forward-word", "w"),
4282 ("vi-backward-word-end", "ge"),
4283 ("vi-forward-word-end", "e"),
4284 ("vi-backward-blank-word", "B"),
4285 ("vi-forward-blank-word", "W"),
4286 ("vi-up-line-or-history", "k — previous line / history"),
4287 ("vi-down-line-or-history", "j — next line / history"),
4288 ("vi-beginning-of-line", "0"),
4289 ("vi-end-of-line", "$"),
4290 ("vi-first-non-blank", "^"),
4291 ("vi-delete", "d"),
4292 ("vi-delete-char", "x"),
4293 ("vi-backward-delete-char", "X"),
4294 ("vi-change", "c"),
4295 ("vi-change-eol", "C"),
4296 ("vi-change-whole-line", "S"),
4297 ("vi-substitute", "s"),
4298 ("vi-yank", "y"),
4299 ("vi-yank-eol", "Y"),
4300 ("vi-yank-whole-line", "yy"),
4301 ("vi-put-after", "p"),
4302 ("vi-put-before", "P"),
4303 ("vi-replace", "R"),
4304 ("vi-replace-chars", "r"),
4305 ("vi-repeat-change", "."),
4306 ("vi-repeat-search", "n"),
4307 ("vi-rev-repeat-search", "N"),
4308 ("vi-find-next-char", "f"),
4309 ("vi-find-prev-char", "F"),
4310 ("vi-find-next-char-skip", "t"),
4311 ("vi-find-prev-char-skip", "T"),
4312 ("vi-undo-change", "u"),
4313 ("vi-join", "J — join with next line"),
4314 ("vi-quoted-insert", "Ctrl-V — literal next"),
4315 ("vi-set-buffer", "select named register"),
4316 ("vi-history-search-backward", "?"),
4317 ("vi-history-search-forward", "/"),
4318 ("vi-match-bracket", "% — jump to matching bracket"),
4319 ("which-command", "show what command would run"),
4321 ("describe-key-briefly", "show binding for next key"),
4322 ("execute-named-cmd", "M-x style command execution"),
4323 ("execute-last-named-cmd", "re-run last named command"),
4324 ("push-line", "save line + clear, runs on next prompt"),
4325 ("push-line-or-edit", "push-line or edit multiline"),
4326 ("push-input", "push to input stack"),
4327 ("get-line", "pop input from stack"),
4328 ("set-mark-command", "set the mark at cursor"),
4329 ("exchange-point-and-mark", "swap cursor + mark"),
4330 ("digit-argument", "begin numeric argument"),
4331 ("universal-argument", "begin numeric argument"),
4332 ("undefined-key", "called when binding lookup fails"),
4333];
4334
4335const TYPESET_FLAGS: &[(&str, &str)] = &[
4339 ("-a", "indexed array"),
4340 ("-A", "associative array (hash)"),
4341 ("-i", "integer (with optional base: `-i 16`)"),
4342 ("-E", "float, scientific notation"),
4343 ("-F", "float, fixed notation"),
4344 ("-l", "lowercase on assignment"),
4345 ("-u", "uppercase on assignment"),
4346 ("-L", "left-justify, width N (`-L4`)"),
4347 ("-R", "right-justify, width N (`-R8`)"),
4348 ("-Z", "zero-pad (right-justified, numeric)"),
4349 ("-r", "readonly"),
4350 ("-x", "export to environment"),
4351 ("-g", "global (skip the local scope this would create)"),
4352 ("-U", "unique — for arrays, drop duplicate elements"),
4353 ("-T", "tie scalar ↔ array (`-T PATH path :`)"),
4354 ("-t", "set the `TAGGED` flag (used by some completions)"),
4355 ("-H", "hide value in `typeset` listing"),
4356 ("-h", "hide builtin/special status"),
4357 ("-f", "operate on functions, not parameters"),
4358 ("-p", "print declarations in re-readable form"),
4359 ("-m", "treat name args as patterns (`typeset -m 'FOO*'`)"),
4360 ("-+", "operate at the next outer scope"),
4361];
4362
4363const TEST_OPERATORS: &[(&str, &str)] = &[
4367 ("-e", "**True if FILE exists**, regardless of type. The catch-all existence test — use `-f` / `-d` etc. to narrow.\n\nExample: `[[ -e $HOME/.zshrc ]] && source $HOME/.zshrc` — guard a source against missing files.\n\nReturns true for symlinks ONLY if the link target exists (use `-L` to test the link itself). Sets `$?` to 0 (true) or 1 (false). Inside `[[ … ]]`, no word-splitting / glob expansion is done on the operand."),
4369 ("-f", "**True if FILE exists AND is a regular file** (not a directory, symlink to dir, device, FIFO, or socket). Follows symlinks — `-f link → file` is true; `-f link → dir` is false.\n\nExample: `for f in *.zsh; do [[ -f $f ]] || continue; source $f; done` — sources every regular `.zsh` file, skipping symlinks-to-dirs that glob accidentally caught."),
4370 ("-d", "**True if FILE exists AND is a directory.** Follows symlinks — symlinks to directories test true. Use `-L $f && [[ ! -d $f ]]` (or `! -h && -d`) to distinguish real-dir from symlink-to-dir.\n\nExample: `[[ -d ~/.config ]] || mkdir -p ~/.config`."),
4371 ("-L", "**True if FILE exists AND is a symbolic link** (regardless of target). Does NOT follow the link — tests the link itself.\n\nExample: `[[ -L $f ]] && rm $f` — remove the symlink without touching its target. Use `-e $f && ! -L $f` to test \"exists AND is not a symlink\". Same operator as `-h`."),
4372 ("-h", "**True if FILE is a symbolic link** — alias for `-L`. Both come from POSIX (`test`); zsh treats them identically. Prefer `-L` for clarity in new code; `-h` is the older spelling kept for `test`/`[`/`/bin/sh` compatibility."),
4373 ("-b", "**True if FILE is a block special device** (e.g. `/dev/disk0`, `/dev/sda`). Block devices buffer I/O in fixed-size blocks; contrast with character devices (`-c`) which transfer byte-at-a-time.\n\nExample: `for d in /dev/disk*; do [[ -b $d ]] && echo \"$d is a block dev\"; done`."),
4374 ("-c", "**True if FILE is a character special device** (e.g. `/dev/tty`, `/dev/null`, `/dev/random`, `/dev/zero`). Character devices transfer one byte at a time and are unbuffered.\n\nExample: `[[ -c /dev/tty ]] && echo 'have a controlling tty'`."),
4375 ("-p", "**True if FILE is a named pipe (FIFO)** — created via `mkfifo`. Anonymous pipes (between processes in a `|` pipeline) are NOT FIFOs and don't test true; `-p` is for filesystem entries.\n\nExample: `mkfifo /tmp/mypipe; [[ -p /tmp/mypipe ]] && echo 'pipe ready'`."),
4376 ("-S", "**True if FILE is a socket** — Unix-domain socket file on the filesystem (created by `bind()`). TCP/UDP sockets don't appear in the filesystem and won't test true; this is for `AF_UNIX` only.\n\nExample: `[[ -S /var/run/docker.sock ]] && echo 'docker up'`."),
4377 ("-t", "**True if file descriptor N is open AND refers to a terminal** — `-t 0` checks stdin, `-t 1` checks stdout, `-t 2` checks stderr. Used to detect interactive vs piped/redirected I/O.\n\nExample: `[[ -t 1 ]] && color=true || color=false` — emit ANSI colors only when stdout is a TTY (skip when piped to a file or another program)."),
4378 ("-r", "**True if FILE is readable by the effective uid** of the process. Honors filesystem ACLs and special bits, not just mode-bit permissions. Caveat: root tests true for any readable file regardless of mode.\n\nExample: `[[ -r $f ]] || { echo \"$f unreadable\" >&2; exit 1; }`."),
4380 ("-w", "**True if FILE is writable by the effective uid.** Note: `-w` only tests permission — actual writes can still fail (readonly filesystem, full disk, IMMUTABLE attribute, etc.). For root, almost always returns true even on permissioned-out files unless filesystem is RO.\n\nExample: `[[ -w /etc ]] || sudo=sudo` — pick whether to wrap with sudo."),
4381 ("-x", "**True if FILE is executable** (for regular files) **or searchable** (for directories — needs `+x` to enter and read inode of contents). Symlinks tested by their target's mode. Honors ACLs.\n\nExample: `[[ -x ./build.sh ]] || chmod +x ./build.sh`."),
4382 ("-s", "**True if FILE exists AND has size greater than zero.** Useful to distinguish empty files from non-empty ones — `-f` matches both, `-s` only matches non-empty.\n\nExample: `[[ -s err.log ]] && cat err.log` — only show the log when it has actual error output."),
4383 ("-u", "**True if FILE has the setuid bit set** (mode `04000`). Setuid binaries run with the file owner's uid regardless of caller. Common on `passwd`, `sudo`, `mount`. Security-sensitive — audit periodically.\n\nExample: `find / -perm -4000 2>/dev/null | while read f; do [[ -u $f ]] && echo SETUID: $f; done`."),
4384 ("-g", "**True if FILE has the setgid bit set** (mode `02000`). On binaries: runs as the file's group. On directories: new files inherit the directory's group instead of the creator's primary group (BSD semantics) — common pattern for shared project directories.\n\nExample: `[[ -g $project_dir ]] || chmod g+s $project_dir`."),
4385 ("-k", "**True if FILE has the sticky bit set** (mode `01000`). On directories like `/tmp`: only the file's owner (or root) can delete or rename files within, regardless of directory write permission. On regular files: historically meant \"keep text segment swapped in\"; now ignored on most systems.\n\nExample: `[[ -k /tmp ]] || echo 'WARNING: /tmp not sticky'`."),
4386 ("-O", "**True if FILE is owned by the effective uid** of the current process. Use to gate operations that should only act on user-owned files (vs system-owned).\n\nExample: `find ~/.config -type f ! -O 2>/dev/null` — flag files in your config dir that aren't yours."),
4387 ("-G", "**True if FILE is owned by the effective gid** of the current process — i.e. the file's group is your primary group. Distinct from `-O`: a file might be owned by another user but in your group.\n\nExample: `[[ -G $shared_log ]] && echo writable`."),
4388 ("-N", "**True if FILE has been modified since it was last read** — `mtime > atime`. Used by `mail`-style checkers to detect new content since the last access. zsh-specific (not in POSIX `test`).\n\nExample: `[[ -N $MAIL ]] && echo 'new mail'` — historically zsh's `$MAILCHECK` feature uses exactly this comparison."),
4389 ("-z", "**True if STRING has length zero.** Inverse of `-n`. The operand is the WHOLE string after expansion — `[[ -z $var ]]` works even when `$var` is unset (unlike `[ -z $var ]` which can fail with \"unary operator expected\" on unset vars).\n\nExample: `[[ -z $TERM ]] && export TERM=xterm-256color`."),
4391 ("-n", "**True if STRING has nonzero length.** Inverse of `-z`. Common idiom for \"is variable set AND non-empty\".\n\nExample: `[[ -n ${VAR:-} ]] && echo \"VAR is set: $VAR\"`. The `:-` makes the test work even with `set -u` (no-unset) enabled. Without quoting inside `[[ ]]`, the test still works because `[[ ]]` doesn't word-split."),
4392 ("=", "**POSIX string equality.** `[[ a = a ]]` is true. Within `[[ … ]]`, the RHS is treated as a literal string — no globbing. Same operator as `==` in zsh `[[ ]]`; use `=` for `/bin/sh` portability, `==` for clarity in zsh-only code.\n\nDo NOT confuse with assignment `=` — `[[ a = b ]]` tests, `var=b` assigns."),
4393 ("==", "**String equality with glob pattern matching on the RHS** (zsh extension). The right operand IS a pattern: `[[ foo == f* ]]` is true, `[[ foo == f? ]]` would need exactly one char after `f`.\n\nQuote the RHS to disable glob: `[[ $name == \"f*\" ]]` matches literal `f*`. With `EXTENDED_GLOB` enabled, `(#i)PAT` for case-insensitive: `[[ Foo == (#i)foo ]]` is true. Use `=~` instead for regex semantics."),
4394 ("!=", "**String inequality with glob pattern matching on the RHS.** Inverse of `==`. The RHS is a zsh pattern (unless quoted).\n\nExample: `[[ $f != *.bak ]] && process $f` — skip backup files. Same EXTENDED_GLOB modifiers (`(#i)`, `(#b)`, etc.) apply as for `==`."),
4395 ("<", "**Lexicographic less-than** — string comparison by locale-aware byte order. NOT numeric. `[[ 10 < 9 ]]` is TRUE (lex order) because `\"1\"` < `\"9\"`.\n\nFor numeric comparison use `-lt` or arithmetic context: `(( 10 < 9 ))` is false. The string comparison respects `LC_COLLATE` — `en_US.UTF-8` may give different results than `C`."),
4396 (">", "**Lexicographic greater-than** — string comparison. Same locale-awareness caveat as `<`: NOT numeric. For numeric `>`, use `-gt` or `(( a > b ))`.\n\nExample: `[[ $version > 1.10 ]]` is FALSE because `\"1.10\"` < `\"1.2\"` lexically. Use a real version-comparator (sort -V, vercmp) for semantic version ordering."),
4397 ("=~", "**Regular expression match** — the RHS is an extended regular expression (ERE by default; PCRE with `setopt REMATCH_PCRE` and `zsh/pcre` loaded). Sets `$MATCH` to the full match and `$match` (array) to the parenthesized groups.\n\nExample: `[[ $line =~ ^([0-9]+):(.+)$ ]] && echo \"line ${match[1]}: ${match[2]}\"`. Inside `[[ ]]` the RHS doesn't need quoting in most cases, but special chars (`(`, `|`) can hit shell parsing — quote when unsure."),
4398 ("-eq", "**Numeric equality** — arguments parsed as integers (or floats with zsh `FORCE_FLOAT`). Differs from `=` / `==` which compare as strings: `[[ 010 -eq 10 ]]` is true; `[[ 010 = 10 ]]` is false (string `\"010\"` ≠ `\"10\"`).\n\nFor arithmetic context, `(( a == b ))` is shorter. Operands can be variable names without `$` per arithmetic-expansion rules — `[[ x -eq 5 ]]` works if `x=5`."),
4400 ("-ne", "**Numeric inequality.** Like `-eq` but inverted. Same integer parsing — leading zeros / hex (`0x10`) / floats handled.\n\nExample: `[[ $rc -ne 0 ]] && exit $rc` — propagate non-zero exit codes from a previous command."),
4401 ("-lt", "**Numeric less-than.** Compares as integers, NOT lexically (unlike `<` which is lexicographic). Always prefer `-lt` over `<` when comparing numbers — `[[ 10 -lt 9 ]]` is correctly false; `[[ 10 < 9 ]]` is wrongly true (string order).\n\nExample: `[[ $count -lt 100 ]] && retry`."),
4402 ("-le", "**Numeric less-than-or-equal.** Integer-aware. Common for loop bounds.\n\nExample: `[[ $i -le $#argv ]] && process ${argv[$i]}` — check whether the index is within array bounds (1-indexed in zsh)."),
4403 ("-gt", "**Numeric greater-than.** Integer-aware. Mirror of `-lt`.\n\nExample: `[[ $(date +%s) -gt $deadline ]] && abort 'timed out'`."),
4404 ("-ge", "**Numeric greater-than-or-equal.** Integer-aware. Common for minimum-version checks: `[[ ${BASH_VERSINFO[0]} -ge 4 ]]` style.\n\nFor float-aware comparison, use arithmetic with `setopt FORCE_FLOAT`: `(( a >= b ))`. zsh's `[[ ]]` numeric tests treat float strings as 0."),
4405 ("-nt", "**True if FILE1 is newer than FILE2** (mtime comparison). True if FILE2 doesn't exist; false if FILE1 doesn't exist. Used in build-style checks: rebuild target if any source is newer.\n\nExample: `[[ $src -nt $obj ]] && cc -c $src -o $obj` — recompile only when source has changed. Compare against multiple: loop or use `find -newer`."),
4407 ("-ot", "**True if FILE1 is older than FILE2.** Inverse of `-nt`. True if FILE1 doesn't exist; false if FILE2 doesn't exist.\n\nExample: `[[ $cache -ot $config ]] && rm $cache` — invalidate cache when config is newer."),
4408 ("-ef", "**True if FILE1 and FILE2 refer to the same inode** on the same filesystem — same physical file, possibly via different paths (symlinks or hard links). Different files with identical content are NOT `-ef`.\n\nExample: `[[ /tmp -ef /private/tmp ]] && echo 'same dir'` — common on macOS where `/tmp` is a symlink. Distinguishes hard-linked duplicates from copies."),
4409 ("!", "**Logical negation** — inverts the truth value of the following test expression. Highest-precedence boolean operator inside `[[ … ]]`.\n\nExample: `[[ ! -f $f ]] && touch $f` — create the file if it doesn't exist. Combine with parens for grouping: `[[ ! ( -f $a || -f $b ) ]]` is true when NEITHER file exists. Same `!` is also pipeline-prefix negation outside `[[ ]]`: `! grep foo bar.txt && echo 'no match'`."),
4411 ("&&", "**Logical AND with short-circuit.** Inside `[[ … && … ]]`: both tests must pass. The right side is only evaluated if the left is true. Lower precedence than `!`, higher than `||`.\n\nExample: `[[ -f $f && -r $f ]]` — exists AND readable. Outside `[[ ]]`, `cmd1 && cmd2` is command-list short-circuit: run cmd2 only if cmd1 succeeded (exit 0)."),
4412 ("||", "**Logical OR with short-circuit.** Inside `[[ … || … ]]`: either test passing makes the whole expression true. Right side skipped if left is true.\n\nExample: `[[ -z $TERM || $TERM == dumb ]] && return` — bail out if terminal is unknown or dumb. Outside `[[ ]]`, the command-list form: `cmd1 || fallback`."),
4413 ("-o", "**POSIX-style OR — DEPRECATED inside `[[ ]]`.** Recognized for `test` / `[` compatibility but documented to be avoided: precedence is ambiguous and ill-defined. Use `||` outside `( )` groups OR rewrite as separate commands.\n\nBackground: zsh's `[[ ]]` does proper short-circuit parsing; `[ ]` with `-o` is parsed as a single command with arguments, leading to surprising precedence."),
4414 ("-a", "**POSIX-style AND — DEPRECATED inside `[[ ]]`.** Same caveats as `-o`: precedence is undefined when mixed with `!` / parens / other binary ops. Use `&&` instead.\n\n`man zshmisc` explicitly recommends against `-a`/`-o` in conditional expressions; they exist only because `[`/`test` traditionally used them."),
4415];
4416
4417const MATH_FUNCTIONS: &[(&str, &str)] = &[
4420 ("sin", "**Sine** of `x` radians. Range: `[-1, 1]`. For degrees, multiply input by `M_PI/180` (M_PI ≈ 3.14159265).\n\nExample: `(( y = sin(M_PI / 2) ))` → 1. Used in animation timing, geometry, signal processing. Argument near multiples of π may lose precision due to floating-point representation of π."),
4422 ("cos", "**Cosine** of `x` radians. Range: `[-1, 1]`. `cos(0) = 1`, `cos(M_PI) = -1`.\n\nExample: `(( c = cos(t * 2 * M_PI / period) ))` — periodic oscillation between -1 and 1. For combined sin+cos angle decomposition, `(sin(t), cos(t))` traces the unit circle."),
4423 ("tan", "**Tangent** of `x` radians = `sin(x) / cos(x)`. Undefined at `x = M_PI/2 + n*M_PI` (where `cos(x) = 0`); returns ±inf or extremely large values near those points.\n\nExample: `(( slope = tan(angle) ))` — convert angle to gradient. Wrap input via `fmod(x, M_PI)` if your formula isn't periodic-safe."),
4424 ("asin", "**Arcsine** — inverse of `sin`. Domain: `[-1, 1]`; range: `[-M_PI/2, M_PI/2]` radians. Returns NaN for `|x| > 1`.\n\nExample: `(( angle = asin(opp / hyp) ))` — recover angle from a right triangle's opposite/hypotenuse ratio."),
4425 ("acos", "**Arccosine** — inverse of `cos`. Domain: `[-1, 1]`; range: `[0, M_PI]` radians. Returns NaN for `|x| > 1`.\n\nExample: dot-product → angle: `(( theta = acos(dot / (mag_a * mag_b)) ))`. Common in 3D math for angle-between-vectors."),
4426 ("atan", "**Arctangent** — inverse of `tan`. Domain: all real; range: `(-M_PI/2, M_PI/2)`. For 2-argument atan2 with quadrant handling, use `atan2(y, x)`.\n\nExample: `(( angle = atan(slope) ))` — convert slope to angle. Range limitation makes `atan` unsuitable for vector → angle conversion; use `atan2` there."),
4427 ("atan2", "**Two-argument arctangent** — `atan2(y, x)` returns the angle of the point `(x, y)` from the positive x-axis. Range: `(-M_PI, M_PI]`. Handles all four quadrants correctly AND the `x=0` cases (returns ±M_PI/2). Always prefer over `atan(y/x)` for vector-to-angle conversion.\n\nExample: `(( bearing = atan2(dy, dx) * 180 / M_PI ))` — heading angle in degrees from coordinate delta."),
4428 ("sinh", "**Hyperbolic sine** = `(e^x - e^-x) / 2`. Range: all real. Unlike `sin`, NOT periodic — grows exponentially for large `|x|`.\n\nExample: catenary curve (hanging chain): `y = a * cosh(x/a)`. Used in physics (relativity, wave equations) and machine learning (tanh-family activations)."),
4429 ("cosh", "**Hyperbolic cosine** = `(e^x + e^-x) / 2`. Range: `[1, +inf)` — always ≥ 1. Even function: `cosh(-x) = cosh(x)`.\n\nExample: `(( y = cosh(x) ))` for catenary shape. Pair with `sinh` for hyperbolic identities: `cosh²(x) - sinh²(x) = 1`."),
4430 ("tanh", "**Hyperbolic tangent** = `sinh(x) / cosh(x)`. Range: `(-1, 1)`. Sigmoidal — saturates smoothly as `|x| → ∞`. Common activation function in neural networks for its zero-centered output (unlike sigmoid).\n\nExample: `(( y = tanh(x) ))` squashes any input into `(-1, 1)`."),
4431 ("asinh", "**Inverse hyperbolic sine** = `log(x + sqrt(x² + 1))`. Domain: all real. Numerically stable for large `|x|` (unlike the closed-form `log()` expression, which loses precision when x is large negative)."),
4432 ("acosh", "**Inverse hyperbolic cosine** = `log(x + sqrt(x² - 1))`. Domain: `[1, +inf)`. Returns NaN for `x < 1`. Range: `[0, +inf)`.\n\nExample: in special relativity, rapidity `φ` from velocity `v/c`: `phi = acosh(gamma)`."),
4433 ("atanh", "**Inverse hyperbolic tangent** = `0.5 * log((1+x) / (1-x))`. Domain: `(-1, 1)`. Returns ±inf at the endpoints, NaN outside. Useful for variance-stabilizing transforms in statistics (Fisher's z-transform of correlation coefficient)."),
4434 ("exp", "**Natural exponential** = e^x where e ≈ 2.71828. Inverse of `log`. For `|x|` large positive, returns inf (overflow at ~709). For `|x|` large negative, underflows to 0.\n\nExample: probability decay `(( p = exp(-lambda * t) ))`. For `e^x - 1` accurately near 0, use `expm1`."),
4436 ("expm1", "**exp(x) − 1**, computed with extra precision near `x = 0`. The naive `exp(x) - 1` loses significant digits when `x` is tiny because `exp(x) ≈ 1 + x + …` and subtracting 1 from ≈1 cancels the meaningful part.\n\nExample: small interest rate: `(( gain = expm1(rate) ))` is far more accurate than `(( gain = exp(rate) - 1 ))` for `rate ≈ 1e-9`."),
4437 ("log", "**Natural logarithm** (base e). Inverse of `exp`. Domain: `(0, +inf)`; `log(0)` = -inf; `log(x)` for `x < 0` returns NaN.\n\nExample: `(( bits = log(n) / log(2) ))` — bits needed to represent `n` distinct values (or use `log2(n)` directly). For accurate `log(1+x)` near 0, use `log1p`."),
4438 ("log2", "**Base-2 logarithm.** Useful when computing bits or binary tree depth. `log2(1024) = 10` exactly.\n\nExample: `(( depth = ceil(log2(node_count)) ))` — minimum binary tree height. Faster + more accurate than `log(x) / log(2)` because the constant `log(2)` doesn't need to be computed."),
4439 ("log10", "**Base-10 logarithm.** Common in engineering / acoustics (decibels: `db = 10 * log10(ratio)`) and order-of-magnitude estimates.\n\nExample: `(( db = 20 * log10(amplitude / reference) ))` — convert linear amplitude to dB. Like `log2`, more accurate than dividing by `log(10)`."),
4440 ("log1p", "**log(1+x)**, accurate near `x = 0`. The naive `log(1+x)` loses precision when `x` is tiny because adding small `x` to 1 hits float-rounding before the log is taken.\n\nExample: log-likelihood of small probability: `(( ll = log1p(-p) ))` — avoids `log(1 - tiny_p)` underflowing to `log(1) = 0`."),
4441 ("pow", "**x raised to power y** — `pow(x, y)` = `x^y`. Same as zsh's `**` operator: `(( c = x ** y ))`. For integer `y`, `**` is often faster; `pow` always uses float arithmetic.\n\nNegative `x` with non-integer `y` returns NaN. `pow(0, 0) = 1` by convention. For exponential of `e`, prefer `exp(y)` over `pow(M_E, y)`."),
4442 ("sqrt", "**Square root** — `sqrt(x)` = `x^0.5`. Domain: `[0, +inf)`; returns NaN for negative input. For complex roots, no native support — use `csqrt` from a math library or compute manually.\n\nExample: distance: `(( dist = sqrt(dx*dx + dy*dy) ))`. For `sqrt(x² + y²)` specifically, prefer `hypot(x, y)` — avoids overflow when intermediate squares are huge."),
4443 ("cbrt", "**Cube root** — works for negative inputs (unlike `pow(x, 1.0/3.0)` which returns NaN for `x < 0` because of how float exponents handle negatives). Domain: all real.\n\nExample: `(( radius = cbrt(3 * volume / (4 * M_PI)) ))` — sphere radius from volume."),
4444 ("hypot", "**Euclidean norm** = `sqrt(x² + y²)`, computed without overflow/underflow even when `x` or `y` is huge. The naive `sqrt(x*x + y*y)` overflows when `x*x` exceeds float max (~1e308); `hypot` rescales internally to avoid it.\n\nExample: vector magnitude: `(( mag = hypot(dx, dy) ))`. Always prefer over `sqrt(x*x + y*y)` for robustness."),
4445 ("abs", "**Absolute value** — `abs(x)` returns `|x|`. For integers in arithmetic context, this is the same as `(( a < 0 ? -a : a ))`. For floats, preserves the type.\n\nExample: difference magnitude: `(( delta = abs(a - b) ))`. Note: `abs(INT_MIN)` overflows on two's-complement integers (the canonical pitfall)."),
4447 ("ceil", "**Round up to the nearest integer** (toward +inf). `ceil(3.1) = 4`, `ceil(-3.1) = -3`. Returns a float — cast to integer with `int(ceil(x))` if you need an int type.\n\nExample: pages needed: `(( pages = ceil(items / per_page) ))`."),
4448 ("floor", "**Round down to the nearest integer** (toward -inf). `floor(3.9) = 3`, `floor(-3.1) = -4`. Note: `floor` and integer truncation differ for negatives — `int(-3.1) = -3` (toward zero), `floor(-3.1) = -4` (toward -inf).\n\nExample: bucketing: `(( bucket = floor(value / bucket_size) ))`."),
4449 ("round", "**Round half-away-from-zero** to nearest integer. `round(2.5) = 3`, `round(-2.5) = -3`. Distinct from IEEE banker's rounding (`rint`) which rounds half-to-even.\n\nExample: nearest pixel: `(( px = round(x * dpi / 72) ))`."),
4450 ("trunc", "**Truncate toward zero** — drop the fractional part. `trunc(3.9) = 3`, `trunc(-3.9) = -3`. Same as the C `(int)` cast or zsh's `int()` function.\n\nDistinct from `floor` for negatives: `floor(-3.9) = -4`, `trunc(-3.9) = -3`."),
4451 ("rint", "**Round to nearest integer using the CURRENT rounding mode** (default IEEE-754 round-half-to-even). `rint(2.5) = 2` (even); `rint(3.5) = 4` (even). Banker's rounding eliminates bias when summing many rounded values.\n\nDiffers from `round` (always-away-from-zero) and from `nearbyint` (`rint` raises the inexact exception, `nearbyint` doesn't)."),
4452 ("gamma", "**Gamma function** Γ(x) — generalization of factorial to real / complex numbers: `Γ(n) = (n-1)!` for positive integer n. `Γ(0.5) = sqrt(M_PI)`. Pole at every non-positive integer; returns ±inf there.\n\nUsed in combinatorics (continuous factorial), statistics (gamma / beta distributions), physics. For large `x`, prefer `lgamma` to avoid overflow."),
4454 ("lgamma", "**log |Γ(x)|** — log of absolute value of gamma function. Avoids overflow that Γ itself hits quickly: Γ(171) overflows float, but `lgamma(171)` is ~706 (representable).\n\nExample: log-binomial coefficient: `(( lc = lgamma(n+1) - lgamma(k+1) - lgamma(n-k+1) ))`. Sign of Γ retrievable separately via `signgam` (not always exposed)."),
4455 ("erf", "**Error function** — `erf(x) = 2/sqrt(π) * ∫₀ˣ e^(-t²) dt`. Used in statistics (normal-distribution CDF: `Φ(z) = (1 + erf(z/sqrt(2))) / 2`), diffusion equations, signal processing.\n\nRange: `(-1, 1)`. Odd function: `erf(-x) = -erf(x)`. `erf(0) = 0`, `erf(inf) = 1`."),
4456 ("erfc", "**Complementary error function** = `1 - erf(x)`. Use instead of `1 - erf(x)` when `x` is large — the naive subtraction loses precision because `erf(x)` approaches 1 and `1 - 0.999…` cancels significant digits.\n\nExample: tail probability: `(( p_tail = erfc(z / sqrt(2)) / 2 ))` — far more accurate than `1 - erf(…)` for z > 5."),
4457 ("j0", "**Bessel function of the first kind, order 0** — `J₀(x)`. Oscillatory solution to the Bessel equation; appears in cylindrical-coordinate problems (drum vibration modes, EM wave propagation in cylinders).\n\nNot in POSIX `<math.h>` but standard in BSD/Linux libm. Range: `[-0.4, 1]` approximately, decaying with √x rate."),
4458 ("j1", "**Bessel function of the first kind, order 1** — `J₁(x)`. `J₁(0) = 0`. Like `j0`, oscillates with √x-decay. Used in optics (Airy disk pattern: intensity is `(2 J₁(x) / x)²`)."),
4459 ("jn", "**Bessel function of the first kind, order n** — `J_n(x)` for integer `n`. Two-arg: `jn(n, x)`. Generalization of `j0` / `j1`; for large `n` the function decays rapidly until `x ≥ n`. Used in FM modulation (sideband amplitudes follow J_n)."),
4460 ("y0", "**Bessel function of the second kind, order 0** — `Y₀(x)`. Domain: `(0, +inf)`; diverges to -inf at `x = 0`. Used together with `J₀` as the second linearly-independent solution to Bessel's equation."),
4461 ("y1", "**Bessel function of the second kind, order 1** — `Y₁(x)`. Like `y0`: diverges at 0, oscillates with √x decay. Pairs with `j1` for general-solution construction in cylindrical-symmetry problems."),
4462 ("yn", "**Bessel function of the second kind, order n** — `Y_n(x)` for integer `n`. Two-arg: `yn(n, x)`. Diverges at `x = 0` faster as `n` grows. Used in optics, antenna theory, heat-equation solutions."),
4463 ("isinf", "**Tests if argument is ±infinity** — returns 1 if `x == +inf` or `x == -inf`, 0 otherwise. Use after computations that might overflow (`pow(big, big)`, `1/0.0`) to detect runaway results.\n\nExample: `(( isinf(result) )) && { print 'overflow' >&2; return 1 }`."),
4465 ("isnan", "**Tests if argument is NaN** (Not-a-Number) — returns 1 if `x` is the IEEE-754 NaN. NaN appears from `0/0`, `inf - inf`, `sqrt(-1)`, and is the only float value where `x != x` is true (NaN comparisons always return false).\n\nExample: `(( isnan(result) )) && { print 'undefined result' >&2; result=0 }`."),
4466 ("finite", "**Tests if argument is finite** — returns 1 if `x` is neither NaN nor ±inf, 0 otherwise. Inverse of `(isnan(x) || isinf(x))`. Less standard than `isnan`/`isinf` separately; on Linux this is `__finite` / `isfinite`."),
4467 ("int", "**Convert to integer by truncating toward zero.** `int(3.9) = 3`, `int(-3.9) = -3`. Same as zsh's `(( i = (int) x ))` cast. For round-half-away-from-zero, use `round`. For floor (-inf direction), use `floor`.\n\nUsed inside arithmetic to force integer type: `(( i = int(rand48() * 100) ))` — random int in 0..99."),
4469 ("float", "**Convert to float** — explicit type cast. Mostly redundant since most math ported return float anyway, but useful when you want to force float arithmetic: `(( q = float(a) / b ))` ensures float division even if `a` and `b` are integer parameters."),
4470 ("rand48", "**Pseudo-random float in `[0, 1)`** — drand48(3) under the hood. Not cryptographically secure (linear congruential generator). Seed via `srand48()` — not directly exposed in zsh math, but the seed comes from process-startup time by default.\n\nExample: `(( dice = int(rand48() * 6) + 1 ))` — uniform 1..6. For dedicated crypto-grade randomness, read from `/dev/urandom` instead."),
4471 ("max", "**Maximum of two or more arguments.** `max(a, b)` for two; `max(a, b, c, …)` works in zsh math context. Float-aware: `max(1, 1.5) = 1.5`.\n\nExample: `(( cap = max(min_size, requested) ))` — clamp lower bound. NOT the same as the GNU coreutils external `/bin/max` (doesn't exist)."),
4472 ("min", "**Minimum of two or more arguments.** Mirror of `max`. Used for clamping upper bound or finding the smallest item in a set of computed values.\n\nExample: `(( delay = min(timeout, exponential_backoff) ))`."),
4473 ("sum", "**Sum of all arguments.** Variadic — `sum(1, 2, 3, 4) = 10`. Convenient for combining a small set of math expressions without writing `(( a + b + c + d ))`.\n\nExample: `(( total = sum($costs) ))` — but be careful: this only works if `$costs` is a scalar expression list, not an array."),
4474 ("copysign", "**copysign(x, y)** — returns the magnitude of `x` with the sign of `y`. `copysign(3, -1) = -3`, `copysign(-3, 1) = 3`. Works for `±0` and `±inf` too. Used to preserve sign through computations that otherwise zero it out."),
4475 ("ilogb", "**Integer binary exponent of x** — returns the unbiased exponent as an int, i.e. `e` such that `|x| ∈ [2^e, 2^(e+1))`. `ilogb(8) = 3`, `ilogb(0.5) = -1`. Faster than `log2(x)` when you only need the integer part.\n\nUsed for fast bit-counting in floats: number of bits to shift to normalize."),
4476 ("logb", "**Binary exponent of x as a float.** Same value as `ilogb` but float-typed. Used in low-level float manipulation where you want to extract the exponent and re-combine via `scalb`."),
4477 ("scalb", "**scalb(x, n)** = `x × 2^n`. Faster than `x * pow(2, n)` because it just adjusts the exponent bits directly, no full multiplication. The inverse of `logb` / `ilogb` in a sense — `scalb(1.0, ilogb(x))` recovers the float's exponent magnitude."),
4478 ("nextafter", "**nextafter(x, y)** — next representable double after `x` in the direction of `y`. Returns the immediate float neighbor — useful for testing float-comparison robustness (`nextafter(0.1, 1.0)` ≠ 0.1) or for iterative algorithms that need to step through every distinct float."),
4479 ("fma", "**Fused multiply-add** = `x*y + z`, computed with a SINGLE rounding step instead of two (one for `*`, one for `+`). More accurate than `x*y + z` when the multiplication and addition would cancel meaningful digits.\n\nUsed in dot products / matrix multiply for numerical stability. Most modern CPUs have a single FMA instruction."),
4480 ("fmod", "**Floating-point remainder** — `fmod(x, y)` returns `x - n*y` where `n = trunc(x/y)`. Has the same sign as `x`. For non-negative remainder, use `(((x % y) + y) % y)` style or `remainder()`.\n\nExample: clock arithmetic: `(( hour = fmod(elapsed_sec / 3600, 24) ))`."),
4481 ("drem", "**IEEE remainder of x/y** — like `fmod` but uses round-half-to-even for the quotient, so the result is in `(-y/2, y/2]`. Standard name on Linux is `remainder`; `drem` is the legacy BSD name kept for compatibility.\n\nDifference vs `fmod`: `drem(7, 3) = 1`, `fmod(7, 3) = 1` — they match for this. But `drem(5, 3) = -1` (round-to-even quotient), `fmod(5, 3) = 2`. Choose based on whether you want truncation or rounding semantics."),
4482];
4483
4484const ZSTYLE_CONTEXTS: &[(&str, &str)] = &[
4489 (":completion:*", "all completion settings"),
4490 (":completion:*:default", "default completion"),
4491 (
4492 ":completion:*:descriptions",
4493 "tag-group descriptions in menus",
4494 ),
4495 (":completion:*:matches", "match grouping / formatting"),
4496 (":completion:*:options", "option-name completion"),
4497 (":completion:*:warnings", "no-match warning style"),
4498 (
4499 ":completion:*:messages",
4500 "info messages from completion ported",
4501 ),
4502 (":completion:*:corrections", "spell-correction style"),
4503 (
4504 ":completion:*:*:*:*:processes",
4505 "process-name completion (`kill <TAB>`)",
4506 ),
4507 (":completion:*:functions", "function-name completion"),
4508 (":completion:*:manuals", "man-page completion"),
4509 (
4510 ":completion:*:hosts",
4511 "hostname completion (ssh, scp, etc.)",
4512 ),
4513 (
4514 ":vcs_info:*",
4515 "version-control info system (`git`/`hg`/`svn` in prompt)",
4516 ),
4517 (":vcs_info:git:*", "git-specific vcs_info"),
4518 (":prompt:*", "prompt customization (themes)"),
4519 (":urlglobber", "URL-glob filtering"),
4520 (":zftp:*", "zftp module configuration"),
4521 (":grep:*", "grep widget configuration"),
4522 (":compinstall", "`compinstall` wizard state"),
4523 (":zle:*", "ZLE widget configuration"),
4524 (":bracketed-paste-magic", "bracketed-paste-magic widget"),
4525 (
4526 ":syntax-highlighting",
4527 "fast-syntax-highlighting / zsh-syntax-highlighting",
4528 ),
4529];
4530
4531const PATTERN_MODIFIERS: &[(&str, &str)] = &[
4534 ("i", "case-insensitive matching for the rest of the pattern"),
4535 ("l", "lowercase chars match upper + lower"),
4536 ("I", "case-sensitive — reset after `(#i)`"),
4537 (
4538 "b",
4539 "activate backreferences (`$match[N]` / `$mbegin` / `$mend`)",
4540 ),
4541 ("B", "deactivate backreferences"),
4542 (
4543 "m",
4544 "set `$MATCH` / `$MBEGIN` / `$MEND` even without backref",
4545 ),
4546 ("M", "deactivate `m`"),
4547 ("a", "`(#aN)` — approximate match with up to N errors"),
4548 ("s", "anchor pattern to start of string"),
4549 ("e", "anchor pattern to end of string"),
4550 (
4551 "c",
4552 "`(#cN,M)` — preceding atom matched between N and M times",
4553 ),
4554 ("u", "use Unicode character properties"),
4555 ("U", "deactivate `u`"),
4556 (
4557 "q",
4558 "treat following pattern as glob qualifier list (`(#q.,L0)`)",
4559 ),
4560];
4561
4562const SUBSCRIPT_FLAGS: &[(&str, &str)] = &[
4566 ("e", "exact match — disable globbing on subscript"),
4567 ("i", "return INDEX of first matching element"),
4568 ("I", "return INDEX of LAST matching element"),
4569 ("r", "return VALUE of first match — search reverse"),
4570 ("R", "as `r` but ranged"),
4571 ("b", "byte offset (with `i` / `I`)"),
4572 ("n", "`(nN)` — Nth match (with `i` / `I` / `r` / `R`)"),
4573 ("w", "word offset (split on `$IFS`)"),
4574 ("W", "word offset with empty fields"),
4575 ("p", "process `\\NNN` escapes in `(s::)` separator"),
4576 ("s", "`(s:STR:)` — split on STR (with `w` / `W`)"),
4577 ("f", "split scalar on newlines (= `(s.\\n.)`)"),
4578 ("k", "match against keys of an associative array"),
4579 ("v", "match against values of an associative array"),
4580];
4581
4582#[derive(Debug, Clone, PartialEq, Eq)]
4587enum LspCompletionContext {
4588 Normal,
4589 ParamFlag,
4590 GlobQualifier,
4591 HistoryDesignator,
4592 ParamColonModifier,
4593 OptionOnly, SignalName, ModuleName, KeymapName, WidgetName, TypesetFlag, ZstyleContext, CompdefFn, TestOperator, MathFunction, PatternModifier, SubscriptFlag, BuiltinFlag(String),
4614 BuiltinLongFlag(String),
4622}
4623
4624fn leading_command_at(line: &str, col: usize) -> Option<(String, usize)> {
4629 let bytes = line.as_bytes();
4630 let cap = col.min(bytes.len());
4631 let mut s: usize = 0;
4635 let mut i = cap;
4636 while i > 0 {
4637 i -= 1;
4638 let c = bytes[i];
4639 if c == b'\n' || c == b';' {
4640 s = i + 1;
4641 break;
4642 }
4643 if (c == b'|' || c == b'&') && i > 0 {
4644 s = i + 1;
4646 break;
4647 }
4648 if c == b'(' {
4654 s = i + 1;
4655 break;
4656 }
4657 }
4658 while s < cap && matches!(bytes[s], b' ' | b'\t') {
4660 s += 1;
4661 }
4662 let mut e = s;
4664 while e < cap && (bytes[e].is_ascii_alphanumeric() || matches!(bytes[e], b'_' | b'-' | b'.')) {
4665 e += 1;
4666 }
4667 if e == s {
4668 return None;
4669 }
4670 let cmd = std::str::from_utf8(&bytes[s..e]).ok()?.to_string();
4671 Some((cmd, e))
4672}
4673
4674fn count_pair(bytes: &[u8], end: usize, tok: [u8; 2]) -> i32 {
4676 let cap = end.min(bytes.len());
4677 let mut n: i32 = 0;
4678 let mut i = 0;
4679 while i + 1 < cap {
4680 if bytes[i] == tok[0] && bytes[i + 1] == tok[1] {
4681 n += 1;
4682 i += 2;
4683 } else {
4684 i += 1;
4685 }
4686 }
4687 n
4688}
4689
4690fn lsp_completion_context(line: &str, col: usize) -> LspCompletionContext {
4698 let bytes = line.as_bytes();
4699 let cap = col.min(bytes.len());
4700
4701 {
4706 let mut k = cap;
4707 while k > 0 {
4708 let c = bytes[k - 1];
4709 if c.is_ascii_alphanumeric()
4710 || matches!(c, b'?' | b'#' | b'$' | b'^' | b'*' | b'-' | b'_')
4711 {
4712 k -= 1;
4713 } else {
4714 break;
4715 }
4716 }
4717 if k > 0 && bytes[k - 1] == b'!' {
4718 let bang = k - 1;
4719 let word_bound = bang == 0
4720 || matches!(
4721 bytes[bang - 1],
4722 b' ' | b'\t' | b';' | b'&' | b'|' | b'(' | b'`' | b'\n'
4723 );
4724 let escaped = bang > 0 && bytes[bang - 1] == b'\\';
4725 let mut paren_pairs: i32 = 0;
4729 let mut j = 0;
4730 while j + 1 < bang {
4731 if bytes[j] == b'(' && bytes[j + 1] == b'(' {
4732 paren_pairs += 1;
4733 j += 2;
4734 continue;
4735 }
4736 if bytes[j] == b')' && bytes[j + 1] == b')' {
4737 paren_pairs -= 1;
4738 j += 2;
4739 continue;
4740 }
4741 j += 1;
4742 }
4743 let in_arith = paren_pairs > 0;
4744 if word_bound && !escaped && !in_arith {
4745 return LspCompletionContext::HistoryDesignator;
4746 }
4747 }
4748 }
4749
4750 {
4752 let mut depth: i32 = 0;
4753 let mut i = cap;
4754 while i > 0 {
4755 i -= 1;
4756 let c = bytes[i];
4757 if c == b')' {
4758 depth += 1;
4759 } else if c == b'(' {
4760 if depth == 0 {
4761 if i >= 2 && bytes[i - 2] == b'$' && bytes[i - 1] == b'{' {
4762 return LspCompletionContext::ParamFlag;
4763 }
4764 if i >= 1 {
4765 let prev = bytes[i - 1];
4766 if matches!(prev, b'*' | b'?' | b']' | b')') {
4767 return LspCompletionContext::GlobQualifier;
4768 }
4769 }
4770 break;
4771 }
4772 depth -= 1;
4773 }
4774 }
4775 }
4776
4777 {
4781 let mut bdepth: i32 = 0;
4782 let mut found_colon = false;
4783 let mut k = cap;
4784 while k > 0 {
4785 k -= 1;
4786 let c = bytes[k];
4787 if c == b'}' {
4788 bdepth += 1;
4789 } else if c == b'{' {
4790 if bdepth == 0 {
4791 if k >= 1 && bytes[k - 1] == b'$' && found_colon {
4792 return LspCompletionContext::ParamColonModifier;
4793 }
4794 break;
4795 }
4796 bdepth -= 1;
4797 } else if c == b':' && bdepth == 0 && !found_colon {
4798 found_colon = true;
4799 }
4800 }
4801 }
4802
4803 {
4806 let mut k = cap;
4807 while k > 0
4809 && (bytes[k - 1].is_ascii_alphabetic() || matches!(bytes[k - 1], b'&' | b'/' | b'g'))
4810 {
4811 k -= 1;
4812 }
4813 if k > 0 && bytes[k - 1] == b':' {
4814 let colon = k - 1;
4818 let mut e = colon;
4819 while e > 0
4820 && (bytes[e - 1].is_ascii_alphanumeric()
4821 || matches!(
4822 bytes[e - 1],
4823 b'?' | b'#' | b'$' | b'^' | b'*' | b'-' | b'_' | b'!'
4824 ))
4825 {
4826 e -= 1;
4827 }
4828 if e < colon && bytes[e] == b'!' {
4829 let bang = e;
4832 let word_bound = bang == 0
4833 || matches!(
4834 bytes[bang - 1],
4835 b' ' | b'\t' | b';' | b'&' | b'|' | b'(' | b'`' | b'\n'
4836 );
4837 if word_bound {
4838 return LspCompletionContext::ParamColonModifier;
4839 }
4840 }
4841 }
4842 }
4843
4844 {
4846 let bytes = line.as_bytes();
4847 let cap = col.min(bytes.len());
4848 {
4851 let mut depth: i32 = 0;
4852 let mut i = cap;
4853 while i > 0 {
4854 i -= 1;
4855 let c = bytes[i];
4856 if c == b')' {
4857 depth += 1;
4858 } else if c == b'(' {
4859 if depth == 0 {
4860 if i + 1 < bytes.len() && bytes[i + 1] == b'#' {
4861 return LspCompletionContext::PatternModifier;
4862 }
4863 break;
4864 }
4865 depth -= 1;
4866 }
4867 }
4868 }
4869 {
4873 let mut depth: i32 = 0;
4874 let mut i = cap;
4875 while i > 0 {
4876 i -= 1;
4877 let c = bytes[i];
4878 if c == b')' {
4879 depth += 1;
4880 } else if c == b'(' {
4881 if depth == 0 {
4882 if i >= 1 && bytes[i - 1] == b'[' {
4883 return LspCompletionContext::SubscriptFlag;
4884 }
4885 break;
4886 }
4887 depth -= 1;
4888 }
4889 }
4890 }
4891 let lbrack = count_pair(bytes, cap, [b'[', b'[']);
4894 let rbrack = count_pair(bytes, cap, [b']', b']']);
4895 if lbrack > rbrack {
4896 return LspCompletionContext::TestOperator;
4897 }
4898 let lparen = count_pair(bytes, cap, [b'(', b'(']);
4900 let rparen = count_pair(bytes, cap, [b')', b')']);
4901 if lparen > rparen {
4902 return LspCompletionContext::MathFunction;
4903 }
4904 }
4905
4906 if let Some((cmd, _arg_start)) = leading_command_at(line, col) {
4908 match cmd.as_str() {
4909 "setopt" | "unsetopt" => return LspCompletionContext::OptionOnly,
4910 "set" => {
4911 let bytes = line.as_bytes();
4914 let cap = col.min(bytes.len());
4915 let mut j = 0;
4916 let mut saw_o = false;
4917 while j + 1 < cap {
4918 if (bytes[j] == b'-' || bytes[j] == b'+') && bytes[j + 1] == b'o' {
4919 saw_o = true;
4920 break;
4921 }
4922 j += 1;
4923 }
4924 if saw_o {
4925 return LspCompletionContext::OptionOnly;
4926 }
4927 }
4928 "kill" => {
4929 let bytes = line.as_bytes();
4933 let cap = col.min(bytes.len());
4934 let mut has_dash = false;
4935 let mut j = 0;
4936 while j < cap {
4937 if bytes[j] == b'-' && j > 0 && matches!(bytes[j - 1], b' ' | b'\t') {
4938 has_dash = true;
4939 break;
4940 }
4941 j += 1;
4942 }
4943 if has_dash {
4944 return LspCompletionContext::SignalName;
4945 }
4946 }
4947 "trap" => return LspCompletionContext::SignalName,
4948 "zmodload" => return LspCompletionContext::ModuleName,
4949 "bindkey" => {
4950 let bytes = line.as_bytes();
4955 let cap = col.min(bytes.len());
4956 let mut last_flag: Option<u8> = None;
4957 let mut j = 0;
4958 while j < cap {
4959 if bytes[j] == b'-'
4960 && j > 0
4961 && matches!(bytes[j - 1], b' ' | b'\t')
4962 && j + 1 < cap
4963 {
4964 last_flag = Some(bytes[j + 1]);
4965 }
4966 j += 1;
4967 }
4968 if matches!(last_flag, Some(b'A') | Some(b'M') | Some(b'N')) {
4969 return LspCompletionContext::KeymapName;
4970 }
4971 return LspCompletionContext::WidgetName;
4972 }
4973 "zle" => {
4974 let bytes = line.as_bytes();
4981 let cap = col.min(bytes.len());
4982 let mut j = cap;
4983 while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
4984 j -= 1;
4985 }
4986 if j < cap && bytes[j] == b'-' {
4987 return LspCompletionContext::BuiltinFlag("zle".to_string());
4988 }
4989 return LspCompletionContext::WidgetName;
4990 }
4991 "typeset" | "declare" | "local" | "readonly" | "integer" | "float" | "export"
4992 | "private" => {
4993 let bytes = line.as_bytes();
4995 let cap = col.min(bytes.len());
4996 let mut j = cap;
4998 while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
4999 j -= 1;
5000 }
5001 if j < cap && bytes[j] == b'-' {
5002 return LspCompletionContext::TypesetFlag;
5003 }
5004 }
5005 "zstyle" => return LspCompletionContext::ZstyleContext,
5006 "compdef" => return LspCompletionContext::CompdefFn,
5007 _ => {}
5008 }
5009 let bytes = line.as_bytes();
5017 let cap = col.min(bytes.len());
5018 let mut j = cap;
5019 while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
5020 j -= 1;
5021 }
5022 let starts_with_dash = j < cap && bytes[j] == b'-';
5023 let starts_with_double_dash = j + 1 < cap && bytes[j] == b'-' && bytes[j + 1] == b'-';
5024 let just_after_builtin = j == cap;
5025 if starts_with_double_dash && is_known_builtin_with_long_flag_docs(&cmd) {
5030 return LspCompletionContext::BuiltinLongFlag(cmd);
5031 }
5032 if (starts_with_dash || just_after_builtin) && is_known_builtin_with_flag_docs(&cmd) {
5033 return LspCompletionContext::BuiltinFlag(cmd);
5034 }
5035 }
5036
5037 LspCompletionContext::Normal
5038}
5039
5040fn derive_inline_flag_desc(body: &str, flag: &str) -> Option<String> {
5053 let needle = format!("`{}`", flag);
5054 let bytes = body.as_bytes();
5055 let nbytes = needle.as_bytes();
5056 let mut best: Option<String> = None;
5058 let mut search_from = 0;
5059 while let Some(pos) = body[search_from..].find(&needle) {
5060 let abs = search_from + pos;
5061 search_from = abs + needle.len();
5062 let mut sstart = abs;
5065 while sstart > 0 {
5066 let c = bytes[sstart - 1];
5067 if c == b'\n' && sstart >= 2 && bytes[sstart - 2] == b'\n' {
5068 break;
5069 }
5070 if c == b'.' && sstart < bytes.len() && matches!(bytes[sstart], b' ' | b'\n') {
5071 sstart += 1; break;
5073 }
5074 sstart -= 1;
5075 }
5076 let mut send = abs + needle.len();
5079 let cap_end = (sstart + 400).min(bytes.len());
5080 while send < cap_end {
5081 let c = bytes[send];
5082 if c == b'.' && send + 1 < bytes.len() && matches!(bytes[send + 1], b' ' | b'\n') {
5083 send += 1; break;
5085 }
5086 if c == b'\n' && send + 1 < bytes.len() && bytes[send + 1] == b'\n' {
5087 break;
5088 }
5089 send += 1;
5090 }
5091 let raw = &body[sstart..send.min(bytes.len())];
5092 let cleaned: String = raw
5094 .split_whitespace()
5095 .collect::<Vec<_>>()
5096 .join(" ")
5097 .trim()
5098 .to_string();
5099 if cleaned.len() < 15 {
5100 continue; }
5102 if cleaned.starts_with('#') {
5104 continue;
5105 }
5106 if best
5108 .as_ref()
5109 .map(|b| b.len() > cleaned.len())
5110 .unwrap_or(true)
5111 {
5112 best = Some(cleaned);
5113 }
5114 }
5115 best.map(|s| s.chars().take(200).collect())
5116}
5117
5118fn is_known_builtin_with_flag_docs(name: &str) -> bool {
5119 let is_compat = crate::ported::builtin::BUILTINS
5120 .iter()
5121 .any(|b| b.node.nam == name);
5122 let is_ext = crate::ext_builtins::EXT_BUILTIN_NAMES.contains(&name);
5123 let is_compsys = crate::compsys::COMPSYS_FN_NAMES.contains(&name);
5129 let is_self = name == "zshrs" || name == "zsh";
5133 if !is_compat && !is_ext && !is_compsys && !is_self {
5134 return false;
5135 }
5136 !extract_builtin_flags(name).is_empty()
5137}
5138
5139pub fn extract_builtin_flags_for_test(name: &str) -> Vec<(String, String)> {
5159 extract_builtin_flags(name)
5160}
5161
5162fn extract_builtin_flags(name: &str) -> Vec<(String, String)> {
5163 use std::sync::Mutex;
5164 use std::sync::OnceLock;
5165 static CACHE: OnceLock<Mutex<std::collections::HashMap<String, Vec<(String, String)>>>> =
5166 OnceLock::new();
5167 let cache = CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
5168 if let Ok(g) = cache.lock() {
5169 if let Some(v) = g.get(name) {
5170 return v.clone();
5171 }
5172 }
5173 if name == "zshrs" || name == "zsh" {
5181 let out: Vec<(String, String)> = ZSHRS_SELF_FLAG_DOCS
5182 .iter()
5183 .map(|(f, d)| (f.to_string(), d.to_string()))
5184 .collect();
5185 if let Ok(mut g) = cache.lock() {
5186 g.insert(name.to_string(), out.clone());
5187 }
5188 return out;
5189 }
5190 if let Some(flags) = lookup_compsys_flag_docs(name) {
5197 let out: Vec<(String, String)> = flags
5198 .iter()
5199 .map(|(f, d)| (f.to_string(), d.to_string()))
5200 .collect();
5201 if let Ok(mut g) = cache.lock() {
5202 g.insert(name.to_string(), out.clone());
5203 }
5204 return out;
5205 }
5206 let body: String = match crate::zsh_builtin_docs::lookup_builtin_doc(name) {
5215 Some((_, b)) => b.to_string(),
5216 None => crate::zsh_ext_builtin_docs::lookup_full(name)
5217 .map(|b| b.to_string())
5218 .unwrap_or_default(),
5219 };
5220 let mut out: Vec<(String, String)> = Vec::new();
5221 let re_bullet = regex::Regex::new(
5245 r"(?m)^\s*-\s+\*\*`(-[A-Za-z+])(?:\s+[^`]*)?`[^*\n]*\*\*[ \t]*[^A-Za-z\n]*[ \t]*(?:\n[ \t]*)?([A-Z][^\n]+)",
5246 )
5247 .unwrap();
5248 for cap in re_bullet.captures_iter(&body) {
5249 let flag = cap.get(1).unwrap().as_str().to_string();
5250 let raw_desc = cap.get(2).map(|m| m.as_str()).unwrap_or("");
5251 let desc: String = raw_desc.trim().chars().take(200).collect();
5252 if !out.iter().any(|(f, _)| f == &flag) {
5253 out.push((flag, desc));
5254 }
5255 }
5256 if out.is_empty() {
5267 let re_inline = regex::Regex::new(r"`(-[A-Za-z+])`").unwrap();
5268 for cap in re_inline.captures_iter(&body) {
5269 let flag = cap.get(1).unwrap().as_str().to_string();
5270 if out.iter().any(|(f, _)| f == &flag) {
5271 continue;
5272 }
5273 let desc = derive_inline_flag_desc(&body, &flag).unwrap_or_default();
5276 out.push((flag, desc));
5277 }
5278 }
5279 if let Some(over) = lookup_builtin_flag_docs_override(name) {
5284 let over_keys: std::collections::HashSet<&str> = over.iter().map(|(f, _)| *f).collect();
5285 out.retain(|(f, _)| !over_keys.contains(f.as_str()));
5286 for (f, d) in over {
5287 out.push((f.to_string(), d.to_string()));
5288 }
5289 }
5290 tracing::debug!(
5291 target: "zshrs::lsp::completion",
5292 builtin = %name,
5293 flag_count = out.len(),
5294 "extract_builtin_flags",
5295 );
5296 if let Ok(mut g) = cache.lock() {
5297 g.insert(name.to_string(), out.clone());
5298 }
5299 out
5300}
5301
5302pub(crate) fn lookup_builtin_flag_docs_override(
5307 name: &str,
5308) -> Option<&'static [(&'static str, &'static str)]> {
5309 BUILTIN_FLAG_DOCS_OVERRIDE
5310 .iter()
5311 .find(|(n, _)| *n == name)
5312 .map(|(_, flags)| *flags)
5313}
5314
5315const BUILTIN_FLAG_DOCS_OVERRIDE: &[(&str, &[(&str, &str)])] = &[
5319 (
5320 "bindkey",
5321 &[(
5322 "-L",
5323 "With `-l`, format output as `bindkey -A` / `-N` replay invocations.",
5324 )],
5325 ),
5326 (
5327 "enable",
5328 &[(
5329 "-p",
5330 "Operate on patterns added with `disable -p` (custom match-pattern hooks).",
5331 )],
5332 ),
5333 (
5334 "example",
5335 &[
5336 ("-a", "Pass arg as the example builtin's first parameter."),
5337 (
5338 "-f",
5339 "Toggle the example builtin's `flag` field (test option).",
5340 ),
5341 ("-g", "Toggle the example builtin's global-state test mode."),
5342 ("-l", "Toggle the example builtin's `long` test mode."),
5343 ("-s", "Toggle the example builtin's stateful test mode."),
5344 ],
5345 ),
5346 (
5347 "fc",
5348 &[(
5349 "-s",
5350 "Substitute `old=new` on the selected line and re-execute (no editor invoked).",
5351 )],
5352 ),
5353 (
5354 "getln",
5355 &[
5356 (
5357 "-A",
5358 "Read into an array (split into words instead of one scalar).",
5359 ),
5360 ("-E", "Don't echo (default; symmetric counterpart to `-e`)."),
5361 ("-c", "Read characters one at a time."),
5362 ("-e", "Echo read text back to terminal as it arrives."),
5363 ("-l", "Read just one line (default)."),
5364 ("-n", "Don't strip trailing newline from the result."),
5365 ],
5366 ),
5367 (
5368 "kill",
5369 &[
5370 (
5371 "-g",
5372 "Send the signal to the process GROUP, not just the process. Job-spec is a pgid.",
5373 ),
5374 (
5375 "-i",
5376 "Interpret arguments as job specs rather than process ids.",
5377 ),
5378 ("-n", "`-n signum` — send numeric signal `signum`."),
5379 (
5380 "-s",
5381 "`-s signame` — send named signal (`TERM`, `HUP`, `KILL`, …).",
5382 ),
5383 ],
5384 ),
5385 (
5386 "print",
5387 &[(
5388 "-f",
5389 "`-f format` — printf-style format string (same semantics as `printf`).",
5390 )],
5391 ),
5392 (
5393 "read",
5394 &[
5395 ("-c", "Read characters one at a time (no line-buffering)."),
5396 ("-e", "Echo read input back to terminal as it arrives."),
5397 ],
5398 ),
5399 (
5400 "sched",
5401 &[
5402 (
5403 "-e",
5404 "`+sched +HH:MM:SS event...` — schedule a command at the given time.",
5405 ),
5406 (
5407 "-i",
5408 "`sched -i id` — remove the scheduled entry with the given id.",
5409 ),
5410 (
5411 "-m",
5412 "`sched -m mask` — match scheduled entries against a glob pattern.",
5413 ),
5414 ("-t", "Print scheduled entries with full timestamps."),
5415 ],
5416 ),
5417 (
5418 "type",
5419 &[
5420 (
5421 "-S",
5422 "Like `-s` but include scripts in `$PATH` as commands.",
5423 ),
5424 (
5425 "-a",
5426 "Print every match for each name (not just the first).",
5427 ),
5428 ("-f", "Skip functions when looking up `name`."),
5429 ("-m", "Treat each name as a glob pattern."),
5430 ("-p", "Print only external commands found in `$path`."),
5431 (
5432 "-s",
5433 "Suppress output; exit 0 if name resolves to a command.",
5434 ),
5435 (
5436 "-w",
5437 "Print one of `alias`/`builtin`/`command`/`function`/`hashed`/`none` per name.",
5438 ),
5439 ],
5440 ),
5441 (
5442 "ulimit",
5443 &[
5444 (
5445 "-H",
5446 "Operate on the hard limit (default with `-S` is the soft limit).",
5447 ),
5448 (
5449 "-N",
5450 "`-N n` — operate on resource number `n` (system-specific integer).",
5451 ),
5452 (
5453 "-S",
5454 "Operate on the soft limit (default if neither `-H` nor `-S` given).",
5455 ),
5456 ("-T", "Maximum number of threads per process."),
5457 (
5458 "-a",
5459 "List all of the current resource limits (default verb).",
5460 ),
5461 ("-c", "Maximum core-file size in 512-byte blocks."),
5462 ("-d", "Maximum data-segment size in kilobytes."),
5463 (
5464 "-f",
5465 "Maximum file size the shell can write in 512-byte blocks.",
5466 ),
5467 ("-i", "Maximum number of pending signals."),
5468 ("-k", "Maximum number of kqueues allocated (BSD)."),
5469 ("-l", "Maximum locked-in-memory address space in kilobytes."),
5470 ("-m", "Maximum resident-set size in kilobytes."),
5471 ("-n", "Maximum number of open file descriptors."),
5472 ("-p", "The number of pseudo-terminals (BSD)."),
5473 ("-q", "Maximum bytes in POSIX message queues."),
5474 ("-r", "Maximum real-time scheduling priority."),
5475 ("-s", "Maximum stack size in kilobytes."),
5476 ("-t", "Maximum CPU time in seconds."),
5477 ("-v", "Maximum virtual-memory address space in kilobytes."),
5478 ("-w", "Maximum kilobytes of swapped-out memory."),
5479 ("-x", "Maximum number of file-locks held."),
5480 ],
5481 ),
5482 (
5483 "where",
5484 &[
5485 (
5486 "-S",
5487 "Like `-s` but include scripts in `$PATH` as commands.",
5488 ),
5489 ("-m", "Treat each name as a glob pattern."),
5490 ("-p", "Print only external commands found in `$path`."),
5491 ("-s", "Suppress output; exit 0 if name resolves."),
5492 (
5493 "-w",
5494 "Print one of `alias`/`builtin`/`command`/`function`/`hashed`/`none` per name.",
5495 ),
5496 (
5497 "-x",
5498 "`-x num` — indent each printed body line by `num` spaces.",
5499 ),
5500 ],
5501 ),
5502 (
5503 "which",
5504 &[
5505 ("-S", "Like `-s` but include scripts in `$PATH`."),
5506 ("-a", "Print every match for each name."),
5507 ("-m", "Treat each name as a glob pattern."),
5508 ("-p", "Print only external commands found in `$path`."),
5509 ("-s", "Suppress output; exit 0 if name resolves."),
5510 (
5511 "-w",
5512 "Print one of `alias`/`builtin`/`command`/`function`/`hashed`/`none` per name.",
5513 ),
5514 (
5515 "-x",
5516 "`-x num` — indent each printed body line by `num` spaces.",
5517 ),
5518 ],
5519 ),
5520 (
5521 "zcompile",
5522 &[
5523 ("-k", "Mark each compiled function for KSH-style autoload."),
5524 ("-m", "With `-c` / `-a`, treat each name as a glob pattern."),
5525 ],
5526 ),
5527 (
5529 "chgrp",
5530 &[
5531 ("-R", "Recursively descend into directories."),
5532 ("-h", "Change group of the symlink itself, not the target."),
5533 ("-s", "Suppress error messages for inaccessible files."),
5534 ],
5535 ),
5536 (
5537 "ln",
5538 &[
5539 (
5540 "-d",
5541 "Create a hard link to a directory (requires privilege).",
5542 ),
5543 (
5544 "-f",
5545 "If `dest` exists, remove it before creating the link.",
5546 ),
5547 (
5548 "-h",
5549 "If `dest` is a symlink, operate on the symlink itself.",
5550 ),
5551 ("-i", "Prompt before overwriting `dest`."),
5552 (
5553 "-n",
5554 "If `dest` is a symlink to a directory, replace the symlink.",
5555 ),
5556 ("-s", "Create a symbolic link instead of a hard link."),
5557 ],
5558 ),
5559 (
5561 "syserror",
5562 &[
5563 (
5564 "-e",
5565 "`-e errvar` — store error string in `$errvar` instead of stderr.",
5566 ),
5567 ("-p", "`-p prefix` — prepend `prefix` to the error message."),
5568 ],
5569 ),
5570 (
5571 "sysread",
5572 &[
5573 (
5574 "-c",
5575 "`-c countvar` — store byte count read in `$countvar`.",
5576 ),
5577 (
5578 "-i",
5579 "`-i infd` — read from file descriptor `infd` instead of stdin.",
5580 ),
5581 (
5582 "-o",
5583 "`-o outfd` — relay bytes to `outfd` as well as storing them.",
5584 ),
5585 ],
5586 ),
5587 (
5588 "syswrite",
5589 &[
5590 (
5591 "-c",
5592 "`-c countvar` — store byte count actually written in `$countvar`.",
5593 ),
5594 ("-o", "`-o outfd` — write to `outfd` instead of stdout."),
5595 ],
5596 ),
5597 (
5598 "zselect",
5599 &[
5600 ("-A", "`-A arrayname` — store ready fds into `arrayname`."),
5601 (
5602 "-t",
5603 "`-t timeout` — timeout in hundredths of a second (centiseconds).",
5604 ),
5605 ],
5606 ),
5607 (
5608 "zsystem",
5609 &[(
5610 "-f",
5611 "`zsystem flock -f var file` — store lock file descriptor in `$var`.",
5612 )],
5613 ),
5614 (
5616 "zsocket",
5617 &[
5618 (
5619 "-a",
5620 "Open a server (listening) socket bound to the named path.",
5621 ),
5622 (
5623 "-d",
5624 "`-d fd` — open the socket on the specified file descriptor.",
5625 ),
5626 ("-l", "List currently-open zsocket file descriptors."),
5627 ("-t", "Set close-on-exec on the socket."),
5628 (
5629 "-v",
5630 "Verbose — print the resulting file descriptor to stdout.",
5631 ),
5632 ],
5633 ),
5634 (
5635 "ztcp",
5636 &[
5637 (
5638 "-a",
5639 "Server mode — accept the next connection on the specified listening fd.",
5640 ),
5641 ("-c", "Close the named ztcp file descriptor."),
5642 ("-d", "`-d fd` — operate on the specified file descriptor."),
5643 (
5644 "-f",
5645 "Force — don't fail if a similar connection already exists.",
5646 ),
5647 (
5648 "-l",
5649 "Listen mode — open a server socket on the given port.",
5650 ),
5651 ("-t", "Set close-on-exec on the socket."),
5652 (
5653 "-v",
5654 "Verbose — print the resulting file descriptor to stdout.",
5655 ),
5656 ],
5657 ),
5658 (
5660 "zdelattr",
5661 &[("-h", "Operate on the symlink itself, not its target.")],
5662 ),
5663 (
5664 "zgetattr",
5665 &[("-h", "Operate on the symlink itself, not its target.")],
5666 ),
5667 (
5668 "zlistattr",
5669 &[("-h", "Operate on the symlink itself, not its target.")],
5670 ),
5671 (
5672 "zsetattr",
5673 &[("-h", "Operate on the symlink itself, not its target.")],
5674 ),
5675 (
5677 "zstyle",
5678 &[
5679 (
5680 "-L",
5681 "`-L [ metapattern [ style ] ]` — list styles in `zstyle`-replay form.",
5682 ),
5683 (
5684 "-e",
5685 "`-e pattern style string ...` — value-as-shell-code (re-evaluated each lookup).",
5686 ),
5687 ],
5688 ),
5689 (
5691 "zpty",
5692 &[
5693 ("-L", "List active zpty sessions with their commands."),
5694 (
5695 "-m",
5696 "With `-r`, treat `pattern` as a match-spec (read until pattern matches).",
5697 ),
5698 (
5699 "-n",
5700 "With `-w`, don't append a newline to written strings.",
5701 ),
5702 ("-t", "Test whether the named zpty session is still alive."),
5703 ],
5704 ),
5705 (
5707 "zle",
5708 &[
5709 (
5710 "-L",
5711 "With `-l`, format output as `zle` replay invocations.",
5712 ),
5713 (
5714 "-a",
5715 "With `-N`, mark new widget as available outside the editor (script-callable).",
5716 ),
5717 ("-c", "With `-R`, clear the screen before re-display."),
5718 (
5719 "-n",
5720 "Pass `-n num` through to the widget invocation (numeric argument).",
5721 ),
5722 ("-r", "With `-T`, remove the named termcap handler."),
5723 (
5724 "-w",
5725 "With `-F`, treat the fd handler as a writeable-fd ready handler.",
5726 ),
5727 ],
5728 ),
5729];
5730
5731fn lookup_compsys_flag_docs(name: &str) -> Option<&'static [(&'static str, &'static str)]> {
5747 COMPSYS_FN_FLAG_DOCS
5748 .iter()
5749 .find(|(n, _)| *n == name)
5750 .map(|(_, flags)| *flags)
5751}
5752
5753fn is_known_builtin_with_long_flag_docs(name: &str) -> bool {
5757 matches!(name, "zshrs" | "zsh")
5758}
5759
5760fn extract_builtin_long_flags(name: &str) -> Vec<(String, String)> {
5767 use std::sync::OnceLock;
5768 if name != "zshrs" && name != "zsh" {
5769 return Vec::new();
5770 }
5771 static CACHE: OnceLock<Vec<(String, String)>> = OnceLock::new();
5772 CACHE
5773 .get_or_init(|| {
5774 let mut out: Vec<(String, String)> = ZSHRS_SELF_LONG_FLAG_DOCS
5775 .iter()
5776 .map(|(f, d)| (f.to_string(), d.to_string()))
5777 .collect();
5778 for (opt_name, opt_desc) in crate::zsh_option_docs::OPTION_DOCS {
5786 let lower = opt_name.to_ascii_lowercase().replace('_', "");
5787 let first_line = opt_desc
5788 .lines()
5789 .find(|l| !l.trim().is_empty())
5790 .unwrap_or("")
5791 .trim();
5792 let short: String = first_line.chars().take(200).collect();
5793 out.push((format!("--{}", lower), short));
5794 out.push((
5795 format!("--no-{}", lower),
5796 format!("Turn OFF `--{}`. Inverse of the setopt option.", lower),
5797 ));
5798 }
5799 out
5800 })
5801 .clone()
5802}
5803
5804const ZSHRS_SELF_FLAG_DOCS: &[(&str, &str)] = &[
5816 ("-b", "End option processing, like `--`. Subsequent arguments are positional even if they start with `-`."),
5817 ("-c", "Take the FIRST argument as a command string to execute. Example: `zshrs -c 'echo hi'`."),
5818 ("-f", "Equivalent to `--no-rcs`: skip sourcing `.zshenv` / `.zshrc` / `.zprofile` / `.zlogin`. Use for clean-environment scripting."),
5819 ("-i", "Force interactive mode even when stdin isn't a terminal (job control + prompt + line editor active)."),
5820 ("-l", "Force login-shell mode: source `.zprofile` / `.zlogin` on startup and `.zlogout` on exit. Equivalent to invoking as `-zshrs`."),
5821 ("-s", "Read commands from standard input. Combine with `-c CMD …` to run CMD first then drain stdin."),
5822 ("-o", "Set a setopt option by name. Example: `-o errexit -o pipefail -o nounset`. Inverse: `+o OPTION` or `--no-OPTION`."),
5823 ("-v", "Verbose: print each input line as it's read. Equivalent to `--verbose` / `setopt VERBOSE`."),
5824 ("-x", "xtrace: print each command and its arguments before execution. Equivalent to `--xtrace` / `setopt XTRACE` / `set -x`."),
5825];
5826
5827const ZSHRS_SELF_LONG_FLAG_DOCS: &[(&str, &str)] = &[
5833 ("--help", "Print the full usage message (every flag, dumper, parity mode) and exit 0."),
5835 ("--version", "Print the zsh version string baked into the binary and exit 0."),
5836 ("--doctor", "Full diagnostic report of shell health, caches, plugin load timings, and performance counters."),
5837 ("--banner", "Print the ZSHRS logo, a box with the version and the builtin and extension totals, and whether the daemon answers on its socket. The `zbanner` builtin prints the same inside a shell, plus that shell's function, alias, parameter and job counts."),
5838 ("--lsp", "Run the Language Server on stdio. Serves completion / hover / definition / references / rename / documentSymbol / foldingRange / semanticTokens / formatting / diagnostics. Consumed by the IntelliJ plugin, Helix, Neovim, VS Code, etc."),
5840 ("--dap", "`--dap HOST:PORT` — Debug Adapter Protocol server. Connects back to the IDE's listener at HOST:PORT and drives breakpoints / step / variables / evaluate."),
5841 ("--dump-reflection", "Emit the JSON blob the IntelliJ \"zshrs\" reflection tool window consumes: builtins / keywords / options / special_vars, each tagged by category."),
5842 ("--docs", "`--docs NAME` — render the same hover card the LSP would return for NAME. Used by the IntelliJ tool window's docs popup; handy for previewing doc output from the CLI. Prints a coloured terminal card on a TTY (or under `--color always` / `$FORCE_COLOR` / `$CLICOLOR_FORCE`) and the raw markdown otherwise, so piping into a markdown renderer works."),
5843 ("--dump-tokens", "`--dump-tokens FILE|-` — one TOKNAME<tab>TOKSTR line per lexer token. Use `-` to read from stdin."),
5845 ("--dump-ast", "`--dump-ast FILE|-` — parser AST as a canonical S-expression. Use `-` to read from stdin."),
5846 ("--dump-wordcode", "`--dump-wordcode FILE|-` — wordcode emitter output: EPROG / WORDS / WC[i] / STRS sections matching zsh's binary cache format."),
5847 ("--dump-zwc", "`--dump-zwc ZWCFILE [FN]` — inspect a compiled .zwc cache. Without FN, list every function. With FN, dump only that function's wordcode."),
5848 ("--disasm", "Print fusevm opcodes for each compiled unit before VM run. Does NOT suppress execution — script still runs."),
5849 ("--gen-docs", "`--gen-docs [PATH] [--out DIR]` — walk PATH (default `.`) collecting every `##` doc-comment block above each function / alias / parameter, render to standalone HTML in `DIR` (default `docs/`)."),
5851 ("--out", "`--out DIR` — destination directory for `--gen-docs` HTML output (default `docs/`)."),
5852 ("--dump-reference-html", "Emit the standalone reference HTML the zshrs project ships at `docs/reference.html` — every builtin / keyword / option / special var as a browseable single-page reference."),
5853 ("--names", "With `--dump-reflection` or `--gen-docs`, restrict the dump / walk to a comma-separated list of NAMES instead of the full set."),
5854 ("--daemon", "Run as the persistent zshrs daemon (used by the IDE / multi-shell scenarios). Holds the rkyv script cache + worker pool warm so subsequent script launches are sub-millisecond."),
5856 ("--color", "`--color WHEN` — control coloured output (`auto` / `always` / `never`). Default `auto`: `$NO_COLOR` forces off, `$FORCE_COLOR` / `$CLICOLOR_FORCE` force on, otherwise follow `$TERM` and stdout TTY status."),
5857 ("--zsh", "Identical-behaviour drop-in for `/bin/zsh`. Caches OFF, daemon OFF — every `source` re-runs the file fresh. Used as the compat-test entrypoint."),
5859 ("--bash", "Identical-behaviour drop-in for `/bin/bash`. Caches / daemon OFF; every echo / source re-fires byte-for-byte against reference bash. Reads bash's startup files, not zsh's: `/etc/profile` + the first of `~/.bash_profile` / `~/.bash_login` / `~/.profile` for a login shell, `/etc/bash.bashrc` (where present) + `~/.bashrc` for an interactive non-login shell, `$BASH_ENV` for a non-interactive shell, `~/.bash_logout` on login-shell exit."),
5860 ("--ksh", "Identical-behaviour drop-in for `/bin/ksh` (ksh-93). Caches / daemon OFF. Startup files: `/etc/profile` + `~/.profile` when logging in, `/etc/ksh.kshrc` (where present) + `$ENV` — defaulting to `~/.kshrc` — when interactive."),
5861 ("--sh", "Identical-behaviour drop-in for `/bin/sh` / POSIX (alias of `--posix`). Caches / daemon OFF. Startup files: `/etc/profile` + `~/.profile` when logging in, `$ENV` (no default) when interactive."),
5862 ("--csh", "Identical-behaviour drop-in for `/bin/csh`. Caches / daemon OFF. Startup files: `/etc/csh.cshrc` + `~/.tcshrc` (or `~/.cshrc`) always, plus `/etc/csh.login` + `~/.login` when logging in and `~/.logout` on exit."),
5863 ("--posix", "Identical-behaviour drop-in for `/bin/sh` / POSIX (Bourne / dash semantics). Caches / daemon OFF. Startup files: `/etc/profile` + `~/.profile` when logging in, `$ENV` (no default) when interactive."),
5864 ("--emulate", "`--emulate MODE` — generic parity alias for `--MODE` (zsh-compat: matches the `emulate zsh` / `emulate bash` etc. builtin)."),
5865 ("--zsh-compat","Alias of `--zsh` (legacy spelling — kept for back-compat with older scripts / CI invocations)."),
5866 ("--no-rcs", "Skip sourcing `.zshenv` / `.zshrc` / `.zprofile` / `.zlogin`. Equivalent to the short `-f` flag."),
5868 ("--verbose", "Print each input line as it's read. Equivalent to short `-v` / `setopt VERBOSE`."),
5869 ("--xtrace", "Print each command and its arguments before executing. Equivalent to short `-x` / `setopt XTRACE` / `set -x`."),
5870 ("--login", "Force login-shell mode. Equivalent to short `-l`."),
5871 ("--interactive", "Force interactive mode. Equivalent to short `-i`."),
5872 ("--norc", "`--bash` only: do not read `~/.bashrc` for an interactive non-login shell (bash's `--norc`)."),
5874 ("--noprofile", "`--bash` only: do not read `/etc/profile` or the `~/.bash_profile` chain for a login shell (bash's `--noprofile`)."),
5875 ("--rcfile", "`--bash` only: `--rcfile FILE` — read FILE instead of `~/.bashrc`. Separate-word form only, as bash requires."),
5876 ("--init-file", "`--bash` only: alias of `--rcfile`."),
5877];
5878
5879const COMPSYS_FN_FLAG_DOCS: &[(&str, &[(&str, &str)])] = &[
5880 (
5881 "_all_labels",
5882 &[
5883 ("-x", "Show the description even when no matches are added."),
5884 ("-1", "Pass `-1` through to `compadd` (suppress duplicate-removal of literal matches)."),
5885 ("-2", "Pass `-2` through to `compadd` (suppress de-duplication based on display string)."),
5886 ("-V", "Pass `-V name` through to `compadd` (put matches in a named unsorted group)."),
5887 ("-J", "Pass `-J name` through to `compadd` (put matches in a named sorted group)."),
5888 ],
5889 ),
5890 (
5891 "_alternative",
5892 &[
5893 ("-O", "Pass `-O name` through to nested `_arguments` calls (preserve the option-spec array)."),
5894 ("-C", "Set the curcontext parameter to `name` while running each alternative."),
5895 ],
5896 ),
5897 (
5898 "_arguments",
5899 &[
5900 ("-n", "Set `$NORMARG` to the position of the first normal argument in the `$words` array."),
5901 ("-s", "Allow option stacking (`-abc` parsed as `-a -b -c`) — required for typical POSIX-style getopts CLIs."),
5902 ("-w", "Even with `-s`, allow stacked options to consume an argument before the next short option."),
5903 ("-W", "Even after a `--` separator, keep parsing further `-x` as options instead of treating them as positional."),
5904 ("-C", "Make `curcontext` available to action handlers in `->state` form."),
5905 ("-R", "Return status 300 instead of 0 when a `->state` action is dispatched (lets callers chain dispatch)."),
5906 ("-S", "Stop parsing options once a non-option word is seen (treat the rest as positionals)."),
5907 ("-A", "Treat any argument matching pattern `pat` as a non-option terminator (e.g. `-A '-*'`)."),
5908 ("-O", "Name an array holding extra `compadd` options to pass to every match."),
5909 ("-M", "Pass match spec `matchspec` to `compadd` (controls case-folding, partial-word matching, etc.)."),
5910 ("--", "Separator between option specs and rest-arg `[helpspec ...]` syntax."),
5911 ("-l", "Long-option style: each rest-arg `helpspec` describes one long option (e.g. `--foo=bar`)."),
5912 ("-i", "Skip option specs whose names match any of the patterns in `pats`."),
5913 ],
5914 ),
5915 (
5916 "_call_program",
5917 &[
5918 ("-l", "Read one line at a time from the external program (don't slurp all output)."),
5919 ("-p", "Treat the tag as a program-call context for the `command` style lookup."),
5920 ],
5921 ),
5922 (
5923 "_canonical_paths",
5924 &[
5925 ("-A", "Store the resolved canonical paths in array variable `var`."),
5926 ("-N", "Don't realpath-resolve — treat the input paths as already canonical."),
5927 ("-M", "Pass `-M matchspec` through to `compadd`."),
5928 ("-J", "Pass `-J name` through to `compadd` (named sorted group)."),
5929 ("-V", "Pass `-V name` through to `compadd` (named unsorted group)."),
5930 ("-1", "Pass `-1` through to `compadd`."),
5931 ("-2", "Pass `-2` through to `compadd`."),
5932 ("-n", "Pass `-n` through to `compadd` (no inserted suffix)."),
5933 ("-f", "Pass `-f` through to `compadd` (mark matches as filenames for suffix/cdpath handling)."),
5934 ("-X", "Pass `-X explanation` through to `compadd` (custom listing-line message)."),
5935 ],
5936 ),
5937 (
5938 "_combination",
5939 &[
5940 ("-s", "Use `pattern` to split each existing style value into fields (default is comma)."),
5941 ],
5942 ),
5943 (
5944 "_command_names",
5945 &[
5946 ("-e", "Complete only external commands found in `$path` (skip aliases, builtins, functions, reserved words)."),
5947 ("-", "Same as `-e`: external-only mode."),
5948 ],
5949 ),
5950 (
5951 "_completers",
5952 &[
5953 ("-p", "List only completer-function names that are valid for use in the `completer` style."),
5954 ],
5955 ),
5956 (
5957 "_describe",
5958 &[
5959 ("-1", "Pass `-1` through to `_next_label`/`compadd`."),
5960 ("-2", "Pass `-2` through to `_next_label`/`compadd`."),
5961 ("-J", "Pass `-J name` through to `_next_label` (named sorted group)."),
5962 ("-V", "Pass `-V name` through to `_next_label` (named unsorted group)."),
5963 ("-x", "Show the description even when no matches are added."),
5964 ("-o", "Treat each `name1`/`name2` array as describing options (default tag is `options`)."),
5965 ("-O", "Like `-o` but each match supports an argument-string suffix after a colon."),
5966 ("-t", "Use `tag` instead of the default `values`/`options` tag."),
5967 ],
5968 ),
5969 (
5970 "_description",
5971 &[
5972 ("-x", "Show the description even when no matches are added."),
5973 ("-1", "Pass `-1` through to `compadd`."),
5974 ("-2", "Pass `-2` through to `compadd`."),
5975 ("-V", "Pass `-V name` through to `compadd`."),
5976 ("-J", "Pass `-J name` through to `compadd` (groups matches in a named sorted group based on group-name style)."),
5977 ],
5978 ),
5979 (
5980 "_dir_list",
5981 &[
5982 ("-s", "Use `sep` instead of `:` as the directory-list separator."),
5983 ("-S", "Keep partially-typed list elements in the completion (allow trailing separator)."),
5984 ],
5985 ),
5986 (
5987 "_email_addresses",
5988 &[
5989 ("-c", "Add only the current-typed prefix as a match (don't expand to full addresses)."),
5990 ("-n", "Restrict to entries returned by the named `plugin` backend (`_email-`<plugin>)."),
5991 ],
5992 ),
5993 (
5994 "_message",
5995 &[
5996 ("-r", "Take `descr` literally as the message text (skip the `format` style lookup)."),
5997 ("-1", "Show the message even when no matches are added."),
5998 ("-2", "Show the message only when matches already exist for some other tag."),
5999 ("-V", "Display the message in a named unsorted group `group`."),
6000 ("-J", "Display the message in a named sorted group `group`."),
6001 ],
6002 ),
6003 (
6004 "_multi_parts",
6005 &[
6006 ("-i", "Insert matches immediately rather than only when uniquely determined."),
6007 ],
6008 ),
6009 (
6010 "_next_label",
6011 &[
6012 ("-x", "Show the description even when no matches are added."),
6013 ("-1", "Pass `-1` through to `compadd`."),
6014 ("-2", "Pass `-2` through to `compadd`."),
6015 ("-V", "Pass `-V name` through to `compadd`."),
6016 ("-J", "Pass `-J name` through to `compadd`."),
6017 ],
6018 ),
6019 (
6020 "_normal",
6021 &[
6022 ("-P", "Treat the current word as following a precommand (`nohup`, `time`, …) — skip to the real command."),
6023 ("-p", "Like `-P` but explicitly name the precommand for context lookup."),
6024 ],
6025 ),
6026 (
6027 "_numbers",
6028 &[
6029 ],
6034 ),
6035 (
6036 "_pick_variant",
6037 &[
6038 ("-b", "Treat `builtin-label` as the variant when the command is a shell builtin."),
6039 ("-c", "Run `command` (with its args) to obtain the version string instead of the default `--version` invocation."),
6040 ("-r", "Store the matched variant name in the parameter `name`."),
6041 ],
6042 ),
6043 (
6044 "_requested",
6045 &[
6046 ("-x", "Show the description even when no matches are added."),
6047 ("-1", "Pass `-1` through to `compadd`."),
6048 ("-2", "Pass `-2` through to `compadd`."),
6049 ("-V", "Pass `-V name` through to `compadd`."),
6050 ("-J", "Pass `-J name` through to `compadd`."),
6051 ],
6052 ),
6053 (
6054 "_sequence",
6055 &[
6056 ("-s", "Use `sep` instead of `,` as the list separator."),
6057 ("-n", "Stop accepting more matches once `max` items have been completed in the sequence."),
6058 ("-d", "Allow duplicate entries in the sequence (default rejects already-typed values)."),
6059 ],
6060 ),
6061 (
6062 "_tags",
6063 &[
6064 ("-C", "Set the curcontext parameter to `name` while iterating the tag list."),
6065 ],
6066 ),
6067 (
6068 "_values",
6069 &[
6070 ("-O", "Name an array holding extra `compadd` options to pass to every match."),
6071 ("-s", "Use `sep` as the value separator between multiple keywords (default is comma)."),
6072 ("-S", "Use `sep` as the keyword-and-argument separator (default is `=`)."),
6073 ("-w", "Examine other typed arguments as well when computing which value-specs are already used."),
6074 ("-C", "Make `curcontext` available to action handlers (must be made local by the caller)."),
6075 ],
6076 ),
6077 (
6078 "_wanted",
6079 &[
6080 ("-x", "Show the description even when no matches are added."),
6081 ("-C", "Set the curcontext parameter to `name` while invoking the inner command."),
6082 ("-1", "Pass `-1` through to `compadd`."),
6083 ("-2", "Pass `-2` through to `compadd`."),
6084 ("-V", "Pass `-V name` through to `compadd`."),
6085 ("-J", "Pass `-J name` through to `compadd`."),
6086 ],
6087 ),
6088 (
6089 "_widgets",
6090 &[
6091 ("-g", "Restrict completions to widget names matching shell pattern `pattern`."),
6092 ],
6093 ),
6094];
6095
6096const COMPSYS_FN_DOCS: &[(&str, &str)] = &[
6101 (
6102 "_main_complete",
6103 "Top-level entry the compsys dispatcher calls for every completion attempt. Walks the configured completer list (`_complete` / `_approximate` / `_match` / …), invoking each until one returns matches. Sets `$compstate[insert]` based on the result. Rust impl in `crate::compsys::ported::_main_complete::_main_complete`.",
6104 ),
6105 (
6106 "_directories",
6107 "Complete directory names only. Equivalent to `_files -/`. Honors `path-files` zstyle and respects `GLOB_DOTS`. Rust impl in `crate::compsys::files::directories_execute`.",
6108 ),
6109 (
6110 "_cargo",
6111 "Completion for the Rust `cargo` command — subcommands, flags, target names, feature names, profile names. Synthesizes from `cargo --list` and the manifest. Rust-native; no shell-script fallback.",
6112 ),
6113 (
6114 "_docker",
6115 "Completion for the `docker` CLI — subcommands, image names, container names/IDs, network names, volume names. Queries the local daemon socket via the `docker` binary; falls back to static-only when the daemon is unavailable.",
6116 ),
6117 (
6118 "_git",
6119 "Completion for `git` — subcommands, branches, tags, refs, remotes, file paths sensitive to `git status`. The most heavily-used compsys function in practice; Rust-native rewrite is several hundred times faster than the upstream shell implementation.",
6120 ),
6121 (
6122 "_kubectl",
6123 "Completion for `kubectl` — subcommands, resource kinds, resource names (queried via `kubectl get`), context/namespace names from kubeconfig.",
6124 ),
6125 (
6126 "_terraform",
6127 "Completion for `terraform` — subcommands, workspace names, state-file paths, providers, modules, variable names from the loaded HCL.",
6128 ),
6129 (
6130 "_ls",
6131 "Completion for `ls` — flags + file paths. Option specs plus file-path completion via `_files`.",
6132 ),
6133 (
6134 "_cd",
6135 "Completion for `cd` — directory paths from `$PWD`, `$cdpath`, and the `dirs` stack. Honors `AUTO_CD` and `CDABLE_VARS`.",
6136 ),
6137 (
6138 "_cp",
6139 "Completion for `cp` — flags + file paths. Source paths exclude the destination; destination directory is offered as the final candidate.",
6140 ),
6141 (
6142 "_mv",
6143 "Completion for `mv` — flags + file/directory paths. Source/destination split identical to `_cp`.",
6144 ),
6145 (
6146 "_rm",
6147 "Completion for `rm` — flags + file paths. `-r` enables directory completion; without it, directories are filtered out.",
6148 ),
6149 (
6150 "_cat",
6151 "Completion for `cat` — file paths only. No subcommands; flags pass through to `_files`.",
6152 ),
6153 (
6154 "_grep",
6155 "Completion for `grep` (GNU/BSD-flavor-aware) — flags then file paths. First positional argument is the pattern (no completion offered for free-text patterns).",
6156 ),
6157];
6158
6159const EXT_BUILTIN_DOCS: &[(&str, &str)] = &[
6166 ("__rust_compile", "Compile and register an inline `rust { … }` block. Internal — emitted by the desugar pass, not meant to be typed by hand.\n\nUsage: `__rust_compile '<base64>' [<line>]`\n\nBefore lexing, `zsh::rust_ffi::desugar` rewrites every `rust { … }` block that starts at a command boundary into a call to this builtin, carrying the block body base64-encoded (argv[0]) plus the block's source line (argv[1], kept for diagnostics). The handler passes the body to `fusevm::ffi::compile_and_register`, which compiles it into a cached cdylib and registers each exported `extern \"C\"` function.\n\nA registered export then runs as an ordinary command word, resolved by `ShellExecutor::try_registered_ffi_command` — consulted only AFTER builtins, shell functions, `$PATH`, and `command_not_found_handler` all miss, so a real command of the same name always wins.\n\nReturns 0 on success; 1 with a `zshrs: <error>` diagnostic on stderr when the block fails to compile or the body argument is missing."),
6171 ("add_zsh_hook", "Add a function to a zsh hook array (chpwd / precmd / preexec / periodic / zshaddhistory / zshexit). `add-zsh-hook chpwd my_chpwd_fn`. Idempotent — re-adding the same function is a no-op."),
6172 ("ai", "Call a language model from the shell. Streams the response to stdout, or assigns it straight into a shell parameter with `-v VAR` / `-a ARRAY` — no fork, no `$(curl … | jq …)`. Providers: anthropic, openai, local (OpenAI-compatible), ollama, gemini. `-c` reports cost and tokens for the session. zshrs-only."),
6173 ("arch", "Print the machine architecture (uname -m equivalent): `x86_64`, `arm64`, `aarch64`, etc."),
6174 ("async", "Run a shell command string on the worker pool and print a job ID at once. `id=$(async 'cmd args')`; collect the output and status later with `await $id`."),
6175 ("await", "Block until the async job with the given ID finishes, print its captured stdout and return its exit status. `await $id`; the ID is required."),
6176 ("barrier", "Run commands in parallel and wait for all of them. `barrier 'cmd1' ::: 'cmd2' ::: 'cmd3'`; returns the highest exit status."),
6177 ("base64", "Encode / decode Base64. `-d` decodes; `-w0` no line wrap. coreutils drop-in."),
6178 ("basename", "Strip leading directories and an optional suffix. `basename /a/b.txt .txt` → `b`. coreutils drop-in."),
6179 ("caller", "Bash-compatible `caller` builtin. With no arg or 0: prints `LINE FUNC` for the current frame; with N>0: `LINE FUNC FILE` for the Nth call-stack frame."),
6180 ("cat", "Concatenate files to stdout. `-n` numbers lines, `-A` shows tabs/EOLs. coreutils drop-in."),
6181 ("cdreplay", "Replay the `compdef` calls deferred while completion was not yet initialised (zinit turbo mode). `cdreplay [-q]`; `-q` replays quietly."),
6182 ("cksum", "Print CRC32 checksum + byte count of each file. coreutils drop-in."),
6183 ("comm", "Compare two sorted files line-by-line. `-1` / `-2` / `-3` suppress columns. coreutils drop-in."),
6184 ("compdef", "Register a completion function for one or more commands. `compdef _git git`. Backed by the rkyv-mmap'd compsys shards; lookups are zero-copy. (SQLite mirrors exist beside the shards for `dbview` / SQL inspection only.)"),
6185 ("compgen", "Bash-compatible word generator. `compgen -W 'foo bar baz' fo` → `foo`. Used by bash-completion scripts ported to zshrs."),
6186 ("compinit", "Initialize the completion system. Walks `$fpath` in parallel via rayon, populates the rkyv-mmap'd autoload/completion shards, marks every `_*` as autoloaded. Default mode skips `.zcompdump` entirely."),
6187 ("complete", "Bash-compatible `complete` command — register a completion spec for a command. zshrs bridges to compsys internally."),
6188 ("compopt", "Bash-compatible `compopt` — modify completion options at runtime."),
6189 ("cut", "Extract fields or character ranges. `-d':' -f1,3` / `-c5-10`. coreutils drop-in."),
6190 ("date", "Print or set the system date. `+%FORMAT` strftime; `-d 'rel'` parse relative; `-u` UTC. coreutils drop-in."),
6191 ("dbview", "Dump the local zshrs SQLite mirror tables (autoload bodies, completion mirror, history FTS). Mirrors only — the authoritative cache is the rkyv-mmap'd shard set; SQLite is hydrated read-only for SQL/`dbview` inspection. `dbview --table autoloads` filters by table."),
6192 ("dircolors", "Emit `LS_COLORS` from a `.dircolors` file. coreutils drop-in."),
6193 ("dirname", "Strip the last path component. `dirname /a/b/c` → `/a/b`. coreutils drop-in."),
6194 ("doctor", "Diagnostic report of shell health — cache stats, autoload coverage, fpath sanity, daemon presence, memory footprint, recent error summary. zshrs-only."),
6195 ("env", "Run a command in a modified environment, or print the current environment. `env -i` empties; `env VAR=val cmd` sets. coreutils drop-in."),
6196 ("expand", "Convert tabs to spaces. `-t N` sets tab width. coreutils drop-in."),
6197 ("expr", "Evaluate an arithmetic / string expression. `expr 2 + 3` → `5`. Prefer `$(( … ))` in zshrs scripts; provided for POSIX compatibility."),
6198 ("factor", "Print prime factors. `factor 60` → `60: 2 2 3 5`. coreutils drop-in."),
6199 ("find", "Walk the filesystem and print / act on matches. Supports `-name`/`-type`/`-mtime`/`-exec`. coreutils drop-in (subset)."),
6200 ("fold", "Wrap each input line to a width. `-w N` width, `-s` break at spaces. coreutils drop-in."),
6201 ("groups", "Print groups the user (or named user) belongs to. coreutils drop-in."),
6202 ("head", "Print the first N lines (`-n N`) or bytes (`-c N`) of each file. coreutils drop-in."),
6203 ("help", "Print help for a builtin. `help cd` shows the cd usage. zshrs-only."),
6204 ("hostname", "Print the system hostname. `-s` short, `-f` FQDN."),
6205 ("id", "Print user / group IDs. `-u` user only, `-g` group only, `-n` names. coreutils drop-in."),
6206 ("intercept", "Register an AOP intercept. `intercept before|after|around <cmd> { body }` runs `body` around every invocation of `<cmd>`. The body is stored as source and compiled on each fire. `$INTERCEPT_NAME`, `$INTERCEPT_ARGS`, `$INTERCEPT_CMD` are bound; `after` adds `$INTERCEPT_MS`, `$INTERCEPT_US`, `$INTERCEPT_STATUS`. zshrs-only."),
6207 ("intercept_proceed", "Inside an `around` intercept body, invoke the underlying command. Required so the intercept doesn't shadow the call permanently."),
6208 ("link", "Create a hard link. `link src dst`. coreutils drop-in."),
6209 ("logname", "Print the user's login name. coreutils drop-in."),
6210 ("mkfifo", "Create named pipes (FIFOs). `mkfifo path …`. coreutils drop-in."),
6211 ("mktemp", "Create a temp file or directory with a unique name. `-d` directory, `-p DIR` parent. coreutils drop-in."),
6212 ("nice", "Run a command with adjusted scheduling priority. `nice -n 10 cmd`. coreutils drop-in."),
6213 ("nl", "Number lines. `-b a` numbers all, `-w N` field width. coreutils drop-in."),
6214 ("nproc", "Print the number of processing units available. `--all` ignores affinity."),
6215 ("paste", "Merge corresponding lines of files. `-d DELIM` separator. coreutils drop-in."),
6216 ("peach", "Parallel for-each on the worker pool: run the template once per item with `{}` replaced by the item, printing output as each finishes. `peach 'convert {} {}.png' *.svg`."),
6217 ("pgrep", "Print PIDs of processes matching a pattern. `-f` matches full command line."),
6218 ("pmap", "Parallel map on the worker pool: run the template once per item with `{}` replaced by the item, printing output in input order. `pmap 'gzip {}' *.log`. Not the process memory-map command of the same name."),
6219 ("printenv", "Print the value of one or more environment variables, or all if none given. coreutils drop-in."),
6220 ("profile", "Profile command execution in-process with nanosecond timing. `profile CMD…` profiles a command line, `profile -s SCRIPT` a script string, `profile -f FUNC [ARGS]` a function call; `--dump` shows and `--clear` resets the accumulated data."),
6221 ("provenance", "Value lineage over bytecode execution. `provenance -m NAME` arms tracking for a parameter; `provenance NAME` then prints where its bytes came from (`$(…)`, `<(…)`, glob, heredoc, an earlier assignment) and every bytecode op that produced or consumed them — `assign`, `expand`, `concat`, `exec`, `call`, `unset` — each with its `$LINENO`. `-j` emits JSON, `-u` untracks, `-c` clears. Records nothing until armed; disable entirely with `[provenance] enabled = false` in `~/.zshrs/zshrs.toml` or `ZSHRS_PROVENANCE=0`."),
6222 ("realpath", "Resolve symlinks and `.` / `..` to a canonical absolute path. coreutils drop-in."),
6223 ("rev", "Reverse each input line character-by-character. coreutils drop-in."),
6224 ("run_tests", "Alias for `ztest_run` — print the per-block test summary and roll the per-block counters into the run-wide totals. Returns 0 on all-pass, 1 if any assertion failed. Port of strykelang's `test_run` / `run_tests` builtin pair."),
6225 ("zassert_contains", "`zassert_contains HAYSTACK NEEDLE [MSG]` — pass when NEEDLE is a substring of HAYSTACK. Bumps the per-block `ztest_pass_count` on success, `ztest_fail_count` on failure; the ✓/✗ glyph + label print on stderr. Port of strykelang's `builtin_assert_contains`."),
6226 ("zassert_dies", "`zassert_dies \"CMD\" [MSG]` — shell-native variant of strykelang's `assert_dies` (which takes a code ref). Runs CMD in a scratch `ShellExecutor` (inheriting the host's `zsh_compat` / `bash_compat` / `posix_mode`); passes iff the command exits non-zero or errors. Use to assert that a misuse correctly fails."),
6227 ("zassert_eq", "`zassert_eq A B [MSG]` — string equality. Like every `zassert_*` builtin, returns 0 on pass / 1 on fail and bumps the per-block counters that `ztest_run` reads."),
6228 ("zassert_err", "`zassert_err V [MSG]` — pass when V is shell-falsy (empty *or* literal \"0\"). Convenient for asserting on `$?` after a command, or on a string that should be empty."),
6229 ("zassert_false", "`zassert_false V [MSG]` — alias of `zassert_err`."),
6230 ("zassert_ge", "`zassert_ge A B [MSG]` — pass iff numeric A ≥ B. Inputs are `trim().parse::<f64>()`'d; non-numeric inputs default to 0."),
6231 ("zassert_gt", "`zassert_gt A B [MSG]` — pass iff numeric A > B."),
6232 ("zassert_le", "`zassert_le A B [MSG]` — pass iff numeric A ≤ B."),
6233 ("zassert_lt", "`zassert_lt A B [MSG]` — pass iff numeric A < B."),
6234 ("zassert_match", "`zassert_match PATTERN STRING [MSG]` — regex match. PATTERN is compiled with the `regex` crate (Rust regex syntax, not POSIX BRE/ERE — so `\\d`/`\\w`/`\\s` work, no backreferences). Fails (rather than panics) on a bad pattern."),
6235 ("zassert_ne", "`zassert_ne A B [MSG]` — string inequality."),
6236 ("zassert_near", "`zassert_near A B [EPS [MSG]]` — floating-point approximate equality: pass iff `|A − B| ≤ EPS`. EPS defaults to `1e-9`. Right for asserting on math results without burning on the last few ULPs."),
6237 ("zassert_ok", "`zassert_ok V [MSG]` — pass when V is shell-truthy (non-empty *and* not literal \"0\"). Use `zassert_eq $? 0 …` for command-success assertions; reserve `zassert_ok` for assertions on populated strings or non-zero counts."),
6238 ("zassert_true", "`zassert_true V [MSG]` — alias of `zassert_ok`."),
6239 ("ztest_run", "Print the per-block test summary (green ✓ All N passed / red ✗ M of N failed) and roll the per-block `ztest_pass_count` / `ztest_fail_count` / `ztest_skip_count` into the run-wide `_total` counters, then zero the per-block counters so a second test block in the same file starts fresh. A sticky `ztest_run_failed` flag latches when any block reports failures — the `--ztest` worker reads it to exit non-zero even if the script ends with `exit 0`. Alias: `run_tests`. Returns 0 on all-pass, 1 if any assertion failed."),
6240 ("ztest_skip", "`ztest_skip MSG` — bump the per-block skip counter and emit a yellow ↷ marker on stderr. Typical use: gate an assertion behind a compat condition using shell short-circuit (`(( cond )) || { ztest_skip \"needs X\"; continue; }`)."),
6241 ("seq", "Print a sequence of numbers. `seq 1 10` / `seq 1 2 10` / `seq -w 1 10`. coreutils drop-in."),
6242 ("sha256sum", "Print or check SHA-256 digests. `-c FILE` checks. coreutils drop-in."),
6243 ("shuf", "Shuffle input lines. `-n N` limit, `-e ITEM…` shuffle args, `-i LO-HI` shuffle range. coreutils drop-in."),
6244 ("sleep", "Pause for the given duration. `sleep 1`, `sleep 0.5`, `sleep 1m`. coreutils drop-in."),
6245 ("sort", "Sort lines. `-n` numeric, `-r` reverse, `-k N` by field, `-u` unique. coreutils drop-in."),
6246 ("sum", "BSD/sysv checksum + 1K-block count. coreutils drop-in."),
6247 ("tac", "Concatenate files in reverse line order. coreutils drop-in."),
6248 ("tail", "Print the last N lines (`-n N`) or follow appends (`-f`). coreutils drop-in."),
6249 ("tee", "Copy stdin to stdout AND to each named file. `-a` append. coreutils drop-in."),
6250 ("touch", "Create a file or update its mtime. `-d STR` set time, `-r REF` copy from REF. coreutils drop-in."),
6251 ("tput", "Terminal-capability query. `tput cols`, `tput setaf 1`. Reads `$TERM` via terminfo."),
6252 ("tr", "Translate / squeeze / delete characters. `tr a-z A-Z` uppercases. coreutils drop-in."),
6253 ("tsort", "Topological sort of partial-order pairs read from stdin. coreutils drop-in."),
6254 ("tty", "Print the controlling terminal device path, or `not a tty` if stdin isn't one."),
6255 ("uname", "Print system info. `-a` all, `-s` kernel, `-m` machine, `-r` release. coreutils drop-in."),
6256 ("unexpand", "Convert leading spaces to tabs. `-a` all spaces. coreutils drop-in."),
6257 ("uniq", "Filter adjacent matching lines. `-c` prefix count, `-d` only duplicates. coreutils drop-in."),
6258 ("unlink", "Remove a single file via the `unlink(2)` syscall (no `-r`, no prompts). coreutils drop-in."),
6259 ("users", "Print the login names of users currently logged in."),
6260 ("wc", "Count newlines, words, bytes. `-l` lines, `-w` words, `-c` bytes. coreutils drop-in."),
6261 ("whoami", "Print the effective user name. coreutils drop-in."),
6262 ("yes", "Repeatedly output a line. `yes` prints `y` forever; `yes STR` prints STR. coreutils drop-in."),
6263 ("zbanner", "Print the ZSHRS logo, a box with the version and the builtin and extension totals, and a live line: the daemon socket and whether it answers, then this shell's function, alias, parameter and job counts. `zshrs --banner` prints the same from outside a shell, without the counts."),
6264 ("zbuild", "Bake one or more shell scripts into a standalone executable that runs them in order under one shell. `zbuild --in a.zsh b.zsh --out app`; `--native` compiles to native code with Cranelift."),
6265 ("zsleep", "Sleep for a possibly fractional number of seconds. `zsleep 0.25`; exactly one argument, no options."),
6266 ("zask", "Ask another shell for input through the daemon and manage the requests. `zask --target shell:N picker|input|dialog|menu [opts]`, `zask progress`, `zask pending`, `zask take`, `zask dismiss ID | --all`, `zask inbox-clear`, `zask response ID DATA`."),
6268 ("zcache", "Inspect and manage the zshrs caches: daemon shards, the catalog and the caches the shell writes itself. `zcache info`, `zcache clean [all|shards|shard NAME|index|shell|log|catalog|stats|zwc|zcompdump|legacy] [--dry-run]`, `zcache verify`, `zcache view TARGET`, `zcache export`, `zcache import`, `zcache daemon status|stop|restart`."),
6269 ("zcmd-result", "Responder side of `zsend --wait`: report the exit status and output of a command a `cmd:execute` event delivered. `zcmd-result --id DELIVERY_ID [--exit-code N] [--stdout TEXT] [--stderr TEXT]`."),
6270 ("zcomplete", "Ask the daemon's `complete` op for candidates (commands, handlers, history) matching a command-line prefix and print the JSON reply. `zcomplete PREFIX [--limit N]`."),
6271 ("zd", "Daemon client. In-process from inside zshrs (Unix socket), the standalone `zd` binary over HTTP otherwise; same arguments either way. `zd ping`, `zd ops`, `zd job list`, `zd lock list`, `zd doctor`."),
6272 ("zhistory", "Read and write the daemon's cross-shell history store (history.db). `zhistory append LINE [--exit-code N] [--cwd DIR]`, `zhistory query [--filter PAT] [--mode match|fts|like|prefix|exact] [--cwd DIR] [--limit N]`, `zhistory count`."),
6273 ("zid", "Print the client id the daemon assigned to this connection."),
6274 ("zjob", "Run and manage jobs owned by the daemon, so they outlive the shell. `zjob submit CMD [ARGS] [--cwd DIR] [--tag T]`, `zjob list`, `zjob status ID`, `zjob output ID [--follow]`, `zjob attach ID`, `zjob kill ID [--signal NAME]`, `zjob cancel ID`, `zjob wait ID`."),
6275 ("zlock", "Named cross-process locks held by the daemon; a lock is released when its holder dies. `zlock try NAME`, `zlock acquire NAME [--timeout S]`, `zlock release NAME TOKEN`, `zlock list`."),
6276 ("zlog", "Inspect and manage the zshrs log files under ~/.zshrs. `zlog` (newest log path), `zlog tail [-n N]`, `zlog grep REGEX`, `zlog clear`, `zlog stats`, `zlog level DIRECTIVE`, `zlog rotate`."),
6277 ("zls", "List the shells connected to the daemon: id, pid, tty, uptime, tags and cwd. `zls --tag NAME` lists only the shells carrying that tag."),
6278 ("znotify", "Show a notification in other shells through the daemon. `znotify SHELL_ID|--all|--tag NAME|--user USER MESSAGE`."),
6279 ("zping", "Round-trip probe against the daemon: prints its uptime and the round-trip time. `zping --all` also lists every registered shell."),
6280 ("zpublish", "Publish an event on the daemon's pub/sub bus from this shell's scope. `zpublish TOPIC [DATA | --json JSON]`; subscribers receive it via `zsubscribe`."),
6281 ("zsend", "Send a command line to other shells through the daemon. `zsend [--json] [--wait] [--timeout S] SHELL_ID|--all|--tag NAME|--user USER CMD...`; `--wait` relays the target's output and exit status."),
6282 ("zsource", "Ask the daemon's compiled-file registry whether a sourced file's cached body is a hit, stale or a miss. `zsource PATH`, `zsource --stat PATH`."),
6283 ("zsubscribe", "Subscribe to pub/sub events and stream them to stdout. `zsubscribe [--json] [--count N] SCOPE.TOPIC` (e.g. `shell:42.commands`, `*.chpwd`), `zsubscribe --list`, `--pause`/`--resume [--id N | --all]`."),
6284 ("zsuggest", "Ask the daemon for a suggestion completing a command-line prefix. `zsuggest PREFIX [--cwd DIR]`."),
6285 ("zsync", "Move canonical subsystem state (aliases, functions, options, ...) between this shell and the daemon. `zsync up SUBSYSTEM KEY VALUE`, `zsync up --all`, `zsync pull SUBSYSTEM`, `zsync diff SUBSYSTEM --overlay JSON`, `zsync watch SUBSYSTEM...`."),
6286 ("ztag", "Add tags to this shell's daemon session and print its tag list. Other shells target tagged shells with `zls --tag`, `zsend --tag`, `znotify --tag` and `tag:NAME.*` subscriptions. `ztag prod-deploy`."),
6287 ("zunsubscribe", "Cancel a `zsubscribe` subscription. `zunsubscribe SCOPE.TOPIC` or `zunsubscribe --id SUBSCRIPTION_ID`."),
6288 ("zuntag", "Remove tags from this shell's daemon session, or every tag with `--all`, and print the remaining list. Inverse of `ztag`."),
6289 ("zwhere", "Query the daemon's definitions catalog for which shell defined an alias, function, parameter, key binding, ... and where. `zwhere [KIND] [NAME] [--prefix P] [--shell-id N] [--limit N]`; `zwhere --kinds` lists the kinds."),
6290];
6291
6292fn document_symbols(state: &State, params: &Value) -> Value {
6295 let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
6296 let text = match state.docs.get(uri) {
6297 Some(t) => t,
6298 None => return Value::Array(vec![]),
6299 };
6300 let mut syms = Vec::new();
6301 for (name, kind, _detail) in scan_symbols(text) {
6302 let mut line_no = 0usize;
6304 for (i, l) in text.lines().enumerate() {
6305 if l.contains(&name) {
6306 line_no = i;
6307 break;
6308 }
6309 }
6310 let lsp_kind: u8 = match kind {
6311 "function" => 12,
6312 "variable" => 13,
6313 _ => 1,
6314 };
6315 syms.push(json!({
6316 "name": name,
6317 "kind": lsp_kind,
6318 "range": {
6319 "start": { "line": line_no, "character": 0 },
6320 "end": { "line": line_no, "character": 0 },
6321 },
6322 "selectionRange": {
6323 "start": { "line": line_no, "character": 0 },
6324 "end": { "line": line_no, "character": 0 },
6325 },
6326 }));
6327 }
6328 Value::Array(syms)
6329}
6330
6331fn scan_symbols(text: &str) -> Vec<(String, &'static str, &'static str)> {
6335 let mut out = Vec::new();
6336 for line in text.lines() {
6337 let t = line.trim_start();
6338 if t.starts_with('#') {
6339 continue;
6340 }
6341 if let Some(rest) = t
6343 .strip_prefix("function ")
6344 .or_else(|| t.strip_prefix("function\t"))
6345 {
6346 if let Some(name) = first_ident(rest) {
6347 out.push((name, "function", "function"));
6348 continue;
6349 }
6350 }
6351 if let Some(idx) = t.find("()") {
6353 let head = &t[..idx];
6354 if let Some(name) = first_ident(head) {
6355 if !head.contains(' ') && !head.contains('\t') {
6356 out.push((name, "function", "function"));
6357 continue;
6358 }
6359 }
6360 }
6361 if let Some(rest) = t.strip_prefix("alias ") {
6363 if let Some(name) = first_ident(rest) {
6364 out.push((name, "alias", "alias"));
6365 continue;
6366 }
6367 }
6368 for prefix in &[
6370 "local ",
6371 "typeset ",
6372 "declare ",
6373 "readonly ",
6374 "export ",
6375 "integer ",
6376 "float ",
6377 ] {
6378 if let Some(rest) = t.strip_prefix(prefix) {
6379 if let Some(name) = first_ident(rest) {
6380 out.push((name, "variable", "variable"));
6381 break;
6382 }
6383 }
6384 }
6385 }
6386 out
6387}
6388
6389fn first_ident(s: &str) -> Option<String> {
6390 let s = s.trim_start();
6391 let mut end = 0;
6392 for c in s.chars() {
6393 if c == '_' || c.is_alphanumeric() {
6394 end += c.len_utf8();
6395 } else {
6396 break;
6397 }
6398 }
6399 if end == 0 {
6400 None
6401 } else {
6402 Some(s[..end].to_string())
6403 }
6404}
6405
6406fn folding_ranges(state: &State, params: &Value) -> Value {
6409 let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
6410 let text = match state.docs.get(uri) {
6411 Some(t) => t,
6412 None => return Value::Array(vec![]),
6413 };
6414 let mut out = Vec::new();
6415 let mut brace_stack: Vec<usize> = Vec::new();
6416 let mut block_stack: Vec<(usize, &str)> = Vec::new();
6417 let mut comment_run_start: Option<usize> = None;
6418 for (i, line) in text.lines().enumerate() {
6419 let t = line.trim_start();
6420 if t.starts_with('#') {
6422 if comment_run_start.is_none() {
6423 comment_run_start = Some(i);
6424 }
6425 } else {
6426 if let Some(start) = comment_run_start.take() {
6427 if i - 1 >= start + 2 {
6428 out.push(json!({
6429 "startLine": start, "endLine": i - 1, "kind": "comment"
6430 }));
6431 }
6432 }
6433 }
6434 for c in line.chars() {
6435 if c == '{' {
6436 brace_stack.push(i);
6437 } else if c == '}' {
6438 if let Some(start) = brace_stack.pop() {
6439 if i > start {
6440 out.push(json!({ "startLine": start, "endLine": i, "kind": "region" }));
6441 }
6442 }
6443 }
6444 }
6445 for tok in t.split_whitespace() {
6446 match tok {
6447 "do" | "then" => block_stack.push((i, tok)),
6448 "done" | "fi" => {
6449 if let Some((start, _)) = block_stack.pop() {
6450 if i > start {
6451 out.push(json!({ "startLine": start, "endLine": i, "kind": "region" }));
6452 }
6453 }
6454 }
6455 "case" => block_stack.push((i, "case")),
6456 "esac" => {
6457 if let Some((start, _)) = block_stack.pop() {
6458 if i > start {
6459 out.push(json!({ "startLine": start, "endLine": i, "kind": "region" }));
6460 }
6461 }
6462 }
6463 _ => {}
6464 }
6465 }
6466 }
6467 Value::Array(out)
6468}
6469
6470fn definition(state: &State, params: &Value) -> Value {
6473 let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
6474 let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
6475 let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
6476 let text = match state.docs.get(uri) {
6477 Some(t) => t,
6478 None => return Value::Null,
6479 };
6480 let word = match word_at(text, line_no, col) {
6481 Some(w) if !w.is_empty() => w,
6482 _ => return Value::Null,
6483 };
6484 let bare = word.strip_prefix('$').unwrap_or(&word);
6485 if let Some(v) = definition_via_ast(state, uri, text, &word, bare) {
6490 return v;
6491 }
6492 for (i, l) in text.lines().enumerate() {
6494 let t = l.trim_start();
6495 let is_def = t.starts_with(&format!("function {}", word))
6496 || t.starts_with(&format!("{}()", word))
6497 || t.starts_with(&format!("{} ()", word));
6498 if is_def {
6499 let start_col = l.find(&word).unwrap_or(0);
6500 return json!({
6501 "uri": uri,
6502 "range": {
6503 "start": { "line": i, "character": start_col },
6504 "end": { "line": i, "character": start_col + word.len() },
6505 },
6506 });
6507 }
6508 }
6509 Value::Null
6510}
6511
6512fn definition_via_ast(
6523 state: &State,
6524 active_uri: &str,
6525 active_text: &str,
6526 word: &str,
6527 bare: &str,
6528) -> Option<Value> {
6529 use crate::lsp_symbols::{find_ast_occurrences, SymbolKind};
6530 let kind = if word.starts_with('$') {
6534 SymbolKind::Global
6535 } else {
6536 SymbolKind::Func
6537 };
6538 let files = collect_active_and_sourced_files(state, active_uri, active_text);
6545 let mut hits: Vec<Value> = Vec::new();
6546 let scan = |kind: SymbolKind, hits: &mut Vec<Value>| {
6547 for (uri, src) in &files {
6548 let lines = find_ast_occurrences(src, bare, kind.clone());
6549 for line in lines {
6550 if !line_is_decl(src, line, bare, &kind) {
6551 continue;
6552 }
6553 if let Some((start, end)) = find_first_word_col(src, line, bare) {
6554 hits.push(json!({
6555 "uri": uri,
6556 "range": {
6557 "start": { "line": line, "character": start },
6558 "end": { "line": line, "character": end },
6559 },
6560 }));
6561 }
6562 }
6563 }
6564 };
6565 scan(kind.clone(), &mut hits);
6566 if hits.is_empty() {
6567 let alt = if matches!(kind, SymbolKind::Global) {
6570 SymbolKind::Func
6571 } else {
6572 SymbolKind::Global
6573 };
6574 scan(alt, &mut hits);
6575 }
6576 match hits.len() {
6577 0 => None,
6578 1 => Some(hits.into_iter().next().unwrap()),
6579 _ => Some(Value::Array(hits)),
6580 }
6581}
6582
6583fn collect_active_and_sourced_files(
6601 state: &State,
6602 active_uri: &str,
6603 active_text: &str,
6604) -> Vec<(String, String)> {
6605 const MAX_FILES: usize = 256;
6606
6607 let read_text = |uri: &str| -> Option<String> {
6609 if uri == active_uri {
6610 return Some(active_text.to_string());
6611 }
6612 state
6613 .docs
6614 .get(uri)
6615 .cloned()
6616 .or_else(|| state.workspace_files.get(uri).cloned())
6617 .or_else(|| file_uri_to_path(uri).and_then(|p| std::fs::read_to_string(p).ok()))
6618 };
6619
6620 let mut component: std::collections::HashMap<String, String> = std::collections::HashMap::new();
6622 component.insert(active_uri.to_string(), active_text.to_string());
6623 let mut queue: Vec<String> = vec![active_uri.to_string()];
6624 while let Some(uri) = queue.pop() {
6625 if component.len() >= MAX_FILES {
6626 break;
6627 }
6628 let parent_text = match component.get(&uri).cloned().or_else(|| read_text(&uri)) {
6629 Some(t) => t,
6630 None => continue,
6631 };
6632 let parent_dir = file_uri_to_path(&uri)
6633 .and_then(|p| p.parent().map(|d| d.to_path_buf()))
6634 .unwrap_or_else(|| std::path::PathBuf::from("."));
6635 for sourced_path in crate::lsp_symbols::collect_sourced_paths(&parent_text, &parent_dir) {
6636 let sourced_uri = format!("file://{}", sourced_path.display());
6637 if component.contains_key(&sourced_uri) {
6638 continue;
6639 }
6640 if let Some(t) = read_text(&sourced_uri) {
6641 component.insert(sourced_uri.clone(), t);
6642 queue.push(sourced_uri);
6643 }
6644 }
6645 }
6646
6647 loop {
6651 if component.len() >= MAX_FILES {
6652 tracing::warn!(
6653 target: "zshrs::lsp::source_chain",
6654 walked = component.len(),
6655 "source-graph component hit MAX_FILES cap",
6656 );
6657 break;
6658 }
6659 let before = component.len();
6660 let candidates: Vec<(String, String)> = state
6664 .all_docs()
6665 .into_iter()
6666 .filter(|(u, _)| !component.contains_key(u.as_str()))
6667 .collect();
6668 for (uri, src) in candidates {
6669 let parent_dir = file_uri_to_path(&uri)
6670 .and_then(|p| p.parent().map(|d| d.to_path_buf()))
6671 .unwrap_or_else(|| std::path::PathBuf::from("."));
6672 let mut sources_into_component = false;
6673 for sourced_path in crate::lsp_symbols::collect_sourced_paths(&src, &parent_dir) {
6674 let sourced_uri = format!("file://{}", sourced_path.display());
6675 if component.contains_key(&sourced_uri) {
6676 sources_into_component = true;
6677 break;
6678 }
6679 }
6680 if sources_into_component {
6681 component.insert(uri, src);
6682 if component.len() >= MAX_FILES {
6683 break;
6684 }
6685 }
6686 }
6687 if component.len() == before {
6688 break;
6689 }
6690 }
6691
6692 component.into_iter().collect()
6693}
6694
6695fn line_is_decl(src: &str, line: u32, name: &str, kind: &crate::lsp_symbols::SymbolKind) -> bool {
6699 let l = match src.lines().nth(line as usize) {
6700 Some(l) => l,
6701 None => return false,
6702 };
6703 let t = l.trim_start();
6704 use crate::lsp_symbols::SymbolKind;
6705 match kind {
6706 SymbolKind::Func => {
6707 t.starts_with(&format!("function {}", name))
6708 || t.starts_with(&format!("function {} ", name))
6709 || t.starts_with(&format!("{}()", name))
6710 || t.starts_with(&format!("{} ()", name))
6711 }
6712 SymbolKind::Global | SymbolKind::Local => {
6713 let prefixes = [
6717 format!("{}=", name),
6718 format!("{}+=", name),
6719 format!("local {}", name),
6720 format!("typeset {}", name),
6721 format!("declare {}", name),
6722 format!("private {}", name),
6723 format!("export {}", name),
6724 format!("readonly {}", name),
6725 format!("integer {}", name),
6726 format!("float {}", name),
6727 ];
6728 prefixes.iter().any(|p| t.starts_with(p.as_str()))
6729 }
6730 }
6731}
6732
6733fn find_first_word_col(src: &str, line: u32, name: &str) -> Option<(u32, u32)> {
6738 let l = src.lines().nth(line as usize)?;
6739 let mut start = 0;
6740 while let Some(p) = l[start..].find(name) {
6741 let abs = start + p;
6742 let before = l[..abs].chars().last();
6743 let after = l[abs + name.len()..].chars().next();
6744 let ok_b = before
6745 .map(|c| !(c.is_alphanumeric() || c == '_' || c == '-'))
6746 .unwrap_or(true);
6747 let ok_a = after
6748 .map(|c| !(c.is_alphanumeric() || c == '_' || c == '-'))
6749 .unwrap_or(true);
6750 if ok_b && ok_a && !line_position_inside_string_or_comment(l, abs) {
6751 return Some((
6752 byte_to_utf16_col(l, abs),
6753 byte_to_utf16_col(l, abs + name.len()),
6754 ));
6755 }
6756 start = abs + name.len();
6757 }
6758 None
6759}
6760
6761fn find_all_word_cols(line_text: &str, name: &str) -> Vec<(u32, u32)> {
6770 find_all_word_cols_kinded(line_text, name, false)
6771}
6772
6773fn find_all_word_cols_kinded(
6774 line_text: &str,
6775 name: &str,
6776 is_variable_ref: bool,
6777) -> Vec<(u32, u32)> {
6778 let mut out = Vec::new();
6779 let mut start = 0;
6780 while let Some(p) = line_text[start..].find(name) {
6781 let abs = start + p;
6782 let before = line_text[..abs].chars().last();
6783 let after = line_text[abs + name.len()..].chars().next();
6784 let ok_b = before
6785 .map(|c| !(c.is_alphanumeric() || c == '_' || c == '-'))
6786 .unwrap_or(true);
6787 let ok_a = after
6788 .map(|c| !(c.is_alphanumeric() || c == '_' || c == '-'))
6789 .unwrap_or(true);
6790 let masked = if is_variable_ref {
6791 line_position_inside_uninterpolating_context(line_text, abs)
6792 } else {
6793 line_position_inside_string_or_comment(line_text, abs)
6794 };
6795 if ok_b && ok_a && !masked {
6796 out.push((abs as u32, (abs + name.len()) as u32));
6797 }
6798 start = abs + name.len();
6799 }
6800 out
6801}
6802
6803fn references(state: &State, params: &Value) -> Value {
6804 let active_uri = params["textDocument"]["uri"]
6805 .as_str()
6806 .unwrap_or("")
6807 .to_string();
6808 let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
6809 let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
6810 let active_text = match state
6813 .docs
6814 .get(&active_uri)
6815 .cloned()
6816 .or_else(|| state.workspace_files.get(&active_uri).cloned())
6817 {
6818 Some(t) => t,
6819 None => return Value::Array(vec![]),
6820 };
6821 let word = match word_at(&active_text, line_no, col) {
6822 Some(w) if !w.is_empty() => w,
6823 _ => return Value::Array(vec![]),
6824 };
6825 match references_via_ast(state, &active_uri, &active_text, line_no as u32, &word) {
6837 Some(v) => v,
6838 None => {
6839 tracing::warn!(
6840 target: "zshrs::lsp::references",
6841 uri = %active_uri,
6842 %word,
6843 line = line_no,
6844 col,
6845 "AST-walk returned no resolution \
6846 (parse failure or cursor not on a declared symbol); \
6847 returning empty rather than falling back to text-search",
6848 );
6849 Value::Array(vec![])
6850 }
6851 }
6852}
6853
6854fn references_via_ast(
6871 state: &State,
6872 active_uri: &str,
6873 active_text: &str,
6874 cursor_line: u32,
6875 cursor_word: &str,
6876) -> Option<Value> {
6877 use crate::lsp_symbols::{find_ast_occurrences, SymbolKind, SymbolTable};
6878
6879 let bare = cursor_word.strip_prefix('$').unwrap_or(cursor_word);
6881
6882 let active_table = SymbolTable::build(active_text)?;
6883 let (name, kind) = match active_table
6888 .symbol_at(cursor_line, bare)
6889 .and_then(|id| active_table.symbols.iter().find(|s| s.id == id))
6890 {
6891 Some(sym) => (sym.name.clone(), sym.kind.clone()),
6892 None => {
6893 let chain_files = collect_active_and_sourced_files(state, active_uri, active_text);
6898 let mut found: Option<SymbolKind> = None;
6899 'outer: for (other_uri, src) in &chain_files {
6900 if other_uri == active_uri {
6901 continue;
6902 }
6903 let Some(t) = SymbolTable::build(src) else {
6904 continue;
6905 };
6906 for s in &t.symbols {
6907 if s.name == bare && matches!(s.kind, SymbolKind::Func | SymbolKind::Global) {
6908 found = Some(s.kind.clone());
6909 break 'outer;
6910 }
6911 }
6912 }
6913 let default_kind = if cursor_word.starts_with('$') {
6914 SymbolKind::Global
6915 } else {
6916 SymbolKind::Func
6917 };
6918 (bare.to_string(), found.unwrap_or(default_kind))
6919 }
6920 };
6921
6922 let mut out: Vec<Value> = Vec::new();
6923
6924 let is_var = matches!(kind, SymbolKind::Global | SymbolKind::Local);
6929
6930 let active_lines: Vec<&str> = active_text.lines().collect();
6934 if let Some(id) = active_table.symbol_at(cursor_line, &name) {
6935 for (line, n) in active_table.occurrences(id) {
6936 if let Some(lt) = active_lines.get(line as usize) {
6937 for (s, e) in find_all_word_cols_kinded(lt, &n, is_var) {
6938 out.push(json!({
6939 "uri": active_uri,
6940 "range": {
6941 "start": { "line": line, "character": s },
6942 "end": { "line": line, "character": e },
6943 },
6944 }));
6945 }
6946 }
6947 }
6948 } else {
6949 let lines = find_ast_occurrences(active_text, &name, kind.clone());
6950 for line in lines {
6951 if let Some(lt) = active_lines.get(line as usize) {
6952 for (s, e) in find_all_word_cols_kinded(lt, &name, is_var) {
6953 out.push(json!({
6954 "uri": active_uri,
6955 "range": {
6956 "start": { "line": line, "character": s },
6957 "end": { "line": line, "character": e },
6958 },
6959 }));
6960 }
6961 }
6962 }
6963 }
6964
6965 if !matches!(kind, SymbolKind::Local) {
6977 let component = collect_active_and_sourced_files(state, active_uri, active_text);
6983 for (uri, src) in &component {
6984 if uri == active_uri {
6985 continue; }
6987 let lines = find_ast_occurrences(src, &name, kind.clone());
6988 let src_lines: Vec<&str> = src.lines().collect();
6989 for line in lines {
6990 if let Some(lt) = src_lines.get(line as usize) {
6991 for (s, e) in find_all_word_cols_kinded(lt, &name, is_var) {
6992 out.push(json!({
6993 "uri": uri,
6994 "range": {
6995 "start": { "line": line, "character": s },
6996 "end": { "line": line, "character": e },
6997 },
6998 }));
6999 }
7000 }
7001 }
7002 }
7003 tracing::debug!(
7004 target: "zshrs::lsp::references_ast",
7005 files_in_component = component.len(),
7006 "source-graph component walked",
7007 );
7008 }
7009
7010 tracing::debug!(
7011 target: "zshrs::lsp::references_ast",
7012 %name,
7013 ?kind,
7014 n_results = out.len(),
7015 "AST-resolved",
7016 );
7017 Some(Value::Array(out))
7018}
7019
7020fn document_highlights(state: &State, params: &Value) -> Value {
7021 let refs = references(state, params);
7023 let arr = refs.as_array().cloned().unwrap_or_default();
7024 Value::Array(
7025 arr.into_iter()
7026 .map(|r| json!({ "range": r["range"], "kind": 1 }))
7027 .collect(),
7028 )
7029}
7030
7031fn prepare_rename(state: &State, params: &Value) -> Value {
7032 let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
7033 let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
7034 let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
7035 let text = match state.docs.get(uri) {
7036 Some(t) => t,
7037 None => {
7038 tracing::debug!(
7039 target: "zshrs::lsp::prepareRename",
7040 line = line_no, col,
7041 "no_doc_for_uri",
7042 );
7043 return Value::Null;
7044 }
7045 };
7046 let line_text = text.lines().nth(line_no).unwrap_or("");
7050 let col = utf16_col_to_byte(line_text, col);
7052 if line_starts_comment_before(line_text, col) {
7053 tracing::debug!(
7054 target: "zshrs::lsp::prepareRename",
7055 line = line_no, col,
7056 "gated_comment",
7057 );
7058 return Value::Null;
7059 }
7060 if let Some(word) = word_at(text, line_no, col) {
7061 if !word.is_empty() {
7062 if let Some(line) = text.lines().nth(line_no) {
7063 if let Some(s) = line.find(&word) {
7064 tracing::debug!(
7065 target: "zshrs::lsp::prepareRename",
7066 %word, line = line_no, "accepted",
7067 );
7068 return json!({
7069 "start": { "line": line_no, "character": byte_to_utf16_col(line, s) },
7070 "end": { "line": line_no, "character": byte_to_utf16_col(line, s + word.len()) },
7071 "placeholder": word,
7072 });
7073 }
7074 }
7075 }
7076 }
7077 tracing::debug!(
7078 target: "zshrs::lsp::prepareRename",
7079 line = line_no, col,
7080 "no_identifier",
7081 );
7082 Value::Null
7083}
7084
7085fn rename(state: &State, params: &Value) -> Value {
7086 let new_name_raw = params["newName"].as_str().unwrap_or("").to_string();
7087 if new_name_raw.is_empty() {
7088 tracing::warn!(target: "zshrs::lsp::rename", "rejecting empty new_name");
7089 return Value::Null;
7090 }
7091 let new_name = match new_name_raw.rfind("::") {
7106 Some(idx) => {
7107 let bare = new_name_raw[idx + 2..].to_string();
7108 tracing::warn!(
7109 target: "zshrs::lsp::rename",
7110 %new_name_raw, %bare,
7111 "stripping `::` qualifier from new_name",
7112 );
7113 bare
7114 }
7115 None => new_name_raw,
7116 };
7117 let refs = references(state, params);
7121 let arr = refs.as_array().cloned().unwrap_or_default();
7122 let mut buckets: HashMap<String, Vec<Value>> = HashMap::new();
7123 let mut total = 0usize;
7124 for r in arr {
7125 let uri = r["uri"].as_str().unwrap_or("").to_string();
7126 if uri.is_empty() {
7127 continue;
7128 }
7129 buckets
7130 .entry(uri)
7131 .or_default()
7132 .push(json!({ "range": r["range"], "newText": new_name }));
7133 total += 1;
7134 }
7135 tracing::info!(
7136 target: "zshrs::lsp::rename",
7137 %new_name,
7138 n_files = buckets.len(),
7139 n_edits = total,
7140 "applied",
7141 );
7142 let mut changes = serde_json::Map::new();
7143 for (uri, edits) in buckets {
7144 changes.insert(uri, Value::Array(edits));
7145 }
7146 json!({ "changes": Value::Object(changes) })
7147}
7148
7149const SEMANTIC_TOKEN_TYPES: &[&str] = &[
7152 "comment", "string", "number", "keyword", "operator", "function", "variable", "parameter", "type", "macro", "property", "regexp", "zshrsExtension", "zshrsCompsys", ];
7167
7168fn semantic_tokens(state: &State, params: &Value) -> Value {
7169 let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
7170 let text = match state.docs.get(uri) {
7171 Some(t) => t,
7172 None => return json!({ "data": [] }),
7173 };
7174 let mut data: Vec<u32> = Vec::new();
7176 let mut last_line: u32 = 0;
7177 let mut last_col: u32 = 0;
7178 for (i, line) in text.lines().enumerate() {
7179 let ln = i as u32;
7180 let mut col = 0usize;
7181 let bytes = line.as_bytes();
7182 while col < bytes.len() {
7183 let rest = &line[col..];
7184 if rest.starts_with('#') {
7186 push_tok(
7187 &mut data,
7188 &mut last_line,
7189 &mut last_col,
7190 ln,
7191 col as u32,
7192 rest.len() as u32,
7193 0,
7194 );
7195 break;
7196 }
7197 if rest.starts_with('"') || rest.starts_with('\'') || rest.starts_with('`') {
7210 let q = rest.as_bytes()[0] as char;
7211 let bb = rest.as_bytes();
7212 let mut close = 1;
7215 while close < bb.len() {
7216 let c = bb[close] as char;
7217 if c == '\\' && q != '\'' && close + 1 < bb.len() {
7218 close += 2;
7219 continue;
7220 }
7221 if c == q {
7222 close += 1;
7223 break;
7224 }
7225 close += 1;
7226 }
7227 if q == '\'' {
7229 push_tok(
7230 &mut data,
7231 &mut last_line,
7232 &mut last_col,
7233 ln,
7234 col as u32,
7235 close as u32,
7236 1,
7237 );
7238 col += close;
7239 continue;
7240 }
7241 let mut seg_start = 0usize;
7246 let mut p = 1usize;
7247 let inner_end =
7252 if close > 0 && close <= bb.len() && bb.get(close - 1) == Some(&(q as u8)) {
7253 close - 1
7254 } else {
7255 close
7256 };
7257 let flush_string = |data: &mut Vec<u32>,
7258 last_line: &mut u32,
7259 last_col: &mut u32,
7260 col: usize,
7261 seg_start: usize,
7262 seg_end: usize| {
7263 if seg_end > seg_start {
7264 push_tok(
7265 data,
7266 last_line,
7267 last_col,
7268 ln,
7269 (col + seg_start) as u32,
7270 (seg_end - seg_start) as u32,
7271 1, );
7273 }
7274 };
7275 while p < inner_end {
7276 let c = bb[p] as char;
7277 if c == '\\' && q != '\'' && p + 1 < inner_end {
7283 p += 2;
7284 continue;
7285 }
7286 if c == '$' {
7287 flush_string(&mut data, &mut last_line, &mut last_col, col, seg_start, p);
7289 let var_start = p;
7295 let mut q2 = p + 1;
7296 let is_arith = q2 + 1 < inner_end && bb[q2] == b'(' && bb[q2 + 1] == b'(';
7303 if is_arith {
7304 let mut depth = 2i32; let arith_start = q2 + 2;
7307 q2 = arith_start;
7308 while q2 < inner_end && depth > 0 {
7309 match bb[q2] {
7310 b'(' => depth += 1,
7311 b')' => depth -= 1,
7312 _ => {}
7313 }
7314 if depth == 0 {
7315 break;
7316 }
7317 q2 += 1;
7318 }
7319 let arith_end = if q2 > 0 && q2 < bb.len() && bb[q2] == b')' {
7325 q2.saturating_sub(0)
7329 } else {
7330 q2
7331 };
7332 push_tok(
7334 &mut data,
7335 &mut last_line,
7336 &mut last_col,
7337 ln,
7338 (col + var_start) as u32,
7339 3,
7340 4,
7341 );
7342 emit_arith_interior(
7345 &mut data,
7346 &mut last_line,
7347 &mut last_col,
7348 ln,
7349 col,
7350 bb,
7351 arith_start,
7352 arith_end,
7353 );
7354 if arith_end + 1 < bb.len()
7356 && bb[arith_end] == b')'
7357 && bb[arith_end + 1] == b')'
7358 {
7359 push_tok(
7360 &mut data,
7361 &mut last_line,
7362 &mut last_col,
7363 ln,
7364 (col + arith_end) as u32,
7365 2,
7366 4,
7367 );
7368 q2 = arith_end + 2;
7369 } else {
7370 q2 = arith_end;
7371 }
7372 seg_start = q2;
7373 p = q2;
7374 continue;
7375 }
7376 if q2 < inner_end && bb[q2] == b'{' {
7377 let mut depth = 1i32;
7381 q2 += 1;
7382 while q2 < inner_end && depth > 0 {
7383 match bb[q2] {
7384 b'{' => depth += 1,
7385 b'}' => depth -= 1,
7386 _ => {}
7387 }
7388 q2 += 1;
7389 }
7390 } else if q2 < inner_end && bb[q2] == b'(' {
7391 let mut depth = 1i32;
7393 q2 += 1;
7394 while q2 < inner_end && depth > 0 {
7395 match bb[q2] {
7396 b'(' => depth += 1,
7397 b')' => depth -= 1,
7398 _ => {}
7399 }
7400 q2 += 1;
7401 }
7402 } else {
7403 while q2 < inner_end {
7405 let cc = bb[q2] as char;
7406 if cc.is_alphanumeric() || cc == '_' {
7407 q2 += 1;
7408 } else {
7409 break;
7410 }
7411 }
7412 if q2 == p + 1 && q2 < inner_end {
7415 let cc = bb[q2] as char;
7416 if "?!$#*@-_0123456789".contains(cc) {
7417 q2 += 1;
7418 }
7419 }
7420 }
7421 if q2 > var_start + 1 {
7422 push_tok(
7424 &mut data,
7425 &mut last_line,
7426 &mut last_col,
7427 ln,
7428 (col + var_start) as u32,
7429 (q2 - var_start) as u32,
7430 6,
7431 );
7432 seg_start = q2;
7433 p = q2;
7434 continue;
7435 }
7436 p += 1;
7439 continue;
7440 }
7441 p += 1;
7442 }
7443 flush_string(
7445 &mut data,
7446 &mut last_line,
7447 &mut last_col,
7448 col,
7449 seg_start,
7450 close,
7451 );
7452 col += close;
7453 continue;
7454 }
7455 if rest.starts_with('$') {
7457 let mut end = 1;
7458 let b = rest.as_bytes();
7459 if end < b.len() && b[end] == b'{' {
7460 end += 1;
7462 while end < b.len() && b[end] != b'}' {
7463 end += 1;
7464 }
7465 if end < b.len() {
7466 end += 1;
7467 }
7468 } else {
7469 while end < b.len() {
7470 let c = b[end] as char;
7471 if c.is_alphanumeric() || c == '_' {
7472 end += 1;
7473 } else {
7474 break;
7475 }
7476 }
7477 if end == 1 {
7478 if end < b.len() {
7480 let c = b[end] as char;
7481 if "?!$#*@-_0123456789".contains(c) {
7482 end += 1;
7483 }
7484 }
7485 }
7486 }
7487 push_tok(
7488 &mut data,
7489 &mut last_line,
7490 &mut last_col,
7491 ln,
7492 col as u32,
7493 end as u32,
7494 6,
7495 );
7496 col += end;
7497 continue;
7498 }
7499 if rest.starts_with("--") && rest.len() > 2 && rest.as_bytes()[2].is_ascii_alphabetic()
7511 {
7512 let bb = rest.as_bytes();
7513 let mut end = 2;
7514 while end < bb.len() {
7515 let c = bb[end];
7516 if c.is_ascii_alphanumeric() || c == b'-' || c == b'_' {
7517 end += 1;
7518 } else {
7519 break;
7520 }
7521 }
7522 push_tok(
7523 &mut data,
7524 &mut last_line,
7525 &mut last_col,
7526 ln,
7527 col as u32,
7528 end as u32,
7529 4, );
7531 col += end;
7532 continue;
7533 }
7534 if rest.starts_with('{') {
7548 let bb = rest.as_bytes();
7549 let mut p = 1usize;
7550 let scan_run = |bb: &[u8], mut p: usize| -> usize {
7551 while p < bb.len() {
7552 let c = bb[p];
7553 if c.is_ascii_alphanumeric() || c == b'-' || c == b'_' {
7554 p += 1;
7555 } else {
7556 break;
7557 }
7558 }
7559 p
7560 };
7561 let after_a = scan_run(bb, p);
7562 let has_dotdot = after_a > p
7563 && bb.get(after_a) == Some(&b'.')
7564 && bb.get(after_a + 1) == Some(&b'.');
7565 if has_dotdot {
7566 p = after_a + 2;
7567 let after_b = scan_run(bb, p);
7568 if after_b > p {
7569 let mut close = after_b;
7571 if bb.get(close) == Some(&b'.') && bb.get(close + 1) == Some(&b'.') {
7572 let after_step = scan_run(bb, close + 2);
7573 if after_step > close + 2 {
7574 close = after_step;
7575 }
7576 }
7577 if bb.get(close) == Some(&b'}') {
7578 let span = close + 1;
7579 push_tok(
7580 &mut data,
7581 &mut last_line,
7582 &mut last_col,
7583 ln,
7584 col as u32,
7585 span as u32,
7586 4, );
7588 col += span;
7589 continue;
7590 }
7591 }
7592 }
7593 }
7594 const OPERATORS: &[&str] = &[
7604 ";;&", "<<<", "<<-", "&&", "||", "|&", "<<", ">>", "&>", ">|", ">!", ">&", "<&",
7605 "<>", "==", "!=", "=~", "+=", "-=", ":=", "?=", "[[", "]]", "((", "))", ";;", ";|",
7606 "|", "&", ">", "<",
7607 ];
7608 let mut op_len = 0usize;
7609 for op in OPERATORS {
7610 if rest.starts_with(op) {
7611 op_len = op.len();
7612 break;
7613 }
7614 }
7615 if op_len > 0 {
7616 push_tok(
7617 &mut data,
7618 &mut last_line,
7619 &mut last_col,
7620 ln,
7621 col as u32,
7622 op_len as u32,
7623 4, );
7625 col += op_len;
7626 continue;
7627 }
7628 let c0 = rest.as_bytes()[0] as char;
7630 if c0.is_ascii_digit() {
7631 let mut end = 0;
7632 let b = rest.as_bytes();
7633 while end < b.len() && (b[end] as char).is_ascii_digit() {
7634 end += 1;
7635 }
7636 push_tok(
7637 &mut data,
7638 &mut last_line,
7639 &mut last_col,
7640 ln,
7641 col as u32,
7642 end as u32,
7643 2,
7644 );
7645 col += end;
7646 continue;
7647 }
7648 use crate::ported::ztype_h::{ialnum, iident, iuser};
7664 let leading_sigil = iuser(c0 as u8)
7665 && !iident(c0 as u8) && rest.as_bytes().get(1).map_or(false, |b| iident(*b));
7667 let extra_sigil = !leading_sigil
7672 && matches!(c0, '+' | '@' | ':' | '^')
7673 && rest.as_bytes().get(1).map_or(false, |b| iident(*b));
7674 let is_sigil = leading_sigil || extra_sigil;
7675 if iident(c0 as u8) || is_sigil {
7676 let b = rest.as_bytes();
7677 let mut end = if is_sigil { 1 } else { 0 };
7678 while end < b.len() {
7679 let c = b[end];
7680 if ialnum(c) || c == b'_' {
7681 end += 1;
7682 } else if matches!(c, b'-' | b'.' | b':')
7683 && end + 1 < b.len()
7684 && (ialnum(b[end + 1]) || b[end + 1] == b'_')
7685 {
7686 end += 1;
7687 } else {
7688 break;
7689 }
7690 }
7691 let w = &rest[..end];
7692 let kind = if KEYWORDS.contains(&w) {
7702 3u32
7703 } else if crate::ext_builtins::EXT_BUILTIN_NAMES.contains(&w)
7704 || crate::daemon::builtins::ZSHRS_BUILTIN_NAMES.contains(&w)
7705 {
7706 12
7707 } else if crate::compsys::COMPSYS_FN_NAMES.contains(&w) {
7708 13
7709 } else if BUILTINS.contains(&w) {
7710 5
7711 } else {
7712 6
7713 };
7714 push_tok(
7715 &mut data,
7716 &mut last_line,
7717 &mut last_col,
7718 ln,
7719 col as u32,
7720 end as u32,
7721 kind,
7722 );
7723 col += end;
7724 continue;
7725 }
7726 col += 1;
7727 }
7728 }
7729 json!({ "data": data })
7730}
7731
7732fn emit_arith_interior(
7744 data: &mut Vec<u32>,
7745 last_line: &mut u32,
7746 last_col: &mut u32,
7747 ln: u32,
7748 col: usize,
7749 bb: &[u8],
7750 arith_start: usize,
7751 arith_end: usize,
7752) {
7753 let mut p = arith_start;
7754 while p < arith_end {
7755 let c = bb[p];
7756 if c == b' ' || c == b'\t' {
7758 p += 1;
7759 continue;
7760 }
7761 if c.is_ascii_digit() {
7763 let mut end = p + 1;
7764 while end < arith_end && (bb[end].is_ascii_digit() || bb[end] == b'.') {
7765 end += 1;
7766 }
7767 push_tok(
7768 data,
7769 last_line,
7770 last_col,
7771 ln,
7772 (col + p) as u32,
7773 (end - p) as u32,
7774 2,
7775 );
7776 p = end;
7777 continue;
7778 }
7779 if c.is_ascii_alphabetic() || c == b'_' {
7781 let mut end = p + 1;
7782 while end < arith_end && (bb[end].is_ascii_alphanumeric() || bb[end] == b'_') {
7783 end += 1;
7784 }
7785 push_tok(
7786 data,
7787 last_line,
7788 last_col,
7789 ln,
7790 (col + p) as u32,
7791 (end - p) as u32,
7792 6,
7793 );
7794 p = end;
7795 continue;
7796 }
7797 let two = if p + 1 < arith_end {
7803 Some(&bb[p..p + 2])
7804 } else {
7805 None
7806 };
7807 let span = match two {
7808 Some(b"**") | Some(b"++") | Some(b"--") | Some(b"<<") | Some(b">>") | Some(b"&&")
7809 | Some(b"||") | Some(b"==") | Some(b"!=") | Some(b"<=") | Some(b">=") | Some(b"+=")
7810 | Some(b"-=") | Some(b"*=") | Some(b"/=") | Some(b"%=") => 2,
7811 _ => 1,
7812 };
7813 push_tok(
7814 data,
7815 last_line,
7816 last_col,
7817 ln,
7818 (col + p) as u32,
7819 span as u32,
7820 4,
7821 );
7822 p += span;
7823 }
7824}
7825
7826fn push_tok(
7827 out: &mut Vec<u32>,
7828 last_line: &mut u32,
7829 last_col: &mut u32,
7830 line: u32,
7831 col: u32,
7832 len: u32,
7833 ty: u32,
7834) {
7835 let delta_line = line - *last_line;
7836 let delta_col = if delta_line == 0 {
7837 col - *last_col
7838 } else {
7839 col
7840 };
7841 out.push(delta_line);
7842 out.push(delta_col);
7843 out.push(len);
7844 out.push(ty);
7845 out.push(0);
7846 *last_line = line;
7847 *last_col = col;
7848}
7849
7850fn code_actions(state: &State, params: &Value) -> Value {
7866 let uri = params["textDocument"]["uri"]
7867 .as_str()
7868 .unwrap_or("")
7869 .to_string();
7870 let text = match state.docs.get(&uri).cloned() {
7871 Some(t) => t,
7872 None => return Value::Array(vec![]),
7873 };
7874 let r = ¶ms["range"];
7875 let start_line = r["start"]["line"].as_u64().unwrap_or(0) as u32;
7876 let start_char = r["start"]["character"].as_u64().unwrap_or(0) as u32;
7877 let end_line = r["end"]["line"].as_u64().unwrap_or(0) as u32;
7878 let end_char = r["end"]["character"].as_u64().unwrap_or(0) as u32;
7879
7880 let mut actions: Vec<Value> = Vec::new();
7881 let same_line = start_line == end_line;
7882 let nonempty = start_line != end_line || start_char != end_char;
7883
7884 if !same_line {
7888 if let Some(action) = make_extract_function_multiline(&uri, &text, start_line, end_line) {
7889 actions.push(action);
7890 }
7891 return Value::Array(actions);
7892 }
7893
7894 let line_text = match text.lines().nth(start_line as usize) {
7897 Some(l) => l,
7898 None => return Value::Array(vec![]),
7899 };
7900 let leading_ws: String = line_text
7901 .chars()
7902 .take_while(|c| c.is_whitespace())
7903 .collect();
7904
7905 let line_has_content = !line_text.trim().is_empty();
7913 let whole_line_selected =
7914 nonempty && selection_covers_whole_line(line_text, start_char, end_char);
7915 if line_has_content && (whole_line_selected || !nonempty) {
7916 let body = if whole_line_selected {
7917 utf16_slice(line_text, start_char, end_char)
7918 .map(str::trim_end)
7919 .unwrap_or_else(|| line_text.trim())
7920 } else {
7921 line_text.trim()
7922 };
7923 actions.push(make_extract_function_singleline(
7924 &uri,
7925 &leading_ws,
7926 start_line,
7927 body,
7928 ));
7929 }
7930
7931 let (eff_start_char, eff_end_char) = if !nonempty {
7935 match snap_to_word_at_cursor(line_text, start_char) {
7936 Some((s, e)) => (s, e),
7937 None => return Value::Array(actions),
7940 }
7941 } else {
7942 (start_char, end_char)
7943 };
7944
7945 if eff_end_char <= eff_start_char {
7946 return Value::Array(actions);
7947 }
7948
7949 let sel = match utf16_slice(line_text, eff_start_char, eff_end_char) {
7950 Some(s) if !s.trim().is_empty() => s,
7951 _ => return Value::Array(actions),
7952 };
7953
7954 let eff_range = json!({
7955 "start": { "line": start_line, "character": eff_start_char },
7956 "end": { "line": start_line, "character": eff_end_char },
7957 });
7958
7959 let in_string = same_line_inside_interpolating_string(line_text, eff_start_char);
7963 let rhs = if in_string && needs_string_wrap_for_extraction(sel) {
7964 format!("\"{}\"", escape_for_double_quoted(sel))
7965 } else {
7966 sel.to_string()
7967 };
7968
7969 actions.push(make_extract_action(
7970 &uri,
7971 &leading_ws,
7972 start_line,
7973 &eff_range,
7974 &rhs,
7975 "EXTRACTED",
7976 "local",
7977 "Extract to variable (`local NAME=…`)",
7978 ));
7979 actions.push(make_extract_action(
7980 &uri,
7981 &leading_ws,
7982 start_line,
7983 &eff_range,
7984 &rhs,
7985 "EXTRACTED",
7986 "readonly",
7987 "Extract to constant (`readonly NAME=…`)",
7988 ));
7989
7990 Value::Array(actions)
7991}
7992
7993fn selection_covers_whole_line(line_text: &str, start_col: u32, end_col: u32) -> bool {
8000 let mut prefix_byte = 0;
8001 let mut suffix_byte = line_text.len();
8002 let mut u16_seen = 0u32;
8003 for (i, ch) in line_text.char_indices() {
8004 if u16_seen == start_col {
8005 prefix_byte = i;
8006 }
8007 u16_seen += ch.len_utf16() as u32;
8008 if u16_seen == end_col {
8009 suffix_byte = i + ch.len_utf8();
8010 }
8011 }
8012 line_text[..prefix_byte].chars().all(char::is_whitespace)
8013 && line_text[suffix_byte..].chars().all(char::is_whitespace)
8014}
8015
8016fn make_extract_function_singleline(uri: &str, leading_ws: &str, line: u32, body: &str) -> Value {
8017 let name = "extracted_function";
8020 let decl = format!("{leading_ws}{name}() {{\n{leading_ws} {body}\n{leading_ws}}}\n");
8021 let insert_range = json!({
8022 "start": { "line": line, "character": 0 },
8023 "end": { "line": line, "character": 0 },
8024 });
8025 let replace_range = json!({
8026 "start": { "line": line, "character": 0 },
8027 "end": { "line": line + 1, "character": 0 },
8028 });
8029 let replacement = format!("{leading_ws}{name}\n");
8030 let changes = json!({
8031 uri: [
8032 { "range": insert_range, "newText": decl },
8033 { "range": replace_range, "newText": replacement },
8034 ]
8035 });
8036 json!({
8037 "title": "Extract to function (`name() { … }`)",
8038 "kind": "refactor.extract",
8039 "edit": { "changes": changes },
8040 })
8041}
8042
8043fn make_extract_function_multiline(
8044 uri: &str,
8045 text: &str,
8046 start_line: u32,
8047 end_line: u32,
8048) -> Option<Value> {
8049 let lines: Vec<&str> = text.lines().collect();
8054 if (start_line as usize) >= lines.len() {
8055 return None;
8056 }
8057 let last = (end_line as usize).min(lines.len() - 1);
8058 let block = &lines[start_line as usize..=last];
8059 if block.iter().all(|l| l.trim().is_empty()) {
8060 return None;
8061 }
8062
8063 let common_indent = block
8066 .iter()
8067 .filter(|l| !l.trim().is_empty())
8068 .map(|l| l.chars().take_while(|c| c.is_whitespace()).count())
8069 .min()
8070 .unwrap_or(0);
8071
8072 let leading_ws: String = block
8073 .iter()
8074 .find(|l| !l.trim().is_empty())
8075 .map(|l| l.chars().take(common_indent).collect())
8076 .unwrap_or_default();
8077
8078 let name = "extracted_function";
8079 let mut decl = String::new();
8080 decl.push_str(&format!("{leading_ws}{name}() {{\n"));
8081 for l in block {
8082 if l.trim().is_empty() {
8083 decl.push('\n');
8084 } else {
8085 let stripped = if l.chars().take(common_indent).all(|c| c.is_whitespace()) {
8088 &l[l.char_indices()
8089 .nth(common_indent)
8090 .map(|(i, _)| i)
8091 .unwrap_or(l.len())..]
8092 } else {
8093 l.trim_start()
8094 };
8095 decl.push_str(&format!("{leading_ws} {stripped}\n"));
8096 }
8097 }
8098 decl.push_str(&format!("{leading_ws}}}\n"));
8099
8100 let insert_range = json!({
8101 "start": { "line": start_line, "character": 0 },
8102 "end": { "line": start_line, "character": 0 },
8103 });
8104 let replace_range = json!({
8105 "start": { "line": start_line, "character": 0 },
8106 "end": { "line": last as u32 + 1, "character": 0 },
8107 });
8108 let replacement = format!("{leading_ws}{name}\n");
8109
8110 let changes = json!({
8111 uri: [
8112 { "range": insert_range, "newText": decl },
8113 { "range": replace_range, "newText": replacement },
8114 ]
8115 });
8116 Some(json!({
8117 "title": "Extract to function (`name() { … }`)",
8118 "kind": "refactor.extract",
8119 "edit": { "changes": changes },
8120 }))
8121}
8122
8123fn make_extract_action(
8124 uri: &str,
8125 leading_ws: &str,
8126 line: u32,
8127 selection_range: &Value,
8128 rhs: &str,
8129 name: &str,
8130 decl_keyword: &str,
8131 title: &str,
8132) -> Value {
8133 let decl_line = format!("{leading_ws}{decl_keyword} {name}={rhs}\n");
8134 let insert_range = json!({
8135 "start": { "line": line, "character": 0 },
8136 "end": { "line": line, "character": 0 },
8137 });
8138 let changes = json!({
8139 uri: [
8140 { "range": insert_range, "newText": decl_line },
8141 { "range": selection_range, "newText": format!("${name}") },
8142 ]
8143 });
8144 json!({
8145 "title": title,
8146 "kind": "refactor.extract",
8147 "edit": { "changes": changes },
8148 })
8149}
8150
8151fn utf16_slice(line_text: &str, start: u32, end: u32) -> Option<&str> {
8155 let mut u16_seen = 0u32;
8156 let mut s_byte: Option<usize> = None;
8157 let mut e_byte: Option<usize> = None;
8158 for (i, ch) in line_text.char_indices() {
8159 if u16_seen == start {
8160 s_byte = Some(i);
8161 }
8162 u16_seen += ch.len_utf16() as u32;
8163 if u16_seen == end {
8164 e_byte = Some(i + ch.len_utf8());
8165 break;
8166 }
8167 }
8168 let s = s_byte?;
8169 let e = e_byte.unwrap_or(line_text.len());
8170 line_text.get(s..e)
8171}
8172
8173fn same_line_inside_interpolating_string(line_text: &str, col: u32) -> bool {
8177 let mut byte_cutoff = line_text.len();
8178 let mut u16_seen = 0u32;
8179 for (i, ch) in line_text.char_indices() {
8180 if u16_seen >= col {
8181 byte_cutoff = i;
8182 break;
8183 }
8184 u16_seen += ch.len_utf16() as u32;
8185 }
8186 let mut in_dq = false;
8187 let mut in_sq = false;
8188 let mut in_bt = false;
8189 let mut chars = line_text[..byte_cutoff].chars().peekable();
8190 while let Some(c) = chars.next() {
8191 match c {
8192 '\\' => {
8193 chars.next();
8194 }
8195 '"' if !in_sq && !in_bt => in_dq = !in_dq,
8196 '\'' if !in_dq && !in_bt => in_sq = !in_sq,
8197 '`' if !in_dq && !in_sq => in_bt = !in_bt,
8198 _ => {}
8199 }
8200 }
8201 in_dq || in_bt
8202}
8203
8204fn needs_string_wrap_for_extraction(selection: &str) -> bool {
8208 let t = selection.trim();
8209 if t.is_empty() {
8210 return false;
8211 }
8212 if (t.starts_with('"') && t.ends_with('"')) || (t.starts_with('\'') && t.ends_with('\'')) {
8213 return false;
8214 }
8215 if let Some(rest) = t.strip_prefix('$') {
8217 let body = rest
8218 .strip_prefix('{')
8219 .and_then(|r| r.strip_suffix('}'))
8220 .unwrap_or(rest);
8221 if !body.is_empty() && body.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
8222 return false;
8223 }
8224 }
8225 true
8226}
8227
8228fn escape_for_double_quoted(s: &str) -> String {
8229 let mut out = String::with_capacity(s.len());
8230 for c in s.chars() {
8231 match c {
8232 '\\' => out.push_str("\\\\"),
8233 '"' => out.push_str("\\\""),
8234 _ => out.push(c),
8235 }
8236 }
8237 out
8238}
8239
8240fn snap_to_word_at_cursor(line_text: &str, cursor_col: u32) -> Option<(u32, u32)> {
8243 let mut byte_cur = line_text.len();
8244 let mut u16_seen = 0u32;
8245 for (i, ch) in line_text.char_indices() {
8246 if u16_seen >= cursor_col {
8247 byte_cur = i;
8248 break;
8249 }
8250 u16_seen += ch.len_utf16() as u32;
8251 }
8252 let is_word_char = |c: char| c.is_ascii_alphanumeric() || c == '_';
8253
8254 if same_line_inside_interpolating_string(line_text, cursor_col) {
8256 let prev_char = line_text[..byte_cur].chars().next_back();
8257 let cur_char = line_text[byte_cur..].chars().next();
8258 if matches!(prev_char, Some('$')) || matches!(cur_char, Some('$')) {
8259 let mut start_byte = byte_cur;
8261 for (i, c) in line_text[..byte_cur].char_indices().rev() {
8262 if c == '$' {
8263 start_byte = i;
8264 break;
8265 }
8266 if !is_word_char(c) {
8267 break;
8268 }
8269 start_byte = i;
8270 }
8271 if cur_char == Some('$') {
8272 start_byte = byte_cur;
8273 }
8274 let mut end_byte = start_byte;
8275 let mut iter = line_text[start_byte..].char_indices();
8276 if let Some((_, first)) = iter.next() {
8277 if first == '$' {
8278 end_byte = start_byte + first.len_utf8();
8279 for (i, c) in iter {
8280 if !is_word_char(c) {
8281 break;
8282 }
8283 end_byte = start_byte + i + c.len_utf8();
8284 }
8285 }
8286 }
8287 if end_byte > start_byte {
8288 return Some((
8289 byte_to_utf16_col(line_text, start_byte),
8290 byte_to_utf16_col(line_text, end_byte),
8291 ));
8292 }
8293 }
8294 let mut start_byte = byte_cur;
8295 for (i, c) in line_text[..byte_cur].char_indices().rev() {
8296 if !is_word_char(c) {
8297 break;
8298 }
8299 start_byte = i;
8300 }
8301 let mut end_byte = byte_cur;
8302 for (i, c) in line_text[byte_cur..].char_indices() {
8303 if !is_word_char(c) {
8304 break;
8305 }
8306 end_byte = byte_cur + i + c.len_utf8();
8307 }
8308 if end_byte > start_byte {
8309 return Some((
8310 byte_to_utf16_col(line_text, start_byte),
8311 byte_to_utf16_col(line_text, end_byte),
8312 ));
8313 }
8314 return None;
8315 }
8316
8317 let mut start_byte = byte_cur;
8319 for (i, c) in line_text[..byte_cur].char_indices().rev() {
8320 if !is_word_char(c) {
8321 break;
8322 }
8323 start_byte = i;
8324 }
8325 let mut end_byte = byte_cur;
8326 for (i, c) in line_text[byte_cur..].char_indices() {
8327 if !is_word_char(c) {
8328 break;
8329 }
8330 end_byte = byte_cur + i + c.len_utf8();
8331 }
8332 if start_byte > 0 {
8334 if let Some((idx, '$')) = line_text[..start_byte].char_indices().next_back() {
8335 let standalone = match line_text[..idx].chars().next_back() {
8336 None => true,
8337 Some(c) => !is_word_char(c),
8338 };
8339 if standalone {
8340 start_byte = idx;
8341 }
8342 }
8343 }
8344 if end_byte > start_byte {
8345 Some((
8346 byte_to_utf16_col(line_text, start_byte),
8347 byte_to_utf16_col(line_text, end_byte),
8348 ))
8349 } else {
8350 None
8351 }
8352}
8353
8354fn byte_to_utf16_col(line_text: &str, byte_idx: usize) -> u32 {
8355 line_text[..byte_idx.min(line_text.len())]
8356 .encode_utf16()
8357 .count() as u32
8358}
8359
8360fn formatting(state: &State, params: &Value) -> Value {
8361 let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
8362 let text = match state.docs.get(uri) {
8363 Some(t) => t.clone(),
8364 None => return Value::Array(vec![]),
8365 };
8366 let opts = ¶ms["options"];
8367 let tab_size = opts["tabSize"].as_u64().unwrap_or(4) as usize;
8368 let insert_spaces = opts["insertSpaces"].as_bool().unwrap_or(true);
8369 let formatted = crate::fmt::format_source(
8378 &text,
8379 &crate::fmt::FmtOptions {
8380 indent_width: tab_size,
8381 use_tabs: !insert_spaces,
8382 },
8383 );
8384 if formatted == text {
8385 return Value::Array(vec![]);
8386 }
8387
8388 let last_line = text.lines().count().saturating_sub(1);
8389 let last_col = text.lines().last().map(|l| l.len()).unwrap_or(0);
8390 Value::Array(vec![json!({
8391 "range": {
8392 "start": { "line": 0, "character": 0 },
8393 "end": { "line": last_line, "character": last_col },
8394 },
8395 "newText": formatted,
8396 })])
8397}
8398
8399fn word_at(text: &str, line_no: usize, col: usize) -> Option<String> {
8402 let line = text.lines().nth(line_no)?;
8403 if col > line.len() {
8404 return None;
8405 }
8406 let bytes = line.as_bytes();
8407 let mut start = col;
8410 while start > 0 {
8411 let c = bytes[start - 1] as char;
8412 if c == '_' || c.is_alphanumeric() || c == '$' {
8413 start -= 1;
8414 } else {
8415 break;
8416 }
8417 }
8418 let mut end = col;
8419 while end < bytes.len() {
8420 let c = bytes[end] as char;
8421 if c == '_' || c.is_alphanumeric() {
8422 end += 1;
8423 } else {
8424 break;
8425 }
8426 }
8427 if start == end {
8428 return None;
8429 }
8430 let is_dollar_var = bytes[start] == b'$';
8441 let in_braced = start > 0 && bytes[start - 1] == b'{';
8442 if !is_dollar_var && !in_braced {
8443 while end < bytes.len() && bytes[end] == b'-' {
8445 let mut p = end + 1;
8446 while p < bytes.len() {
8447 let c = bytes[p] as char;
8448 if c == '_' || c.is_alphanumeric() {
8449 p += 1;
8450 } else {
8451 break;
8452 }
8453 }
8454 if p > end + 1 {
8455 end = p;
8456 } else {
8457 break;
8458 }
8459 }
8460 while start > 1 && bytes[start - 1] == b'-' {
8462 let mut p = start - 1;
8463 while p > 0 {
8464 let c = bytes[p - 1] as char;
8465 if c == '_' || c.is_alphanumeric() {
8466 p -= 1;
8467 } else {
8468 break;
8469 }
8470 }
8471 if p < start - 1 {
8472 start = p;
8473 } else {
8474 break;
8475 }
8476 }
8477 }
8478 Some(line[start..end].to_string())
8479}
8480
8481const KEYWORDS: &[&str] = &[
8487 "[[",
8488 "]]",
8489 "always",
8490 "case",
8491 "do",
8492 "done",
8493 "elif",
8494 "else",
8495 "end",
8496 "esac",
8497 "fi",
8498 "for",
8499 "foreach",
8500 "if",
8501 "in",
8502 "repeat",
8503 "select",
8504 "then",
8505 "until",
8506 "while",
8507 "declare",
8508 "export",
8509 "float",
8510 "integer",
8511 "let",
8512 "local",
8513 "readonly",
8514 "set",
8515 "shift",
8516 "typeset",
8517 "function",
8518 "{",
8519 "}",
8520 ".",
8521 "eval",
8522 "exec",
8523 "source",
8524 "trap",
8525 "break",
8526 "continue",
8527 "exit",
8528 "logout",
8529 "return",
8530 "builtin",
8531 "command",
8532 "coproc",
8533 "nocorrect",
8534 "noglob",
8535 "time",
8536 "!",
8537];
8538const BUILTINS: &[&str] = &[
8542 ".",
8543 ":",
8544 "[",
8545 "alias",
8546 "autoload",
8547 "bg",
8548 "break",
8549 "bye",
8550 "cap",
8551 "cd",
8552 "chdir",
8553 "chgrp",
8554 "chmod",
8555 "chown",
8556 "clone",
8557 "continue",
8558 "declare",
8559 "dirs",
8560 "disable",
8561 "disown",
8562 "echo",
8563 "echotc",
8564 "echoti",
8565 "emulate",
8566 "enable",
8567 "eval",
8568 "example",
8569 "exit",
8570 "export",
8571 "false",
8572 "fc",
8573 "fg",
8574 "float",
8575 "functions",
8576 "getcap",
8577 "getln",
8578 "getopts",
8579 "hash",
8580 "hashinfo",
8581 "history",
8582 "integer",
8583 "jobs",
8584 "kill",
8585 "let",
8586 "ln",
8587 "local",
8588 "log",
8589 "logout",
8590 "mem",
8591 "mkdir",
8592 "mv",
8593 "nameref",
8594 "patdebug",
8595 "pcre_compile",
8596 "pcre_match",
8597 "pcre_study",
8598 "popd",
8599 "print",
8600 "printf",
8601 "private",
8602 "pushd",
8603 "pushln",
8604 "pwd",
8605 "r",
8606 "read",
8607 "readonly",
8608 "rehash",
8609 "return",
8610 "rm",
8611 "rmdir",
8612 "set",
8613 "setcap",
8614 "setopt",
8615 "shift",
8616 "source",
8617 "stat",
8618 "strftime",
8619 "suspend",
8620 "sync",
8621 "syserror",
8622 "sysopen",
8623 "sysread",
8624 "sysseek",
8625 "syswrite",
8626 "test",
8627 "times",
8628 "trap",
8629 "true",
8630 "ttyctl",
8631 "type",
8632 "typeset",
8633 "umask",
8634 "unalias",
8635 "unfunction",
8636 "unhash",
8637 "unset",
8638 "unsetopt",
8639 "wait",
8640 "whence",
8641 "where",
8642 "which",
8643 "zask",
8644 "zcache",
8645 "zcompdump",
8646 "zcompile",
8647 "zcomplete",
8648 "zcurses",
8649 "zd",
8650 "zdelattr",
8651 "zf_chgrp",
8652 "zf_chmod",
8653 "zf_chown",
8654 "zf_ln",
8655 "zf_mkdir",
8656 "zf_mv",
8657 "zf_rm",
8658 "zf_rmdir",
8659 "zf_sync",
8660 "zformat",
8661 "zftp",
8662 "zgdbmpath",
8663 "zgetattr",
8664 "zhistory",
8665 "zid",
8666 "zjob",
8667 "zlistattr",
8668 "zlock",
8669 "zlog",
8670 "zls",
8671 "zmodload",
8672 "znotify",
8673 "zparseopts",
8674 "zping",
8675 "zprof",
8676 "zpty",
8677 "zpublish",
8678 "zregexparse",
8679 "zselect",
8680 "zsend",
8681 "zsetattr",
8682 "zsocket",
8683 "zsource",
8684 "zstat",
8685 "zstyle",
8686 "zsubscribe",
8687 "zsuggest",
8688 "zsync",
8689 "zsystem",
8690 "ztag",
8691 "ztcp",
8692 "ztie",
8693 "zunsubscribe",
8694 "zuntag",
8695 "zuntie",
8696 "zwc",
8697 "zwhere",
8698];
8699const OPTIONS: &[&str] = &[
8703 "ALIASES",
8704 "ALIASFUNCDEF",
8705 "ALLEXPORT",
8706 "ALWAYSLASTPROMPT",
8707 "ALWAYSTOEND",
8708 "APPENDCREATE",
8709 "APPENDHISTORY",
8710 "AUTOCD",
8711 "AUTOCONTINUE",
8712 "AUTOLIST",
8713 "AUTOMENU",
8714 "AUTONAMEDIRS",
8715 "AUTOPARAMKEYS",
8716 "AUTOPARAMSLASH",
8717 "AUTOPUSHD",
8718 "AUTOREMOVESLASH",
8719 "AUTORESUME",
8720 "BADPATTERN",
8721 "BANGHIST",
8722 "BAREGLOBQUAL",
8723 "BASHAUTOLIST",
8724 "BASHREMATCH",
8725 "BEEP",
8726 "BGNICE",
8727 "BRACECCL",
8728 "BRACEEXPAND",
8729 "BSDECHO",
8730 "CASEGLOB",
8731 "CASEMATCH",
8732 "CASEPATHS",
8733 "CBASES",
8734 "CDABLEVARS",
8735 "CDSILENT",
8736 "CHASEDOTS",
8737 "CHASELINKS",
8738 "CHECKJOBS",
8739 "CHECKRUNNINGJOBS",
8740 "CLOBBER",
8741 "CLOBBEREMPTY",
8742 "COMBININGCHARS",
8743 "COMPLETEALIASES",
8744 "COMPLETEINWORD",
8745 "CONTINUEONERROR",
8746 "CORRECT",
8747 "CORRECTALL",
8748 "CPRECEDENCES",
8749 "CSHJUNKIEHISTORY",
8750 "CSHJUNKIELOOPS",
8751 "CSHJUNKIEQUOTES",
8752 "CSHNULLCMD",
8753 "CSHNULLGLOB",
8754 "DEBUGBEFORECMD",
8755 "DOTGLOB",
8756 "DVORAK",
8757 "EMACS",
8758 "EQUALS",
8759 "ERREXIT",
8760 "ERRRETURN",
8761 "EVALLINENO",
8762 "EXEC",
8763 "EXTENDEDGLOB",
8764 "EXTENDEDHISTORY",
8765 "FLOWCONTROL",
8766 "FORCEFLOAT",
8767 "FUNCTIONARGZERO",
8768 "GLOB",
8769 "GLOBALEXPORT",
8770 "GLOBALRCS",
8771 "GLOBASSIGN",
8772 "GLOBCOMPLETE",
8773 "GLOBDOTS",
8774 "GLOBSTARSHORT",
8775 "GLOBSUBST",
8776 "HASHALL",
8777 "HASHCMDS",
8778 "HASHDIRS",
8779 "HASHEXECUTABLESONLY",
8780 "HASHLISTALL",
8781 "HISTALLOWCLOBBER",
8782 "HISTAPPEND",
8783 "HISTBEEP",
8784 "HISTEXPAND",
8785 "HISTEXPIREDUPSFIRST",
8786 "HISTFCNTLLOCK",
8787 "HISTFINDNODUPS",
8788 "HISTIGNOREALLDUPS",
8789 "HISTIGNOREDUPS",
8790 "HISTIGNORESPACE",
8791 "HISTLEXWORDS",
8792 "HISTNOFUNCTIONS",
8793 "HISTNOSTORE",
8794 "HISTREDUCEBLANKS",
8795 "HISTSAVEBYCOPY",
8796 "HISTSAVENODUPS",
8797 "HISTSUBSTPATTERN",
8798 "HISTVERIFY",
8799 "HUP",
8800 "IGNOREBRACES",
8801 "IGNORECLOSEBRACES",
8802 "IGNOREEOF",
8803 "INCAPPENDHISTORY",
8804 "INCAPPENDHISTORYTIME",
8805 "INTERACTIVE",
8806 "INTERACTIVECOMMENTS",
8807 "KSHARRAYS",
8808 "KSHAUTOLOAD",
8809 "KSHGLOB",
8810 "KSHOPTIONPRINT",
8811 "KSHTYPESET",
8812 "KSHZEROSUBSCRIPT",
8813 "LISTAMBIGUOUS",
8814 "LISTBEEP",
8815 "LISTPACKED",
8816 "LISTROWSFIRST",
8817 "LISTTYPES",
8818 "LOCALLOOPS",
8819 "LOCALOPTIONS",
8820 "LOCALPATTERNS",
8821 "LOCALTRAPS",
8822 "LOG",
8823 "LOGIN",
8824 "LONGLISTJOBS",
8825 "MAGICEQUALSUBST",
8826 "MAILWARN",
8827 "MAILWARNING",
8828 "MARKDIRS",
8829 "MENUCOMPLETE",
8830 "MONITOR",
8831 "MULTIBYTE",
8832 "MULTIFUNCDEF",
8833 "MULTIOS",
8834 "NOMATCH",
8835 "NOTIFY",
8836 "NULLGLOB",
8837 "NUMERICGLOBSORT",
8838 "OCTALZEROES",
8839 "ONECMD",
8840 "OVERSTRIKE",
8841 "PATHDIRS",
8842 "PATHSCRIPT",
8843 "PHYSICAL",
8844 "PIPEFAIL",
8845 "POSIXALIASES",
8846 "POSIXARGZERO",
8847 "POSIXBUILTINS",
8848 "POSIXCD",
8849 "POSIXIDENTIFIERS",
8850 "POSIXJOBS",
8851 "POSIXSTRINGS",
8852 "POSIXTRAPS",
8853 "PRINTEIGHTBIT",
8854 "PRINTEXITVALUE",
8855 "PRIVILEGED",
8856 "PROMPTBANG",
8857 "PROMPTCR",
8858 "PROMPTPERCENT",
8859 "PROMPTSP",
8860 "PROMPTSUBST",
8861 "PROMPTVARS",
8862 "PUSHDIGNOREDUPS",
8863 "PUSHDMINUS",
8864 "PUSHDSILENT",
8865 "PUSHDTOHOME",
8866 "RCEXPANDPARAM",
8867 "RCQUOTES",
8868 "RCS",
8869 "RECEXACT",
8870 "REMATCHPCRE",
8871 "RMSTARSILENT",
8872 "RMSTARWAIT",
8873 "SHAREHISTORY",
8874 "SHFILEEXPANSION",
8875 "SHGLOB",
8876 "SHINSTDIN",
8877 "SHNULLCMD",
8878 "SHOPTIONLETTERS",
8879 "SHORTLOOPS",
8880 "SHORTREPEAT",
8881 "SHWORDSPLIT",
8882 "SINGLECOMMAND",
8883 "SINGLELINEZLE",
8884 "SOURCETRACE",
8885 "STDIN",
8886 "SUNKEYBOARDHACK",
8887 "TRACKALL",
8888 "TRANSIENTRPROMPT",
8889 "TRAPSASYNC",
8890 "TYPESETSILENT",
8891 "TYPESETTOUNSET",
8892 "UNSET",
8893 "VERBOSE",
8894 "VI",
8895 "WARNCREATEGLOBAL",
8896 "WARNNESTEDVAR",
8897 "XTRACE",
8898 "ZLE",
8899];
8900const KEYWORD_DOCS: &[(&str, &str)] = &[
8909 (
8910 "if",
8911 "Conditional. `if cmd; then …; elif cmd; then …; else …; fi`",
8912 ),
8913 (
8914 "for",
8915 "Loop. `for var in words; do …; done` or `for ((init; cond; step)); do …; done`",
8916 ),
8917 (
8918 "while",
8919 "Loop. `while cmd; do …; done` — runs the body while `cmd` succeeds.",
8920 ),
8921 (
8922 "until",
8923 "Loop. `until cmd; do …; done` — runs the body while `cmd` fails.",
8924 ),
8925 (
8926 "case",
8927 "Pattern match. `case word in pat1) …;; pat2) …;; esac`",
8928 ),
8929 (
8930 "select",
8931 "Interactive menu. `select var in items; do …; done`",
8932 ),
8933 ("repeat", "Counted loop. `repeat N; do …; done`"),
8934 ("then", "Body separator for `if`/`elif`. `if cmd; then body; fi`"),
8940 ("else", "Alternative branch for `if`. `if cmd; then a; else b; fi`"),
8941 ("elif", "Alternative test in an `if` chain. `if a; then …; elif b; then …; fi`"),
8942 ("do", "Body-introducer for `for`/`while`/`until`/`select`/`repeat`. `for v in …; do body; done`"),
8943 ("esac", "Closes a `case` statement. `case word in pat) …;; esac`"),
8944 ("in", "Word-list introducer for `for` and `case`. `for v in a b c; do …; done`"),
8945 (
8946 "{",
8947 "Command-group open brace. `{ cmd1; cmd2; }` runs the commands in the current shell (no subshell), grouping them as one syntactic unit. Reserved word — must be followed by whitespace or a newline.",
8948 ),
8949 (
8950 "}",
8951 "Command-group close brace. Pairs with `{ … }`. Reserved word — preceded by `;` or newline.",
8952 ),
8953 (
8954 "!",
8955 "Pipeline negation. `! cmd` inverts `cmd`'s exit status — zero becomes non-zero, non-zero becomes zero. As the first word of a command. Distinct from `!` history expansion (which is a lexer-stage substitution, not a reserved word).",
8956 ),
8957 (
8958 "fi",
8959 "Closes an `if` block. `if cmd; then body; fi`. Required terminator — without it the parser keeps reading until EOF.",
8960 ),
8961 (
8962 "done",
8963 "Closes a `for` / `foreach` / `while` / `until` / `select` / `repeat` loop body. `for v in a b c; do echo $v; done`. Required terminator.",
8964 ),
8965 (
8966 "end",
8967 "Closes the alternate-form compound statement (`foreach NAME (WORDS) … end`, `if COND … end`, `while COND … end`). Csh-style syntactic mirror of `fi` / `done` / `esac` for users coming from csh / tcsh.",
8968 ),
8969 (
8970 "declare",
8971 "Alias for `typeset`. Set variable attributes. `-a` array, `-A` assoc, `-i` integer, `-r` readonly.",
8972 ),
8973 (
8974 "function",
8975 "Function declaration. `function foo { body }` or `foo() { body }`",
8976 ),
8977 (
8978 "local",
8979 "Declare a function-scope variable. `local var=value` or `local -i var=42`",
8980 ),
8981 (
8982 "typeset",
8983 "Set variable attributes. `-a` array, `-A` assoc, `-i` integer, `-r` readonly.",
8984 ),
8985 ("export", "Mark a variable for export to the environment."),
8986 ("readonly", "Mark a variable as read-only."),
8987 ("integer", "Shorthand for `typeset -i`."),
8988 ("float", "Shorthand for `typeset -F` (floating point)."),
8989 (
8990 "return",
8991 "Return from a function or sourced script with the given status.",
8992 ),
8993 (
8994 "break",
8995 "Exit the innermost loop, or N levels up with `break N`.",
8996 ),
8997 (
8998 "continue",
8999 "Skip to the next iteration of the innermost loop.",
9000 ),
9001 ("exit", "Exit the shell with the given status."),
9002 ("time", "Time the execution of the following pipeline."),
9003 (
9004 "coproc",
9005 "Run a command as a coprocess (background, attached I/O).",
9006 ),
9007];
9008
9009const BUILTIN_DOCS: &[(&str, &str)] = &[
9010 ("cd", "Change the working directory."),
9011 ("pwd", "Print the working directory."),
9012 (
9013 "pushd",
9014 "Push the current directory onto the stack and `cd`.",
9015 ),
9016 ("popd", "Pop a directory off the stack and `cd` to it."),
9017 ("alias", "Define a command alias. `alias name=value`"),
9018 ("setopt", "Turn on a zsh option. `setopt EXTENDED_GLOB`"),
9019 ("unsetopt", "Turn off a zsh option."),
9020 (
9021 "zstyle",
9022 "Set a context-aware style (used by compsys, prompts, etc.).",
9023 ),
9024 (
9025 "zmodload",
9026 "Load a zsh binary module (e.g. `zsh/datetime`, `zsh/stat`).",
9027 ),
9028 (
9029 "autoload",
9030 "Mark a function to be loaded from `fpath` on first call.",
9031 ),
9032 ("bindkey", "Bind a key sequence to a ZLE widget."),
9033 ("compdef", "Register a completion function for a command."),
9034 (
9035 "source",
9036 "Execute a file in the current shell context. Same as `.`.",
9037 ),
9038 ("eval", "Concatenate args and execute them as shell code."),
9039 (
9040 "exec",
9041 "Replace the current process with the given command.",
9042 ),
9043 ("trap", "Set a signal or pseudo-signal handler."),
9044 (
9045 "echo",
9046 "Print arguments separated by spaces, with a trailing newline.",
9047 ),
9048 (
9049 "print",
9050 "zsh-extended print. `-r` raw, `-n` no newline, `-l` one per line.",
9051 ),
9052 ("printf", "C-style formatted print."),
9053 ("read", "Read a line into a variable. `read -r var`"),
9054 (
9055 "test",
9056 "Evaluate a conditional. Same as `[`. Prefer `[[ … ]]` in zsh.",
9057 ),
9058 ("kill", "Send a signal to a job or pid."),
9059 ("jobs", "List background jobs."),
9060 ("fg", "Bring a job to the foreground."),
9061 ("bg", "Resume a stopped job in the background."),
9062 ("hash", "Print or modify the command hash table."),
9063 (
9064 "unhash",
9065 "Remove an entry from the hash / alias / function table.",
9066 ),
9067 ("history", "Show the command history."),
9068 ("fc", "List, edit, or re-execute history entries."),
9069 (
9070 "command",
9071 "Bypass aliases and functions to run the named command.",
9072 ),
9073 (
9074 "type",
9075 "Show how a name would be interpreted (alias / builtin / function / file).",
9076 ),
9077 ("whence", "Same as `type` but with more formatting options."),
9078 (
9079 "builtin",
9080 "Run the named builtin, bypassing any function / alias.",
9081 ),
9082 ("set", "Set positional parameters or options."),
9083 ("unset", "Remove a variable."),
9084 (
9085 "getopts",
9086 "Parse positional parameters in the style of GNU getopt.",
9087 ),
9088 ("let", "Evaluate an arithmetic expression. `let count++`"),
9089 (
9095 ":",
9096 "Null command. Returns true. Side-effects of argument expansion still happen.",
9097 ),
9098 (
9099 "[",
9100 "Alias for `test`. `[ expr ]` — POSIX conditional. Prefer `[[ expr ]]` in zsh.",
9101 ),
9102 ("bye", "Alias for `exit`. Exit the shell with the given status."),
9103 ("chdir", "Alias for `cd`. Change the working directory."),
9104 (
9105 "compctl",
9106 "Old completion control (compctl mechanism). Largely superseded by `compdef` / compsys.",
9107 ),
9108 ("declare", "Alias for `typeset`. Set variable attributes."),
9109 (
9110 "hashinfo",
9111 "Print internal hash-table statistics. Debug builtin in `zsh/parameter`-adjacent code.",
9112 ),
9113 (
9114 "mem",
9115 "Print zsh memory-allocator statistics. Debug builtin compiled only with `--enable-zsh-mem`.",
9116 ),
9117 (
9118 "noglob",
9119 "Precommand modifier. Disable filename generation for the next command. `noglob ls *.tmp`",
9120 ),
9121 (
9122 "patdebug",
9123 "Print pattern-matcher internals for a glob/regex. Debug builtin from `zsh/pattern`.",
9124 ),
9125 ("r", "Re-execute the previous command. Shorthand for `fc -e -`."),
9126 (
9127 "unfunction",
9128 "Remove a function definition. Equivalent to `unhash -f` / `unset -f name`.",
9129 ),
9130 ("zf_chgrp", "zftp: change group of remote files. Mirrors `chgrp(1)`."),
9133 ("zf_chmod", "zftp: change mode of remote files. Mirrors `chmod(1)`."),
9134 ("zf_chown", "zftp: change owner of remote files. Mirrors `chown(1)`."),
9135 ("zf_ln", "zftp: link / rename remote files. Mirrors `ln(1)`."),
9136 ("zf_mkdir", "zftp: create remote directories. Mirrors `mkdir(1)`."),
9137 ("zf_mv", "zftp: move / rename remote files. Mirrors `mv(1)`."),
9138 ("zf_rm", "zftp: remove remote files. Mirrors `rm(1)`."),
9139 ("zf_rmdir", "zftp: remove remote directories. Mirrors `rmdir(1)`."),
9140 ("zf_sync", "zftp: flush pending writes on the FTP control channel."),
9141];
9142
9143const SPECIAL_VAR_DOCS: &[(&str, &str)] = &[
9144 ("$0", "Script name."),
9145 ("$?", "Exit status of the last command."),
9146 ("$!", "PID of the most recent background command."),
9147 ("$$", "PID of the current shell."),
9148 ("$#", "Number of positional parameters."),
9149 ("$*", "All positional parameters as one word (IFS-joined)."),
9150 ("$@", "All positional parameters as separate words."),
9151 ("$-", "Currently set option flags."),
9152 ("$_", "Last argument of the previous command."),
9153 ("$PATH", "Colon-separated command lookup path."),
9154 ("$HOME", "User's home directory."),
9155 ("$USER", "Current user."),
9156 ("$PWD", "Current working directory."),
9157 ("$OLDPWD", "Previous working directory (used by `cd -`)."),
9158 ("$ZSH_VERSION", "zsh / zshrs version string."),
9159 (
9160 "$RANDOM",
9161 "Each read returns a fresh pseudo-random integer.",
9162 ),
9163 ("$LINENO", "Current line number in the script."),
9164 ("$SECONDS", "Seconds since the shell started."),
9165 ("$EPOCHSECONDS", "Unix epoch seconds (zsh/datetime)."),
9166 (
9167 "$EPOCHREALTIME",
9168 "Unix epoch with microsecond precision (zsh/datetime).",
9169 ),
9170 (
9171 "$fpath",
9172 "Array of directories searched for autoloaded functions.",
9173 ),
9174 ("$path", "Array version of $PATH."),
9175 ("$argv", "Array of positional parameters (same as $@)."),
9176 ("$pipestatus", "Exit statuses of each pipeline element."),
9177 (
9178 "$SHELL",
9179 "Pathname of the login shell. Honored by many tools as the default user shell.",
9180 ),
9181 (
9182 "$EDITOR",
9183 "Preferred editor for tools that invoke an editor (`fc`, `git`, `crontab`, …).",
9184 ),
9185 (
9186 "$VISUAL",
9187 "Preferred full-screen editor. Takes precedence over `$EDITOR` when set.",
9188 ),
9189];
9190
9191pub fn dump_reflection_json() -> String {
9207 let mut all = serde_json::Map::new();
9208
9209 let mut compat = serde_json::Map::new();
9215 for b in crate::ported::builtin::BUILTINS.iter() {
9216 compat.insert(b.node.nam.clone(), Value::String("compat".into()));
9217 all.insert(b.node.nam.clone(), Value::String("compat".into()));
9218 }
9219 let mut keywords = serde_json::Map::new();
9235 for (name, _token) in crate::ported::hashtable::RESWDS {
9236 keywords.insert(name.to_string(), Value::String("keyword".into()));
9237 all.insert(name.to_string(), Value::String("keyword".into()));
9238 }
9239 let mut options = serde_json::Map::new();
9247 for (name, _doc) in crate::zsh_option_docs::OPTION_DOCS {
9248 options.insert((*name).to_string(), Value::String("option".into()));
9249 all.insert((*name).to_string(), Value::String("option".into()));
9250 }
9251 for (alias, _canon) in crate::zsh_option_docs::OPTION_ALIASES {
9252 options.insert((*alias).to_string(), Value::String("option".into()));
9253 all.insert((*alias).to_string(), Value::String("option".into()));
9254 }
9255 let mut special_vars = serde_json::Map::new();
9276 let mut add_special = |name: &str,
9277 special_vars: &mut serde_json::Map<String, Value>,
9278 all: &mut serde_json::Map<String, Value>| {
9279 let key = format!("${}", name);
9280 special_vars.insert(key.clone(), Value::String("special".into()));
9281 all.insert(key, Value::String("special".into()));
9282 };
9283 for (name, _doc) in crate::zsh_special_var_docs::SPECIAL_VAR_DOCS {
9284 add_special(name, &mut special_vars, &mut all);
9285 }
9286 for (alias, _canon) in crate::zsh_special_var_docs::SPECIAL_VAR_ALIASES {
9290 add_special(alias, &mut special_vars, &mut all);
9291 }
9292 let mut compsys = serde_json::Map::new();
9297 for n in crate::compsys::COMPSYS_FN_NAMES {
9298 compsys.insert((*n).to_string(), Value::String("compsys".into()));
9299 all.insert((*n).to_string(), Value::String("compsys".into()));
9300 }
9301 let mut extensions = serde_json::Map::new();
9313 for n in crate::ext_builtins::EXT_BUILTIN_NAMES {
9314 extensions.insert((*n).to_string(), Value::String("extension".into()));
9315 all.insert((*n).to_string(), Value::String("extension".into()));
9316 }
9317 for n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES {
9318 extensions.insert((*n).to_string(), Value::String("extension".into()));
9319 all.insert((*n).to_string(), Value::String("extension".into()));
9320 }
9321 let mut operators = serde_json::Map::new();
9323 for (op, _body) in OPERATOR_DOCS {
9324 operators.insert((*op).to_string(), Value::String("operator".into()));
9325 all.insert((*op).to_string(), Value::String("operator".into()));
9326 }
9327 let mut builtins = serde_json::Map::new();
9341 for k in compat.keys() {
9342 builtins.insert(k.clone(), Value::String("builtin".into()));
9343 }
9344 for (k, _) in &extensions {
9345 builtins.insert(k.clone(), Value::String("builtin".into()));
9346 }
9347 serde_json::to_string_pretty(&json!({
9348 "all": all,
9349 "builtins": builtins,
9350 "compat": compat,
9351 "keywords": keywords,
9352 "options": options,
9353 "special_vars": special_vars,
9354 "compsys": compsys,
9355 "extensions": extensions,
9356 "operators": operators,
9357 }))
9358 .unwrap_or_else(|_| "{}".into())
9359}
9360
9361pub fn all_canonical_names() -> Vec<String> {
9366 use std::collections::BTreeSet;
9367 let mut set: BTreeSet<String> = BTreeSet::new();
9368 for b in crate::ported::builtin::BUILTINS.iter() {
9369 set.insert(b.node.nam.clone());
9370 }
9371 for (n, t) in crate::ported::hashtable::RESWDS {
9372 if *t == crate::ported::zsh_h::TYPESET {
9373 continue;
9374 }
9375 set.insert((*n).to_string());
9376 }
9377 for o in crate::ported::options::ZSH_OPTIONS_SET.iter() {
9378 set.insert((*o).to_string());
9379 }
9380 for (name, _) in crate::zsh_special_var_docs::SPECIAL_VAR_DOCS {
9384 if name
9387 .chars()
9388 .next()
9389 .map(|c| c.is_ascii_alphabetic() || c == '_')
9390 .unwrap_or(false)
9391 {
9392 set.insert(format!("${}", name));
9393 } else {
9394 set.insert((*name).to_string());
9395 }
9396 }
9397 for n in crate::compsys::COMPSYS_FN_NAMES {
9398 set.insert((*n).to_string());
9399 }
9400 for n in crate::ext_builtins::EXT_BUILTIN_NAMES {
9401 set.insert((*n).to_string());
9402 }
9403 for n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES {
9404 set.insert((*n).to_string());
9405 }
9406 for (op, _) in OPERATOR_DOCS {
9407 set.insert((*op).to_string());
9408 }
9409 set.into_iter().collect()
9410}
9411
9412pub fn closest_name(query: &str) -> Option<String> {
9420 let names = all_canonical_names();
9421 let q_bare = query.strip_prefix('$').unwrap_or(query);
9422 let max_dist = std::cmp::max(2, q_bare.len() / 3);
9423 let mut best: Option<(usize, String)> = None;
9424 for n in names {
9425 let n_bare = n.strip_prefix('$').unwrap_or(&n);
9426 let d = edit_distance(q_bare, n_bare);
9427 if d > max_dist {
9428 continue;
9429 }
9430 match best {
9431 None => best = Some((d, n)),
9432 Some((bd, _)) if d < bd => best = Some((d, n)),
9433 _ => {}
9434 }
9435 }
9436 best.map(|(_, n)| n)
9437}
9438
9439fn edit_distance(a: &str, b: &str) -> usize {
9443 let av: Vec<char> = a.chars().collect();
9444 let bv: Vec<char> = b.chars().collect();
9445 let m = av.len();
9446 let n = bv.len();
9447 if m == 0 {
9448 return n;
9449 }
9450 if n == 0 {
9451 return m;
9452 }
9453 let mut prev: Vec<usize> = (0..=n).collect();
9454 let mut cur: Vec<usize> = vec![0; n + 1];
9455 for i in 1..=m {
9456 cur[0] = i;
9457 for j in 1..=n {
9458 let cost = if av[i - 1] == bv[j - 1] { 0 } else { 1 };
9459 cur[j] = (cur[j - 1] + 1).min(prev[j] + 1).min(prev[j - 1] + cost);
9460 }
9461 std::mem::swap(&mut prev, &mut cur);
9462 }
9463 prev[n]
9464}
9465
9466pub fn dump_reference_html() -> String {
9476 use std::fmt::Write;
9477
9478 let mut out = String::new();
9479
9480 let compat = crate::ext_builtins::compat_builtin_names();
9485 write_chapter(
9486 &mut out,
9487 "ch-lsp-compat",
9488 "Compat Builtin Index",
9489 &format!(
9490 "{} entries · zsh-faithful ports from <code>ported::builtin::BUILTINS</code>. \
9491 Each mirrors an upstream <code>Src/Builtins/*.c</code> entry 1:1, with the \
9492 hover body extracted from <code>man zshall</code> yodl. See also: \
9493 <a href=\"#ch-lsp-extensions\">Extension Builtin Index</a> for zshrs-only \
9494 additions.",
9495 compat.len()
9496 ),
9497 &compat,
9498 "compat",
9499 );
9500
9501 let keywords: Vec<String> = crate::ported::hashtable::RESWDS
9509 .iter()
9510 .map(|(n, _)| n.to_string())
9511 .collect();
9512 write_chapter(
9513 &mut out,
9514 "ch-lsp-keywords",
9515 "Keyword Index",
9516 &format!(
9517 "{} entries · zsh reserved words from <code>Src/hashtable.c</code> \
9518 <code>reswds[]</code>. Mirrors the <code>man zshmisc</code> \
9519 \"Reserved Words\" section. Declarers (<code>declare</code>, \
9520 <code>export</code>, <code>float</code>, <code>integer</code>, \
9521 <code>local</code>, <code>readonly</code>, <code>typeset</code>) \
9522 are reserved AND also appear in the Builtin Index — they're both.",
9523 keywords.len()
9524 ),
9525 &keywords,
9526 "keyword",
9527 );
9528
9529 let mut options: Vec<String> = crate::ported::options::ZSH_OPTIONS_SET
9531 .iter()
9532 .map(|s| s.to_string())
9533 .collect();
9534 options.sort();
9535 write_chapter(
9536 &mut out,
9537 "ch-lsp-options",
9538 "Option Index",
9539 &format!(
9540 "{} entries · the canonical zsh option registry. \
9541 Set / clear via <code>setopt NAME</code> / <code>unsetopt NAME</code>.",
9542 options.len()
9543 ),
9544 &options,
9545 "option",
9546 );
9547
9548 let mut specials: Vec<String> = crate::zsh_special_var_docs::SPECIAL_VAR_DOCS
9554 .iter()
9555 .map(|(name, _)| {
9556 if name
9557 .chars()
9558 .next()
9559 .map(|c| c.is_ascii_alphabetic() || c == '_')
9560 .unwrap_or(false)
9561 {
9562 format!("${}", name)
9563 } else {
9564 (*name).to_string()
9565 }
9566 })
9567 .collect();
9568 specials.sort();
9569 specials.dedup();
9570 write_chapter(
9571 &mut out,
9572 "ch-lsp-specials",
9573 "Special Variable Index",
9574 &format!(
9575 "{} entries · zsh-defined parameters and well-known env vars. \
9576 Includes both scalar (<code>$?</code>) and array (<code>$path</code>) forms.",
9577 specials.len()
9578 ),
9579 &specials,
9580 "special",
9581 );
9582
9583 let mut compsys_names: Vec<String> = crate::compsys::COMPSYS_FN_NAMES
9585 .iter()
9586 .map(|s| s.to_string())
9587 .collect();
9588 compsys_names.sort();
9589 write_chapter(
9590 &mut out,
9591 "ch-lsp-compsys",
9592 "Compsys Function Index",
9593 &format!(
9594 "{} entries · the <code>_arguments</code> / <code>_files</code> / \
9595 <code>_describe</code> family of completion functions. Native Rust \
9596 implementations in the <code>compsys</code> crate replace the \
9597 upstream zsh shell-function versions for performance.",
9598 compsys_names.len()
9599 ),
9600 &compsys_names,
9601 "compsys",
9602 );
9603
9604 let ext_names = crate::ext_builtins::extension_builtin_names();
9606 write_chapter(
9607 &mut out,
9608 "ch-lsp-extensions",
9609 "Extension Builtin Index",
9610 &format!(
9611 "{} entries · zshrs-only builtins with NO upstream zsh counterpart. \
9612 Split across in-process builtins (coreutils drop-ins, <code>async</code>/\
9613 <code>await</code>/<code>barrier</code>, <code>doctor</code>, \
9614 <code>intercept</code>, contrib autoloads) and daemon-backed <code>z*</code> \
9615 builtins (<code>zd</code>, <code>zcache</code>, <code>zls</code>, \
9616 <code>zlock</code>, <code>zpublish</code>, …) that proxy to the local \
9617 <code>zshrs-daemon</code> for cross-shell state.",
9618 ext_names.len()
9619 ),
9620 &ext_names,
9621 "extension",
9622 );
9623
9624 let op_names: Vec<String> = OPERATOR_DOCS
9626 .iter()
9627 .map(|(op, _)| (*op).to_string())
9628 .collect();
9629 write_chapter(
9630 &mut out,
9631 "ch-lsp-operators",
9632 "Operator / Punctuation Index",
9633 &format!(
9634 "{} entries · pipelines (<code>|</code>, <code>|&</code>), list ops \
9635 (<code>&&</code>, <code>||</code>, <code>;</code>, <code>&</code>, \
9636 <code>;;</code>), redirects (<code>></code>, <code>>></code>, \
9637 <code><<</code>, <code><<<</code>, <code>&></code>, …), \
9638 conditional/arithmetic openers (<code>[[</code>, <code>]]</code>, <code>((</code>, \
9639 <code>))</code>), substitution forms (<code>$(</code>, <code>${{</code>, \
9640 <code>$((</code>, <code><(</code>, <code>>(</code>), test ops \
9641 (<code>-e</code>, <code>-eq</code>, <code>=~</code>, …), pattern chars \
9642 (<code>*</code>, <code>?</code>, <code>**</code>, <code>~</code>), brace \
9643 expansion (<code>{{a,b,c}}</code>, <code>{{1..10}}</code>), and assignment \
9644 (<code>=</code>, <code>+=</code>). Sourced from <code>man zshmisc</code> \
9645 section prose — these have no per-name yodl <code>item</code> blocks so \
9646 they're hand-curated.",
9647 op_names.len()
9648 ),
9649 &op_names,
9650 "operator",
9651 );
9652
9653 out
9654}
9655
9656fn write_chapter(
9657 out: &mut String,
9658 id: &str,
9659 title: &str,
9660 meta_html: &str,
9661 names: &[String],
9662 kind: &str,
9663) {
9664 use std::fmt::Write;
9665 let _ = writeln!(
9666 out,
9667 "\n <!-- ════════════════════════════════════════════════════════════════════ -->\n\
9668 \n <section class=\"tutorial-section\" id=\"{id}\">\n\
9669 \n <h2>{title}</h2>\n\
9670 \n <p class=\"chapter-meta\">{meta_html}</p>",
9671 );
9672 for n in names {
9673 let body = lookup_doc(n);
9674 let body_only = body.split_once("\n\n").map(|(_, b)| b).unwrap_or("");
9678 let anchor = anchor_for(kind, n);
9679 let _ = writeln!(
9680 out,
9681 "\n <article class=\"doc-entry\" id=\"{anchor}\">\n\
9682 \n <h3><code>{}</code> <a class=\"doc-anchor\" href=\"#{anchor}\">¶</a></h3>\n\
9683 {} </article>",
9684 html_escape(n),
9685 md_to_html(body_only),
9686 );
9687 }
9688 out.push_str("\n </section>\n");
9689}
9690
9691fn anchor_for(kind: &str, name: &str) -> String {
9692 let mut slug = String::new();
9698 for c in name.chars() {
9699 match c {
9700 c if c.is_ascii_alphanumeric() => slug.push(c),
9701 '_' => slug.push('_'),
9702 '-' => slug.push_str("dash"),
9703 ':' => slug.push_str("colon"),
9704 '.' => slug.push_str("dot"),
9705 '[' => slug.push_str("lbracket"),
9706 ']' => slug.push_str("rbracket"),
9707 '(' => slug.push_str("lparen"),
9708 ')' => slug.push_str("rparen"),
9709 '{' => slug.push_str("lbrace"),
9710 '}' => slug.push_str("rbrace"),
9711 '?' => slug.push_str("qmark"),
9712 '!' => slug.push_str("bang"),
9713 '$' => slug.push_str("dollar"),
9714 '#' => slug.push_str("hash"),
9715 '*' => slug.push_str("star"),
9716 '@' => slug.push_str("at"),
9717 '/' => slug.push_str("slash"),
9718 '+' => slug.push_str("plus"),
9719 '=' => slug.push_str("eq"),
9720 _ => slug.push('-'),
9721 }
9722 }
9723 let slug = slug.trim_matches('-').to_string();
9724 if slug.is_empty() {
9725 format!("doc-lsp-{}-unnamed", kind)
9726 } else {
9727 format!("doc-lsp-{}-{}", kind, slug)
9728 }
9729}
9730
9731fn html_escape(s: &str) -> String {
9732 let mut out = String::with_capacity(s.len());
9733 for c in s.chars() {
9734 match c {
9735 '&' => out.push_str("&"),
9736 '<' => out.push_str("<"),
9737 '>' => out.push_str(">"),
9738 '"' => out.push_str("""),
9739 _ => out.push(c),
9740 }
9741 }
9742 out
9743}
9744
9745fn md_to_html(s: &str) -> String {
9752 use std::fmt::Write;
9753 let mut out = String::new();
9754 for para in s.split("\n\n") {
9755 let para = para.trim_matches('\n');
9756 if para.is_empty() {
9757 continue;
9758 }
9759 let joined: String = para
9762 .split('\n')
9763 .map(str::trim_end)
9764 .collect::<Vec<_>>()
9765 .join(" ");
9766 let _ = writeln!(out, " <p>{}</p>", inline_md(&joined));
9767 }
9768 out
9769}
9770
9771fn inline_md(s: &str) -> String {
9772 let bytes = s.as_bytes();
9776 let mut out = String::with_capacity(s.len() + 16);
9777 let mut i = 0;
9778 while i < bytes.len() {
9779 let c = bytes[i] as char;
9780 if c == '`' {
9782 out.push_str("<code>");
9783 i += 1;
9784 while i < bytes.len() && bytes[i] != b'`' {
9785 i = push_html_escaped(&mut out, bytes, i);
9786 }
9787 out.push_str("</code>");
9788 if i < bytes.len() {
9789 i += 1; }
9791 continue;
9792 }
9793 if c == '*' && i + 1 < bytes.len() && bytes[i + 1] as char == '*' {
9795 if let Some(end) = find_close(bytes, i + 2, b"**") {
9796 out.push_str("<strong>");
9797 out.push_str(&inline_md(
9798 std::str::from_utf8(&bytes[i + 2..end]).unwrap_or(""),
9799 ));
9800 out.push_str("</strong>");
9801 i = end + 2;
9802 continue;
9803 }
9804 }
9805 if c == '_'
9808 && (i == 0 || !(bytes[i - 1] as char).is_alphanumeric())
9809 && i + 1 < bytes.len()
9810 && !(bytes[i + 1] as char).is_whitespace()
9811 {
9812 if let Some(end) = find_close(bytes, i + 1, b"_") {
9813 let after_ok =
9814 end + 1 >= bytes.len() || !(bytes[end + 1] as char).is_alphanumeric();
9815 if after_ok {
9816 out.push_str("<em>");
9817 out.push_str(&inline_md(
9818 std::str::from_utf8(&bytes[i + 1..end]).unwrap_or(""),
9819 ));
9820 out.push_str("</em>");
9821 i = end + 1;
9822 continue;
9823 }
9824 }
9825 }
9826 i = push_html_escaped(&mut out, bytes, i);
9830 }
9831 out
9832}
9833
9834fn push_html_escaped(out: &mut String, bytes: &[u8], i: usize) -> usize {
9840 let b = bytes[i];
9841 if b < 0x80 {
9842 match b {
9843 b'&' => out.push_str("&"),
9844 b'<' => out.push_str("<"),
9845 b'>' => out.push_str(">"),
9846 _ => out.push(b as char),
9847 }
9848 i + 1
9849 } else {
9850 let len = match b {
9851 0xF0..=0xF4 => 4,
9852 0xE0..=0xEF => 3,
9853 0xC0..=0xDF => 2,
9854 _ => 1,
9855 };
9856 let end = (i + len).min(bytes.len());
9857 out.push_str(std::str::from_utf8(&bytes[i..end]).unwrap_or("\u{fffd}"));
9858 end
9859 }
9860}
9861
9862fn find_close(bytes: &[u8], start: usize, needle: &[u8]) -> Option<usize> {
9863 let mut i = start;
9864 while i + needle.len() <= bytes.len() {
9865 if &bytes[i..i + needle.len()] == needle {
9866 return Some(i);
9867 }
9868 i += 1;
9869 }
9870 None
9871}
9872
9873#[cfg(test)]
9874mod tests {
9875 use super::*;
9876
9877 #[test]
9880 fn compsys_flag_table_covers_wanted_with_all_six_flags() {
9881 let flags = extract_builtin_flags("_wanted");
9886 let names: Vec<&str> = flags.iter().map(|(f, _)| f.as_str()).collect();
9887 assert_eq!(
9888 names,
9889 vec!["-x", "-C", "-1", "-2", "-V", "-J"],
9890 "_wanted flag set drifted from man zshcompsys signature",
9891 );
9892 for (f, d) in &flags {
9893 assert!(!d.is_empty(), "flag {f} has no description");
9894 }
9895 }
9896
9897 #[test]
9898 fn compsys_flag_table_overrides_bullet_scraper_for_arguments() {
9899 let flags = extract_builtin_flags("_arguments");
9904 assert!(
9905 flags.len() >= 12,
9906 "expected _arguments to surface >=12 flags from man-derived table, got {}",
9907 flags.len()
9908 );
9909 let names: std::collections::HashSet<&str> =
9912 flags.iter().map(|(f, _)| f.as_str()).collect();
9913 for must_have in ["-n", "-s", "-w", "-W", "-C", "-R", "-S", "-A", "-O", "-M"] {
9914 assert!(
9915 names.contains(must_have),
9916 "_arguments missing canonical flag {must_have}"
9917 );
9918 }
9919 }
9920
9921 #[test]
9922 fn every_compsys_fn_in_man_is_in_inventory() {
9923 for name in [
9931 "_call_program",
9932 "_canonical_paths",
9933 "_combination",
9934 "_command_names",
9935 "_completers",
9936 "_dir_list",
9937 "_email_addresses",
9938 "_multi_parts",
9939 "_numbers",
9940 "_pick_variant",
9941 "_sequence",
9942 "_tags",
9943 "_widgets",
9944 ] {
9945 assert!(
9946 crate::compsys::COMPSYS_FN_NAMES.contains(&name),
9947 "{name}: missing from COMPSYS_FN_NAMES inventory",
9948 );
9949 }
9950 assert!(is_known_builtin_with_flag_docs("_canonical_paths"));
9951 assert!(is_known_builtin_with_flag_docs("_widgets"));
9952 assert!(is_known_builtin_with_flag_docs("_call_program"));
9953 }
9954
9955 #[test]
9958 fn word_at_middle_of_identifier() {
9959 let _g = crate::test_util::global_state_lock();
9960 let src = "cd /tmp\nlocal x=1\n";
9961 assert_eq!(word_at(src, 0, 1), Some("cd".into()));
9962 assert_eq!(word_at(src, 0, 2), Some("cd".into()));
9964 }
9965
9966 #[test]
9967 fn word_at_includes_dollar_prefix() {
9968 let _g = crate::test_util::global_state_lock();
9969 let src = "echo $HOME\n";
9970 assert_eq!(word_at(src, 0, 6), Some("$HOME".into()));
9971 }
9972
9973 #[test]
9981 fn word_at_extends_through_hyphen_for_function_name() {
9982 let _g = crate::test_util::global_state_lock();
9983 let src = "daemon-ping arg\n";
9984 assert_eq!(word_at(src, 0, 0), Some("daemon-ping".into()));
9986 assert_eq!(word_at(src, 0, 3), Some("daemon-ping".into()));
9987 assert_eq!(word_at(src, 0, 7), Some("daemon-ping".into()));
9989 assert_eq!(word_at(src, 0, 10), Some("daemon-ping".into()));
9990 }
9991
9992 #[test]
9993 fn word_at_extends_through_multiple_hyphens() {
9994 let _g = crate::test_util::global_state_lock();
9995 let src = "daemon-job-submit -- cmd\n";
9996 assert_eq!(word_at(src, 0, 8), Some("daemon-job-submit".into()));
9997 assert_eq!(word_at(src, 0, 13), Some("daemon-job-submit".into()));
9998 }
9999
10000 #[test]
10001 fn word_at_dollar_var_does_not_extend_through_hyphen() {
10002 let _g = crate::test_util::global_state_lock();
10003 let src = "echo $x-y suffix\n";
10004 assert_eq!(word_at(src, 0, 6), Some("$x".into()));
10007 }
10008
10009 #[test]
10010 fn word_at_braced_var_does_not_extend_through_hyphen() {
10011 let _g = crate::test_util::global_state_lock();
10012 let src = "echo ${x-default}\n";
10013 assert_eq!(word_at(src, 0, 7), Some("x".into()));
10016 }
10017
10018 #[test]
10019 fn word_at_returns_none_off_word() {
10020 let _g = crate::test_util::global_state_lock();
10021 let src = "echo hi\n";
10022 assert!(matches!(word_at(src, 0, 5), None | Some(_)));
10024 assert_eq!(word_at(src, 0, 999), None);
10026 }
10027
10028 #[test]
10031 fn user_doc_attaches_to_function_with_keyword_form() {
10032 let src = "## Print a hello banner with the user's name.\n\
10036 ## Used by the README demo.\n\
10037 function greet {\n print hi\n}\n";
10038 let doc = super::find_user_symbol_doc(src, "greet").expect("doc");
10039 assert!(doc.contains("Print a hello banner"), "got {doc:?}");
10040 assert!(doc.contains("Used by the README demo"), "got {doc:?}");
10041 assert!(doc.contains("\n\n---\n\n"), "missing divider: {doc:?}");
10042 assert!(doc.contains("user-defined function `greet`"), "got {doc:?}");
10043 }
10044
10045 #[test]
10046 fn user_doc_attaches_to_posix_function_form() {
10047 let src = "## Sum two integers.\n\
10048 add() {\n print $(( $1 + $2 ))\n}\n";
10049 let doc = super::find_user_symbol_doc(src, "add").expect("doc");
10050 assert!(doc.contains("Sum two integers"), "got {doc:?}");
10051 assert!(doc.contains("user-defined function"), "got {doc:?}");
10052 }
10053
10054 #[test]
10055 fn user_doc_attaches_to_alias() {
10056 let src = "## Short for `ls --color=auto -lAh`.\nalias ll='ls --color=auto -lAh'\n";
10057 let doc = super::find_user_symbol_doc(src, "ll").expect("doc");
10058 assert!(doc.contains("Short for"), "got {doc:?}");
10059 assert!(doc.contains("user-defined alias"), "got {doc:?}");
10060 }
10061
10062 #[test]
10063 fn user_doc_attaches_to_typeset_parameter() {
10064 let src = "## Maximum retries before giving up.\ntypeset -i MAX_RETRIES=5\n";
10065 let doc = super::find_user_symbol_doc(src, "MAX_RETRIES").expect("doc");
10066 assert!(doc.contains("Maximum retries"), "got {doc:?}");
10067 assert!(doc.contains("user-defined parameter"), "got {doc:?}");
10068 }
10069
10070 #[test]
10071 fn user_doc_multi_paragraph_with_blank_double_hash() {
10072 let src = "## First paragraph describing what the function does.\n\
10074 ##\n\
10075 ## Second paragraph about edge cases.\n\
10076 function foo() {}\n";
10077 let doc = super::find_user_symbol_doc(src, "foo").expect("doc");
10078 assert!(doc.contains("First paragraph"), "got {doc:?}");
10079 assert!(doc.contains("Second paragraph"), "got {doc:?}");
10080 assert!(doc.contains("\n\n"), "expected paragraph break: {doc:?}");
10082 }
10083
10084 #[test]
10085 fn user_doc_skips_blank_lines_between_block_and_def() {
10086 let src = "## Doc for the function.\n\nfunction f() {}\n";
10088 let doc = super::find_user_symbol_doc(src, "f").expect("doc");
10089 assert!(doc.contains("Doc for the function"), "got {doc:?}");
10090 }
10091
10092 #[test]
10093 fn user_doc_ignores_single_hash_comments() {
10094 let src = "# This is just a code comment, not a docstring.\n\
10101 function f() {}\n";
10102 let card = super::find_user_symbol_doc(src, "f").expect("minimal card");
10103 assert!(
10104 !card.contains("just a code comment"),
10105 "plain `#` comments must not leak into doc card: {card:?}"
10106 );
10107 }
10108
10109 #[test]
10110 fn user_doc_returns_none_when_symbol_absent() {
10111 let src = "## Doc for greet.\nfunction greet() {}\n";
10112 assert!(super::find_user_symbol_doc(src, "nonexistent").is_none());
10113 }
10114
10115 #[test]
10116 fn user_doc_returns_none_when_no_doc_block() {
10117 let src = "function greet() {}\n";
10121 let card = super::find_user_symbol_doc(src, "greet").expect("minimal card");
10122 assert!(
10123 card.contains("greet"),
10124 "card must name the symbol: {card:?}"
10125 );
10126 }
10127
10128 #[test]
10129 fn user_doc_stops_at_intervening_single_hash_line() {
10130 let src = "## Real doc here.\n\
10136 # Inline comment unrelated to the doc.\n\
10137 function f() {}\n";
10138 let card = super::find_user_symbol_doc(src, "f").expect("minimal card");
10139 assert!(
10140 !card.contains("Real doc here"),
10141 "intervening `#` must break doc collection: {card:?}"
10142 );
10143 }
10144
10145 #[test]
10148 fn module_doc_collects_top_block_after_shebang() {
10149 let src = "#!/usr/bin/env zsh\n\
10150 ## foo.zsh — short summary.\n\
10151 ## Provides foo, bar, baz helpers.\n\
10152 \n\
10153 function foo() {}\n";
10154 let doc = super::extract_module_doc(src).expect("doc");
10155 assert!(doc.contains("foo.zsh"), "got {doc:?}");
10156 assert!(doc.contains("Provides foo"), "got {doc:?}");
10157 }
10158
10159 #[test]
10160 fn module_doc_collects_top_block_without_shebang() {
10161 let src = "## bar.zsh — utility library.\n\
10162 function bar() {}\n";
10163 let doc = super::extract_module_doc(src).expect("doc");
10164 assert!(doc.contains("bar.zsh"), "got {doc:?}");
10165 }
10166
10167 #[test]
10168 fn module_doc_supports_double_hash_paragraph_breaks() {
10169 let src = "## First paragraph of the module summary.\n\
10170 ##\n\
10171 ## Second paragraph with details.\n\
10172 function foo() {}\n";
10173 let doc = super::extract_module_doc(src).expect("doc");
10174 assert!(doc.contains("First paragraph"), "got {doc:?}");
10175 assert!(doc.contains("Second paragraph"), "got {doc:?}");
10176 assert!(doc.contains("\n\n"), "expected paragraph break: {doc:?}");
10177 }
10178
10179 #[test]
10180 fn module_doc_skips_blank_lines_between_shebang_and_block() {
10181 let src = "#!/usr/bin/env zsh\n\
10182 \n\
10183 \n\
10184 ## Module doc starts here.\n\
10185 function foo() {}\n";
10186 let doc = super::extract_module_doc(src).expect("doc");
10187 assert!(doc.contains("Module doc starts here"), "got {doc:?}");
10188 }
10189
10190 #[test]
10191 fn module_doc_returns_none_when_no_block() {
10192 let src = "#!/usr/bin/env zsh\nfunction foo() {}\n";
10193 assert!(super::extract_module_doc(src).is_none());
10194 }
10195
10196 #[test]
10197 fn module_doc_returns_none_for_plain_single_hash_comments() {
10198 let src = "#!/usr/bin/env zsh\n\
10200 # This is a regular code comment, not a docstring.\n\
10201 function foo() {}\n";
10202 assert!(super::extract_module_doc(src).is_none());
10203 }
10204
10205 #[test]
10206 fn module_doc_stops_at_first_real_code_line() {
10207 let src = "## Module doc line 1.\n\
10208 function foo() {}\n\
10209 ## This should NOT be collected — below the def.\n";
10210 let doc = super::extract_module_doc(src).expect("doc");
10211 assert_eq!(doc, "Module doc line 1.");
10212 }
10213
10214 #[test]
10217 fn scan_symbols_finds_function_keyword_form() {
10218 let _g = crate::test_util::global_state_lock();
10219 let src = "function greet {\n print hi\n}\n";
10220 let s = scan_symbols(src);
10221 assert!(s.iter().any(|(n, k, _)| n == "greet" && *k == "function"));
10222 }
10223
10224 #[test]
10225 fn scan_symbols_finds_paren_form() {
10226 let _g = crate::test_util::global_state_lock();
10227 let src = "foo() {\n :\n}\n";
10228 let s = scan_symbols(src);
10229 assert!(s.iter().any(|(n, k, _)| n == "foo" && *k == "function"));
10230 }
10231
10232 #[test]
10233 fn scan_symbols_finds_locals_and_aliases() {
10234 let _g = crate::test_util::global_state_lock();
10235 let src = "local x=1\nalias ll='ls -la'\nexport PATH=/bin\n";
10236 let s = scan_symbols(src);
10237 assert!(s.iter().any(|(n, k, _)| n == "x" && *k == "variable"));
10238 assert!(s.iter().any(|(n, k, _)| n == "ll" && *k == "alias"));
10239 assert!(s.iter().any(|(n, k, _)| n == "PATH" && *k == "variable"));
10240 }
10241
10242 #[test]
10243 fn scan_symbols_ignores_comments() {
10244 let _g = crate::test_util::global_state_lock();
10245 let src = "# function fake { }\n# alias evil=rm\n: real\n";
10246 let s = scan_symbols(src);
10247 assert!(s.is_empty(), "scan_symbols leaked comment content: {:?}", s);
10248 }
10249
10250 #[test]
10253 fn lookup_doc_returns_markdown_for_known_builtin() {
10254 let _g = crate::test_util::global_state_lock();
10255 let doc = lookup_doc("cd");
10256 assert!(doc.starts_with("**cd**"), "got: {}", doc);
10257 assert!(
10259 doc.contains("Change the current directory"),
10260 "expected upstream cd prose; got: {}",
10261 doc
10262 );
10263 }
10264
10265 #[test]
10266 fn lookup_doc_handles_keywords_and_special_vars() {
10267 let _g = crate::test_util::global_state_lock();
10268 assert!(
10270 lookup_doc("if").contains("zero exit status"),
10271 "expected upstream if prose; got: {}",
10272 lookup_doc("if")
10273 );
10274 assert!(
10276 lookup_doc("$?").contains("exit status"),
10277 "expected $? doc; got: {}",
10278 lookup_doc("$?")
10279 );
10280 }
10281
10282 #[test]
10283 fn lookup_doc_handles_pure_symbolic_specials() {
10284 let _g = crate::test_util::global_state_lock();
10289 let s = lookup_doc("$");
10291 assert!(
10292 !s.is_empty() && s.contains("process ID"),
10293 "lookup_doc('$') should return the PID doc; got: {:?}",
10294 s,
10295 );
10296 for sym in &["$?", "$*", "$#", "$@", "$-", "$_", "$!"] {
10299 let card = lookup_doc(sym);
10300 assert!(
10301 !card.is_empty(),
10302 "lookup_doc({:?}) returned empty; pure-symbolic specials must resolve",
10303 sym,
10304 );
10305 }
10306 }
10307
10308 #[test]
10309 fn lookup_doc_special_var_wins_over_module_builtin_entry() {
10310 let _g = crate::test_util::global_state_lock();
10320 for name in &[
10325 "prompt",
10326 "path",
10327 "aliases",
10328 "jobdirs",
10329 "jobstates",
10330 "commands",
10331 "modules",
10332 "widgets",
10333 ] {
10334 let card = lookup_doc(name);
10335 assert!(
10336 card.contains("special variable"),
10337 "lookup_doc({:?}) classified as: {:?} — expected 'special variable'",
10338 name,
10339 card.lines().take(3).collect::<Vec<_>>(),
10340 );
10341 }
10342 for name in &[
10348 "cd",
10349 "echo",
10350 "set",
10351 "shift",
10352 "unset",
10353 "functions",
10354 "history",
10355 ] {
10356 let card = lookup_doc(name);
10357 assert!(
10358 card.contains("zsh builtin") || card.contains("zshrs"),
10359 "lookup_doc({:?}) lost its builtin classification: {:?}",
10360 name,
10361 card.lines().take(3).collect::<Vec<_>>(),
10362 );
10363 }
10364 }
10365
10366 #[test]
10367 fn lookup_doc_empty_for_unknown() {
10368 let _g = crate::test_util::global_state_lock();
10369 assert_eq!(lookup_doc("definitely_not_a_zsh_thing_xx"), "");
10370 }
10371
10372 #[test]
10375 fn diagnose_clean_file_returns_no_diagnostics() {
10376 let _g = crate::test_util::global_state_lock();
10377 let src = "if [[ -d /tmp ]]; then\n echo ok\nfi\n";
10378 let d = diagnose(src);
10379 assert!(d.is_empty(), "diagnose flagged clean file: {:?}", d);
10380 }
10381
10382 #[test]
10383 fn diagnose_flags_unmatched_brace() {
10384 let _g = crate::test_util::global_state_lock();
10385 let src = "function broken {\n echo missing close\n";
10386 let d = diagnose(src);
10387 assert!(
10388 d.iter()
10389 .any(|v| v["message"].as_str().unwrap_or("").contains("unclosed `{`")),
10390 "expected unclosed-brace diagnostic, got: {:?}",
10391 d
10392 );
10393 }
10394
10395 #[test]
10396 fn diagnose_flags_unclosed_if_block() {
10397 let _g = crate::test_util::global_state_lock();
10398 let src = "if true\nthen\necho\n";
10399 let d = diagnose(src);
10400 assert!(
10401 d.iter().any(|v| v["message"]
10402 .as_str()
10403 .unwrap_or("")
10404 .contains("unclosed `if`")),
10405 "expected unclosed-if diagnostic, got: {:?}",
10406 d
10407 );
10408 }
10409
10410 #[test]
10411 fn diagnose_ignores_braces_inside_strings() {
10412 let _g = crate::test_util::global_state_lock();
10413 let src = "echo \"a } b\" '{ }' \n";
10414 let d = diagnose(src);
10415 assert!(
10416 d.is_empty(),
10417 "string-internal braces tripped diagnose: {:?}",
10418 d
10419 );
10420 }
10421
10422 #[test]
10428 fn diagnose_ignores_block_keywords_inside_comments() {
10429 let _g = crate::test_util::global_state_lock();
10430 let src = "# operations — length, case, slice, concat, search.\nx=1\n";
10431 let d = diagnose(src);
10432 assert!(
10433 d.is_empty(),
10434 "block keyword `case` inside comment flagged: {:?}",
10435 d
10436 );
10437 }
10438
10439 #[test]
10442 fn diagnose_ignores_all_block_keywords_in_comments_and_strings() {
10443 let _g = crate::test_util::global_state_lock();
10444 let src = "# if for while until case select repeat\n\
10445 echo \"if for while until case select repeat\"\n\
10446 x=1\n";
10447 let d = diagnose(src);
10448 assert!(
10449 d.is_empty(),
10450 "block keywords in comments/strings flagged: {:?}",
10451 d
10452 );
10453 }
10454
10455 #[test]
10459 fn diagnose_ignores_terminators_inside_comments() {
10460 let _g = crate::test_util::global_state_lock();
10461 let src = "# fi done esac comment\nx=1\n";
10462 let d = diagnose(src);
10463 assert!(
10464 d.is_empty(),
10465 "terminator keywords in comments flagged: {:?}",
10466 d
10467 );
10468 }
10469
10470 #[test]
10477 fn diagnose_done_with_trailing_semicolon_pops_for() {
10478 let _g = crate::test_util::global_state_lock();
10479 let src = "for i in 1 2 3; do echo $i; done;\n";
10480 let d = diagnose(src);
10481 assert!(d.is_empty(), "`done;` must pop for: {:?}", d);
10482 }
10483
10484 #[test]
10489 fn diagnose_three_done_with_trailing_semicolon_pops_three_fors() {
10490 let _g = crate::test_util::global_state_lock();
10491 let src = "for a in 1; do for b in 1; do for c in 1; do echo $a$b$c; done; done; done\n";
10492 let d = diagnose(src);
10493 assert!(d.is_empty(), "three `done;` must pop three `for`: {:?}", d);
10494 }
10495
10496 #[test]
10499 fn diagnose_fi_with_trailing_semicolon_pops_if() {
10500 let _g = crate::test_util::global_state_lock();
10501 let src = "if true; then echo hi; fi;\n";
10502 let d = diagnose(src);
10503 assert!(d.is_empty(), "`fi;` must pop if: {:?}", d);
10504 }
10505
10506 #[test]
10511 fn diagnose_oneliner_case_arm_paren_not_flagged() {
10512 let _g = crate::test_util::global_state_lock();
10513 let src = "case foo in foo|bar) echo yes ;; *) echo no ;; esac\n";
10514 let d = diagnose(src);
10515 assert!(d.is_empty(), "one-liner case-arm `)` flagged: {:?}", d);
10516 }
10517
10518 #[test]
10525 fn diagnose_param_subst_with_escaped_brackets_no_flag() {
10526 let _g = crate::test_util::global_state_lock();
10527 let src = "level=${log_line#\\[}; date=${log_line#*\\][}\n";
10528 let d = diagnose(src);
10529 assert!(
10530 d.is_empty(),
10531 "param-subst with escaped brackets flagged: {:?}",
10532 d
10533 );
10534 }
10535
10536 #[test]
10544 fn diagnose_select_as_argument_not_block_opener() {
10545 let _g = crate::test_util::global_state_lock();
10546 let src = "zstyle ':completion:*' menu select\n";
10547 let d = diagnose(src);
10548 assert!(d.is_empty(), "`select` as builtin arg flagged: {:?}", d);
10549 }
10550
10551 #[test]
10554 fn diagnose_select_block_form_balances() {
10555 let _g = crate::test_util::global_state_lock();
10556 let src = "select x in a b c; do echo $x; break; done\n";
10557 let d = diagnose(src);
10558 assert!(
10559 d.is_empty(),
10560 "block-form `select … do … done` flagged: {:?}",
10561 d
10562 );
10563 }
10564
10565 #[test]
10572 fn find_user_symbol_doc_returns_minimal_card_when_no_docblock() {
10573 let _g = crate::test_util::global_state_lock();
10574 let src = "greet() {\n echo hello\n}\n";
10575 let r = find_user_symbol_doc(src, "greet").expect("UDF must hover");
10576 assert!(
10577 r.contains("user-defined function"),
10578 "card must label kind, got {:?}",
10579 r
10580 );
10581 assert!(
10582 r.contains("greet"),
10583 "card must include the symbol name, got {:?}",
10584 r
10585 );
10586 }
10587
10588 #[test]
10591 fn find_user_symbol_doc_minimal_card_for_user_variable() {
10592 let _g = crate::test_util::global_state_lock();
10593 let src = "fn() {\n local start=$EPOCHREALTIME\n}\n";
10594 let r = find_user_symbol_doc(src, "start").expect("user var must hover");
10595 assert!(
10596 r.contains("user-defined parameter") || r.contains("user-defined variable"),
10597 "card must label parameter/variable kind, got {:?}",
10598 r
10599 );
10600 assert!(
10601 r.contains("start"),
10602 "card must include the var name, got {:?}",
10603 r
10604 );
10605 }
10606
10607 #[test]
10610 fn find_user_symbol_doc_prefers_documented_definition() {
10611 let _g = crate::test_util::global_state_lock();
10612 let src = "## says hi\ngreet() {\n echo hi\n}\n";
10613 let r = find_user_symbol_doc(src, "greet").expect("UDF must hover");
10614 assert!(
10615 r.contains("says hi"),
10616 "documented form must include the doc block, got {:?}",
10617 r
10618 );
10619 }
10620
10621 #[test]
10628 fn semantic_tokens_brace_range_emits_single_operator_span() {
10629 let _g = crate::test_util::global_state_lock();
10630 let mut state = State::default();
10631 let uri = "file:///t.zsh";
10632 state
10633 .docs
10634 .insert(uri.to_string(), "echo {a..e}\n".to_string());
10635 let r = semantic_tokens(&state, &json!({ "textDocument": { "uri": uri } }));
10636 let data = r["data"].as_array().expect("data array");
10637 let mut found = false;
10640 for chunk in data.chunks(5) {
10641 if chunk.len() == 5 && chunk[2].as_u64() == Some(6) && chunk[3].as_u64() == Some(4) {
10642 found = true;
10643 break;
10644 }
10645 }
10646 assert!(
10647 found,
10648 "brace range `{{a..e}}` must emit a single 6-byte operator token, got data={:?}",
10649 data
10650 );
10651 }
10652
10653 #[test]
10657 fn semantic_tokens_brace_range_uppercase_endpoints_emit_operator() {
10658 let _g = crate::test_util::global_state_lock();
10659 let mut state = State::default();
10660 let uri = "file:///t.zsh";
10661 state
10662 .docs
10663 .insert(uri.to_string(), "echo {A..E}\n".to_string());
10664 let r = semantic_tokens(&state, &json!({ "textDocument": { "uri": uri } }));
10665 let data = r["data"].as_array().expect("data array");
10666 let mut found = false;
10667 for chunk in data.chunks(5) {
10668 if chunk.len() == 5 && chunk[2].as_u64() == Some(6) && chunk[3].as_u64() == Some(4) {
10669 found = true;
10670 break;
10671 }
10672 }
10673 assert!(
10674 found,
10675 "brace range `{{A..E}}` must emit a single 6-byte operator token, got data={:?}",
10676 data
10677 );
10678 }
10679
10680 #[test]
10689 fn semantic_tokens_arith_in_string_breaks_interior_into_atoms() {
10690 let _g = crate::test_util::global_state_lock();
10691 let mut state = State::default();
10692 let uri = "file:///t.zsh";
10693 state
10694 .docs
10695 .insert(uri.to_string(), "echo \"$(( -7 % 3 ))\"\n".to_string());
10696 let r = semantic_tokens(&state, &json!({ "textDocument": { "uri": uri } }));
10697 let data = r["data"].as_array().expect("data array");
10698 let mut numbers = 0usize;
10705 let mut operators = 0usize;
10706 for chunk in data.chunks(5) {
10707 if chunk.len() == 5 {
10708 match chunk[3].as_u64() {
10709 Some(2) => numbers += 1,
10710 Some(4) => operators += 1,
10711 _ => {}
10712 }
10713 }
10714 }
10715 assert!(
10716 numbers >= 2,
10717 "expected ≥2 number tokens inside $((…)), got {numbers}; data={:?}",
10718 data
10719 );
10720 assert!(
10721 operators >= 3,
10722 "expected ≥3 operator tokens ($((, %, )) ) inside $((…)), got {operators}; data={:?}",
10723 data
10724 );
10725 }
10726
10727 #[test]
10733 fn semantic_tokens_long_cli_flag_emits_operator_span() {
10734 let _g = crate::test_util::global_state_lock();
10735 let mut state = State::default();
10736 let uri = "file:///t.zsh";
10737 state.docs.insert(
10738 uri.to_string(),
10739 "parse_demo --verbose --debug arg1\n".to_string(),
10740 );
10741 let r = semantic_tokens(&state, &json!({ "textDocument": { "uri": uri } }));
10742 let data = r["data"].as_array().expect("data array");
10743 let mut saw_9_op = false;
10746 let mut saw_7_op = false;
10747 for chunk in data.chunks(5) {
10748 if chunk.len() == 5 && chunk[3].as_u64() == Some(4) {
10749 match chunk[2].as_u64() {
10750 Some(9) => saw_9_op = true,
10751 Some(7) => saw_7_op = true,
10752 _ => {}
10753 }
10754 }
10755 }
10756 assert!(
10757 saw_9_op,
10758 "`--verbose` (9 bytes) must emit an operator token, got {:?}",
10759 data
10760 );
10761 assert!(
10762 saw_7_op,
10763 "`--debug` (7 bytes) must emit an operator token, got {:?}",
10764 data
10765 );
10766 }
10767
10768 #[test]
10771 fn semantic_tokens_brace_range_with_step_emits_operator() {
10772 let _g = crate::test_util::global_state_lock();
10773 let mut state = State::default();
10774 let uri = "file:///t.zsh";
10775 state
10776 .docs
10777 .insert(uri.to_string(), "echo {1..10..2}\n".to_string());
10778 let r = semantic_tokens(&state, &json!({ "textDocument": { "uri": uri } }));
10779 let data = r["data"].as_array().expect("data array");
10780 let mut found = false;
10781 for chunk in data.chunks(5) {
10782 if chunk.len() == 5 && chunk[2].as_u64() == Some(10) && chunk[3].as_u64() == Some(4) {
10783 found = true;
10784 break;
10785 }
10786 }
10787 assert!(
10788 found,
10789 "brace range `{{1..10..2}}` must emit a single 10-byte operator token, got data={:?}",
10790 data
10791 );
10792 }
10793
10794 #[test]
10799 fn find_user_symbol_doc_documented_wins_over_earlier_undocumented() {
10800 let _g = crate::test_util::global_state_lock();
10801 let src = "\
10802fn foo() { echo first }\n\
10803\n\
10804## second def with docs\n\
10805foo() { echo second }\n\
10806";
10807 let r = find_user_symbol_doc(src, "foo").expect("UDF must hover");
10808 assert!(
10809 r.contains("second def with docs"),
10810 "documented later-def must win, got {:?}",
10811 r
10812 );
10813 }
10814
10815 #[test]
10820 fn diagnose_accepts_every_examples_demo_zsh_file() {
10821 let _g = crate::test_util::global_state_lock();
10822 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
10823 let demos_dir = manifest_dir.join("examples").join("demos");
10824 let mut failures: Vec<(String, Vec<Value>)> = Vec::new();
10825 let entries = std::fs::read_dir(&demos_dir).expect("examples/demos must exist");
10826 let mut total = 0usize;
10827 for entry in entries {
10828 let path = entry.unwrap().path();
10829 if path.extension().and_then(|s| s.to_str()) != Some("zsh") {
10830 continue;
10831 }
10832 total += 1;
10833 let text = std::fs::read_to_string(&path).unwrap();
10834 let d = diagnose(&text);
10835 if !d.is_empty() {
10836 failures.push((path.file_name().unwrap().to_string_lossy().into_owned(), d));
10837 }
10838 }
10839 assert!(total > 0, "no demos found under {}", demos_dir.display());
10840 assert!(
10841 failures.is_empty(),
10842 "{}/{} demos flagged by LSP diagnose():\n{:#?}",
10843 failures.len(),
10844 total,
10845 failures,
10846 );
10847 }
10848
10849 #[test]
10857 fn diagnose_does_not_flag_dollar_hash_as_comment() {
10858 let _g = crate::test_util::global_state_lock();
10859 let src = "[[ $# -gt 0 ]] && echo args\n";
10863 let d = diagnose(src);
10864 assert!(d.is_empty(), "`$#` mis-handled as comment marker: {:?}", d);
10865 }
10866
10867 #[test]
10868 fn diagnose_does_not_flag_param_length_as_comment() {
10869 let _g = crate::test_util::global_state_lock();
10870 let src = "echo ${#args}\nif [[ ${#arr} -gt 0 ]]; then echo nonempty; fi\n";
10872 let d = diagnose(src);
10873 assert!(
10874 d.is_empty(),
10875 "`${{#var}}` mis-handled as comment marker: {:?}",
10876 d
10877 );
10878 }
10879
10880 #[test]
10881 fn diagnose_handles_double_bracket_as_pair() {
10882 let _g = crate::test_util::global_state_lock();
10883 let src = "[[ -n \"$x\" ]]\n";
10886 let d = diagnose(src);
10887 assert!(d.is_empty(), "`[[ ]]` mis-handled as two `[`s: {:?}", d);
10888 }
10889
10890 #[test]
10891 fn diagnose_handles_arithmetic_double_paren_as_pair() {
10892 let _g = crate::test_util::global_state_lock();
10893 let src = "(( i++ ))\n";
10895 let d = diagnose(src);
10896 assert!(d.is_empty(), "`(( ))` mis-handled as two `(`s: {:?}", d);
10897 }
10898
10899 #[test]
10900 fn diagnose_does_not_flag_case_arm_paren_as_unmatched() {
10901 let _g = crate::test_util::global_state_lock();
10902 let src = "case \"$x\" in\n -h|--help) echo usage ;;\n *) echo other ;;\nesac\n";
10905 let d = diagnose(src);
10906 assert!(d.is_empty(), "case-arm `)` flagged as unmatched: {:?}", d);
10907 }
10908
10909 #[test]
10910 fn diagnose_still_flags_unmatched_paren_outside_case() {
10911 let _g = crate::test_util::global_state_lock();
10912 let src = "echo bare )\n";
10915 let d = diagnose(src);
10916 assert!(
10917 d.iter().any(|v| v["message"]
10918 .as_str()
10919 .unwrap_or("")
10920 .contains("unmatched `)`")),
10921 "real unmatched `)` was not flagged: {:?}",
10922 d
10923 );
10924 }
10925
10926 #[test]
10934 fn format_strips_trailing_whitespace() {
10935 let _g = crate::test_util::global_state_lock();
10936 let src = "echo hi \n echo bye\t\n";
10937 let out = crate::fmt::format_source(src, &crate::fmt::FmtOptions::default());
10938 assert_eq!(out, "echo hi\necho bye\n");
10939 }
10940
10941 #[test]
10942 fn format_ensures_trailing_newline() {
10943 let _g = crate::test_util::global_state_lock();
10944 let src = "echo hi";
10945 let out = crate::fmt::format_source(src, &crate::fmt::FmtOptions::default());
10946 assert!(out.ends_with('\n'));
10947 }
10948
10949 #[test]
10952 fn dump_reflection_json_is_valid_and_has_builtins() {
10953 let _g = crate::test_util::global_state_lock();
10954 let s = dump_reflection_json();
10955 let v: Value = serde_json::from_str(&s).expect("valid JSON");
10956 assert!(v["builtins"].is_object());
10957 assert!(v["keywords"].is_object());
10958 assert!(v["options"].is_object());
10959 assert!(v["special_vars"].is_object());
10960 assert!(v["builtins"]["cd"].is_string());
10965 assert!(v["keywords"]["if"].is_string());
10966 assert!(v["options"]["EXTENDED_GLOB"].is_string());
10967 for want in ["$PS1", "$PS2", "$PS3", "$PS4", "$psvar", "$PROMPT2"] {
10973 assert!(
10974 v["special_vars"][want].is_string(),
10975 "special_vars missing `{}` — reflection should source from SPECIAL_VAR_DOCS",
10976 want,
10977 );
10978 }
10979 for want in ["$?", "$*", "$#", "$@", "$-", "$_", "$!", "$$"] {
10982 assert!(
10983 v["special_vars"][want].is_string(),
10984 "special_vars missing symbolic `{}`",
10985 want,
10986 );
10987 assert_eq!(
10988 v["all"][want].as_str(),
10989 Some("special"),
10990 "`all` lost the special-var tag for `{}` to another registry",
10991 want,
10992 );
10993 }
10994 assert!(
10997 v["options"].as_object().unwrap().len() >= 700,
10998 "dump_reflection options regressed to {}; expected full OPTION_DOCS + aliases (~750)",
10999 v["options"].as_object().unwrap().len(),
11000 );
11001 assert!(
11002 v["special_vars"].as_object().unwrap().len() >= 250,
11003 "dump_reflection special_vars regressed to {}; expected full SPECIAL_VAR_DOCS (~280)",
11004 v["special_vars"].as_object().unwrap().len(),
11005 );
11006 }
11007
11008 #[test]
11011 fn completion_offers_builtins_for_short_prefix() {
11012 let _g = crate::test_util::global_state_lock();
11013 let mut state = State::default();
11014 state.docs.insert("file:///t.zsh".into(), "cd".into());
11015 let params = json!({
11016 "textDocument": { "uri": "file:///t.zsh" },
11017 "position": { "line": 0, "character": 2 },
11018 });
11019 let result = completion(&state, ¶ms);
11020 let items = result["items"].as_array().unwrap();
11021 assert!(
11022 items.iter().any(|i| i["label"] == "cd"),
11023 "items: {:?}",
11024 items
11025 );
11026 }
11027
11028 #[test]
11029 fn completion_offers_daemon_z_star_builtins() {
11030 let _g = crate::test_util::global_state_lock();
11037 let mut state = State::default();
11038 state.docs.insert("file:///t.zsh".into(), "zwh".into());
11039 let params = json!({
11040 "textDocument": { "uri": "file:///t.zsh" },
11041 "position": { "line": 0, "character": 3 },
11042 });
11043 let result = completion(&state, ¶ms);
11044 let items = result["items"].as_array().unwrap();
11045 assert!(
11046 items.iter().any(|i| i["label"] == "zwhere"),
11047 "no `zwhere` in completion items for `zwh` prefix: {:?}",
11048 items
11049 .iter()
11050 .map(|i| i["label"].as_str().unwrap_or("?"))
11051 .collect::<Vec<_>>(),
11052 );
11053 }
11054
11055 #[test]
11056 fn completion_offers_ext_builtins_and_compsys_fns() {
11057 let _g = crate::test_util::global_state_lock();
11063 for (input, want) in &[("dat", "date"), ("_argu", "_arguments"), ("vare", "vared")] {
11064 let mut state = State::default();
11065 state.docs.insert("file:///t.zsh".into(), (*input).into());
11066 let params = json!({
11067 "textDocument": { "uri": "file:///t.zsh" },
11068 "position": { "line": 0, "character": input.len() },
11069 });
11070 let result = completion(&state, ¶ms);
11071 let items = result["items"].as_array().unwrap();
11072 assert!(
11073 items.iter().any(|i| i["label"] == *want),
11074 "no `{}` in completion for `{}` prefix: {:?}",
11075 want,
11076 input,
11077 items
11078 .iter()
11079 .map(|i| i["label"].as_str().unwrap_or("?"))
11080 .collect::<Vec<_>>(),
11081 );
11082 }
11083 }
11084
11085 #[test]
11086 fn completion_offers_snippet_templates() {
11087 let _g = crate::test_util::global_state_lock();
11093 let mut state = State::default();
11094 state.docs.insert("file:///t.zsh".into(), "if".into());
11095 let params = json!({
11096 "textDocument": { "uri": "file:///t.zsh" },
11097 "position": { "line": 0, "character": 2 },
11098 });
11099 let result = completion(&state, ¶ms);
11100 let items = result["items"].as_array().unwrap();
11101 let snippet = items
11102 .iter()
11103 .find(|i| i["label"].as_str() == Some("if …"))
11104 .unwrap_or_else(|| panic!("no `if …` snippet in items: {:?}", items));
11105 assert_eq!(snippet["kind"], 15, "snippet kind must be 15 (Snippet)");
11106 assert_eq!(
11107 snippet["insertTextFormat"], 2,
11108 "snippet insertTextFormat must be 2 (Snippet — placeholders honored)"
11109 );
11110 let body = snippet["insertText"].as_str().unwrap();
11111 assert!(
11112 body.contains("then") && body.contains("fi"),
11113 "snippet body wrong: {}",
11114 body
11115 );
11116 }
11117
11118 #[test]
11119 fn completion_suppressed_inside_double_quoted_literal() {
11120 let _g = crate::test_util::global_state_lock();
11125 let mut state = State::default();
11126 state
11127 .docs
11128 .insert("file:///t.zsh".into(), r#"echo "hello if"#.into());
11129 let params = json!({
11130 "textDocument": { "uri": "file:///t.zsh" },
11131 "position": { "line": 0, "character": 14 },
11133 });
11134 let result = completion(&state, ¶ms);
11135 let items = result["items"].as_array().unwrap();
11136 assert!(
11137 items.is_empty(),
11138 "expected 0 items inside dq literal, got {}: {:?}",
11139 items.len(),
11140 items
11141 .iter()
11142 .take(5)
11143 .map(|i| i["label"].as_str().unwrap_or("?"))
11144 .collect::<Vec<_>>(),
11145 );
11146 }
11147
11148 #[test]
11149 fn completion_active_inside_command_substitution_inside_dq() {
11150 let _g = crate::test_util::global_state_lock();
11154 let mut state = State::default();
11155 state
11156 .docs
11157 .insert("file:///t.zsh".into(), r#"echo "x $(cd"#.into());
11158 let params = json!({
11159 "textDocument": { "uri": "file:///t.zsh" },
11160 "position": { "line": 0, "character": 12 },
11162 });
11163 let result = completion(&state, ¶ms);
11164 let items = result["items"].as_array().unwrap();
11165 assert!(
11166 items.iter().any(|i| i["label"] == "cd"),
11167 "expected `cd` to surface inside $() within dq: {:?}",
11168 items
11169 .iter()
11170 .take(5)
11171 .map(|i| i["label"].as_str().unwrap_or("?"))
11172 .collect::<Vec<_>>(),
11173 );
11174 }
11175
11176 #[test]
11177 fn completion_active_inside_backticks_inside_dq() {
11178 let _g = crate::test_util::global_state_lock();
11179 let mut state = State::default();
11180 state
11181 .docs
11182 .insert("file:///t.zsh".into(), "echo \"x `cd".into());
11183 let params = json!({
11184 "textDocument": { "uri": "file:///t.zsh" },
11185 "position": { "line": 0, "character": 11 },
11186 });
11187 let result = completion(&state, ¶ms);
11188 let items = result["items"].as_array().unwrap();
11189 assert!(
11190 items.iter().any(|i| i["label"] == "cd"),
11191 "expected `cd` to surface inside backticks within dq",
11192 );
11193 }
11194
11195 #[test]
11196 fn completion_active_inside_param_expansion_inside_dq() {
11197 let _g = crate::test_util::global_state_lock();
11200 let mut state = State::default();
11201 state
11202 .docs
11203 .insert("file:///t.zsh".into(), r#"echo "x ${P"#.into());
11204 let params = json!({
11205 "textDocument": { "uri": "file:///t.zsh" },
11206 "position": { "line": 0, "character": 11 },
11207 });
11208 let result = completion(&state, ¶ms);
11209 let items = result["items"].as_array().unwrap();
11210 assert!(
11211 !items.is_empty(),
11212 "expected non-empty items inside ${{...}} within dq",
11213 );
11214 }
11215
11216 #[test]
11217 fn completion_suppressed_inside_single_quoted_literal() {
11218 let _g = crate::test_util::global_state_lock();
11221 let mut state = State::default();
11222 state
11223 .docs
11224 .insert("file:///t.zsh".into(), "echo 'hello if".into());
11225 let params = json!({
11226 "textDocument": { "uri": "file:///t.zsh" },
11227 "position": { "line": 0, "character": 14 },
11228 });
11229 let result = completion(&state, ¶ms);
11230 let items = result["items"].as_array().unwrap();
11231 assert!(items.is_empty(), "expected 0 items inside sq literal");
11232 }
11233
11234 #[test]
11235 fn completion_suppressed_inside_comment() {
11236 let _g = crate::test_util::global_state_lock();
11238 let mut state = State::default();
11239 state
11240 .docs
11241 .insert("file:///t.zsh".into(), "# todo: if".into());
11242 let params = json!({
11243 "textDocument": { "uri": "file:///t.zsh" },
11244 "position": { "line": 0, "character": 10 },
11245 });
11246 let result = completion(&state, ¶ms);
11247 let items = result["items"].as_array().unwrap();
11248 assert!(items.is_empty(), "expected 0 items inside comment");
11249 }
11250
11251 #[test]
11252 fn completion_active_after_closing_double_quote() {
11253 let _g = crate::test_util::global_state_lock();
11257 let mut state = State::default();
11258 state
11259 .docs
11260 .insert("file:///t.zsh".into(), r#"echo "x" if"#.into());
11261 let params = json!({
11262 "textDocument": { "uri": "file:///t.zsh" },
11263 "position": { "line": 0, "character": 11 },
11264 });
11265 let result = completion(&state, ¶ms);
11266 let items = result["items"].as_array().unwrap();
11267 assert!(
11268 items.iter().any(|i| i["label"] == "if"),
11269 "expected `if` to surface after closed dq string"
11270 );
11271 }
11272
11273 #[test]
11276 fn completion_param_flags_inside_dollar_brace_paren() {
11277 let _g = crate::test_util::global_state_lock();
11283 let mut state = State::default();
11284 state.docs.insert("file:///t.zsh".into(), "echo ${(".into());
11285 let params = json!({
11286 "textDocument": { "uri": "file:///t.zsh" },
11287 "position": { "line": 0, "character": 8 },
11288 });
11289 let result = completion(&state, ¶ms);
11290 let items = result["items"].as_array().unwrap();
11291 for want in &["L", "U", "@", "#", "f", "F", "j", "s", "q"] {
11292 assert!(
11293 items.iter().any(|i| i["label"] == *want),
11294 "missing param flag `{}` in completion; got {:?}",
11295 want,
11296 items
11297 .iter()
11298 .map(|i| i["label"].as_str().unwrap_or("?"))
11299 .collect::<Vec<_>>(),
11300 );
11301 }
11302 assert!(
11304 !items
11305 .iter()
11306 .any(|i| i["label"] == "cd" || i["label"] == "if"),
11307 "param-flag context leaked normal completion: {:?}",
11308 items
11309 .iter()
11310 .take(20)
11311 .map(|i| i["label"].as_str().unwrap_or("?"))
11312 .collect::<Vec<_>>(),
11313 );
11314 }
11315
11316 #[test]
11317 fn completion_param_flags_after_partial_flag() {
11318 let _g = crate::test_util::global_state_lock();
11321 let mut state = State::default();
11322 state
11323 .docs
11324 .insert("file:///t.zsh".into(), "echo ${(b".into());
11325 let params = json!({
11326 "textDocument": { "uri": "file:///t.zsh" },
11327 "position": { "line": 0, "character": 9 },
11328 });
11329 let result = completion(&state, ¶ms);
11330 let items = result["items"].as_array().unwrap();
11331 assert!(
11332 items.len() >= 40,
11333 "expected full flag table (40+), got {}",
11334 items.len(),
11335 );
11336 }
11337
11338 #[test]
11339 fn completion_param_flags_inside_nested_dollar_brace() {
11340 let _g = crate::test_util::global_state_lock();
11344 let mut state = State::default();
11345 state
11346 .docs
11347 .insert("file:///t.zsh".into(), "echo ${${(".into());
11348 let params = json!({
11349 "textDocument": { "uri": "file:///t.zsh" },
11350 "position": { "line": 0, "character": 10 },
11351 });
11352 let result = completion(&state, ¶ms);
11353 let items = result["items"].as_array().unwrap();
11354 assert!(items.iter().any(|i| i["label"] == "L"), "missing `L`");
11355 }
11356
11357 #[test]
11358 fn completion_no_param_flag_when_paren_already_closed() {
11359 let _g = crate::test_util::global_state_lock();
11362 let mut state = State::default();
11363 state
11364 .docs
11365 .insert("file:///t.zsh".into(), "echo ${(b)var".into());
11366 let params = json!({
11367 "textDocument": { "uri": "file:///t.zsh" },
11368 "position": { "line": 0, "character": 13 },
11369 });
11370 let result = completion(&state, ¶ms);
11371 let items = result["items"].as_array().unwrap();
11372 let single_char_only = !items.is_empty()
11378 && items
11379 .iter()
11380 .all(|i| i["label"].as_str().unwrap_or("").chars().count() == 1);
11381 assert!(
11382 !single_char_only,
11383 "param-flag table leaked past closing `)`"
11384 );
11385 }
11386
11387 #[test]
11388 fn completion_glob_qualifier_after_star_paren() {
11389 let _g = crate::test_util::global_state_lock();
11392 let mut state = State::default();
11393 state.docs.insert("file:///t.zsh".into(), "ls *(".into());
11394 let params = json!({
11395 "textDocument": { "uri": "file:///t.zsh" },
11396 "position": { "line": 0, "character": 5 },
11397 });
11398 let result = completion(&state, ¶ms);
11399 let items = result["items"].as_array().unwrap();
11400 for want in &["/", ".", "@", "*", "r", "w", "x", "U", "G"] {
11401 assert!(
11402 items.iter().any(|i| i["label"] == *want),
11403 "missing glob qualifier `{}`; got {:?}",
11404 want,
11405 items
11406 .iter()
11407 .take(20)
11408 .map(|i| i["label"].as_str().unwrap_or("?"))
11409 .collect::<Vec<_>>(),
11410 );
11411 }
11412 assert!(
11413 !items
11414 .iter()
11415 .any(|i| i["label"] == "cd" || i["label"] == "if"),
11416 "glob-qualifier context leaked normal completion",
11417 );
11418 }
11419
11420 #[test]
11421 fn completion_glob_qualifier_after_question_mark() {
11422 let _g = crate::test_util::global_state_lock();
11425 let mut state = State::default();
11426 state.docs.insert("file:///t.zsh".into(), "ls ?(".into());
11427 let params = json!({
11428 "textDocument": { "uri": "file:///t.zsh" },
11429 "position": { "line": 0, "character": 5 },
11430 });
11431 let result = completion(&state, ¶ms);
11432 let items = result["items"].as_array().unwrap();
11433 assert!(items.iter().any(|i| i["label"] == "."));
11434 }
11435
11436 #[test]
11437 fn completion_no_glob_qualifier_for_plain_subshell() {
11438 let _g = crate::test_util::global_state_lock();
11442 let mut state = State::default();
11443 state.docs.insert("file:///t.zsh".into(), "echo (".into());
11444 let params = json!({
11445 "textDocument": { "uri": "file:///t.zsh" },
11446 "position": { "line": 0, "character": 6 },
11447 });
11448 let result = completion(&state, ¶ms);
11449 let items = result["items"].as_array().unwrap();
11450 let has_normal = items
11452 .iter()
11453 .any(|i| i["label"] == "cd" || i["label"] == "if");
11454 let single_char_only = !items.is_empty()
11455 && items
11456 .iter()
11457 .all(|i| i["label"].as_str().unwrap_or("").chars().count() == 1);
11458 assert!(
11459 has_normal || !single_char_only,
11460 "subshell `(` mis-triggered glob qualifier table"
11461 );
11462 }
11463
11464 #[test]
11465 fn completion_param_flag_table_has_50_entries() {
11466 assert!(
11470 PARAM_FLAG_DOCS.len() >= 49,
11471 "PARAM_FLAG_DOCS dropped below 49 entries: {}",
11472 PARAM_FLAG_DOCS.len()
11473 );
11474 }
11475
11476 #[test]
11479 fn completion_history_designator_after_bang_at_word_start() {
11480 let _g = crate::test_util::global_state_lock();
11481 let mut state = State::default();
11482 state.docs.insert("file:///t.zsh".into(), "!".into());
11483 let params = json!({
11484 "textDocument": { "uri": "file:///t.zsh" },
11485 "position": { "line": 0, "character": 1 },
11486 });
11487 let result = completion(&state, ¶ms);
11488 let items = result["items"].as_array().unwrap();
11489 for want in &["!", "$", "^", "*", "#"] {
11490 assert!(
11491 items.iter().any(|i| i["label"] == *want),
11492 "missing history designator `{}`; got {:?}",
11493 want,
11494 items
11495 .iter()
11496 .map(|i| i["label"].as_str().unwrap_or("?"))
11497 .collect::<Vec<_>>(),
11498 );
11499 }
11500 assert!(!items
11502 .iter()
11503 .any(|i| i["label"] == "cd" || i["label"] == "if"));
11504 }
11505
11506 #[test]
11507 fn completion_history_designator_after_bang_midline() {
11508 let _g = crate::test_util::global_state_lock();
11510 let mut state = State::default();
11511 state.docs.insert("file:///t.zsh".into(), "vim !".into());
11512 let params = json!({
11513 "textDocument": { "uri": "file:///t.zsh" },
11514 "position": { "line": 0, "character": 5 },
11515 });
11516 let result = completion(&state, ¶ms);
11517 let items = result["items"].as_array().unwrap();
11518 assert!(items.iter().any(|i| i["label"] == "$"));
11519 }
11520
11521 #[test]
11522 fn completion_no_history_designator_inside_arithmetic() {
11523 let _g = crate::test_util::global_state_lock();
11526 let mut state = State::default();
11527 state.docs.insert("file:///t.zsh".into(), "(( a !".into());
11528 let params = json!({
11529 "textDocument": { "uri": "file:///t.zsh" },
11530 "position": { "line": 0, "character": 6 },
11531 });
11532 let result = completion(&state, ¶ms);
11533 let items = result["items"].as_array().unwrap();
11534 assert!(
11538 !items.iter().any(|i| i["label"] == "?str?"),
11539 "history table leaked into `((…))` arithmetic context",
11540 );
11541 }
11542
11543 #[test]
11544 fn completion_no_history_designator_after_alnum() {
11545 let _g = crate::test_util::global_state_lock();
11547 let mut state = State::default();
11548 state.docs.insert("file:///t.zsh".into(), "foo!".into());
11549 let params = json!({
11550 "textDocument": { "uri": "file:///t.zsh" },
11551 "position": { "line": 0, "character": 4 },
11552 });
11553 let result = completion(&state, ¶ms);
11554 let items = result["items"].as_array().unwrap();
11555 assert!(
11556 !items.iter().any(|i| i["label"] == "?str?"),
11557 "history table fired after alnum-preceded `!`",
11558 );
11559 }
11560
11561 #[test]
11562 fn completion_param_modifier_after_colon_in_dollar_brace() {
11563 let _g = crate::test_util::global_state_lock();
11565 let mut state = State::default();
11566 state
11567 .docs
11568 .insert("file:///t.zsh".into(), "echo ${var:".into());
11569 let params = json!({
11570 "textDocument": { "uri": "file:///t.zsh" },
11571 "position": { "line": 0, "character": 11 },
11572 });
11573 let result = completion(&state, ¶ms);
11574 let items = result["items"].as_array().unwrap();
11575 for want in &["h", "t", "r", "e", "-", "=", "+", "?", "s", "gs", "q", "Q"] {
11576 assert!(
11577 items.iter().any(|i| i["label"] == *want),
11578 "missing modifier `{}`; got {:?}",
11579 want,
11580 items
11581 .iter()
11582 .map(|i| i["label"].as_str().unwrap_or("?"))
11583 .collect::<Vec<_>>(),
11584 );
11585 }
11586 }
11587
11588 #[test]
11589 fn completion_param_modifier_after_partial_modifier() {
11590 let _g = crate::test_util::global_state_lock();
11593 let mut state = State::default();
11594 state
11595 .docs
11596 .insert("file:///t.zsh".into(), "echo ${var:h".into());
11597 let params = json!({
11598 "textDocument": { "uri": "file:///t.zsh" },
11599 "position": { "line": 0, "character": 12 },
11600 });
11601 let result = completion(&state, ¶ms);
11602 let items = result["items"].as_array().unwrap();
11603 assert!(
11604 items.len() >= 25,
11605 "expected full modifier table; got {}",
11606 items.len()
11607 );
11608 }
11609
11610 #[test]
11611 fn completion_param_modifier_after_history_bang_colon() {
11612 let _g = crate::test_util::global_state_lock();
11614 let mut state = State::default();
11615 state.docs.insert("file:///t.zsh".into(), "vim !!:".into());
11616 let params = json!({
11617 "textDocument": { "uri": "file:///t.zsh" },
11618 "position": { "line": 0, "character": 7 },
11619 });
11620 let result = completion(&state, ¶ms);
11621 let items = result["items"].as_array().unwrap();
11622 assert!(items.iter().any(|i| i["label"] == "h"));
11623 assert!(items.iter().any(|i| i["label"] == "t"));
11624 }
11625
11626 #[test]
11627 fn completion_no_param_modifier_outside_dollar_brace() {
11628 let _g = crate::test_util::global_state_lock();
11631 let mut state = State::default();
11632 state.docs.insert("file:///t.zsh".into(), "foo:".into());
11633 let params = json!({
11634 "textDocument": { "uri": "file:///t.zsh" },
11635 "position": { "line": 0, "character": 4 },
11636 });
11637 let result = completion(&state, ¶ms);
11638 let items = result["items"].as_array().unwrap();
11639 let has_normal = items
11641 .iter()
11642 .any(|i| i["label"] == "cd" || i["label"] == "if");
11643 let modifier_only = !items.is_empty()
11644 && items.iter().all(|i| {
11645 let l = i["label"].as_str().unwrap_or("");
11646 l.chars().count() <= 2
11647 });
11648 assert!(
11649 has_normal || !modifier_only,
11650 "bare `:` mis-triggered modifier table",
11651 );
11652 }
11653
11654 #[test]
11655 fn completion_history_designator_table_has_9_entries() {
11656 assert!(
11657 HISTORY_DESIGNATOR_DOCS.len() >= 9,
11658 "HISTORY_DESIGNATOR_DOCS dropped below 9: {}",
11659 HISTORY_DESIGNATOR_DOCS.len()
11660 );
11661 }
11662
11663 fn complete_at(input: &str, col: usize) -> Vec<Value> {
11666 let _g = crate::test_util::global_state_lock();
11667 let mut state = State::default();
11668 state.docs.insert("file:///t.zsh".into(), input.into());
11669 let params = json!({
11670 "textDocument": { "uri": "file:///t.zsh" },
11671 "position": { "line": 0, "character": col },
11672 });
11673 completion(&state, ¶ms)["items"]
11674 .as_array()
11675 .cloned()
11676 .unwrap_or_default()
11677 }
11678
11679 #[test]
11680 fn logical_line_joins_backslash_continuations() {
11681 let text = "_arguments -s \\\n '-v[loud]' \\\n '*:file:_files'\n";
11683 let (joined, col, first) = super::logical_line_at(text, 2, 3);
11686 assert_eq!(joined, "_arguments -s '-v[loud]' '*:file:_files'");
11687 assert_eq!(first, 0);
11688 assert_eq!(&joined[col..col + 1], "*");
11691 }
11692
11693 #[test]
11694 fn logical_line_leaves_an_uncontinued_line_alone() {
11695 let text = "echo one\necho two\n";
11696 let (joined, col, first) = super::logical_line_at(text, 1, 4);
11697 assert_eq!(joined, "echo two");
11698 assert_eq!(col, 4);
11699 assert_eq!(first, 1);
11700 }
11701
11702 #[test]
11703 fn logical_line_does_not_join_through_a_comment_or_an_escaped_backslash() {
11704 let text = "# note \\\necho two\n";
11706 let (joined, _, _) = super::logical_line_at(text, 1, 0);
11707 assert_eq!(joined, "echo two");
11708 let text2 = "print a\\\\\nprint b\n";
11710 let (joined2, _, _) = super::logical_line_at(text2, 1, 0);
11711 assert_eq!(joined2, "print b");
11712 }
11713
11714 #[test]
11715 fn builtin_flag_context_survives_a_continuation() {
11716 let _g = crate::test_util::global_state_lock();
11719 let mut state = State::default();
11720 let text = "print \\\n -\n";
11721 state.docs.insert("file:///t.zsh".into(), text.into());
11722 let params = json!({
11723 "textDocument": { "uri": "file:///t.zsh" },
11724 "position": { "line": 1, "character": 3 },
11725 });
11726 let items = completion(&state, ¶ms)["items"]
11727 .as_array()
11728 .cloned()
11729 .unwrap_or_default();
11730 let labels: Vec<&str> = items.iter().filter_map(|i| i["label"].as_str()).collect();
11731 assert!(
11732 labels.contains(&"-l"),
11733 "no print flags across continuation: {labels:?}"
11734 );
11735 let range = &items[0]["textEdit"]["range"];
11737 assert_eq!(
11738 range["start"]["line"],
11739 json!(1),
11740 "edit range on the wrong line"
11741 );
11742 }
11743
11744 #[test]
11745 fn arg_spec_context_survives_a_continuation() {
11746 let text = "_arguments -s \\\n '-v[loud]' \\\n '*:file:_fi'\n";
11749 let (joined, col, _) = super::logical_line_at(text, 2, 13);
11750 match super::arg_spec_part_at(&joined, col) {
11751 Some(super::ArgSpecPart::Action { word_start }) => {
11752 assert_eq!(&joined[word_start..col], "_fi");
11753 }
11754 other => panic!("expected Action across the continuation, got {other:?}"),
11755 }
11756 }
11757
11758 #[test]
11759 fn arg_spec_part_detects_head_message_and_action() {
11760 let line = " _arguments '-o[out]:outfile:_files'";
11763 let head = line.find("[out").unwrap() + 2;
11764 assert_eq!(
11765 super::arg_spec_part_at(line, head),
11766 Some(super::ArgSpecPart::Head)
11767 );
11768 let msg = line.find("outfile").unwrap() + 3;
11769 assert_eq!(
11770 super::arg_spec_part_at(line, msg),
11771 Some(super::ArgSpecPart::Message)
11772 );
11773 let act = line.find("_files").unwrap() + 3;
11774 match super::arg_spec_part_at(line, act) {
11775 Some(super::ArgSpecPart::Action { word_start }) => {
11776 assert_eq!(&line[word_start..act], "_fi", "action word start is wrong");
11777 }
11778 other => panic!("expected Action, got {other:?}"),
11779 }
11780 }
11781
11782 #[test]
11783 fn arg_spec_action_argument_is_opaque() {
11784 let line = " _arguments '*:f:_files -g pat'";
11788 let col = line.find("pat").unwrap() + 3;
11789 assert_eq!(
11790 super::arg_spec_part_at(line, col),
11791 Some(super::ArgSpecPart::ActionOpaque),
11792 );
11793 }
11794
11795 #[test]
11796 fn arg_spec_literal_list_is_opaque_not_unknown() {
11797 let line = " _arguments '1:cmd:(build test)'";
11802 let col = line.find("build").unwrap() + 5;
11803 assert_eq!(
11804 super::arg_spec_part_at(line, col),
11805 Some(super::ArgSpecPart::ActionOpaque),
11806 );
11807 }
11808
11809 #[test]
11810 fn arg_spec_part_is_none_outside_a_spec() {
11811 let line = " echo 'a:b:c'";
11813 let col = line.find("b:c").unwrap();
11814 assert_eq!(super::arg_spec_part_at(line, col), None);
11815 let line2 = " _arguments -s ";
11817 assert_eq!(super::arg_spec_part_at(line2, line2.len()), None);
11818 let line3 = " _arguments '*:f:_files' ";
11820 assert_eq!(super::arg_spec_part_at(line3, line3.len()), None);
11821 }
11822
11823 #[test]
11824 fn completion_inside_arg_spec_action_offers_completers_and_action_syntax() {
11825 let line = "_arguments '*:file:_fi'";
11829 let items = complete_at(line, line.find("_fi").unwrap() + 3);
11830 let labels: Vec<&str> = items.iter().filter_map(|i| i["label"].as_str()).collect();
11831 assert!(labels.contains(&"_files"), "no _files in {labels:?}");
11832 assert!(labels.contains(&"->state"), "no ->state action form");
11833 assert!(labels.contains(&"(list)"), "no literal-list action form");
11834 assert!(
11837 !labels.contains(&"while"),
11838 "keyword leaked into spec action"
11839 );
11840 }
11841
11842 #[test]
11843 fn completion_inside_a_literal_value_list_stays_quiet() {
11844 let line = "_arguments '1:cmd:(build test)'";
11847 let items = complete_at(line, line.find("build").unwrap() + 5);
11848 assert!(items.is_empty(), "literal list offered items: {items:?}");
11849 }
11850
11851 #[test]
11852 fn hover_reaches_a_completer_named_inside_a_spec() {
11853 let _g = crate::test_util::global_state_lock();
11858 let mut state = State::default();
11859 let text = "_arguments '*:file:_files'\n";
11860 state.docs.insert("file:///t.zsh".into(), text.into());
11861 let col = text.find("_files").unwrap() + 2;
11862 let params = json!({
11863 "textDocument": { "uri": "file:///t.zsh" },
11864 "position": { "line": 0, "character": col },
11865 });
11866 let card = super::hover(&state, ¶ms);
11867 let body = card["contents"]["value"].as_str().unwrap_or_default();
11868 assert!(body.starts_with("**_files**"), "got: {body}");
11869 assert!(
11870 body.contains("_compsys function_"),
11871 "compsys functions must not be labelled builtins: {body}",
11872 );
11873 }
11874
11875 #[test]
11876 fn hover_stays_suppressed_for_ordinary_string_contents() {
11877 let _g = crate::test_util::global_state_lock();
11880 let mut state = State::default();
11881 let text = "echo 'print this'\n";
11882 state.docs.insert("file:///t.zsh".into(), text.into());
11883 let col = text.find("print").unwrap() + 2;
11884 let params = json!({
11885 "textDocument": { "uri": "file:///t.zsh" },
11886 "position": { "line": 0, "character": col },
11887 });
11888 assert_eq!(super::hover(&state, ¶ms), Value::Null);
11889 }
11890
11891 #[test]
11892 fn completion_inside_arg_spec_description_stays_quiet() {
11893 let line = "_arguments '-v[be lo]'";
11896 let items = complete_at(line, line.find("lo]").unwrap() + 2);
11897 assert!(items.is_empty(), "description offered items: {items:?}");
11898 }
11899
11900 #[test]
11901 fn completion_setopt_surfaces_options_only() {
11902 let items = complete_at("setopt extend", 13);
11903 assert!(
11905 items.iter().any(|i| i["label"] == "extended_glob"
11906 || i["label"] == "extendedglob"
11907 || i["label"] == "EXTENDED_GLOB"),
11908 "no extended_glob variant"
11909 );
11910 assert!(!items
11911 .iter()
11912 .any(|i| i["label"] == "cd" || i["label"] == "if"));
11913 }
11914
11915 #[test]
11916 fn completion_unsetopt_surfaces_options_only() {
11917 let items = complete_at("unsetopt nul", 12);
11918 assert!(items.iter().any(|i| i["label"]
11919 .as_str()
11920 .unwrap_or("")
11921 .to_lowercase()
11922 .contains("null")));
11923 assert!(!items.iter().any(|i| i["label"] == "if"));
11924 }
11925
11926 #[test]
11927 fn completion_set_dash_o_surfaces_options() {
11928 let items = complete_at("set -o errex", 12);
11929 assert!(items.iter().any(|i| i["label"]
11930 .as_str()
11931 .unwrap_or("")
11932 .to_lowercase()
11933 .contains("err")));
11934 }
11935
11936 #[test]
11937 fn completion_kill_dash_surfaces_signals() {
11938 let items = complete_at("kill -", 6);
11939 for want in &["HUP", "INT", "TERM", "KILL", "USR1"] {
11940 assert!(
11941 items.iter().any(|i| i["label"] == *want),
11942 "missing signal `{}`",
11943 want
11944 );
11945 }
11946 }
11947
11948 #[test]
11949 fn completion_trap_surfaces_signals() {
11950 let items = complete_at("trap 'cmd' ", 11);
11951 for want in &["INT", "TERM", "EXIT", "ZERR", "DEBUG"] {
11952 assert!(
11953 items.iter().any(|i| i["label"] == *want),
11954 "missing signal `{}`",
11955 want
11956 );
11957 }
11958 }
11959
11960 #[test]
11961 fn completion_zmodload_surfaces_modules() {
11962 let items = complete_at("zmodload ", 9);
11963 for want in &[
11964 "zsh/zle",
11965 "zsh/datetime",
11966 "zsh/system",
11967 "zsh/parameter",
11968 "zsh/mathfunc",
11969 ] {
11970 assert!(
11971 items.iter().any(|i| i["label"] == *want),
11972 "missing module `{}`",
11973 want
11974 );
11975 }
11976 }
11977
11978 #[test]
11979 fn completion_bindkey_dash_M_surfaces_keymaps() {
11980 let items = complete_at("bindkey -M ", 11);
11981 for want in &["emacs", "vicmd", "viins", "viopp"] {
11982 assert!(
11983 items.iter().any(|i| i["label"] == *want),
11984 "missing keymap `{}`",
11985 want
11986 );
11987 }
11988 }
11989
11990 #[test]
11991 fn completion_bindkey_bare_surfaces_widgets() {
11992 let items = complete_at("bindkey '^A' acce", 17);
11995 assert!(items.iter().any(|i| i["label"] == "accept-line"));
11996 assert!(items.iter().any(|i| i["label"] == "accept-and-hold"));
11997 }
11998
11999 #[test]
12000 fn completion_zle_surfaces_widgets() {
12001 let items = complete_at("zle for", 7);
12002 assert!(items.iter().any(|i| i["label"] == "forward-char"));
12003 assert!(items.iter().any(|i| i["label"] == "forward-word"));
12004 }
12005
12006 #[test]
12007 fn completion_typeset_dash_surfaces_flags() {
12008 let items = complete_at("typeset -", 9);
12009 for want in &["-a", "-A", "-i", "-g", "-r", "-x", "-U", "-f"] {
12010 assert!(
12011 items.iter().any(|i| i["label"] == *want),
12012 "missing flag `{}`",
12013 want
12014 );
12015 }
12016 }
12017
12018 #[test]
12019 fn completion_typeset_no_flag_falls_through() {
12020 let items = complete_at("typeset FOO", 11);
12022 assert!(!items.iter().any(|i| i["label"] == "-a"));
12024 }
12025
12026 #[test]
12027 fn completion_zstyle_surfaces_contexts() {
12028 let items = complete_at("zstyle ", 7);
12029 assert!(items.iter().any(|i| i["label"] == ":completion:*"));
12030 assert!(items.iter().any(|i| i["label"] == ":vcs_info:*"));
12031 }
12032
12033 #[test]
12034 fn completion_compdef_surfaces_compsys_fns() {
12035 let items = complete_at("compdef ", 8);
12036 assert!(items
12038 .iter()
12039 .any(|i| i["label"].as_str().unwrap_or("").starts_with('_')));
12040 }
12041
12042 #[test]
12043 fn completion_inside_double_bracket_surfaces_test_ops() {
12044 let items = complete_at("[[ -f /tmp/x && -", 17);
12045 for want in &["-f", "-d", "-e", "-z", "-n", "=~"] {
12046 assert!(
12047 items.iter().any(|i| i["label"] == *want),
12048 "missing test op `{}`",
12049 want
12050 );
12051 }
12052 }
12053
12054 #[test]
12055 fn completion_inside_double_paren_surfaces_math_fns() {
12056 let items = complete_at("(( x = sq", 9);
12057 for want in &["sqrt", "sin", "cos", "log", "exp"] {
12058 assert!(
12059 items.iter().any(|i| i["label"] == *want),
12060 "missing math fn `{}`",
12061 want
12062 );
12063 }
12064 }
12065
12066 #[test]
12067 fn completion_inside_dollar_double_paren_surfaces_math_fns() {
12068 let items = complete_at("echo $(( ab", 11);
12069 assert!(items.iter().any(|i| i["label"] == "abs"));
12070 }
12071
12072 #[test]
12073 fn completion_inside_pattern_modifier_paren() {
12074 let items = complete_at("ls *.(#", 7);
12075 for want in &["i", "l", "b", "m", "a", "s", "e"] {
12076 assert!(
12077 items.iter().any(|i| i["label"] == *want),
12078 "missing pattern mod `{}` — got {:?}",
12079 want,
12080 items
12081 .iter()
12082 .take(20)
12083 .map(|i| i["label"].as_str().unwrap_or("?"))
12084 .collect::<Vec<_>>(),
12085 );
12086 }
12087 }
12088
12089 #[test]
12090 fn completion_inside_subscript_flag_paren() {
12091 let items = complete_at("echo ${arr[(", 12);
12092 for want in &["i", "I", "r", "R", "e", "n"] {
12093 assert!(
12094 items.iter().any(|i| i["label"] == *want),
12095 "missing subscript flag `{}`",
12096 want
12097 );
12098 }
12099 }
12100
12101 #[test]
12104 fn completion_signal_table_has_30_entries() {
12105 assert!(
12106 SIGNAL_NAMES.len() >= 30,
12107 "SIGNAL_NAMES: {}",
12108 SIGNAL_NAMES.len()
12109 );
12110 }
12111
12112 #[test]
12113 fn completion_module_table_has_30_entries() {
12114 assert!(
12115 ZSH_MODULE_NAMES.len() >= 30,
12116 "ZSH_MODULE_NAMES: {}",
12117 ZSH_MODULE_NAMES.len()
12118 );
12119 }
12120
12121 #[test]
12122 fn completion_keymap_table_has_10_entries() {
12123 assert!(
12124 KEYMAP_NAMES.len() >= 10,
12125 "KEYMAP_NAMES: {}",
12126 KEYMAP_NAMES.len()
12127 );
12128 }
12129
12130 #[test]
12131 fn completion_widget_table_has_100_entries() {
12132 assert!(
12133 ZLE_WIDGET_NAMES.len() >= 100,
12134 "ZLE_WIDGET_NAMES: {}",
12135 ZLE_WIDGET_NAMES.len()
12136 );
12137 }
12138
12139 #[test]
12140 fn completion_typeset_flag_table_has_20_entries() {
12141 assert!(
12142 TYPESET_FLAGS.len() >= 20,
12143 "TYPESET_FLAGS: {}",
12144 TYPESET_FLAGS.len()
12145 );
12146 }
12147
12148 #[test]
12149 fn completion_test_op_table_has_30_entries() {
12150 assert!(
12151 TEST_OPERATORS.len() >= 30,
12152 "TEST_OPERATORS: {}",
12153 TEST_OPERATORS.len()
12154 );
12155 }
12156
12157 #[test]
12158 fn completion_math_fn_table_has_40_entries() {
12159 assert!(
12160 MATH_FUNCTIONS.len() >= 40,
12161 "MATH_FUNCTIONS: {}",
12162 MATH_FUNCTIONS.len()
12163 );
12164 }
12165
12166 #[test]
12167 fn completion_zstyle_context_table_has_15_entries() {
12168 assert!(
12169 ZSTYLE_CONTEXTS.len() >= 15,
12170 "ZSTYLE_CONTEXTS: {}",
12171 ZSTYLE_CONTEXTS.len()
12172 );
12173 }
12174
12175 #[test]
12176 fn completion_pattern_modifier_table_has_10_entries() {
12177 assert!(
12178 PATTERN_MODIFIERS.len() >= 10,
12179 "PATTERN_MODIFIERS: {}",
12180 PATTERN_MODIFIERS.len()
12181 );
12182 }
12183
12184 #[test]
12185 fn completion_subscript_flag_table_has_10_entries() {
12186 assert!(
12187 SUBSCRIPT_FLAGS.len() >= 10,
12188 "SUBSCRIPT_FLAGS: {}",
12189 SUBSCRIPT_FLAGS.len()
12190 );
12191 }
12192
12193 #[test]
12194 fn completion_keywords_includes_canonical_reswds() {
12195 let _g = crate::test_util::global_state_lock();
12206 let mut state = State::default();
12209 state.docs.insert("file:///t.zsh".into(), "e".into());
12210 let params = json!({
12211 "textDocument": { "uri": "file:///t.zsh" },
12212 "position": { "line": 0, "character": 1 },
12213 });
12214 let items = completion(&state, ¶ms)["items"]
12215 .as_array()
12216 .unwrap()
12217 .clone();
12218 let labels: Vec<&str> = items
12219 .iter()
12220 .map(|i| i["label"].as_str().unwrap_or(""))
12221 .collect();
12222 assert!(
12223 labels.iter().any(|l| *l == "end"),
12224 "RESWDS `end` not surfaced — labels: {:?}",
12225 labels
12226 .iter()
12227 .filter(|l| l.starts_with('e'))
12228 .take(10)
12229 .collect::<Vec<_>>(),
12230 );
12231 }
12232
12233 #[test]
12234 fn completion_builtin_flag_print_dash_tab() {
12235 let _g = crate::test_util::global_state_lock();
12240 let mut state = State::default();
12241 state.docs.insert("file:///t.zsh".into(), "print -".into());
12242 let params = json!({
12243 "textDocument": { "uri": "file:///t.zsh" },
12244 "position": { "line": 0, "character": 7 },
12245 });
12246 let items = completion(&state, ¶ms)["items"]
12247 .as_array()
12248 .unwrap()
12249 .clone();
12250 let labels: Vec<&str> = items
12251 .iter()
12252 .map(|i| i["label"].as_str().unwrap_or(""))
12253 .collect();
12254 for want in &[
12255 "-a", "-b", "-c", "-n", "-N", "-o", "-O", "-P", "-r", "-R", "-z",
12256 ] {
12257 assert!(
12258 labels.iter().any(|l| l == want),
12259 "missing `print -` flag `{}` — got {:?}",
12260 want,
12261 labels.iter().take(20).collect::<Vec<_>>(),
12262 );
12263 }
12264 assert!(
12265 items.len() >= 15,
12266 "expected ≥15 print flags, got {}",
12267 items.len(),
12268 );
12269 }
12270
12271 #[test]
12272 fn completion_dollar_prefix_matches_canonical_specials() {
12273 let _g = crate::test_util::global_state_lock();
12281 let mut state = State::default();
12282 state.docs.insert("file:///t.zsh".into(), "$HIST".into());
12283 let params = json!({
12284 "textDocument": { "uri": "file:///t.zsh" },
12285 "position": { "line": 0, "character": 5 },
12286 });
12287 let result = completion(&state, ¶ms);
12288 let items = result["items"].as_array().unwrap();
12289 let labels: Vec<&str> = items
12290 .iter()
12291 .map(|i| i["label"].as_str().unwrap_or(""))
12292 .collect();
12293 for want in &["$HISTCMD", "$HISTNO", "$HISTCHARS"] {
12294 assert!(
12295 labels.iter().any(|l| l == want),
12296 "missing `{}` for `$HIST` prefix",
12297 want,
12298 );
12299 }
12300 }
12301
12302 #[test]
12303 fn completion_special_vars_includes_full_canonical_set() {
12304 let _g = crate::test_util::global_state_lock();
12310 for prefix in &["PS", "PROMPT", "psvar"] {
12311 let mut state = State::default();
12312 state.docs.insert("file:///t.zsh".into(), (*prefix).into());
12313 let params = json!({
12314 "textDocument": { "uri": "file:///t.zsh" },
12315 "position": { "line": 0, "character": prefix.len() },
12316 });
12317 let result = completion(&state, ¶ms);
12318 let items = result["items"].as_array().unwrap();
12319 let labels: Vec<&str> = items
12320 .iter()
12321 .map(|i| i["label"].as_str().unwrap_or(""))
12322 .collect();
12323 match *prefix {
12324 "PS" => {
12325 for want in &["PS1", "PS2", "PS3", "PS4"] {
12326 assert!(
12327 labels.iter().any(|l| l == want),
12328 "missing `{}` for prefix `{}`",
12329 want,
12330 prefix,
12331 );
12332 }
12333 }
12334 "PROMPT" => {
12335 for want in &["PROMPT", "PROMPT2", "PROMPT3", "PROMPT4"] {
12336 assert!(
12337 labels.iter().any(|l| l == want),
12338 "missing `{}` for prefix `{}`",
12339 want,
12340 prefix,
12341 );
12342 }
12343 }
12344 "psvar" => {
12345 assert!(labels.iter().any(|l| *l == "psvar"), "missing `psvar`",);
12346 }
12347 _ => {}
12348 }
12349 }
12350 }
12351
12352 #[test]
12353 fn completion_param_modifier_table_has_30_entries() {
12354 assert!(
12355 PARAM_MODIFIER_DOCS.len() >= 30,
12356 "PARAM_MODIFIER_DOCS dropped below 30: {}",
12357 PARAM_MODIFIER_DOCS.len()
12358 );
12359 }
12360
12361 #[test]
12362 fn completion_glob_qualifier_table_has_30_entries() {
12363 assert!(
12367 GLOB_QUALIFIER_DOCS.len() >= 30,
12368 "GLOB_QUALIFIER_DOCS dropped below 30 entries: {}",
12369 GLOB_QUALIFIER_DOCS.len()
12370 );
12371 }
12372
12373 #[test]
12374 fn completion_snippet_table_has_60_plus_entries() {
12375 assert!(
12380 SNIPPETS.len() >= 60,
12381 "snippet table dropped below 60 entries: {}",
12382 SNIPPETS.len()
12383 );
12384 }
12385
12386 #[test]
12389 fn folding_ranges_finds_brace_and_do_blocks() {
12390 let _g = crate::test_util::global_state_lock();
12391 let mut state = State::default();
12392 state.docs.insert(
12393 "file:///t.zsh".into(),
12394 "function f {\n echo\n}\nfor x in 1 2 3; do\n print $x\ndone\n".into(),
12395 );
12396 let params = json!({ "textDocument": { "uri": "file:///t.zsh" } });
12397 let result = folding_ranges(&state, ¶ms);
12398 let arr = result.as_array().unwrap();
12399 assert!(
12401 arr.iter().any(|r| r["startLine"] == 0 && r["endLine"] == 2),
12402 "missing brace fold: {:?}",
12403 arr
12404 );
12405 assert!(
12406 arr.iter().any(|r| r["startLine"] == 3 && r["endLine"] == 5),
12407 "missing for/do fold: {:?}",
12408 arr
12409 );
12410 }
12411
12412 #[test]
12415 fn references_returns_call_sites() {
12416 let _g = crate::test_util::global_state_lock();
12417 let mut state = State::default();
12418 state.docs.insert(
12419 "file:///t.zsh".into(),
12420 "function greet { echo hi }\ngreet\ngreet world\n".into(),
12421 );
12422 let params = json!({
12423 "textDocument": { "uri": "file:///t.zsh" },
12424 "position": { "line": 0, "character": 9 }, "context": { "includeDeclaration": true },
12426 });
12427 let refs = references(&state, ¶ms);
12428 let arr = refs.as_array().unwrap();
12429 assert_eq!(arr.len(), 3, "expected 3 refs, got: {:?}", arr);
12431 }
12432
12433 #[test]
12434 fn references_follows_source_chain_outside_workspace() {
12435 let _g = crate::test_util::global_state_lock();
12440 let mut state = State::default();
12441 let tmp = std::env::temp_dir().join(format!(
12444 "zshrs-ref-source-chain-{}",
12445 std::time::SystemTime::now()
12446 .duration_since(std::time::UNIX_EPOCH)
12447 .map(|d| d.as_nanos())
12448 .unwrap_or(0),
12449 ));
12450 std::fs::create_dir_all(&tmp).unwrap();
12451 let sourced = tmp.join("helpers.zsh");
12452 std::fs::write(&sourced, "greet world\ngreet again\n").unwrap();
12453 let active_text = format!(
12454 "function greet {{ echo hi }}\nsource {}\n",
12455 sourced.display()
12456 );
12457 state.docs.insert("file:///t.zsh".into(), active_text);
12458 let params = json!({
12459 "textDocument": { "uri": "file:///t.zsh" },
12460 "position": { "line": 0, "character": 9 }, "context": { "includeDeclaration": true },
12462 });
12463 let refs = references(&state, ¶ms);
12464 let arr = refs.as_array().unwrap();
12465 assert!(
12467 arr.len() >= 3,
12468 "source-chain following missed refs, got {}: {:?}",
12469 arr.len(),
12470 arr,
12471 );
12472 let sourced_uri = format!("file://{}", sourced.canonicalize().unwrap().display());
12474 assert!(
12475 arr.iter()
12476 .any(|r| r["uri"].as_str() == Some(sourced_uri.as_str())),
12477 "no ref pointing at sourced file `{}`: {:?}",
12478 sourced_uri,
12479 arr,
12480 );
12481 let _ = std::fs::remove_dir_all(&tmp);
12482 }
12483
12484 #[test]
12487 fn line_starts_comment_before_shebang() {
12488 let line = "#!/usr/bin/env zsh";
12491 let pos = line.find("env").unwrap();
12492 assert!(line_starts_comment_before(line, pos));
12493 }
12494
12495 #[test]
12496 fn line_starts_comment_before_inline() {
12497 let line = "echo hi; # call cd later";
12499 let pos = line.find("cd").unwrap();
12500 assert!(line_starts_comment_before(line, pos));
12501 }
12502
12503 #[test]
12504 fn line_starts_comment_before_string_with_hash_is_not_a_comment() {
12505 let line = r#"echo "x #y"; cd"#;
12508 let pos = line.rfind("cd").unwrap();
12509 assert!(
12510 !line_starts_comment_before(line, pos),
12511 "code after a string containing `#` must still be code"
12512 );
12513 }
12514
12515 #[test]
12516 fn line_starts_comment_before_single_quote_with_hash() {
12517 let line = "echo 'x #y'; cd";
12519 let pos = line.rfind("cd").unwrap();
12520 assert!(!line_starts_comment_before(line, pos));
12521 }
12522
12523 #[test]
12524 fn line_starts_comment_before_backtick_with_hash() {
12525 let line = "`echo #foo`; cd";
12531 let pos = line.rfind("cd").unwrap();
12532 assert!(!line_starts_comment_before(line, pos));
12533 }
12534
12535 #[test]
12536 fn line_starts_comment_negative_at_start() {
12537 let line = "cd /tmp";
12538 assert!(!line_starts_comment_before(line, 0));
12539 }
12540
12541 #[test]
12544 fn hover_on_shebang_env_is_suppressed() {
12545 let _g = crate::test_util::global_state_lock();
12546 let mut state = State::default();
12547 state.docs.insert(
12548 "file:///t.zsh".into(),
12549 "#!/usr/bin/env zsh\necho hi\n".into(),
12550 );
12551 let params = json!({
12554 "textDocument": { "uri": "file:///t.zsh" },
12555 "position": { "line": 0, "character": 12 },
12556 });
12557 let h = hover(&state, ¶ms);
12558 assert!(h.is_null(), "hover on shebang `env` must be null, got: {h}");
12559 }
12560
12561 #[test]
12562 fn hover_on_builtin_inside_comment_is_suppressed() {
12563 let _g = crate::test_util::global_state_lock();
12564 let mut state = State::default();
12565 state
12566 .docs
12567 .insert("file:///t.zsh".into(), "echo hi # call cd later\n".into());
12568 let cd_pos = "echo hi # call ".len();
12571 let params = json!({
12572 "textDocument": { "uri": "file:///t.zsh" },
12573 "position": { "line": 0, "character": cd_pos },
12574 });
12575 let h = hover(&state, ¶ms);
12576 assert!(h.is_null(), "comment-text hover must be null, got: {h}");
12577 }
12578
12579 #[test]
12580 fn hover_on_shebang_with_module_doc_returns_module_card() {
12581 let _g = crate::test_util::global_state_lock();
12586 let mut state = State::default();
12587 state.docs.insert(
12588 "file:///lib.zsh".into(),
12589 "#!/usr/bin/env zsh\n\
12590 ## libfoo.zsh — utility helpers.\n\
12591 ## Provides foo / bar / baz.\n\
12592 function foo() {}\n"
12593 .into(),
12594 );
12595 let params = json!({
12598 "textDocument": { "uri": "file:///lib.zsh" },
12599 "position": { "line": 0, "character": 12 },
12600 });
12601 let h = hover(&state, ¶ms);
12602 assert!(!h.is_null(), "shebang+##block hover should return card");
12603 let body = h["contents"]["value"].as_str().unwrap_or("");
12604 assert!(body.contains("libfoo.zsh"), "got {body:?}");
12605 assert!(body.contains("zsh module"), "got {body:?}");
12606 }
12607
12608 #[test]
12609 fn hover_on_source_path_uri_cached_returns_target_module_doc() {
12610 let _g = crate::test_util::global_state_lock();
12614 let mut state = State::default();
12615 state.docs.insert(
12616 "file:///proj/main.zsh".into(),
12617 "source ./helpers.zsh\nhelpers_init\n".into(),
12618 );
12619 state.docs.insert(
12620 "file:///proj/helpers.zsh".into(),
12621 "## helpers.zsh — shared bootstrap.\n\
12622 function helpers_init() {}\n"
12623 .into(),
12624 );
12625 let pos = "source ".len() + 2; let params = json!({
12628 "textDocument": { "uri": "file:///proj/main.zsh" },
12629 "position": { "line": 0, "character": pos },
12630 });
12631 let h = hover(&state, ¶ms);
12632 assert!(
12633 !h.is_null(),
12634 "source-path hover should return target's module doc"
12635 );
12636 let body = h["contents"]["value"].as_str().unwrap_or("");
12637 assert!(body.contains("helpers.zsh"), "got {body:?}");
12638 assert!(body.contains("shared bootstrap"), "got {body:?}");
12639 }
12640
12641 #[test]
12642 fn hover_on_dot_source_path_resolves_same_way() {
12643 let _g = crate::test_util::global_state_lock();
12645 let mut state = State::default();
12646 state
12647 .docs
12648 .insert("file:///proj/main.zsh".into(), ". ./helpers.zsh\n".into());
12649 state.docs.insert(
12650 "file:///proj/helpers.zsh".into(),
12651 "## dot-sourced helpers.\nfunction h() {}\n".into(),
12652 );
12653 let pos = ". ".len() + 2;
12654 let params = json!({
12655 "textDocument": { "uri": "file:///proj/main.zsh" },
12656 "position": { "line": 0, "character": pos },
12657 });
12658 let h = hover(&state, ¶ms);
12659 assert!(!h.is_null(), ". PATH hover should resolve too");
12660 let body = h["contents"]["value"].as_str().unwrap_or("");
12661 assert!(body.contains("dot-sourced helpers"), "got {body:?}");
12662 }
12663
12664 #[test]
12665 fn hover_on_source_path_without_target_doc_returns_null() {
12666 let _g = crate::test_util::global_state_lock();
12669 let mut state = State::default();
12670 state.docs.insert(
12671 "file:///t.zsh".into(),
12672 "source /nonexistent/path/that/does/not/exist.zsh\n".into(),
12673 );
12674 let pos = "source ".len() + 5;
12675 let params = json!({
12676 "textDocument": { "uri": "file:///t.zsh" },
12677 "position": { "line": 0, "character": pos },
12678 });
12679 let h = hover(&state, ¶ms);
12680 assert!(h.is_null(), "missing source target must not fabricate doc");
12681 }
12682
12683 #[test]
12684 fn hover_on_user_function_with_doc_returns_user_card() {
12685 let _g = crate::test_util::global_state_lock();
12690 let mut state = State::default();
12691 state.docs.insert(
12692 "file:///t.zsh".into(),
12693 "## Sum two numbers and print the result.\n\
12694 function my_sum_helper() { print $(( $1 + $2 )) }\n\
12695 my_sum_helper 1 2\n"
12696 .into(),
12697 );
12698 let params = json!({
12700 "textDocument": { "uri": "file:///t.zsh" },
12701 "position": { "line": 2, "character": 0 },
12702 });
12703 let h = hover(&state, ¶ms);
12704 assert!(!h.is_null(), "user-fn hover should fire");
12705 let body = h["contents"]["value"].as_str().unwrap_or("");
12706 assert!(body.contains("Sum two numbers"), "got {body:?}");
12707 assert!(body.contains("user-defined function"), "got {body:?}");
12708 }
12709
12710 #[test]
12711 fn hover_on_real_builtin_outside_comment_still_works() {
12712 let _g = crate::test_util::global_state_lock();
12713 let mut state = State::default();
12714 state
12715 .docs
12716 .insert("file:///t.zsh".into(), "cd /tmp\n".into());
12717 let params = json!({
12718 "textDocument": { "uri": "file:///t.zsh" },
12719 "position": { "line": 0, "character": 0 },
12720 });
12721 let h = hover(&state, ¶ms);
12722 assert!(!h.is_null(), "real builtin must still hover");
12723 }
12724
12725 #[test]
12730 fn position_inside_double_quoted_string_detected() {
12731 let line = "echo \"cd to dir\"";
12735 let cd_start = line.find("cd").unwrap();
12736 let cd_end = cd_start + 2;
12737 assert!(position_inside_string_literal(line, cd_start, cd_end));
12738 }
12739
12740 #[test]
12744 fn position_inside_single_quoted_string_detected() {
12745 let line = "echo 'cd to dir'";
12746 let cd_start = line.find("cd").unwrap();
12747 let cd_end = cd_start + 2;
12748 assert!(position_inside_string_literal(line, cd_start, cd_end));
12749 }
12750
12751 #[test]
12756 fn position_inside_backtick_string_detected() {
12757 let line = "echo `cd to dir`";
12758 let cd_start = line.find("cd").unwrap();
12759 let cd_end = cd_start + 2;
12760 assert!(position_inside_string_literal(line, cd_start, cd_end));
12761 }
12762
12763 #[test]
12767 fn position_inside_parameter_expansion_is_code() {
12768 let line = "echo \"${HOME}/x\"";
12770 let home_start = line.find("HOME").unwrap();
12771 let home_end = home_start + 4;
12772 assert!(
12773 !position_inside_string_literal(line, home_start, home_end),
12774 "`${{HOME}}` inside double-quotes is code, not string text"
12775 );
12776 }
12777
12778 #[test]
12780 fn position_outside_string_is_code() {
12781 let line = "cd /tmp";
12782 assert!(!position_inside_string_literal(line, 0, 2));
12783 }
12784
12785 #[test]
12787 fn position_after_closing_quote_is_code() {
12788 let line = "echo \"foo\" cd";
12790 let cd_start = line.find(" cd").unwrap() + 1;
12791 let cd_end = cd_start + 2;
12792 assert!(!position_inside_string_literal(line, cd_start, cd_end));
12793 }
12794
12795 #[test]
12797 fn classify_comment_outranks_string() {
12798 let line = "# echo \"cd\"";
12801 let cd_start = line.find("cd").unwrap();
12802 let cd_end = cd_start + 2;
12803 assert_eq!(
12804 classify_hover_position(line, cd_start, cd_end),
12805 HoverGate::Comment
12806 );
12807 }
12808
12809 #[test]
12811 fn classify_string_literal() {
12812 let line = "echo \"cd to dir\"";
12813 let cd_start = line.find("cd").unwrap();
12814 let cd_end = cd_start + 2;
12815 assert_eq!(
12816 classify_hover_position(line, cd_start, cd_end),
12817 HoverGate::StringLiteral
12818 );
12819 }
12820
12821 #[test]
12823 fn classify_bare_code() {
12824 let line = "cd /tmp";
12825 assert_eq!(classify_hover_position(line, 0, 2), HoverGate::Code);
12826 }
12827
12828 #[test]
12835 fn rename_strips_colon_colon_qualifier() {
12836 let _g = crate::test_util::global_state_lock();
12837 let mut state = State::default();
12838 state.docs.insert(
12839 "file:///t.zsh".into(),
12840 "function handle { echo hi }\nhandle\nhandle x\n".into(),
12841 );
12842 let params = json!({
12843 "textDocument": { "uri": "file:///t.zsh" },
12844 "position": { "line": 0, "character": 9 }, "newName": "Demo::handle2",
12846 });
12847 let r = rename(&state, ¶ms);
12848 let changes = r["changes"].as_object().expect("changes");
12849 let edits = changes["file:///t.zsh"].as_array().expect("edits");
12850 assert!(
12851 !edits.is_empty(),
12852 "expected at least 1 edit, got: {edits:?}"
12853 );
12854 for e in edits {
12855 assert_eq!(
12856 e["newText"],
12857 json!("handle2"),
12858 "qualifier must be stripped; got: {e:?}"
12859 );
12860 }
12861 }
12862
12863 #[test]
12866 fn rename_passes_through_bare_new_name() {
12867 let _g = crate::test_util::global_state_lock();
12868 let mut state = State::default();
12869 state.docs.insert(
12870 "file:///t.zsh".into(),
12871 "function handle { echo hi }\nhandle\n".into(),
12872 );
12873 let params = json!({
12874 "textDocument": { "uri": "file:///t.zsh" },
12875 "position": { "line": 0, "character": 9 },
12876 "newName": "handle2",
12877 });
12878 let r = rename(&state, ¶ms);
12879 let edits = r["changes"]["file:///t.zsh"].as_array().expect("edits");
12880 for e in edits {
12881 assert_eq!(e["newText"], json!("handle2"));
12882 }
12883 }
12884
12885 #[test]
12888 fn rename_function_crosses_files() {
12889 let _g = crate::test_util::global_state_lock();
12892 let mut state = State::default();
12893 state.docs.insert(
12894 "file:///lib.zsh".into(),
12895 "function greet { echo hi }\n".into(),
12896 );
12897 state.docs.insert(
12898 "file:///rc.zsh".into(),
12899 "source lib.zsh\ngreet\ngreet world\n".into(),
12900 );
12901 let params = json!({
12902 "textDocument": { "uri": "file:///lib.zsh" },
12903 "position": { "line": 0, "character": 9 }, "context": { "includeDeclaration": true },
12905 "newName": "salute",
12906 });
12907 let r = rename(&state, ¶ms);
12908 let changes = r["changes"].as_object().expect("rename has changes map");
12909 assert!(
12910 changes.contains_key("file:///lib.zsh"),
12911 "lib.zsh edited: {changes:?}"
12912 );
12913 assert!(
12914 changes.contains_key("file:///rc.zsh"),
12915 "rc.zsh edited: {changes:?}"
12916 );
12917 let lib_edits = changes["file:///lib.zsh"].as_array().unwrap();
12919 let rc_edits = changes["file:///rc.zsh"].as_array().unwrap();
12920 assert_eq!(lib_edits.len(), 1);
12921 assert_eq!(rc_edits.len(), 2);
12922 for e in lib_edits.iter().chain(rc_edits.iter()) {
12923 assert_eq!(e["newText"], "salute");
12924 }
12925 }
12926
12927 #[test]
12928 fn rename_rejects_empty_new_name() {
12929 let _g = crate::test_util::global_state_lock();
12930 let mut state = State::default();
12931 state.docs.insert(
12932 "file:///t.zsh".into(),
12933 "function greet { echo hi }\n".into(),
12934 );
12935 let params = json!({
12936 "textDocument": { "uri": "file:///t.zsh" },
12937 "position": { "line": 0, "character": 9 },
12938 "context": { "includeDeclaration": true },
12939 "newName": "",
12940 });
12941 let r = rename(&state, ¶ms);
12942 assert!(r.is_null(), "empty new_name must be rejected");
12943 }
12944
12945 #[test]
12946 fn workspace_walk_picks_up_unopened_zsh_files() {
12947 let _g = crate::test_util::global_state_lock();
12951 let tmp = std::env::temp_dir().join(format!(
12952 "zshrs-workspace-test-{}-{}",
12953 std::process::id(),
12954 std::time::SystemTime::now()
12955 .duration_since(std::time::UNIX_EPOCH)
12956 .map(|d| d.as_nanos())
12957 .unwrap_or(0)
12958 ));
12959 std::fs::create_dir_all(&tmp).unwrap();
12960 let lib_path = tmp.join("lib.zsh");
12961 let rc_path = tmp.join("rc.zsh");
12962 std::fs::write(&lib_path, "function greet { echo hi }\n").unwrap();
12963 let rc_text = format!("source {}\ngreet\ngreet world\n", lib_path.display());
12968 std::fs::write(&rc_path, &rc_text).unwrap();
12969 let rc_uri = format!("file://{}", rc_path.display());
12970
12971 let mut state = State::default();
12972 state.docs.insert(rc_uri.clone(), rc_text.clone());
12974 let init = json!({ "rootUri": format!("file://{}", tmp.display()) });
12976 ingest_workspace_init(&mut state, &init);
12977 let lib_uri = format!("file://{}", lib_path.display());
12979 assert!(
12980 state.workspace_files.contains_key(&lib_uri),
12981 "workspace walk picked up lib.zsh: keys={:?}",
12982 state.workspace_files.keys().collect::<Vec<_>>(),
12983 );
12984 let canon_lib_path = std::fs::canonicalize(&lib_path).unwrap_or(lib_path.clone());
12990 let canon_lib_uri = format!("file://{}", canon_lib_path.display());
12991 let params = json!({
12996 "textDocument": { "uri": rc_uri },
12997 "position": { "line": 1, "character": 0 },
12998 "context": { "includeDeclaration": true },
12999 "newName": "salute",
13000 });
13001 let r = rename(&state, ¶ms);
13002 let changes = r["changes"].as_object().expect("changes map");
13003 assert!(
13004 changes.contains_key(&canon_lib_uri),
13005 "lib.zsh (workspace) edited: keys={:?}",
13006 changes.keys().collect::<Vec<_>>(),
13007 );
13008 assert!(
13009 changes.contains_key(&rc_uri),
13010 "rc.zsh (open) edited: keys={:?}",
13011 changes.keys().collect::<Vec<_>>(),
13012 );
13013 assert_eq!(changes[&canon_lib_uri].as_array().unwrap().len(), 1);
13015 assert_eq!(changes[&rc_uri].as_array().unwrap().len(), 2);
13016
13017 let _ = std::fs::remove_dir_all(&tmp);
13019 }
13020
13021 #[test]
13022 fn workspace_walk_skips_node_modules_and_git() {
13023 let _g = crate::test_util::global_state_lock();
13024 let tmp = std::env::temp_dir().join(format!(
13025 "zshrs-skip-test-{}-{}",
13026 std::process::id(),
13027 std::time::SystemTime::now()
13028 .duration_since(std::time::UNIX_EPOCH)
13029 .map(|d| d.as_nanos())
13030 .unwrap_or(0)
13031 ));
13032 std::fs::create_dir_all(tmp.join(".git")).unwrap();
13033 std::fs::create_dir_all(tmp.join("node_modules")).unwrap();
13034 std::fs::write(tmp.join(".git").join("hooks.zsh"), "should_skip=1\n").unwrap();
13035 std::fs::write(tmp.join("node_modules").join("util.zsh"), "should_skip=1\n").unwrap();
13036 std::fs::write(tmp.join("real.zsh"), "should_pick_up=1\n").unwrap();
13037
13038 let mut state = State::default();
13039 let init = json!({ "rootUri": format!("file://{}", tmp.display()) });
13040 ingest_workspace_init(&mut state, &init);
13041 assert_eq!(
13042 state.workspace_files.len(),
13043 1,
13044 "only real.zsh picked up: keys={:?}",
13045 state.workspace_files.keys().collect::<Vec<_>>(),
13046 );
13047 let _ = std::fs::remove_dir_all(&tmp);
13048 }
13049
13050 #[test]
13051 fn is_zsh_source_filename_accepts_dotfiles_and_extensions() {
13052 assert!(is_zsh_source_filename("foo.zsh"));
13053 assert!(is_zsh_source_filename("foo.sh"));
13054 assert!(is_zsh_source_filename(".zshrc"));
13055 assert!(is_zsh_source_filename(".zshenv"));
13056 assert!(is_zsh_source_filename(".zsh_aliases"));
13057 assert!(!is_zsh_source_filename("foo.py"));
13058 assert!(!is_zsh_source_filename(".gitignore"));
13059 assert!(!is_zsh_source_filename("README.md"));
13060 }
13061
13062 fn run_code_actions(text: &str, sl: u32, sc: u32, el: u32, ec: u32) -> Vec<Value> {
13065 let _g = crate::test_util::global_state_lock();
13066 let mut state = State::default();
13067 state.docs.insert("file:///t.zsh".into(), text.to_string());
13068 let params = json!({
13069 "textDocument": { "uri": "file:///t.zsh" },
13070 "range": {
13071 "start": { "line": sl, "character": sc },
13072 "end": { "line": el, "character": ec },
13073 },
13074 });
13075 match code_actions(&state, ¶ms) {
13076 Value::Array(v) => v,
13077 _ => Vec::new(),
13078 }
13079 }
13080
13081 #[test]
13082 fn code_actions_single_line_offers_var_const_and_function() {
13083 let acts = run_code_actions(" echo hello\n", 0, 4, 0, 14);
13084 let titles: Vec<&str> = acts
13085 .iter()
13086 .map(|a| a["title"].as_str().unwrap_or(""))
13087 .collect();
13088 assert!(
13090 titles.iter().any(|t| t.contains("variable")),
13091 "missing Extract Variable: {:?}",
13092 titles,
13093 );
13094 assert!(
13095 titles.iter().any(|t| t.contains("constant")),
13096 "missing Extract Constant: {:?}",
13097 titles,
13098 );
13099 assert!(
13100 titles.iter().any(|t| t.contains("function")),
13101 "missing Extract Function: {:?}",
13102 titles,
13103 );
13104 }
13105
13106 #[test]
13107 fn code_actions_subexpression_skips_function_extract() {
13108 let acts = run_code_actions("echo hello world\n", 0, 5, 0, 10);
13114 let titles: Vec<&str> = acts
13115 .iter()
13116 .map(|a| a["title"].as_str().unwrap_or(""))
13117 .collect();
13118 assert!(titles.iter().any(|t| t.contains("variable")));
13119 assert!(
13120 !titles.iter().any(|t| t.contains("function")),
13121 "function extract leaked on sub-expression: {:?}",
13122 titles,
13123 );
13124 }
13125
13126 #[test]
13127 fn code_actions_multiline_only_offers_function_extract() {
13128 let text = "if true; then\n echo a\n echo b\nfi\n";
13131 let acts = run_code_actions(text, 1, 0, 3, 0);
13132 let titles: Vec<&str> = acts
13133 .iter()
13134 .map(|a| a["title"].as_str().unwrap_or(""))
13135 .collect();
13136 assert_eq!(acts.len(), 1, "expected exactly one action: {:?}", titles);
13137 assert!(titles[0].contains("function"));
13138 let changes = &acts[0]["edit"]["changes"]["file:///t.zsh"];
13141 let edits = changes.as_array().expect("edits array");
13142 assert_eq!(edits.len(), 2);
13143 let decl = edits[0]["newText"].as_str().unwrap_or("");
13144 assert!(
13145 decl.contains("extracted_function() {")
13146 && decl.contains("echo a")
13147 && decl.contains("echo b"),
13148 "decl missing body lines: {:?}",
13149 decl,
13150 );
13151 let call = edits[1]["newText"].as_str().unwrap_or("");
13152 assert!(
13153 call.trim() == "extracted_function",
13154 "call must be bare: {:?}",
13155 call
13156 );
13157 }
13158
13159 #[test]
13160 fn code_actions_multiline_preserves_relative_indent() {
13161 let text = "if outer; then\n if inner; then\n echo nested\n fi\nfi\n";
13165 let acts = run_code_actions(text, 1, 0, 3, 0);
13166 assert_eq!(acts.len(), 1);
13167 let decl = acts[0]["edit"]["changes"]["file:///t.zsh"][0]["newText"]
13168 .as_str()
13169 .unwrap_or("");
13170 assert!(
13174 decl.contains(" echo nested"),
13175 "relative indent lost: {:?}",
13176 decl,
13177 );
13178 }
13179
13180 #[test]
13181 fn code_actions_caret_only_snaps_to_word() {
13182 let acts = run_code_actions("echo greeting\n", 0, 8, 0, 8);
13185 assert!(
13186 acts.iter()
13187 .any(|a| a["title"].as_str().unwrap_or("").contains("variable")),
13188 "caret-only didn't snap to a word: {:?}",
13189 acts.iter().map(|a| a["title"].clone()).collect::<Vec<_>>(),
13190 );
13191 }
13192
13193 #[test]
13194 fn code_actions_caret_only_offers_extract_function() {
13195 let acts = run_code_actions("echo greeting\n", 0, 8, 0, 8);
13203 let titles: Vec<&str> = acts
13204 .iter()
13205 .map(|a| a["title"].as_str().unwrap_or(""))
13206 .collect();
13207 assert!(
13208 titles.iter().any(|t| t.contains("function")),
13209 "caret-only must include Extract Function for Cmd-Opt-M: {:?}",
13210 titles,
13211 );
13212 let fn_act = acts
13215 .iter()
13216 .find(|a| a["title"].as_str().unwrap_or("").contains("function"))
13217 .expect("function action present");
13218 let decl = fn_act["edit"]["changes"]["file:///t.zsh"][0]["newText"]
13219 .as_str()
13220 .unwrap_or("");
13221 assert!(
13222 decl.contains("echo greeting"),
13223 "caret-only function extract should wrap the whole line, not just the word: {:?}",
13224 decl,
13225 );
13226 }
13227
13228 #[test]
13229 fn code_actions_caret_on_whitespace_still_offers_function() {
13230 let acts = run_code_actions(" echo hello\n", 0, 2, 0, 2);
13235 let titles: Vec<&str> = acts
13236 .iter()
13237 .map(|a| a["title"].as_str().unwrap_or(""))
13238 .collect();
13239 assert!(
13240 titles.iter().any(|t| t.contains("function")),
13241 "cursor on whitespace must still emit Extract Function: {:?}",
13242 titles,
13243 );
13244 }
13245
13246 #[test]
13247 fn code_actions_caret_on_blank_line_returns_empty() {
13248 let acts = run_code_actions("foo\n\nbar\n", 1, 0, 1, 0);
13252 assert!(
13253 acts.is_empty(),
13254 "blank line should produce no actions: {:?}",
13255 acts.iter().map(|a| a["title"].clone()).collect::<Vec<_>>(),
13256 );
13257 }
13258
13259 #[test]
13260 fn prepare_rename_rejects_in_comment() {
13261 let _g = crate::test_util::global_state_lock();
13262 let mut state = State::default();
13263 state
13264 .docs
13265 .insert("file:///t.zsh".into(), "echo hi # rename me\n".into());
13266 let pos = "echo hi # rename ".len();
13267 let params = json!({
13268 "textDocument": { "uri": "file:///t.zsh" },
13269 "position": { "line": 0, "character": pos },
13270 });
13271 let r = prepare_rename(&state, ¶ms);
13272 assert!(r.is_null(), "prepareRename in comment must reject");
13273 }
13274
13275 #[test]
13276 fn long_flag_completion_inside_command_substitution() {
13277 let line = "x=$(zshrs --";
13283 let ctx = super::lsp_completion_context(line, line.len());
13284 assert!(
13285 matches!(ctx, super::LspCompletionContext::BuiltinLongFlag(ref n) if n == "zshrs"),
13286 "expected BuiltinLongFlag(zshrs) inside `$(`, got {ctx:?}",
13287 );
13288 let line = "diff <(zshrs --";
13290 let ctx = super::lsp_completion_context(line, line.len());
13291 assert!(
13292 matches!(ctx, super::LspCompletionContext::BuiltinLongFlag(ref n) if n == "zshrs"),
13293 "expected BuiltinLongFlag(zshrs) inside `<(`, got {ctx:?}",
13294 );
13295 let line = "(zshrs --";
13297 let ctx = super::lsp_completion_context(line, line.len());
13298 assert!(
13299 matches!(ctx, super::LspCompletionContext::BuiltinLongFlag(ref n) if n == "zshrs"),
13300 "expected BuiltinLongFlag(zshrs) inside `(`, got {ctx:?}",
13301 );
13302 }
13303
13304 #[test]
13305 fn zshrs_long_flag_table_includes_gen_docs() {
13306 let flags = super::extract_builtin_long_flags("zshrs");
13311 let names: std::collections::HashSet<&str> =
13312 flags.iter().map(|(f, _)| f.as_str()).collect();
13313 for must_have in [
13314 "--gen-docs",
13315 "--out",
13316 "--dump-reference-html",
13317 "--names",
13318 "--daemon",
13319 "--color",
13320 ] {
13321 assert!(
13322 names.contains(must_have),
13323 "ZSHRS_SELF_LONG_FLAG_DOCS missing {must_have}",
13324 );
13325 }
13326 }
13327
13328 #[test]
13329 fn zle_dash_dispatches_to_builtin_flag_not_widget_name() {
13330 let line = "zle -";
13338 let ctx = super::lsp_completion_context(line, line.len());
13339 assert!(
13340 matches!(ctx, super::LspCompletionContext::BuiltinFlag(ref n) if n == "zle"),
13341 "expected BuiltinFlag(zle) for `zle -`, got {ctx:?}",
13342 );
13343 let bare = "zle ";
13345 let ctx2 = super::lsp_completion_context(bare, bare.len());
13346 assert!(
13347 matches!(ctx2, super::LspCompletionContext::WidgetName),
13348 "expected WidgetName for `zle `, got {ctx2:?}",
13349 );
13350 let flags = extract_builtin_flags("zle");
13353 let names: std::collections::HashSet<&str> =
13354 flags.iter().map(|(f, _)| f.as_str()).collect();
13355 for must_have in ["-l", "-L", "-N", "-K", "-D", "-A", "-R", "-M"] {
13356 assert!(
13357 names.contains(must_have),
13358 "zle missing flag {must_have} from BUILTIN_FLAG_DOCS_OVERRIDE",
13359 );
13360 }
13361 }
13362
13363 #[test]
13364 fn print_flag_descriptions_present_for_next_line_desc_pattern() {
13365 let flags = extract_builtin_flags("print");
13373 let by: std::collections::HashMap<&str, &str> = flags
13374 .iter()
13375 .map(|(f, d)| (f.as_str(), d.as_str()))
13376 .collect();
13377
13378 assert!(
13380 by.get("-b").is_some_and(|d| d.contains("Recognize")),
13381 "-b should describe escape sequence recognition, got {:?}",
13382 by.get("-b"),
13383 );
13384 assert!(
13385 by.get("-m")
13386 .is_some_and(|d| d.contains("Take the first argument")),
13387 "-m should describe pattern matching, got {:?}",
13388 by.get("-m"),
13389 );
13390
13391 let expectations: &[(&str, &str)] = &[
13394 ("-a", "Print arguments with the column"),
13395 ("-c", "Print the arguments in columns"),
13396 ("-D", "Treat the arguments as paths"),
13397 ("-l", "Print the arguments separated by newlines"),
13398 ("-n", "Do not add a newline"),
13399 ("-o", "Print the arguments sorted in ascending"),
13400 ("-r", "Ignore the escape conventions"),
13401 ("-s", "Place the results in the history list"),
13402 ("-z", "Push the arguments onto the editing buffer"),
13403 ];
13404 for (flag, needle) in expectations {
13405 let got = by.get(flag).copied().unwrap_or("<missing>");
13406 assert!(
13407 got.contains(needle),
13408 "flag {flag} description should contain {:?}, got {:?}",
13409 needle,
13410 got,
13411 );
13412 }
13413 }
13414
13415 #[test]
13416 fn zshrs_self_long_flag_completion_covers_zshrs_specific_plus_setopt_mirrors() {
13417 assert!(super::is_known_builtin_with_long_flag_docs("zshrs"));
13422 assert!(super::is_known_builtin_with_long_flag_docs("zsh"));
13423 assert!(!super::is_known_builtin_with_long_flag_docs("print"));
13424
13425 let flags = super::extract_builtin_long_flags("zshrs");
13426 let by: std::collections::HashMap<&str, &str> = flags
13427 .iter()
13428 .map(|(f, d)| (f.as_str(), d.as_str()))
13429 .collect();
13430
13431 for spec in [
13433 "--help",
13434 "--version",
13435 "--doctor",
13436 "--lsp",
13437 "--dap",
13438 "--dump-tokens",
13439 "--dump-ast",
13440 "--dump-wordcode",
13441 "--dump-zwc",
13442 "--dump-reflection",
13443 "--docs",
13444 "--disasm",
13445 "--zsh",
13446 "--bash",
13447 "--ksh",
13448 "--sh",
13449 "--csh",
13450 "--posix",
13451 "--emulate",
13452 "--zsh-compat",
13453 "--no-rcs",
13454 "--verbose",
13455 "--xtrace",
13456 "--login",
13457 "--interactive",
13458 "--norc",
13459 "--noprofile",
13460 "--rcfile",
13461 "--init-file",
13462 ] {
13463 assert!(
13464 by.contains_key(spec),
13465 "zshrs long-flag table missing {spec}",
13466 );
13467 let d = by.get(spec).unwrap();
13468 let letters = d.chars().filter(|c| c.is_ascii_alphabetic()).count();
13469 assert!(
13470 letters >= 10,
13471 "{spec} description should be substantive, got {:?}",
13472 d,
13473 );
13474 }
13475 for mirror in [
13478 "--autocd",
13479 "--errexit",
13480 "--pipefail",
13481 "--nullglob",
13482 "--extendedglob",
13483 "--no-autocd",
13484 "--no-errexit",
13485 "--no-pipefail",
13486 ] {
13487 assert!(
13488 by.contains_key(mirror),
13489 "setopt-mirror flag {mirror} missing — OPTION_DOCS not flowing through?",
13490 );
13491 }
13492 assert!(
13496 flags.len() > 400,
13497 "expected > 400 long-flag entries (hand table + setopt mirrors × 2), got {}",
13498 flags.len(),
13499 );
13500 }
13501
13502 #[test]
13503 fn zshrs_self_flag_completion_lists_standard_short_flags() {
13504 assert!(super::is_known_builtin_with_flag_docs("zshrs"));
13511 assert!(super::is_known_builtin_with_flag_docs("zsh"));
13512
13513 let flags = extract_builtin_flags("zshrs");
13514 let names: std::collections::HashSet<&str> =
13515 flags.iter().map(|(f, _)| f.as_str()).collect();
13516 for must_have in ["-b", "-c", "-f", "-i", "-l", "-s", "-o", "-v", "-x"] {
13517 assert!(
13518 names.contains(must_have),
13519 "zshrs self-flag table missing {must_have}",
13520 );
13521 }
13522 for (f, d) in &flags {
13523 let letters = d.chars().filter(|c| c.is_ascii_alphabetic()).count();
13524 assert!(
13525 letters >= 10,
13526 "zshrs {f} description should be substantive, got {:?}",
13527 d,
13528 );
13529 }
13530 let zsh_flags = extract_builtin_flags("zsh");
13532 assert_eq!(zsh_flags.len(), flags.len());
13533 }
13534
13535 #[test]
13540 fn audit_all_builtin_flag_coverage() {
13541 let mut all_names: Vec<String> = Vec::new();
13542 for b in crate::ported::builtin::BUILTINS.iter() {
13543 all_names.push(b.node.nam.to_string());
13544 }
13545 for n in crate::ext_builtins::EXT_BUILTIN_NAMES.iter() {
13546 all_names.push(n.to_string());
13547 }
13548 for n in crate::compsys::COMPSYS_FN_NAMES.iter() {
13549 all_names.push(n.to_string());
13550 }
13551 all_names.sort();
13552 all_names.dedup();
13553
13554 let mut total_flags = 0usize;
13555 let mut empty_descs = 0usize;
13556 let mut builtins_with_any_flag = 0usize;
13557 let mut builtins_with_empty: Vec<(String, usize, usize)> = Vec::new();
13558 fn useful(d: &str) -> bool {
13562 let letters = d.chars().filter(|c| c.is_ascii_alphabetic()).count();
13563 letters >= 3
13564 }
13565 for name in &all_names {
13566 let flags = extract_builtin_flags(name);
13567 if flags.is_empty() {
13568 continue;
13569 }
13570 builtins_with_any_flag += 1;
13571 let total = flags.len();
13572 let empty = flags.iter().filter(|(_, d)| !useful(d)).count();
13573 total_flags += total;
13574 empty_descs += empty;
13575 if empty > 0 {
13576 let preview: Vec<String> = flags
13577 .iter()
13578 .filter(|(_, d)| !useful(d))
13579 .take(3)
13580 .map(|(f, d)| format!("{f}={:?}", d))
13581 .collect();
13582 builtins_with_empty.push((
13583 format!("{name} [{}]", preview.join(", ")),
13584 empty,
13585 total,
13586 ));
13587 }
13588 }
13589 eprintln!(
13590 "audit: {} builtins with flags, {} total flag entries, {} empty desc ({:.1}%)",
13591 builtins_with_any_flag,
13592 total_flags,
13593 empty_descs,
13594 100.0 * empty_descs as f64 / total_flags.max(1) as f64,
13595 );
13596 builtins_with_empty.sort_by(|a, b| b.1.cmp(&a.1));
13597 for (name, empty, total) in builtins_with_empty.iter().take(40) {
13598 eprintln!(" {name} : {empty}/{total} empty");
13599 }
13600 }
13601}