use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::io::{self, BufRead, BufReader, Read, Write};
use std::sync::Mutex;
fn read_message<R: BufRead>(reader: &mut R) -> io::Result<Option<Value>> {
let mut content_length: Option<usize> = None;
loop {
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 {
return Ok(None);
}
if line == "\r\n" || line == "\n" {
break;
}
if let Some(rest) = line.strip_prefix("Content-Length:") {
content_length =
Some(rest.trim().parse().map_err(|_| {
io::Error::new(io::ErrorKind::InvalidData, "bad Content-Length")
})?);
}
}
let len = content_length
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing Content-Length"))?;
let mut buf = vec![0u8; len];
reader.read_exact(&mut buf)?;
let v: Value =
serde_json::from_slice(&buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(Some(v))
}
fn write_message<W: Write>(writer: &mut W, msg: &Value) -> io::Result<()> {
let body = serde_json::to_vec(msg)?;
write!(writer, "Content-Length: {}\r\n\r\n", body.len())?;
writer.write_all(&body)?;
writer.flush()
}
#[derive(Default)]
struct State {
docs: HashMap<String, String>,
workspace_files: HashMap<String, String>,
workspace_roots: Vec<std::path::PathBuf>,
}
impl State {
fn all_docs(&self) -> Vec<(String, String)> {
let mut out: HashMap<String, String> = self.workspace_files.clone();
for (k, v) in &self.docs {
out.insert(k.clone(), v.clone());
}
let mut v: Vec<(String, String)> = out.into_iter().collect();
v.sort_by(|a, b| a.0.cmp(&b.0));
v
}
}
const ZSH_EXT: &[&str] = &["zsh", "sh"];
const ZSH_BASENAMES: &[&str] = &[
".zshrc",
".zshenv",
".zprofile",
".zlogin",
".zlogout",
".zsh_aliases",
".zsh_functions",
".zshrc.local",
"zshrc",
];
const SKIP_DIRS: &[&str] = &[
".git",
".hg",
".svn",
"node_modules",
"target",
"build",
"dist",
".idea",
".vscode",
".cache",
".direnv",
".venv",
"venv",
"__pycache__",
];
const MAX_WORKSPACE_FILES: usize = 10_000;
const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
fn is_zsh_source_filename(name: &str) -> bool {
if let Some(ext) = name.rsplit('.').next() {
if ext != name && ZSH_EXT.contains(&ext) {
return true;
}
}
ZSH_BASENAMES.contains(&name)
}
fn path_to_file_uri(p: &std::path::Path) -> Option<String> {
let abs = if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir().ok()?.join(p)
};
let s = abs.to_str()?;
Some(format!("file://{s}"))
}
fn file_uri_to_path(uri: &str) -> Option<std::path::PathBuf> {
uri.strip_prefix("file://").map(std::path::PathBuf::from)
}
fn scan_workspace_root(root: &std::path::Path, out: &mut HashMap<String, String>) {
let mut stack: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
if out.len() >= MAX_WORKSPACE_FILES {
tracing::warn!(
target: "zshrs::lsp::workspace",
cap = MAX_WORKSPACE_FILES,
"workspace scan capped",
);
return;
}
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(e) => {
tracing::trace!(target: "zshrs::lsp::workspace", path=?dir, %e, "read_dir failed");
continue;
}
};
for ent in entries.flatten() {
let path = ent.path();
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
let ty = match ent.file_type() {
Ok(t) => t,
Err(_) => continue,
};
if ty.is_dir() {
if SKIP_DIRS.contains(&name)
|| name.starts_with('.') && !ZSH_BASENAMES.iter().any(|b| b == &name)
{
continue;
}
stack.push(path);
continue;
}
if !ty.is_file() {
continue;
}
if !is_zsh_source_filename(name) {
continue;
}
let md = match ent.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if md.len() > MAX_FILE_BYTES {
continue;
}
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(_) => continue,
};
if let Some(uri) = path_to_file_uri(&path) {
out.insert(uri, text);
if out.len() >= MAX_WORKSPACE_FILES {
return;
}
}
}
}
}
fn ingest_workspace_init(state: &mut State, params: &Value) {
let mut roots: Vec<std::path::PathBuf> = Vec::new();
if let Some(uri) = params.get("rootUri").and_then(|v| v.as_str()) {
if let Some(p) = file_uri_to_path(uri) {
roots.push(p);
}
}
if let Some(folders) = params.get("workspaceFolders").and_then(|v| v.as_array()) {
for f in folders {
if let Some(uri) = f.get("uri").and_then(|v| v.as_str()) {
if let Some(p) = file_uri_to_path(uri) {
roots.push(p);
}
}
}
}
let mut seen = std::collections::HashSet::new();
roots.retain(|p| seen.insert(p.clone()));
if roots.is_empty() {
tracing::info!(target: "zshrs::lsp::workspace", "no roots in initialize");
return;
}
let mut buf: HashMap<String, String> = HashMap::new();
for r in &roots {
scan_workspace_root(r, &mut buf);
}
tracing::info!(
target: "zshrs::lsp::workspace",
roots = roots.len(),
files = buf.len(),
"scanned",
);
state.workspace_roots = roots;
state.workspace_files = buf;
}
fn refresh_workspace_file(state: &mut State, uri: &str) {
if state.workspace_roots.is_empty() {
return;
}
let path = match file_uri_to_path(uri) {
Some(p) => p,
None => return,
};
let inside_root = state.workspace_roots.iter().any(|r| path.starts_with(r));
if !inside_root {
return;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if !is_zsh_source_filename(name) {
return;
}
}
match std::fs::read_to_string(&path) {
Ok(t) => {
state.workspace_files.insert(uri.to_string(), t);
}
Err(_) => {
state.workspace_files.remove(uri);
}
}
}
pub fn run_lsp() -> i32 {
tracing::info!(
target: "zshrs::lsp",
pid = std::process::id(),
"starting --lsp",
);
let mut state = State::default();
let stdin = io::stdin();
let mut reader = BufReader::new(stdin.lock());
let stdout = io::stdout();
let mut writer = stdout.lock();
let log_path = std::env::var("ZSHRS_LSP_LOG").ok();
let mut log = log_path.as_ref().and_then(|p| {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(p)
.ok()
});
loop {
let msg = match read_message(&mut reader) {
Ok(Some(m)) => m,
Ok(None) => {
tracing::info!(target: "zshrs::lsp", "stdin EOF, shutting down");
break;
}
Err(e) => {
if let Some(l) = log.as_mut() {
let _ = writeln!(l, "← read error: {}", e);
}
tracing::error!(target: "zshrs::lsp", %e, "read error, shutting down");
break;
}
};
if let Some(l) = log.as_mut() {
let _ = writeln!(l, "← {}", msg);
}
let method = msg
.get("method")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let id = msg.get("id").cloned();
let params = msg.get("params").cloned().unwrap_or(Value::Null);
tracing::trace!(
target: "zshrs::lsp::req",
method = method.as_deref().unwrap_or("?"),
id = ?id,
);
let response = match method.as_deref() {
Some("initialize") => {
ingest_workspace_init(&mut state, ¶ms);
Some(handle_initialize(id, ¶ms))
}
Some("initialized") => None,
Some("shutdown") => Some(reply(id, json!(null))),
Some("exit") => break,
Some("textDocument/didOpen") => {
if let (Some(uri), Some(text)) = (
params["textDocument"]["uri"].as_str(),
params["textDocument"]["text"].as_str(),
) {
state.docs.insert(uri.to_string(), text.to_string());
publish_diagnostics(&mut writer, uri, text, &mut log);
}
None
}
Some("textDocument/didChange") => {
if let Some(uri) = params["textDocument"]["uri"].as_str() {
if let Some(changes) = params["contentChanges"].as_array() {
if let Some(t) = changes.last().and_then(|c| c["text"].as_str()) {
state.docs.insert(uri.to_string(), t.to_string());
publish_diagnostics(&mut writer, uri, t, &mut log);
}
}
}
None
}
Some("textDocument/didClose") => {
if let Some(uri) = params["textDocument"]["uri"].as_str() {
state.docs.remove(uri);
}
None
}
Some("textDocument/didSave") => {
if let Some(uri) = params["textDocument"]["uri"].as_str() {
if let Some(text) = state.docs.get(uri).cloned() {
publish_diagnostics(&mut writer, uri, &text, &mut log);
}
refresh_workspace_file(&mut state, uri);
}
None
}
Some("textDocument/completion") => Some(reply(id, completion(&state, ¶ms))),
Some("textDocument/hover") => Some(reply(id, hover(&state, ¶ms))),
Some("textDocument/documentSymbol") => {
Some(reply(id, document_symbols(&state, ¶ms)))
}
Some("textDocument/foldingRange") => Some(reply(id, folding_ranges(&state, ¶ms))),
Some("textDocument/definition") => Some(reply(id, definition(&state, ¶ms))),
Some("textDocument/references") => Some(reply(id, references(&state, ¶ms))),
Some("textDocument/documentHighlight") => {
Some(reply(id, document_highlights(&state, ¶ms)))
}
Some("textDocument/rename") => Some(reply(id, rename(&state, ¶ms))),
Some("textDocument/prepareRename") => Some(reply(id, prepare_rename(&state, ¶ms))),
Some("textDocument/semanticTokens/full") => {
Some(reply(id, semantic_tokens(&state, ¶ms)))
}
Some("textDocument/formatting") => Some(reply(id, formatting(&state, ¶ms))),
Some("textDocument/codeAction") => Some(reply(id, code_actions(&state, ¶ms))),
Some(_) if id.is_some() => Some(reply_error(id, -32601, "Method not found")),
_ => None,
};
if let Some(resp) = response {
if let Some(l) = log.as_mut() {
let _ = writeln!(l, "→ {}", resp);
}
if let Err(e) = write_message(&mut writer, &resp) {
if let Some(l) = log.as_mut() {
let _ = writeln!(l, "write error: {}", e);
}
break;
}
}
}
0
}
fn reply(id: Option<Value>, result: Value) -> Value {
json!({
"jsonrpc": "2.0",
"id": id.unwrap_or(Value::Null),
"result": result,
})
}
fn reply_error(id: Option<Value>, code: i32, message: &str) -> Value {
json!({
"jsonrpc": "2.0",
"id": id.unwrap_or(Value::Null),
"error": { "code": code, "message": message },
})
}
fn handle_initialize(id: Option<Value>, _params: &Value) -> Value {
reply(
id,
json!({
"capabilities": {
"textDocumentSync": { "openClose": true, "change": 1, "save": true },
"completionProvider": {
"triggerCharacters": ["$", "{", "(", "[", "#", "*", "?", "!", "-", ":"],
"resolveProvider": false,
},
"hoverProvider": true,
"definitionProvider": true,
"referencesProvider": true,
"documentHighlightProvider": true,
"documentSymbolProvider": true,
"foldingRangeProvider": true,
"renameProvider": { "prepareProvider": true },
"documentFormattingProvider": true,
"codeActionProvider": {
"codeActionKinds": [
"refactor.extract",
],
},
"semanticTokensProvider": {
"legend": {
"tokenTypes": SEMANTIC_TOKEN_TYPES,
"tokenModifiers": [],
},
"full": true,
"range": false,
},
},
"serverInfo": { "name": "zshrs-lsp", "version": env!("CARGO_PKG_VERSION") },
}),
)
}
fn publish_diagnostics<W: Write>(
writer: &mut W,
uri: &str,
text: &str,
log: &mut Option<std::fs::File>,
) {
let diags = diagnose(text);
let msg = json!({
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": { "uri": uri, "diagnostics": diags },
});
if let Some(l) = log.as_mut() {
let _ = writeln!(l, "→ {}", msg);
}
let _ = write_message(writer, &msg);
}
fn diagnose(text: &str) -> Vec<Value> {
let mut diags = Vec::new();
let mut stack: Vec<(char, usize, usize)> = Vec::new(); let mut block_stack: Vec<(&str, usize, usize)> = Vec::new();
for (line_no, line) in text.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
continue;
}
let mut i = 0usize;
let bytes = line.as_bytes();
while i < bytes.len() {
let c = bytes[i] as char;
match c {
'(' => {
if bytes.get(i + 1) == Some(&b'(') {
stack.push(('A', line_no, i));
i += 2;
continue;
}
stack.push(('(', line_no, i));
}
')' => {
if bytes.get(i + 1) == Some(&b')')
&& stack.last().map(|x| x.0) == Some('A')
{
stack.pop();
i += 2;
continue;
}
if stack.last().map(|x| x.0) == Some('(') {
stack.pop();
} else {
let in_case =
block_stack.iter().any(|(kw, _, _)| *kw == "case");
if !in_case {
diags.push(diagnostic(
line_no,
i,
1,
"unmatched `)`",
1,
));
}
}
}
'{' => stack.push(('{', line_no, i)),
'}' => {
if stack.last().map(|x| x.0) == Some('{') {
stack.pop();
} else {
diags.push(diagnostic(line_no, i, 1, "unmatched `}`", 1));
}
}
'[' => {
if bytes.get(i + 1) == Some(&b'[') {
stack.push(('D', line_no, i));
i += 2;
continue;
}
stack.push(('[', line_no, i));
}
']' => {
if bytes.get(i + 1) == Some(&b']')
&& stack.last().map(|x| x.0) == Some('D')
{
stack.pop();
i += 2;
continue;
}
if stack.last().map(|x| x.0) == Some('[') {
stack.pop();
} else {
diags.push(diagnostic(line_no, i, 1, "unmatched `]`", 1));
}
}
'\\' => {
i += 2;
continue;
}
'"' | '\'' | '`' => {
let q = c;
i += 1;
while i < bytes.len() {
let cc = bytes[i] as char;
if cc == '\\' && q != '\'' && i + 1 < bytes.len() {
i += 2;
continue;
}
if cc == q {
break;
}
i += 1;
}
}
'#' => {
let prev = if i == 0 {
None
} else {
Some(bytes[i - 1] as char)
};
let is_comment_start = match prev {
None => true,
Some(p) => {
p.is_whitespace()
|| p == ';'
|| p == '&'
|| p == '|'
|| p == '('
}
};
if is_comment_start {
break;
}
}
_ => {}
}
i += 1;
}
for kw in line.split_whitespace() {
match kw {
"if" | "for" | "while" | "until" | "case" | "select" | "repeat" => {
block_stack.push((kw, line_no, 0));
}
"fi" => {
if block_stack.last().map(|x| x.0) == Some("if") {
block_stack.pop();
} else {
diags.push(diagnostic(line_no, 0, 2, "unmatched `fi`", 1));
}
}
"done" => {
let last = block_stack.last().map(|x| x.0);
if matches!(
last,
Some("for")
| Some("while")
| Some("until")
| Some("select")
| Some("repeat")
) {
block_stack.pop();
} else {
diags.push(diagnostic(line_no, 0, 4, "unmatched `done`", 1));
}
}
"esac" => {
if block_stack.last().map(|x| x.0) == Some("case") {
block_stack.pop();
} else {
diags.push(diagnostic(line_no, 0, 4, "unmatched `esac`", 1));
}
}
_ => {}
}
}
}
for (c, line, col) in stack {
diags.push(diagnostic(line, col, 1, &format!("unclosed `{}`", c), 1));
}
for (kw, line, col) in block_stack {
diags.push(diagnostic(
line,
col,
kw.len(),
&format!("unclosed `{}` block", kw),
1,
));
}
diags
}
fn diagnostic(line: usize, col: usize, len: usize, msg: &str, severity: u8) -> Value {
json!({
"range": {
"start": { "line": line, "character": col },
"end": { "line": line, "character": col + len },
},
"severity": severity,
"source": "zshrs",
"message": msg,
})
}
fn completion(state: &State, params: &Value) -> Value {
let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
let text = state.docs.get(uri);
let line = text.and_then(|t| t.lines().nth(line_no));
if let Some(l) = line {
if cursor_in_uninterpolated_string(l, col) {
return json!({ "isIncomplete": false, "items": [] });
}
}
fn ctx_item(label: &str, detail: &str, doc_md: &str) -> Value {
json!({
"label": label,
"kind": 14, "detail": detail,
"filterText": label,
"insertText": label,
"sortText": format!("0_{}", label),
"documentation": {
"kind": "markdown",
"value": doc_md,
},
})
}
fn ctx_item_chain(label: &str, detail: &str, doc_md: &str) -> Value {
json!({
"label": label,
"kind": 14,
"detail": detail,
"filterText": label,
"insertText": label,
"sortText": format!("0_{}", label),
"documentation": {
"kind": "markdown",
"value": doc_md,
},
"command": {
"title": "Re-trigger completion",
"command": "editor.action.triggerSuggest",
},
})
}
if let Some(l) = line {
match lsp_completion_context(l, col) {
LspCompletionContext::ParamFlag => {
let items: Vec<Value> = PARAM_FLAG_DOCS
.iter()
.map(|(flag, doc)| ctx_item_chain(flag, *doc,
&format!("**`(`{}`)`** — {}\n\n_zsh parameter expansion flag — `${{(FLAGS)var}}`_", flag, doc)))
.collect();
return json!({ "isIncomplete": false, "items": items });
}
LspCompletionContext::GlobQualifier => {
let items: Vec<Value> = GLOB_QUALIFIER_DOCS
.iter()
.map(|(q, doc)| ctx_item_chain(q, *doc,
&format!("**`(`{}`)`** — {}\n\n_zsh glob qualifier — `*(QUALIFIERS)`_", q, doc)))
.collect();
return json!({ "isIncomplete": false, "items": items });
}
LspCompletionContext::HistoryDesignator => {
let items: Vec<Value> = HISTORY_DESIGNATOR_DOCS
.iter()
.map(|(d, doc)| ctx_item(d, *doc,
&format!("**`!{}`** — {}\n\n_zsh history event designator_", d, doc)))
.collect();
return json!({ "isIncomplete": false, "items": items });
}
LspCompletionContext::ParamColonModifier => {
let items: Vec<Value> = PARAM_MODIFIER_DOCS
.iter()
.map(|(m, doc)| ctx_item_chain(m, *doc,
&format!("**`:{}`** — {}\n\n_zsh modifier — `${{var:MOD}}` / `!event:MOD`_", m, doc)))
.collect();
return json!({ "isIncomplete": false, "items": items });
}
LspCompletionContext::OptionOnly => {
let mut items = Vec::new();
for o in crate::ported::options::ZSH_OPTIONS_SET.iter() {
items.push(json!({
"label": o,
"kind": 21, "detail": "zsh option (setopt / unsetopt)",
}));
}
return json!({ "isIncomplete": false, "items": items });
}
LspCompletionContext::SignalName => {
let items: Vec<Value> = SIGNAL_NAMES
.iter()
.map(|(n, doc)| ctx_item(n, *doc,
&format!("**SIG{}** — {}\n\n_signal name — `kill -{}` / `trap … {}`_", n, doc, n, n)))
.collect();
return json!({ "isIncomplete": false, "items": items });
}
LspCompletionContext::ModuleName => {
let items: Vec<Value> = ZSH_MODULE_NAMES
.iter()
.map(|(n, doc)| ctx_item(n, *doc,
&format!("**`{}`** — {}\n\n_zsh module — `zmodload {}`_", n, doc, n)))
.collect();
return json!({ "isIncomplete": false, "items": items });
}
LspCompletionContext::KeymapName => {
let items: Vec<Value> = KEYMAP_NAMES
.iter()
.map(|(n, doc)| ctx_item(n, *doc, &format!("**`{}`** — {}", n, doc)))
.collect();
return json!({ "isIncomplete": false, "items": items });
}
LspCompletionContext::WidgetName => {
let items: Vec<Value> = ZLE_WIDGET_NAMES
.iter()
.map(|(n, doc)| ctx_item(n, *doc, &format!("**`{}`** — {}\n\n_ZLE widget_", n, doc)))
.collect();
return json!({ "isIncomplete": false, "items": items });
}
LspCompletionContext::TypesetFlag => {
let items: Vec<Value> = TYPESET_FLAGS
.iter()
.map(|(f, doc)| ctx_item(f, *doc,
&format!("**`{}`** — {}\n\n_typeset / declare / local / readonly flag_", f, doc)))
.collect();
return json!({ "isIncomplete": false, "items": items });
}
LspCompletionContext::ZstyleContext => {
let items: Vec<Value> = ZSTYLE_CONTEXTS
.iter()
.map(|(c, doc)| ctx_item(c, *doc, &format!("**`{}`** — {}\n\n_zstyle context pattern_", c, doc)))
.collect();
return json!({ "isIncomplete": false, "items": items });
}
LspCompletionContext::CompdefFn => {
let mut items = Vec::new();
for n in crate::compsys::COMPSYS_FN_NAMES {
items.push(ctx_item(n, "compsys completion function",
&format!("**`{}`** — compsys completion function", n)));
}
return json!({ "isIncomplete": false, "items": items });
}
LspCompletionContext::TestOperator => {
let items: Vec<Value> = TEST_OPERATORS
.iter()
.map(|(op, doc)| ctx_item(op, *doc,
&format!("**`{}`** — {}\n\n_inside `[[ … ]]` conditional_", op, doc)))
.collect();
return json!({ "isIncomplete": false, "items": items });
}
LspCompletionContext::MathFunction => {
let items: Vec<Value> = MATH_FUNCTIONS
.iter()
.map(|(n, doc)| ctx_item(n, *doc,
&format!("**`{}(…)`** — {}\n\n_math function — inside `((…))` / `$((…))` (most require `zmodload zsh/mathfunc`)_", n, doc)))
.collect();
return json!({ "isIncomplete": false, "items": items });
}
LspCompletionContext::PatternModifier => {
let items: Vec<Value> = PATTERN_MODIFIERS
.iter()
.map(|(m, doc)| ctx_item_chain(m, *doc,
&format!("**`(#{})`** — {}\n\n_extended-glob pattern modifier (needs `EXTENDED_GLOB`)_", m, doc)))
.collect();
return json!({ "isIncomplete": false, "items": items });
}
LspCompletionContext::SubscriptFlag => {
let items: Vec<Value> = SUBSCRIPT_FLAGS
.iter()
.map(|(f, doc)| ctx_item_chain(f, *doc,
&format!("**`({})`** — {}\n\n_array subscript flag — `${{arr[({})pattern]}}`_", f, doc, f)))
.collect();
return json!({ "isIncomplete": false, "items": items });
}
LspCompletionContext::BuiltinFlag(ref builtin_name) => {
let flags = extract_builtin_flags(builtin_name);
let bname = builtin_name.clone();
let cur_word: String = if let Some(l) = line {
let bytes = l.as_bytes();
let cap = col.min(bytes.len());
let mut j = cap;
while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
j -= 1;
}
String::from_utf8_lossy(&bytes[j..cap]).to_string()
} else {
"-".to_string()
};
let stack_prefix: String = if cur_word.starts_with('-') && cur_word.len() >= 2 {
cur_word.clone()
} else {
"-".to_string()
};
let items: Vec<Value> = flags
.into_iter()
.map(|(flag, desc)| {
let letter = flag.trim_start_matches('-');
let label = if stack_prefix == "-" {
flag.clone()
} else {
format!("{}{}", stack_prefix, letter)
};
let detail = if desc.is_empty() {
format!("option flag for `{}`", bname)
} else {
desc.clone()
};
let doc_md = if desc.is_empty() {
format!("**`{}`** — option flag for `{}`", flag, bname)
} else {
format!("**`{}`** — {}\n\n_option flag for `{}`_", flag, desc, bname)
};
ctx_item(&label, &detail, &doc_md)
})
.collect();
return json!({ "isIncomplete": false, "items": items });
}
LspCompletionContext::Normal => {}
}
}
let prefix = line
.map(|line| {
let upto = &line[..line.len().min(col)];
let start = upto
.rfind(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$' || c == '-'))
.map(|i| i + 1)
.unwrap_or(0);
upto[start..].to_string()
})
.unwrap_or_default();
let mut items = Vec::new();
let push = |items: &mut Vec<Value>, label: &str, kind: u8, detail: &str| {
items.push(json!({
"label": label,
"kind": kind,
"detail": detail,
"filterText": label,
"insertText": label,
}));
};
let pre = prefix.to_lowercase();
let want = |s: &str| pre.is_empty() || s.to_lowercase().starts_with(&pre);
for k in KEYWORDS {
if want(k) {
push(&mut items, k, 14, "keyword");
}
}
for (name, _token) in crate::ported::hashtable::RESWDS {
if want(name) {
push(&mut items, name, 14, "keyword");
}
}
for b in BUILTINS {
if want(b) {
push(&mut items, b, 3, "builtin");
}
}
for b in crate::ported::builtin::BUILTINS.iter() {
if want(&b.node.nam) {
push(&mut items, &b.node.nam, 3, "builtin");
}
}
for n in crate::ext_builtins::EXT_BUILTIN_NAMES {
if want(n) {
push(&mut items, n, 3, "extension builtin");
}
}
for n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES {
if want(n) {
push(&mut items, n, 3, "extension builtin (daemon)");
}
}
for n in crate::compsys::COMPSYS_FN_NAMES {
if want(n) {
push(&mut items, n, 3, "compsys function");
}
}
for o in OPTIONS {
if want(o) {
push(&mut items, o, 21, "option");
}
}
for o in crate::ported::options::ZSH_OPTIONS_SET.iter() {
if want(o) {
push(&mut items, o, 21, "option");
}
}
let prefix_has_dollar = prefix.starts_with('$');
let bare_prefix: String = prefix.strip_prefix('$').map(|s| s.to_string()).unwrap_or_default();
let bare_prefix_lc = bare_prefix.to_lowercase();
for (name, _doc) in crate::zsh_special_var_docs::SPECIAL_VAR_DOCS {
if !name.chars().next().map(|c| c.is_ascii_alphabetic() || c == '_').unwrap_or(false) {
continue;
}
if want(name) {
push(&mut items, name, 6, "special variable");
} else if prefix_has_dollar
&& (bare_prefix_lc.is_empty()
|| name.to_lowercase().starts_with(&bare_prefix_lc))
{
let with_sigil = format!("${}", name);
push(&mut items, &with_sigil, 6, "special variable");
}
}
for (alias, _canon) in crate::zsh_special_var_docs::SPECIAL_VAR_ALIASES {
if !alias.chars().next().map(|c| c.is_ascii_alphabetic() || c == '_').unwrap_or(false) {
continue;
}
if want(alias) {
push(&mut items, alias, 6, "special variable");
} else if prefix_has_dollar
&& (bare_prefix_lc.is_empty()
|| alias.to_lowercase().starts_with(&bare_prefix_lc))
{
let with_sigil = format!("${}", alias);
push(&mut items, &with_sigil, 6, "special variable");
}
}
for (prefix, body, detail) in SNIPPETS {
if !want(prefix) {
continue;
}
items.push(json!({
"label": format!("{} …", prefix),
"kind": 15u8,
"detail": detail,
"filterText": prefix,
"insertText": body,
"insertTextFormat": 2u8,
}));
}
if let Some(t) = text {
for (name, kind, detail) in scan_symbols(t) {
if want(&name) {
let lsp_kind: u8 = match kind {
"function" => 3,
"variable" => 6,
_ => 1,
};
push(&mut items, &name, lsp_kind, detail);
}
}
}
json!({ "isIncomplete": false, "items": items })
}
const SNIPPETS: &[(&str, &str, &str)] = &[
("if", "if ${1:cmd}; then\n ${2:body}\nfi${0}", "if/then/fi block (snippet)"),
("ifelse", "if ${1:cmd}; then\n ${2:body}\nelse\n ${3:alt}\nfi${0}", "if/else/fi block (snippet)"),
("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)"),
("elsif", "elif ${1:cmd}; then\n ${2:body}${0}", "elif branch (snippet)"),
("unless", "if ! ${1:cmd}; then\n ${2:body}\nfi${0}", "negated if (snippet)"),
("for", "for ${1:item} in ${2:list}; do\n ${3:body}\ndone${0}", "for loop (snippet)"),
("forin", "for ${1:item} in \"${2:\\${array[@]}}\"; do\n ${3:body}\ndone${0}", "for over quoted-array expansion (snippet)"),
("forarith", "for ((${1:i}=0; \\$${1:i} < ${2:n}; ${1:i}++)); do\n ${3:body}\ndone${0}", "C-style arithmetic for (snippet)"),
("foreach", "foreach ${1:item} (${2:list})\n ${3:body}\nend${0}", "zsh-alt foreach…end (snippet)"),
("while", "while ${1:cmd}; do\n ${2:body}\ndone${0}", "while loop (snippet)"),
("until", "until ${1:cmd}; do\n ${2:body}\ndone${0}", "until loop (snippet)"),
("case", "case ${1:word} in\n ${2:pattern})\n ${3:body}\n ;;\n *)\n ${4:default}\n ;;\nesac${0}", "case/esac (snippet)"),
("select", "select ${1:choice} in ${2:items}; do\n ${3:body}\n break\ndone${0}", "select interactive menu (snippet)"),
("repeat", "repeat ${1:N}; do\n ${2:body}\ndone${0}", "repeat N times (snippet)"),
("break", "break ${1:1}${0}", "break N levels (snippet)"),
("continue", "continue ${1:1}${0}", "continue N levels (snippet)"),
("return", "return ${1:0}${0}", "return status (snippet)"),
("fn", "${1:name}() {\n ${2:body}\n}${0}", "function declaration (snippet)"),
("function", "function ${1:name} {\n ${2:body}\n}${0}", "function keyword form (snippet)"),
("anonfn", "() {\n ${1:body}\n} ${2:args}${0}", "anonymous function (snippet)"),
("local", "local ${1:var}=${2:value}${0}", "local declaration (snippet)"),
("locals", "local ${1:a}=\"\\$1\" ${2:b}=\"\\$2\" ${3:c}=\"\\$3\"${0}", "local positional-arg unpack (snippet)"),
("typeset", "typeset -${1:gAi} ${2:name}${3:=value}${0}", "typeset with attributes (snippet)"),
("export", "export ${1:NAME}=\"${2:value}\"${0}", "export env var (snippet)"),
("readonly", "readonly ${1:NAME}=\"${2:value}\"${0}", "readonly var (snippet)"),
("integer", "integer ${1:name}=${2:0}${0}", "integer typeset shorthand (snippet)"),
("array", "${1:name}=(${2:a b c})${0}", "indexed array literal (snippet)"),
("assoc", "typeset -A ${1:name}\n${1:name}=(\n [${2:key1}]=${3:val1}\n [${4:key2}]=${5:val2}\n)${0}", "associative array (snippet)"),
("trap", "trap '${1:handler}' ${2:INT TERM EXIT}${0}", "signal trap (snippet)"),
("setopt", "setopt ${1:EXTENDED_GLOB NULL_GLOB PIPE_FAIL}${0}", "setopt one or more options (snippet)"),
("unsetopt", "unsetopt ${1:CASE_GLOB}${0}", "unsetopt options (snippet)"),
("autoload", "autoload -Uz ${1:funcname}${0}", "autoload function with -Uz (snippet)"),
("compdef", "compdef ${1:_completer} ${2:command}${0}", "register completion (snippet)"),
("bindkey", "bindkey '${1:^X^E}' ${2:edit-command-line}${0}", "ZLE bindkey (snippet)"),
("alias", "alias ${1:name}='${2:command}'${0}", "alias (snippet)"),
("galias", "alias -g ${1:NAME}='${2:expansion}'${0}", "global alias (snippet)"),
("salias", "alias -s ${1:ext}='${2:opener}'${0}", "suffix alias (snippet)"),
("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)"),
("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)"),
("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)"),
("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)"),
("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)"),
("shebang", "#!/usr/bin/env zshrs\n${0}", "zshrs shebang (snippet)"),
("safeshebang", "#!/usr/bin/env zsh\nemulate -L zsh\nsetopt err_return no_unset pipe_fail extended_glob\n${0}", "strict-mode shebang (snippet)"),
("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)"),
("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)"),
("strict", "emulate -L zsh\nsetopt err_return no_unset pipe_fail extended_glob${0}", "strict-mode options (snippet)"),
("while-read", "while IFS= read -r ${1:line}; do\n ${2:body}\ndone < ${3:file}${0}", "read-loop over file (snippet)"),
("for-each-line", "for ${1:line} in \"\\${(@f)\\$(cat ${2:file})}\"; do\n ${3:body}\ndone${0}", "for-each-line via process subst (snippet)"),
("cat-pipe", "${1:cmd} | while read -r ${2:line}; do\n ${3:body}\ndone${0}", "pipe-to-while (snippet)"),
("heredoc", "cat <<EOT\n${1:body}\nEOT${0}", "heredoc (snippet)"),
("heredocl", "cat <<-EOT\n\t${1:body}\nEOT${0}", "tab-stripped heredoc (snippet)"),
("herestring", "${1:cmd} <<< \"${2:input}\"${0}", "here-string (snippet)"),
("psub-in", "${1:cmd} < <(${2:producer})${0}", "process substitution (input) (snippet)"),
("psub-out", "${1:cmd} > >(${2:consumer})${0}", "process substitution (output) (snippet)"),
("subshell", "(\n ${1:body}\n)${0}", "subshell (snippet)"),
("printfmt", "printf '%s\\\\n' \"${1:args}\"${0}", "printf line-per-arg (snippet)"),
("dirtest", "[[ -d \"${1:path}\" ]] && ${2:body}${0}", "directory-test guard (snippet)"),
("filetest", "[[ -f \"${1:path}\" ]] && ${2:body}${0}", "regular-file guard (snippet)"),
("regexm", "if [[ \"${1:str}\" =~ ${2:pattern} ]]; then\n ${3:body} # \\$match[*] / \\$MATCH\nfi${0}", "regex match into \\$match (snippet)"),
("notempty", "[[ -n \"${1:var}\" ]] || ${2:return 1}${0}", "non-empty guard (snippet)"),
("async", "${1:job}=\\$(async ${2:'expensive_command'})\n${3:# … other work …}\n${4:result}=\\$(await \\$${1:job})${0}", "async + await pair (snippet)"),
("barrier", "barrier '${1:task1}' ::: '${2:task2}' ::: '${3:task3}'${0}", "barrier (parallel + join) (snippet)"),
("peach", "peach ${1:array} {\n ${2:body} # uses \\$it for each element\n}${0}", "parallel for-each on worker pool (snippet)"),
("intercept", "intercept ${1:before} ${2:command} {\n ${3:body}\n}${0}", "AOP intercept (snippet)"),
("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)"),
("argspec", "_arguments \\\\\n '(-h --help)'{-h,--help}'[show help]' \\\\\n '(-v --verbose)'{-v,--verbose}'[verbose]' \\\\\n ':${1:argname}:${2:_files}'${0}", "_arguments spec (snippet)"),
("filesspec", "_files -g '${1:*.zsh}'${0}", "_files glob spec (snippet)"),
("valspec", "_values '${1:tag}' \\\\\n '${2:one}[${3:desc}]' \\\\\n '${4:two}[${5:desc}]'${0}", "_values descriptor (snippet)"),
("describe", "_describe '${1:group}' ${2:choices_array}${0}", "_describe (snippet)"),
("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)"),
("gitcommit", "git add -A && git commit -m \"${1:message}\" && git push${0}", "git add+commit+push (snippet)"),
("curlget", "curl -fsSL ${1:https://example.com/api} | ${2:jq .}${0}", "curl GET + jq pipe (snippet)"),
("jsonget", "${1:cmd} | jq -r '${2:.field}'${0}", "extract JSON field via jq (snippet)"),
("zmodload", "zmodload zsh/${1:datetime}${0}", "load zsh module (snippet)"),
];
fn hover(state: &State, params: &Value) -> Value {
let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
let text = match state.docs.get(uri) {
Some(t) => t,
None => {
tracing::trace!(target: "zshrs::lsp::hover", line = line_no, col, "no_doc_for_uri");
return Value::Null;
}
};
let word = word_at(text, line_no, col).unwrap_or_default();
if word.is_empty() {
tracing::trace!(target: "zshrs::lsp::hover", line = line_no, col, "empty_word");
return Value::Null;
}
let line_text = text.lines().nth(line_no).unwrap_or("");
let (word_start, word_end) = word_span_at(line_text, col).unwrap_or((col, col));
let gate = classify_hover_position(line_text, word_start, word_end);
if gate != HoverGate::Code {
tracing::debug!(
target: "zshrs::lsp::hover",
line = line_no, col, %word,
gated = ?gate,
"suppressed",
);
return Value::Null;
}
let doc = lookup_doc(&word);
if doc.is_empty() {
tracing::trace!(target: "zshrs::lsp::hover", line = line_no, col, %word, "miss");
return Value::Null;
}
tracing::debug!(target: "zshrs::lsp::hover", line = line_no, col, %word, "hit");
json!({
"contents": {
"kind": "markdown",
"value": doc,
}
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HoverGate {
Code,
Comment,
StringLiteral,
}
fn position_inside_string_literal(line_text: &str, start: usize, end: usize) -> bool {
let bytes = line_text.as_bytes();
let cap = end.min(bytes.len());
if start < cap && bytes[start] == b'$'
&& bytes[start + 1..cap]
.iter()
.all(|b| b.is_ascii_alphanumeric() || *b == b'_')
{
return false;
}
if start > 0 && start < cap && bytes[start - 1] == b'$' {
let span_ok = bytes[start..cap]
.iter()
.all(|b| b.is_ascii_alphanumeric() || *b == b'_');
if span_ok {
return false;
}
}
let limit = start.min(bytes.len());
let mut i = 0;
let mut in_str: Option<u8> = None;
let mut interp_depth: i32 = 0;
while i < limit {
let c = bytes[i];
if interp_depth > 0 {
match c {
b'{' => interp_depth += 1,
b'}' => interp_depth -= 1,
_ => {}
}
i += 1;
continue;
}
if let Some(q) = in_str {
if (q == b'"' || q == b'`') && c == b'\\' && i + 1 < bytes.len() {
i += 2;
continue;
}
if (q == b'"' || q == b'`')
&& c == b'$'
&& i + 1 < bytes.len()
&& bytes[i + 1] == b'{'
{
interp_depth = 1;
i += 2;
continue;
}
if c == q {
in_str = None;
}
i += 1;
continue;
}
match c {
b'#' => return false,
b'"' | b'\'' | b'`' => in_str = Some(c),
_ => {}
}
i += 1;
}
if interp_depth > 0 {
return false;
}
if in_str.is_none() {
return false;
}
let q = in_str.unwrap();
let mut j = start;
while j < end.min(bytes.len()) {
if (q == b'"' || q == b'`') && bytes[j] == b'\\' && j + 1 < bytes.len() {
j += 2;
continue;
}
if bytes[j] == q {
return false;
}
j += 1;
}
true
}
pub(crate) fn classify_hover_position(line_text: &str, start: usize, end: usize) -> HoverGate {
if line_starts_comment_before(line_text, start) {
return HoverGate::Comment;
}
if position_inside_string_literal(line_text, start, end) {
return HoverGate::StringLiteral;
}
HoverGate::Code
}
fn word_span_at(line_text: &str, col: usize) -> Option<(usize, usize)> {
let bytes = line_text.as_bytes();
if col > bytes.len() {
return None;
}
let mut start = col;
while start > 0 {
let c = bytes[start - 1] as char;
if c == '_' || c.is_alphanumeric() || c == '$' {
start -= 1;
} else {
break;
}
}
let mut end = col;
while end < bytes.len() {
let c = bytes[end] as char;
if c == '_' || c.is_alphanumeric() {
end += 1;
} else {
break;
}
}
if start == end {
return None;
}
let is_dollar_var = bytes[start] == b'$';
let in_braced = start > 0 && bytes[start - 1] == b'{';
if !is_dollar_var && !in_braced {
while end < bytes.len() && bytes[end] == b'-' {
let mut p = end + 1;
while p < bytes.len() {
let c = bytes[p] as char;
if c == '_' || c.is_alphanumeric() {
p += 1;
} else {
break;
}
}
if p > end + 1 {
end = p;
} else {
break;
}
}
while start > 1 && bytes[start - 1] == b'-' {
let mut p = start - 1;
while p > 0 {
let c = bytes[p - 1] as char;
if c == '_' || c.is_alphanumeric() {
p -= 1;
} else {
break;
}
}
if p < start - 1 {
start = p;
} else {
break;
}
}
}
Some((start, end))
}
pub(crate) fn cursor_in_uninterpolated_string(line: &str, col: usize) -> bool {
let bytes = line.as_bytes();
let cap = col.min(bytes.len());
let mut stack: Vec<u8> = Vec::new();
let mut i = 0;
while i < cap {
let c = bytes[i];
let top = stack.last().copied();
if matches!(top, Some(b'"') | Some(b'`')) && c == b'\\' && i + 1 < cap {
i += 2;
continue;
}
match top {
Some(b'\'') => {
if c == b'\'' {
stack.pop();
}
i += 1;
continue;
}
Some(b'"') => {
if c == b'"' {
stack.pop();
i += 1;
continue;
}
if c == b'$' && i + 1 < cap {
let nxt = bytes[i + 1];
if nxt == b'(' {
stack.push(b'(');
i += 2;
continue;
}
if nxt == b'{' {
stack.push(b'{');
i += 2;
continue;
}
}
if c == b'`' {
stack.push(b'`');
i += 1;
continue;
}
i += 1;
continue;
}
Some(b'`') => {
if c == b'`' {
stack.pop();
i += 1;
continue;
}
if c == b'$' && i + 1 < cap {
let nxt = bytes[i + 1];
if nxt == b'(' {
stack.push(b'(');
i += 2;
continue;
}
if nxt == b'{' {
stack.push(b'{');
i += 2;
continue;
}
}
i += 1;
continue;
}
Some(b'(') => {
if c == b')' {
stack.pop();
i += 1;
continue;
}
}
Some(b'{') => {
if c == b'}' {
stack.pop();
i += 1;
continue;
}
}
_ => {}
}
match c {
b'"' => stack.push(b'"'),
b'\'' => stack.push(b'\''),
b'`' => stack.push(b'`'),
b'$' if i + 1 < cap => {
let nxt = bytes[i + 1];
if nxt == b'(' {
stack.push(b'(');
i += 2;
continue;
}
if nxt == b'{' {
stack.push(b'{');
i += 2;
continue;
}
if nxt == b'\'' {
stack.push(b'\'');
i += 2;
continue;
}
}
b'#' => {
let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
let comment_open = matches!(
prev,
None | Some(b' ') | Some(b'\t') | Some(b';') | Some(b'&') | Some(b'|')
);
if comment_open {
return true;
}
}
_ => {}
}
i += 1;
}
matches!(stack.last().copied(), Some(b'"') | Some(b'\''))
}
pub(crate) fn line_position_inside_string_or_comment(line: &str, end: usize) -> bool {
let bytes = line.as_bytes();
let cap = end.min(bytes.len());
let mut in_dq = false;
let mut in_sq = false;
let mut in_bt = false;
let mut i = 0;
while i < cap {
let c = bytes[i];
if in_dq {
if c == b'\\' && i + 1 < cap {
i += 2;
continue;
}
if c == b'"' {
in_dq = false;
}
} else if in_sq {
if c == b'\'' {
in_sq = false;
}
} else if in_bt {
if c == b'\\' && i + 1 < cap {
i += 2;
continue;
}
if c == b'`' {
in_bt = false;
}
} else if c == b'#' {
return true;
} else if c == b'"' {
in_dq = true;
} else if c == b'\'' {
in_sq = true;
} else if c == b'`' {
in_bt = true;
}
i += 1;
}
in_dq || in_sq || in_bt
}
pub(crate) fn line_position_inside_uninterpolating_context(line: &str, end: usize) -> bool {
let bytes = line.as_bytes();
let cap = end.min(bytes.len());
let mut in_dq = false;
let mut in_sq = false;
let mut in_bt = false;
let mut i = 0;
while i < cap {
let c = bytes[i];
if in_dq {
if c == b'\\' && i + 1 < cap {
i += 2;
continue;
}
if c == b'"' {
in_dq = false;
}
} else if in_sq {
if c == b'\'' {
in_sq = false;
}
} else if in_bt {
if c == b'\\' && i + 1 < cap {
i += 2;
continue;
}
if c == b'`' {
in_bt = false;
}
} else if c == b'#' {
let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
let starts_comment = match prev {
None => true,
Some(p) => matches!(p, b' ' | b'\t' | b';' | b'&' | b'|' | b'('),
};
if starts_comment {
return true;
}
} else if c == b'"' {
in_dq = true;
} else if c == b'\'' {
in_sq = true;
} else if c == b'`' {
in_bt = true;
}
i += 1;
}
in_sq
}
pub(crate) fn line_starts_comment_before(line: &str, end: usize) -> bool {
let bytes = line.as_bytes();
let cap = end.min(bytes.len());
let mut in_dq = false;
let mut in_sq = false;
let mut in_bt = false;
let mut i = 0;
while i < cap {
let c = bytes[i];
if in_dq {
if c == b'\\' && i + 1 < cap {
i += 2;
continue;
}
if c == b'"' {
in_dq = false;
}
} else if in_sq {
if c == b'\'' {
in_sq = false;
}
} else if in_bt {
if c == b'\\' && i + 1 < cap {
i += 2;
continue;
}
if c == b'`' {
in_bt = false;
}
} else {
if c == b'#' {
return true;
}
if c == b'"' {
in_dq = true;
} else if c == b'\'' {
in_sq = true;
} else if c == b'`' {
in_bt = true;
}
}
i += 1;
}
false
}
pub fn lookup_doc(name: &str) -> String {
if let Some(d) = OPERATOR_DOCS.iter().find(|(k, _)| *k == name) {
return format!("**{}** — _zsh operator_\n\n{}", d.0, d.1);
}
if let Some((canon, body)) = crate::zsh_keyword_docs::lookup_keyword_doc(name) {
return format!("**{}** — _zsh keyword_\n\n{}", canon, body);
}
let is_keyword = crate::ported::hashtable::RESWDS
.iter()
.any(|(n, _)| *n == name);
let is_real_builtin = crate::ported::builtin::BUILTINS
.iter()
.any(|b| b.node.nam == name);
if is_keyword && !is_real_builtin {
if let Some(d) = KEYWORD_DOCS.iter().find(|(k, _)| *k == name) {
return format!("**{}** — _zsh keyword_\n\n{}", d.0, d.1);
}
return format!("**{}** — _zsh keyword_", name);
}
let is_extension = crate::ext_builtins::EXT_BUILTIN_NAMES.contains(&name)
|| crate::daemon::builtins::ZSHRS_BUILTIN_NAMES.contains(&name);
if is_extension {
if let Some(body) = crate::zsh_ext_builtin_docs::lookup_full(name) {
return format!("**{}** — _zshrs extension builtin_\n\n{}", name, body);
}
if let Some(d) = EXT_BUILTIN_DOCS.iter().find(|(k, _)| *k == name) {
return format!("**{}** — _zshrs extension builtin_\n\n{}", d.0, d.1);
}
}
if !is_real_builtin {
if let Some((canon, body)) = crate::zsh_special_var_docs::lookup_special_var_doc(name) {
return format!("**${}** — _special variable_\n\n{}", canon, body);
}
let bare = name.strip_prefix('$').unwrap_or(name);
if !bare.is_empty() && bare != name {
if let Some((canon, body)) = crate::zsh_special_var_docs::lookup_special_var_doc(bare) {
return format!("**${}** — _special variable_\n\n{}", canon, body);
}
}
}
if let Some((canon, body)) = crate::zsh_builtin_docs::lookup_builtin_doc(name) {
return format!("**{}** — _zsh builtin_\n\n{}", canon, body);
}
if let Some((canon, body)) = crate::zsh_special_var_docs::lookup_special_var_doc(name) {
return format!("**${}** — _special variable_\n\n{}", canon, body);
}
let bare = name.strip_prefix('$').unwrap_or(name);
if !bare.is_empty() && bare != name {
if let Some((canon, body)) = crate::zsh_special_var_docs::lookup_special_var_doc(bare) {
return format!("**${}** — _special variable_\n\n{}", canon, body);
}
}
if let Some((canon, body)) = crate::zsh_option_docs::lookup_option_doc(name) {
return format!("**{}** — _zsh option_\n\n{}", canon, body);
}
if let Some(d) = KEYWORD_DOCS.iter().find(|(k, _)| *k == name) {
return format!("**{}** — _zsh keyword_\n\n{}", d.0, d.1);
}
if let Some(d) = BUILTIN_DOCS.iter().find(|(k, _)| *k == name) {
return format!("**{}** — _zsh builtin_\n\n{}", d.0, d.1);
}
if name.starts_with('$') {
if let Some(d) = SPECIAL_VAR_DOCS.iter().find(|(k, _)| *k == name) {
return format!("**{}** — _special variable_\n\n{}", d.0, d.1);
}
}
if let Some(d) = OPTION_DOCS_FALLBACK.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)) {
return format!("**{}** — _zsh option_\n\n{}", d.0, d.1);
}
if let Some(body) = crate::zsh_ext_builtin_docs::lookup_full(name) {
return format!("**{}** — _zshrs extension builtin_\n\n{}", name, body);
}
if let Some(d) = EXT_BUILTIN_DOCS.iter().find(|(k, _)| *k == name) {
return format!("**{}** — _zshrs extension builtin_\n\n{}", d.0, d.1);
}
if let Some(d) = COMPSYS_FN_DOCS.iter().find(|(k, _)| *k == name) {
return format!("**{}** — _compsys function_\n\n{}", d.0, d.1);
}
String::new()
}
const OPERATOR_DOCS: &[(&str, &str)] = &[
("|", "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)."),
("|&", "Pipeline merging stderr. `cmd1 |& cmd2` = `cmd1 2>&1 | cmd2`. Both stdout AND stderr of `cmd1` are piped to `cmd2`."),
("&&", "Logical AND list operator. `cmd1 && cmd2` runs `cmd2` only if `cmd1` succeeded (exit status 0). Short-circuits."),
("||", "Logical OR list operator. `cmd1 || cmd2` runs `cmd2` only if `cmd1` failed (non-zero exit). Short-circuits."),
(";", "Sequential list separator. `cmd1; cmd2` runs `cmd2` after `cmd1` finishes, regardless of its exit status."),
("&", "Background list operator. `cmd &` runs `cmd` asynchronously in the background; the shell does not wait. Sets `$!` to the job's PID."),
(";;", "Case-branch terminator. Ends a `case` arm: `case x in pat) cmds ;; esac`. Stops case dispatch after this arm."),
(";;&", "Case-branch fall-through-and-test-next. Continues to the next `case` arm and tests its pattern."),
(";|", "Case-branch unconditional fall-through. Continues to the next `case` arm and runs it without testing its pattern."),
("!", "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)."),
(">", "Stdout redirect. `cmd > file` writes `cmd`'s stdout to `file` (overwrite). With `NO_CLOBBER`, refuses to overwrite an existing file — use `>|` or `>!` to force."),
(">>", "Stdout append. `cmd >> file` appends `cmd`'s stdout to `file` (creates if missing)."),
("<", "Stdin redirect. `cmd < file` makes `file` the source of `cmd`'s stdin."),
("<<", "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."),
("<<-", "Heredoc with tab-stripping. Like `<<` but every leading tab on body lines (and the terminator) is removed — lets you indent the heredoc for readability."),
("<<<", "Here-string. `cmd <<< 'text'` makes the literal string `text` the source of `cmd`'s stdin. Adds a trailing newline."),
("&>", "Redirect stdout + stderr together. `cmd &> file` = `cmd > file 2>&1`. Shorthand for the common combined redirect."),
("&>>", "Append stdout + stderr together. `cmd &>> file` = `cmd >> file 2>&1`."),
(">&", "Redirect a file descriptor. `2>&1` sends stderr to wherever stdout currently points. `>& file` is also accepted as `&> file`."),
("<&", "Duplicate an input file descriptor. `cmd <&3` reads from fd 3. `<& -` closes stdin."),
("<>", "Read+write redirect. `cmd <> file` opens `file` for both reading and writing on stdin."),
(">|", "Force-overwrite redirect. Equivalent to `>` but ignores `NO_CLOBBER`."),
(">!", "Same as `>|` — force-overwrite, bypass `NO_CLOBBER`."),
("[[", "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."),
("]]", "Close zsh conditional expression. Pairs with `[[`. Must be a separate word — `[[ -n $x]]` is a syntax error; use `[[ -n $x ]]`."),
("[", "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."),
("]", "Close POSIX `test`. Pairs with `[`."),
("((", "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++ ))`."),
("))", "Close arithmetic command. Pairs with `((`."),
("$(", "Command substitution open. `$(cmd)` runs `cmd` and substitutes its trimmed-trailing-newline stdout. Nestable: `$(echo $(date))`. Preferred over backticks."),
("${", "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."),
("$((", "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."),
("<(", "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."),
(">(", "Process substitution (output). `cmd >(consumer)` exposes a filename to `cmd`; anything `cmd` writes there flows to `consumer`'s stdin."),
("`", "Backtick command substitution. ``cmd`` runs `cmd` and substitutes its stdout. Legacy form — prefer `$(cmd)` for nestability and quoting clarity."),
("-e", "File-exists test. `[[ -e PATH ]]` is true if `PATH` exists (any type — file / dir / link / socket / ...)."),
("-f", "Regular-file test. `[[ -f PATH ]]` is true if `PATH` exists AND is a regular file (not a directory / symlink / device)."),
("-d", "Directory test. `[[ -d PATH ]]` is true if `PATH` exists AND is a directory."),
("-r", "Readable test. `[[ -r PATH ]]` is true if `PATH` exists AND is readable by the current process."),
("-w", "Writable test. `[[ -w PATH ]]` is true if `PATH` exists AND is writable by the current process."),
("-x", "Executable test. `[[ -x PATH ]]` is true if `PATH` exists AND has execute permission (or for directories, search permission)."),
("-s", "Non-empty test. `[[ -s PATH ]]` is true if `PATH` exists AND has size > 0."),
("-L", "Symlink test. `[[ -L PATH ]]` is true if `PATH` is a symbolic link (does NOT dereference)."),
("-h", "Same as `-L` — symlink test."),
("-z", "Empty-string test. `[[ -z $s ]]` is true if `$s` is the empty string."),
("-n", "Non-empty-string test. `[[ -n $s ]]` is true if `$s` has length > 0. Equivalent to `[[ $s ]]`."),
("-eq", "Numeric equality. `[[ a -eq b ]]` is true if integers `a` and `b` are equal. For strings use `==`."),
("-ne", "Numeric inequality. `[[ a -ne b ]]` is true if integers `a` and `b` differ."),
("-lt", "Numeric less-than. `[[ a -lt b ]]` is true if integer `a` < `b`."),
("-le", "Numeric less-or-equal. `[[ a -le b ]]` is true if integer `a` ≤ `b`."),
("-gt", "Numeric greater-than. `[[ a -gt b ]]` is true if integer `a` > `b`."),
("-ge", "Numeric greater-or-equal. `[[ a -ge b ]]` is true if integer `a` ≥ `b`."),
("-ot", "Older-than test. `[[ A -ot B ]]` is true if file `A` has an older mtime than `B`."),
("-nt", "Newer-than test. `[[ A -nt B ]]` is true if file `A` has a newer mtime than `B`."),
("-ef", "Same-file test. `[[ A -ef B ]]` is true if `A` and `B` are the same inode (hard-linked / same path)."),
("==", "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\" ]]`."),
("!=", "Pattern-mismatch (inside `[[ … ]]`). Inverse of `==`. Quote the RHS for literal comparison."),
("=~", "Regex match (inside `[[ … ]]`). `[[ $s =~ pat ]]` matches `$s` against the regex `pat`. Capture groups land in `$match` / `$MATCH` / `$BASH_REMATCH`."),
("*", "Glob: match zero or more characters of any name (excluding leading `.` unless `GLOB_DOTS` is set). Also a multiplication operator inside `(( … ))`."),
("?", "Glob: match exactly one character. Also the last-exit-status variable when used as `$?`."),
("**", "Recursive glob (zsh extended). `**/*.rs` matches `*.rs` at any depth under the current directory. Requires `EXTENDED_GLOB` for additional pattern operators."),
("~", "Pattern exclude (with `EXTENDED_GLOB`). `*~README` matches everything except `README`. Also tilde expansion: `~` = `$HOME`, `~user` = user's home, `~+` = `$PWD`, `~-` = `$OLDPWD`."),
("^", "Pattern negate first-match (with `EXTENDED_GLOB`). `^*.rs` matches everything that's NOT `*.rs`. Inside `[…]` ranges, negates: `[^abc]`."),
("{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."),
("{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}`."),
("=", "Assignment. `VAR=value`. NO whitespace around `=`. With `local` / `typeset`: `local VAR=value` declares + assigns."),
("+=", "Append assignment. `VAR+=more` appends to a scalar; for arrays `arr+=(x y)` appends elements. Numeric for `integer`: `(( count += 1 ))`."),
(":=", "Conditional-assign default (inside `${…}`). `${VAR:=fallback}` assigns `fallback` to `VAR` (and substitutes it) if `VAR` is unset or empty."),
("?=", "Error-if-unset (inside `${…}`). `${VAR:?msg}` substitutes `$VAR` if set, else prints `msg` to stderr and exits."),
];
const PARAM_FLAG_DOCS: &[(&str, &str)] = &[
("-", "sort decimal integers numerically (signed)"),
("@", "prevent double-quoted joining of arrays"),
("*", "enable extended globs for pattern arguments"),
("#", "interpret numeric expression as character code"),
("%", "expand prompt sequences (`%P` for prompt-only escapes)"),
("~", "treat strings in parameter flag arguments as patterns"),
("0", "split words on null bytes"),
("A", "assign as an array parameter (in `${...=...}` etc)"),
("a", "sort in array index order (with `O` to reverse)"),
("b", "backslash-quote pattern characters only"),
("B", "include index of beginning of match in `#`, `%` expressions"),
("C", "capitalize words"),
("c", "count characters in an array (with `${(c)#...}`)"),
("D", "perform directory name abbreviation"),
("E", "include index of one past end of match in `#`, `%` expressions"),
("e", "perform single-word shell expansions"),
("F", "join arrays with newlines"),
("f", "split the result on newlines"),
("g", "process echo array sequences (needs options like `gec`)"),
("I", "search Nth match in `#`, `%`, `/` expressions (`(I:N:)`)"),
("i", "sort case-insensitively"),
("j", "join arrays with specified string (`(j:STR:)`)"),
("k", "substitute keys of associative arrays"),
("l", "left-pad resulting words (`(l:N:)`, `(l:N::pad:)`)"),
("L", "lower case all letters"),
("m", "count multibyte width in padding calculation"),
("M", "include matched portion in `#`, `%` expressions"),
("N", "include length of match in `#`, `%` expressions"),
("n", "sort positive decimal integers numerically (unsigned)"),
("o", "sort in ascending order (lexically if no other sort option)"),
("O", "sort in descending order (lexically if no other sort option)"),
("p", "handle print escapes in parameter flag string arguments"),
("P", "use parameter value as name of parameter for redirected lookup"),
("q", "quote with backslashes (`q-` shell-quote, `qq` single-quote, `qqq` double-quote, `qqqq` $'...')"),
("Q", "remove one level of quoting"),
("R", "include rest (unmatched portion) in `#`, `%` expressions"),
("r", "right-pad resulting words (`(r:N:)`, `(r:N::pad:)`)"),
("S", "match non-greedy in `/`, `//`, or search substrings in `%`/`#` expressions"),
("s", "split words on specified string (`(s:STR:)`)"),
("t", "substitute type of parameter (`scalar`, `array`, `association`, `integer`, `float`, plus flags)"),
("u", "substitute first occurrence of each unique word"),
("U", "upper case all letters"),
("v", "substitute values of associative arrays (with `k`)"),
("V", "visibility enhancements for special characters"),
("w", "count words in array or string (with `${(w)#...}`)"),
("W", "count words including empty words (with `${(W)#...}`)"),
("X", "report parsing errors and exit substitution on failure"),
("z", "split words as if a zsh command line"),
("Z", "split words as if a zsh command line (with options — `(Z:cn:)`, `(Z:Cn:)`)"),
];
const GLOB_QUALIFIER_DOCS: &[(&str, &str)] = &[
("/", "directories"),
("F", "non-empty directories"),
(".", "plain files (regular)"),
("@", "symbolic links"),
("=", "sockets"),
("p", "named pipes (FIFOs)"),
("*", "executable plain files (mode `0111`)"),
("%", "device files (block or character)"),
("r", "owner-readable"),
("w", "owner-writable"),
("x", "owner-executable"),
("A", "group-readable"),
("I", "group-writable"),
("E", "group-executable"),
("R", "world-readable"),
("W", "world-writable"),
("X", "world-executable"),
("s", "setuid"),
("S", "setgid"),
("t", "sticky bit set"),
("U", "owned by current effective uid"),
("G", "owned by current effective gid"),
("u", "owned by specified uid (`u:LOGIN:` / `u<UID>`)"),
("g", "owned by specified gid (`g:GROUP:` / `g<GID>`)"),
("f", "exact file mode match (`f:SPEC:`, eg `f:u+w:`)"),
("a", "atime (`a-N` younger than N days, `a+N` older)"),
("m", "mtime (`m-N` / `m+N`; suffixes `M`/`w`/`h`/`m`/`s`)"),
("c", "ctime (`c-N` / `c+N`)"),
("L", "size in bytes (`L-N`, `L+N`, suffixes `k`/`m`/`p`)"),
("l", "link count (`l-N` / `l+N`)"),
("d", "files on device DEV (`d<DEV>`)"),
("o", "order ascending (`oN` name, `oL` size, `om` mtime, `oa` atime, `oc` ctime, `od` depth, `oe:cmd:` custom)"),
("O", "order descending (same suffixes as `o`)"),
("[", "slice / range (`[N]`, `[N,M]`, `[N,-1]`)"),
("^", "negate the rest of the qualifier list"),
("-", "follow symbolic links when testing subsequent qualifiers"),
("M", "mark directories with trailing `/`"),
("T", "mark types with file-type indicator (`/=@*%|`)"),
("N", "set NULL_GLOB for this glob only (no match → empty)"),
("D", "include dotfiles in matches"),
("n", "numeric sort (use with `o` / `O`)"),
("Y", "early termination after N matches (`Y<N>`)"),
("P", "prepend WORD to each result (`P:WORD:`)"),
("e", "evaluate expression on each candidate (`e:EXPR:`); `$REPLY` is the filename"),
("+", "true if `cmd FILENAME` exits 0 (`+cmd`)"),
];
const HISTORY_DESIGNATOR_DOCS: &[(&str, &str)] = &[
("!", "previous command (`!!`)"),
("N", "command N from history (`!42`)"),
("-N", "N commands back (`!-3` = third-to-last)"),
("str", "most recent command starting with `str` (`!ls`)"),
("?str?", "most recent command containing `str` (`!?docker?`)"),
("#", "current command line typed so far"),
("$", "last argument of previous command (= `!!:$`)"),
("^", "first argument of previous command (= `!!:^`)"),
("*", "all arguments of previous command (= `!!:*`)"),
(":", "introduce a word designator / modifier — `!!:1`, `!!:s/old/new/`, `!!:h`"),
];
const PARAM_MODIFIER_DOCS: &[(&str, &str)] = &[
("-", "`${var:-WORD}` — use WORD if `var` unset or empty"),
("=", "`${var:=WORD}` — assign WORD to `var` (and use it) if unset/empty"),
("?", "`${var:?MSG}` — print MSG to stderr + exit if `var` unset/empty"),
("+", "`${var:+WORD}` — use WORD if `var` IS set (the inverse of `:-`)"),
("0", "`${var:OFFSET:LENGTH}` — substring (zero-based; negative offset = from end)"),
("h", "head — strip last path component (like `dirname`)"),
("t", "tail — keep ONLY last path component (like `basename`)"),
("r", "root — strip the final `.ext` suffix"),
("e", "extension — keep ONLY the final `.ext` (no leading dot)"),
("a", "absolute — textually resolve `..` / `.` against `$PWD`"),
("A", "absolute + resolve symlinks (like `realpath`)"),
("c", "PATH lookup — replace bare command with full path via `$PATH`"),
("P", "physical path — resolve all symlinks"),
("f", "repeat `:h` until the result is no longer an existing directory"),
("F", "`:F:N:` — repeat `:h` N times"),
("s", "`:s/OLD/NEW/` — substitute first OLD with NEW"),
("gs", "`:gs/OLD/NEW/` — global substitute (every occurrence)"),
("&", "repeat the last `:s` substitution"),
("g&", "repeat the last `:s` substitution globally"),
("q", "quote — backslash-escape all metacharacters"),
("Q", "unquote — remove ONE level of quoting"),
("x", "quote, breaking at whitespace into separate words"),
("l", "lowercase first character"),
("u", "uppercase first character"),
("L", "lowercase ENTIRE string"),
("U", "uppercase ENTIRE string"),
("C", "capitalize each word (`Title Case`)"),
("S", "sort array elements ascending"),
("O", "sort array elements descending"),
("#", "`${var:#PATTERN}` — remove array elements matching PATTERN (with `(@)`)"),
("|", "`${arr:|other}` — set difference (elements of `arr` not in `other`)"),
("*", "`${arr:*other}` — set intersection"),
("^", "`${arr:^other}` — interleave (zip) two arrays"),
("^^", "`${arr:^^other}` — distributed zip (every pair)"),
];
const SIGNAL_NAMES: &[(&str, &str)] = &[
("HUP", "1 — hangup (terminal closed)"),
("INT", "2 — interrupt (Ctrl-C)"),
("QUIT", "3 — quit + core dump (Ctrl-\\)"),
("ILL", "4 — illegal instruction"),
("TRAP", "5 — trace/breakpoint trap"),
("ABRT", "6 — abort (`abort()` syscall)"),
("BUS", "7 — bus error"),
("FPE", "8 — floating-point exception"),
("KILL", "9 — kill (uncatchable, unblockable)"),
("USR1", "10 — user-defined signal 1"),
("SEGV", "11 — segmentation fault"),
("USR2", "12 — user-defined signal 2"),
("PIPE", "13 — write to pipe with no readers"),
("ALRM", "14 — alarm clock (`alarm()`)"),
("TERM", "15 — termination request (default `kill`)"),
("CHLD", "17 — child process state change"),
("CONT", "18 — continue if stopped"),
("STOP", "19 — stop (uncatchable)"),
("TSTP", "20 — terminal stop (Ctrl-Z)"),
("TTIN", "21 — background process needs tty input"),
("TTOU", "22 — background process tty output"),
("URG", "23 — urgent socket data"),
("XCPU", "24 — CPU time limit exceeded"),
("XFSZ", "25 — file size limit exceeded"),
("VTALRM", "26 — virtual timer alarm"),
("PROF", "27 — profiling timer alarm"),
("WINCH", "28 — window size change"),
("IO", "29 — async I/O ready"),
("PWR", "30 — power failure"),
("SYS", "31 — bad syscall"),
("EXIT", "0 — shell exit (special — `trap ... EXIT`)"),
("ZERR", "zsh — fires on any non-zero exit status"),
("DEBUG", "zsh — fires before every command (with `DEBUG_BEFORE_CMD`)"),
];
const ZSH_MODULE_NAMES: &[(&str, &str)] = &[
("zsh/attr", "extended file attribute manipulation"),
("zsh/cap", "POSIX capability sets"),
("zsh/clone", "fork the shell to a new session"),
("zsh/compctl", "legacy `compctl` completion (deprecated)"),
("zsh/complete", "core programmable completion machinery"),
("zsh/complist", "completion list display + menuselect keymap"),
("zsh/computil", "internal helpers used by `_arguments` / `_describe`"),
("zsh/curses", "ncurses bindings (`zcurses`)"),
("zsh/datetime", "`strftime` builtin + `$EPOCHSECONDS` / `$EPOCHREALTIME`"),
("zsh/db/gdbm", "GDBM key-value store as a zsh associative array"),
("zsh/deltochar", "`delete-to-char` / `zap-to-char` ZLE widgets"),
("zsh/example", "template module (skeleton; not useful)"),
("zsh/files", "in-shell file ops (`mkdir`, `chmod`, `mv`, `rm`, `chown`, `sync`, `ln`)"),
("zsh/langinfo", "locale info (`$langinfo`)"),
("zsh/mapfile", "read/write a file as an assoc array"),
("zsh/mathfunc", "`sin`, `cos`, `sqrt`, `log`, `exp`, … math functions for `((…))`"),
("zsh/nearcolor", "approximate-color terminal fallback"),
("zsh/newuser", "first-run user setup helper"),
("zsh/parameter", "reflection — `$functions`, `$aliases`, `$options`, `$commands`, `$parameters`, etc."),
("zsh/pcre", "Perl-compatible regex (`pcre_match` / `=~`)"),
("zsh/regex", "POSIX extended regex (`=~`)"),
("zsh/sched", "in-shell scheduler (`sched +5 cmd`)"),
("zsh/net/socket", "Unix-domain socket builtin (`zsocket`)"),
("zsh/stat", "`stat` builtin returning fields into a hash"),
("zsh/system", "low-level syscalls (`sysread`, `syswrite`, `syserror`, `sysopen`)"),
("zsh/net/tcp", "TCP socket builtin (`ztcp`)"),
("zsh/termcap", "termcap parameter access (`$termcap`)"),
("zsh/terminfo", "terminfo parameter access (`$terminfo`)"),
("zsh/zftp", "FTP client built into the shell"),
("zsh/zle", "Zsh Line Editor — `bindkey`, `zle`, widget registration"),
("zsh/zleparameter", "ZLE introspection — `$widgets`, `$keymaps`"),
("zsh/zprof", "profiling — `zprof` builtin"),
("zsh/zpty", "spawn commands in a pseudo-terminal"),
("zsh/zselect", "`select(2)` on fds with a timeout"),
("zsh/zutil", "core utilities — `zparseopts`, `zformat`, `zstyle`, `zregexparse`"),
];
const KEYMAP_NAMES: &[(&str, &str)] = &[
("emacs", "GNU Readline emacs bindings (default)"),
("vicmd", "vi command-mode keymap"),
("viins", "vi insert-mode keymap"),
("viopp", "vi operator-pending keymap (for `d` / `c` / `y`)"),
("visual", "vi visual-mode keymap"),
(".safe", "minimal fallback keymap — only `self-insert` + `accept-line`"),
("main", "alias — whichever keymap is currently the editing map"),
("command", "vi-mode command-line input keymap"),
("menuselect", "active inside `menu-select` widget"),
("isearch", "active inside incremental-search widgets"),
("listscroll", "active when scrolling completion list"),
];
const ZLE_WIDGET_NAMES: &[(&str, &str)] = &[
("backward-char", "move one character left"),
("forward-char", "move one character right"),
("backward-word", "move one word left"),
("forward-word", "move one word right"),
("beginning-of-line", "move to start of line"),
("end-of-line", "move to end of line"),
("beginning-of-buffer-or-history", "start of buffer / previous-history at top"),
("end-of-buffer-or-history", "end of buffer / next-history at bottom"),
("self-insert", "insert the typed character"),
("accept-line", "submit current line for execution"),
("accept-and-hold", "submit + keep line in buffer"),
("accept-and-infer-next-history", "submit + recall the line after this in history"),
("backward-delete-char", "delete character before cursor"),
("delete-char", "delete character under cursor"),
("backward-kill-word", "delete word before cursor (saves to kill ring)"),
("kill-word", "delete word after cursor"),
("backward-kill-line", "delete from cursor to start of line"),
("kill-line", "delete from cursor to end of line"),
("kill-whole-line", "delete entire line"),
("kill-region", "delete from mark to cursor"),
("yank", "paste last kill"),
("yank-pop", "rotate to earlier kill (after `yank`)"),
("transpose-chars", "swap two characters"),
("transpose-words", "swap two words"),
("up-case-word", "uppercase next word"),
("down-case-word", "lowercase next word"),
("capitalize-word", "capitalize next word"),
("quoted-insert", "literal-insert next key (e.g. for control chars)"),
("overwrite-mode", "toggle insert / overwrite"),
("undo", "undo last edit"),
("redo", "redo last undone edit"),
("clear-screen", "clear terminal + redraw"),
("redisplay", "force redraw"),
("send-break", "abandon line (SIGINT-equivalent)"),
("up-line-or-history", "previous line / previous history entry"),
("down-line-or-history", "next line / next history entry"),
("up-history", "previous history entry"),
("down-history", "next history entry"),
("beginning-of-history", "first history entry"),
("end-of-history", "last history entry (current line)"),
("history-incremental-search-backward", "Ctrl-R — incremental search backward"),
("history-incremental-search-forward", "Ctrl-S — incremental search forward"),
("history-search-backward", "search history matching current line prefix"),
("history-search-forward", "forward variant of `history-search-backward`"),
("history-beginning-search-backward", "search backward keeping cursor position"),
("history-beginning-search-forward", "search forward keeping cursor position"),
("infer-next-history", "infer next-history based on previous match"),
("insert-last-word", "insert last word of previous line (`!!:$`)"),
("complete-word", "complete the current word"),
("expand-or-complete", "expand alias / glob, else complete"),
("expand-or-complete-prefix", "as above but with prefix match"),
("list-choices", "show completion options without inserting"),
("menu-complete", "cycle through completions"),
("menu-expand-or-complete", "expand / cycle"),
("reverse-menu-complete", "cycle backward"),
("delete-char-or-list", "delete-char if not at EOL, else list-choices"),
("complete-prefix", "complete current prefix"),
("expand-cmd-path", "expand command to full path"),
("expand-word", "expand current word"),
("vi-cmd-mode", "switch to vi command mode"),
("vi-insert", "switch to vi insert mode"),
("vi-insert-bol", "insert at start of line"),
("vi-add-next", "append after current char (vi `a`)"),
("vi-add-eol", "append at end of line (vi `A`)"),
("vi-backward-char", "h"),
("vi-forward-char", "l"),
("vi-backward-word", "b"),
("vi-forward-word", "w"),
("vi-backward-word-end", "ge"),
("vi-forward-word-end", "e"),
("vi-backward-blank-word", "B"),
("vi-forward-blank-word", "W"),
("vi-up-line-or-history", "k — previous line / history"),
("vi-down-line-or-history", "j — next line / history"),
("vi-beginning-of-line", "0"),
("vi-end-of-line", "$"),
("vi-first-non-blank", "^"),
("vi-delete", "d"),
("vi-delete-char", "x"),
("vi-backward-delete-char", "X"),
("vi-change", "c"),
("vi-change-eol", "C"),
("vi-change-whole-line", "S"),
("vi-substitute", "s"),
("vi-yank", "y"),
("vi-yank-eol", "Y"),
("vi-yank-whole-line", "yy"),
("vi-put-after", "p"),
("vi-put-before", "P"),
("vi-replace", "R"),
("vi-replace-chars", "r"),
("vi-repeat-change", "."),
("vi-repeat-search", "n"),
("vi-rev-repeat-search", "N"),
("vi-find-next-char", "f"),
("vi-find-prev-char", "F"),
("vi-find-next-char-skip", "t"),
("vi-find-prev-char-skip", "T"),
("vi-undo-change", "u"),
("vi-join", "J — join with next line"),
("vi-quoted-insert", "Ctrl-V — literal next"),
("vi-set-buffer", "select named register"),
("vi-history-search-backward", "?"),
("vi-history-search-forward", "/"),
("vi-match-bracket", "% — jump to matching bracket"),
("which-command", "show what command would run"),
("describe-key-briefly", "show binding for next key"),
("execute-named-cmd", "M-x style command execution"),
("execute-last-named-cmd", "re-run last named command"),
("push-line", "save line + clear, runs on next prompt"),
("push-line-or-edit", "push-line or edit multiline"),
("push-input", "push to input stack"),
("get-line", "pop input from stack"),
("set-mark-command", "set the mark at cursor"),
("exchange-point-and-mark", "swap cursor + mark"),
("digit-argument", "begin numeric argument"),
("universal-argument", "begin numeric argument"),
("undefined-key", "called when binding lookup fails"),
];
const TYPESET_FLAGS: &[(&str, &str)] = &[
("-a", "indexed array"),
("-A", "associative array (hash)"),
("-i", "integer (with optional base: `-i 16`)"),
("-E", "float, scientific notation"),
("-F", "float, fixed notation"),
("-l", "lowercase on assignment"),
("-u", "uppercase on assignment"),
("-L", "left-justify, width N (`-L4`)"),
("-R", "right-justify, width N (`-R8`)"),
("-Z", "zero-pad (right-justified, numeric)"),
("-r", "readonly"),
("-x", "export to environment"),
("-g", "global (skip the local scope this would create)"),
("-U", "unique — for arrays, drop duplicate elements"),
("-T", "tie scalar ↔ array (`-T PATH path :`)"),
("-t", "set the `TAGGED` flag (used by some completions)"),
("-H", "hide value in `typeset` listing"),
("-h", "hide builtin/special status"),
("-f", "operate on functions, not parameters"),
("-p", "print declarations in re-readable form"),
("-m", "treat name args as patterns (`typeset -m 'FOO*'`)"),
("-+", "operate at the next outer scope"),
];
const TEST_OPERATORS: &[(&str, &str)] = &[
("-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."),
("-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."),
("-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`."),
("-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`."),
("-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."),
("-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`."),
("-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'`."),
("-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'`."),
("-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'`."),
("-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)."),
("-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; }`."),
("-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."),
("-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`."),
("-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."),
("-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`."),
("-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`."),
("-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'`."),
("-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."),
("-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`."),
("-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."),
("-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`."),
("-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."),
("=", "**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."),
("==", "**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."),
("!=", "**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 `==`."),
("<", "**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`."),
(">", "**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."),
("=~", "**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."),
("-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`."),
("-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."),
("-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`."),
("-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)."),
("-gt", "**Numeric greater-than.** Integer-aware. Mirror of `-lt`.\n\nExample: `[[ $(date +%s) -gt $deadline ]] && abort 'timed out'`."),
("-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."),
("-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`."),
("-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."),
("-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."),
("!", "**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'`."),
("&&", "**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)."),
("||", "**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`."),
("-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."),
("-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."),
];
const MATH_FUNCTIONS: &[(&str, &str)] = &[
("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 π."),
("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."),
("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."),
("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."),
("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."),
("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."),
("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."),
("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)."),
("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`."),
("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)`."),
("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)."),
("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)`."),
("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)."),
("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`."),
("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`."),
("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`."),
("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."),
("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)`."),
("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`."),
("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)`."),
("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."),
("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."),
("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."),
("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)."),
("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) ))`."),
("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) ))`."),
("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) ))`."),
("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`."),
("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)."),
("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."),
("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)."),
("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`."),
("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."),
("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."),
("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)²`)."),
("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)."),
("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."),
("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."),
("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."),
("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 }`."),
("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 }`."),
("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`."),
("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."),
("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."),
("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."),
("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)."),
("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) ))`."),
("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."),
("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."),
("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."),
("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`."),
("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."),
("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."),
("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."),
("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) ))`."),
("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."),
];
const ZSTYLE_CONTEXTS: &[(&str, &str)] = &[
(":completion:*", "all completion settings"),
(":completion:*:default", "default completion"),
(":completion:*:descriptions", "tag-group descriptions in menus"),
(":completion:*:matches", "match grouping / formatting"),
(":completion:*:options", "option-name completion"),
(":completion:*:warnings", "no-match warning style"),
(":completion:*:messages", "info messages from completion ported"),
(":completion:*:corrections", "spell-correction style"),
(":completion:*:*:*:*:processes", "process-name completion (`kill <TAB>`)"),
(":completion:*:functions", "function-name completion"),
(":completion:*:manuals", "man-page completion"),
(":completion:*:hosts", "hostname completion (ssh, scp, etc.)"),
(":vcs_info:*", "version-control info system (`git`/`hg`/`svn` in prompt)"),
(":vcs_info:git:*", "git-specific vcs_info"),
(":prompt:*", "prompt customization (themes)"),
(":urlglobber", "URL-glob filtering"),
(":zftp:*", "zftp module configuration"),
(":grep:*", "grep widget configuration"),
(":compinstall", "`compinstall` wizard state"),
(":zle:*", "ZLE widget configuration"),
(":bracketed-paste-magic", "bracketed-paste-magic widget"),
(":syntax-highlighting", "fast-syntax-highlighting / zsh-syntax-highlighting"),
];
const PATTERN_MODIFIERS: &[(&str, &str)] = &[
("i", "case-insensitive matching for the rest of the pattern"),
("l", "lowercase chars match upper + lower"),
("I", "case-sensitive — reset after `(#i)`"),
("b", "activate backreferences (`$match[N]` / `$mbegin` / `$mend`)"),
("B", "deactivate backreferences"),
("m", "set `$MATCH` / `$MBEGIN` / `$MEND` even without backref"),
("M", "deactivate `m`"),
("a", "`(#aN)` — approximate match with up to N errors"),
("s", "anchor pattern to start of string"),
("e", "anchor pattern to end of string"),
("c", "`(#cN,M)` — preceding atom matched between N and M times"),
("u", "use Unicode character properties"),
("U", "deactivate `u`"),
("q", "treat following pattern as glob qualifier list (`(#q.,L0)`)"),
];
const SUBSCRIPT_FLAGS: &[(&str, &str)] = &[
("e", "exact match — disable globbing on subscript"),
("i", "return INDEX of first matching element"),
("I", "return INDEX of LAST matching element"),
("r", "return VALUE of first match — search reverse"),
("R", "as `r` but ranged"),
("b", "byte offset (with `i` / `I`)"),
("n", "`(nN)` — Nth match (with `i` / `I` / `r` / `R`)"),
("w", "word offset (split on `$IFS`)"),
("W", "word offset with empty fields"),
("p", "process `\\NNN` escapes in `(s::)` separator"),
("s", "`(s:STR:)` — split on STR (with `w` / `W`)"),
("f", "split scalar on newlines (= `(s.\\n.)`)"),
("k", "match against keys of an associative array"),
("v", "match against values of an associative array"),
];
#[derive(Debug, Clone, PartialEq, Eq)]
enum LspCompletionContext {
Normal,
ParamFlag,
GlobQualifier,
HistoryDesignator,
ParamColonModifier,
OptionOnly, SignalName, ModuleName, KeymapName, WidgetName, TypesetFlag, ZstyleContext, CompdefFn, TestOperator, MathFunction, PatternModifier, SubscriptFlag, BuiltinFlag(String),
}
fn leading_command_at(line: &str, col: usize) -> Option<(String, usize)> {
let bytes = line.as_bytes();
let cap = col.min(bytes.len());
let mut s: usize = 0;
let mut i = cap;
while i > 0 {
i -= 1;
let c = bytes[i];
if c == b'\n' || c == b';' {
s = i + 1;
break;
}
if (c == b'|' || c == b'&') && i > 0 {
s = i + 1;
break;
}
}
while s < cap && matches!(bytes[s], b' ' | b'\t') {
s += 1;
}
let mut e = s;
while e < cap && (bytes[e].is_ascii_alphanumeric() || matches!(bytes[e], b'_' | b'-' | b'.')) {
e += 1;
}
if e == s {
return None;
}
let cmd = std::str::from_utf8(&bytes[s..e]).ok()?.to_string();
Some((cmd, e))
}
fn count_pair(bytes: &[u8], end: usize, tok: [u8; 2]) -> i32 {
let cap = end.min(bytes.len());
let mut n: i32 = 0;
let mut i = 0;
while i + 1 < cap {
if bytes[i] == tok[0] && bytes[i + 1] == tok[1] {
n += 1;
i += 2;
} else {
i += 1;
}
}
n
}
fn lsp_completion_context(line: &str, col: usize) -> LspCompletionContext {
let bytes = line.as_bytes();
let cap = col.min(bytes.len());
{
let mut k = cap;
while k > 0 {
let c = bytes[k - 1];
if c.is_ascii_alphanumeric()
|| matches!(c, b'?' | b'#' | b'$' | b'^' | b'*' | b'-' | b'_')
{
k -= 1;
} else {
break;
}
}
if k > 0 && bytes[k - 1] == b'!' {
let bang = k - 1;
let word_bound = bang == 0
|| matches!(
bytes[bang - 1],
b' ' | b'\t' | b';' | b'&' | b'|' | b'(' | b'`' | b'\n'
);
let escaped = bang > 0 && bytes[bang - 1] == b'\\';
let mut paren_pairs: i32 = 0;
let mut j = 0;
while j + 1 < bang {
if bytes[j] == b'(' && bytes[j + 1] == b'(' {
paren_pairs += 1;
j += 2;
continue;
}
if bytes[j] == b')' && bytes[j + 1] == b')' {
paren_pairs -= 1;
j += 2;
continue;
}
j += 1;
}
let in_arith = paren_pairs > 0;
if word_bound && !escaped && !in_arith {
return LspCompletionContext::HistoryDesignator;
}
}
}
{
let mut depth: i32 = 0;
let mut i = cap;
while i > 0 {
i -= 1;
let c = bytes[i];
if c == b')' {
depth += 1;
} else if c == b'(' {
if depth == 0 {
if i >= 2 && bytes[i - 2] == b'$' && bytes[i - 1] == b'{' {
return LspCompletionContext::ParamFlag;
}
if i >= 1 {
let prev = bytes[i - 1];
if matches!(prev, b'*' | b'?' | b']' | b')') {
return LspCompletionContext::GlobQualifier;
}
}
break;
}
depth -= 1;
}
}
}
{
let mut bdepth: i32 = 0;
let mut found_colon = false;
let mut k = cap;
while k > 0 {
k -= 1;
let c = bytes[k];
if c == b'}' {
bdepth += 1;
} else if c == b'{' {
if bdepth == 0 {
if k >= 1 && bytes[k - 1] == b'$' && found_colon {
return LspCompletionContext::ParamColonModifier;
}
break;
}
bdepth -= 1;
} else if c == b':' && bdepth == 0 && !found_colon {
found_colon = true;
}
}
}
{
let mut k = cap;
while k > 0
&& (bytes[k - 1].is_ascii_alphabetic()
|| matches!(bytes[k - 1], b'&' | b'/' | b'g'))
{
k -= 1;
}
if k > 0 && bytes[k - 1] == b':' {
let colon = k - 1;
let mut e = colon;
while e > 0
&& (bytes[e - 1].is_ascii_alphanumeric()
|| matches!(bytes[e - 1], b'?' | b'#' | b'$' | b'^' | b'*' | b'-' | b'_' | b'!'))
{
e -= 1;
}
if e < colon && bytes[e] == b'!' {
let bang = e;
let word_bound = bang == 0
|| matches!(
bytes[bang - 1],
b' ' | b'\t' | b';' | b'&' | b'|' | b'(' | b'`' | b'\n'
);
if word_bound {
return LspCompletionContext::ParamColonModifier;
}
}
}
}
{
let bytes = line.as_bytes();
let cap = col.min(bytes.len());
{
let mut depth: i32 = 0;
let mut i = cap;
while i > 0 {
i -= 1;
let c = bytes[i];
if c == b')' {
depth += 1;
} else if c == b'(' {
if depth == 0 {
if i + 1 < bytes.len() && bytes[i + 1] == b'#' {
return LspCompletionContext::PatternModifier;
}
break;
}
depth -= 1;
}
}
}
{
let mut depth: i32 = 0;
let mut i = cap;
while i > 0 {
i -= 1;
let c = bytes[i];
if c == b')' {
depth += 1;
} else if c == b'(' {
if depth == 0 {
if i >= 1 && bytes[i - 1] == b'[' {
return LspCompletionContext::SubscriptFlag;
}
break;
}
depth -= 1;
}
}
}
let lbrack = count_pair(bytes, cap, [b'[', b'[']);
let rbrack = count_pair(bytes, cap, [b']', b']']);
if lbrack > rbrack {
return LspCompletionContext::TestOperator;
}
let lparen = count_pair(bytes, cap, [b'(', b'(']);
let rparen = count_pair(bytes, cap, [b')', b')']);
if lparen > rparen {
return LspCompletionContext::MathFunction;
}
}
if let Some((cmd, _arg_start)) = leading_command_at(line, col) {
match cmd.as_str() {
"setopt" | "unsetopt" => return LspCompletionContext::OptionOnly,
"set" => {
let bytes = line.as_bytes();
let cap = col.min(bytes.len());
let mut j = 0;
let mut saw_o = false;
while j + 1 < cap {
if (bytes[j] == b'-' || bytes[j] == b'+') && bytes[j + 1] == b'o' {
saw_o = true;
break;
}
j += 1;
}
if saw_o {
return LspCompletionContext::OptionOnly;
}
}
"kill" => {
let bytes = line.as_bytes();
let cap = col.min(bytes.len());
let mut has_dash = false;
let mut j = 0;
while j < cap {
if bytes[j] == b'-' && j > 0 && matches!(bytes[j - 1], b' ' | b'\t') {
has_dash = true;
break;
}
j += 1;
}
if has_dash {
return LspCompletionContext::SignalName;
}
}
"trap" => return LspCompletionContext::SignalName,
"zmodload" => return LspCompletionContext::ModuleName,
"bindkey" => {
let bytes = line.as_bytes();
let cap = col.min(bytes.len());
let mut last_flag: Option<u8> = None;
let mut j = 0;
while j < cap {
if bytes[j] == b'-' && j > 0 && matches!(bytes[j - 1], b' ' | b'\t') && j + 1 < cap {
last_flag = Some(bytes[j + 1]);
}
j += 1;
}
if matches!(last_flag, Some(b'A') | Some(b'M') | Some(b'N')) {
return LspCompletionContext::KeymapName;
}
return LspCompletionContext::WidgetName;
}
"zle" => return LspCompletionContext::WidgetName,
"typeset" | "declare" | "local" | "readonly" | "integer" | "float" | "export" | "private" => {
let bytes = line.as_bytes();
let cap = col.min(bytes.len());
let mut j = cap;
while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
j -= 1;
}
if j < cap && bytes[j] == b'-' {
return LspCompletionContext::TypesetFlag;
}
}
"zstyle" => return LspCompletionContext::ZstyleContext,
"compdef" => return LspCompletionContext::CompdefFn,
_ => {}
}
let bytes = line.as_bytes();
let cap = col.min(bytes.len());
let mut j = cap;
while j > 0 && !matches!(bytes[j - 1], b' ' | b'\t') {
j -= 1;
}
let starts_with_dash = j < cap && bytes[j] == b'-';
let just_after_builtin = j == cap;
if (starts_with_dash || just_after_builtin)
&& is_known_builtin_with_flag_docs(&cmd)
{
return LspCompletionContext::BuiltinFlag(cmd);
}
}
LspCompletionContext::Normal
}
fn derive_inline_flag_desc(body: &str, flag: &str) -> Option<String> {
let needle = format!("`{}`", flag);
let bytes = body.as_bytes();
let nbytes = needle.as_bytes();
let mut best: Option<String> = None;
let mut search_from = 0;
while let Some(pos) = body[search_from..].find(&needle) {
let abs = search_from + pos;
search_from = abs + needle.len();
let mut sstart = abs;
while sstart > 0 {
let c = bytes[sstart - 1];
if c == b'\n' && sstart >= 2 && bytes[sstart - 2] == b'\n' {
break;
}
if c == b'.' && sstart < bytes.len() && matches!(bytes[sstart], b' ' | b'\n') {
sstart += 1; break;
}
sstart -= 1;
}
let mut send = abs + needle.len();
let cap_end = (sstart + 400).min(bytes.len());
while send < cap_end {
let c = bytes[send];
if c == b'.'
&& send + 1 < bytes.len()
&& matches!(bytes[send + 1], b' ' | b'\n')
{
send += 1; break;
}
if c == b'\n' && send + 1 < bytes.len() && bytes[send + 1] == b'\n' {
break;
}
send += 1;
}
let raw = &body[sstart..send.min(bytes.len())];
let cleaned: String = raw
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_string();
if cleaned.len() < 15 {
continue; }
if cleaned.starts_with('#') {
continue;
}
if best
.as_ref()
.map(|b| b.len() > cleaned.len())
.unwrap_or(true)
{
best = Some(cleaned);
}
}
best.map(|s| s.chars().take(200).collect())
}
fn is_known_builtin_with_flag_docs(name: &str) -> bool {
let is_compat = crate::ported::builtin::BUILTINS
.iter()
.any(|b| b.node.nam == name);
let is_ext = crate::ext_builtins::EXT_BUILTIN_NAMES.contains(&name);
let is_compsys = crate::compsys::COMPSYS_FN_NAMES.contains(&name);
if !is_compat && !is_ext && !is_compsys {
return false;
}
!extract_builtin_flags(name).is_empty()
}
fn extract_builtin_flags(name: &str) -> Vec<(String, String)> {
use std::sync::OnceLock;
use std::sync::Mutex;
static CACHE: OnceLock<Mutex<std::collections::HashMap<String, Vec<(String, String)>>>> =
OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
if let Ok(g) = cache.lock() {
if let Some(v) = g.get(name) {
return v.clone();
}
}
if let Some(flags) = lookup_compsys_flag_docs(name) {
let out: Vec<(String, String)> = flags
.iter()
.map(|(f, d)| (f.to_string(), d.to_string()))
.collect();
if let Ok(mut g) = cache.lock() {
g.insert(name.to_string(), out.clone());
}
return out;
}
let body: String = match crate::zsh_builtin_docs::lookup_builtin_doc(name) {
Some((_, b)) => b.to_string(),
None => match crate::zsh_ext_builtin_docs::lookup_full(name) {
Some(b) => b.to_string(),
None => return Vec::new(),
},
};
let mut out: Vec<(String, String)> = Vec::new();
let re_bullet = regex::Regex::new(
r"(?m)^\s*-\s+\*\*`(-[A-Za-z+])(?:\s+[^`]*)?`\*\*[^A-Za-z\n]*([^\n]+)"
).unwrap();
for cap in re_bullet.captures_iter(&body) {
let flag = cap.get(1).unwrap().as_str().to_string();
let raw_desc = cap.get(2).map(|m| m.as_str()).unwrap_or("");
let desc: String = raw_desc.trim().chars().take(200).collect();
if !out.iter().any(|(f, _)| f == &flag) {
out.push((flag, desc));
}
}
if out.is_empty() {
let re_inline = regex::Regex::new(r"`(-[A-Za-z+])`").unwrap();
for cap in re_inline.captures_iter(&body) {
let flag = cap.get(1).unwrap().as_str().to_string();
if out.iter().any(|(f, _)| f == &flag) {
continue;
}
let desc = derive_inline_flag_desc(&body, &flag).unwrap_or_default();
out.push((flag, desc));
}
}
tracing::debug!(
target: "zshrs::lsp::completion",
builtin = %name,
flag_count = out.len(),
"extract_builtin_flags",
);
if let Ok(mut g) = cache.lock() {
g.insert(name.to_string(), out.clone());
}
out
}
fn lookup_compsys_flag_docs(name: &str) -> Option<&'static [(&'static str, &'static str)]> {
COMPSYS_FN_FLAG_DOCS
.iter()
.find(|(n, _)| *n == name)
.map(|(_, flags)| *flags)
}
const COMPSYS_FN_FLAG_DOCS: &[(&str, &[(&str, &str)])] = &[
(
"_all_labels",
&[
("-x", "Show the description even when no matches are added."),
("-1", "Pass `-1` through to `compadd` (suppress duplicate-removal of literal matches)."),
("-2", "Pass `-2` through to `compadd` (suppress de-duplication based on display string)."),
("-V", "Pass `-V name` through to `compadd` (put matches in a named unsorted group)."),
("-J", "Pass `-J name` through to `compadd` (put matches in a named sorted group)."),
],
),
(
"_alternative",
&[
("-O", "Pass `-O name` through to nested `_arguments` calls (preserve the option-spec array)."),
("-C", "Set the curcontext parameter to `name` while running each alternative."),
],
),
(
"_arguments",
&[
("-n", "Set `$NORMARG` to the position of the first normal argument in the `$words` array."),
("-s", "Allow option stacking (`-abc` parsed as `-a -b -c`) — required for typical POSIX-style getopts CLIs."),
("-w", "Even with `-s`, allow stacked options to consume an argument before the next short option."),
("-W", "Even after a `--` separator, keep parsing further `-x` as options instead of treating them as positional."),
("-C", "Make `curcontext` available to action handlers in `->state` form."),
("-R", "Return status 300 instead of 0 when a `->state` action is dispatched (lets callers chain dispatch)."),
("-S", "Stop parsing options once a non-option word is seen (treat the rest as positionals)."),
("-A", "Treat any argument matching pattern `pat` as a non-option terminator (e.g. `-A '-*'`)."),
("-O", "Name an array holding extra `compadd` options to pass to every match."),
("-M", "Pass match spec `matchspec` to `compadd` (controls case-folding, partial-word matching, etc.)."),
("--", "Separator between option specs and rest-arg `[helpspec ...]` syntax."),
("-l", "Long-option style: each rest-arg `helpspec` describes one long option (e.g. `--foo=bar`)."),
("-i", "Skip option specs whose names match any of the patterns in `pats`."),
],
),
(
"_call_program",
&[
("-l", "Read one line at a time from the external program (don't slurp all output)."),
("-p", "Treat the tag as a program-call context for the `command` style lookup."),
],
),
(
"_canonical_paths",
&[
("-A", "Store the resolved canonical paths in array variable `var`."),
("-N", "Don't realpath-resolve — treat the input paths as already canonical."),
("-M", "Pass `-M matchspec` through to `compadd`."),
("-J", "Pass `-J name` through to `compadd` (named sorted group)."),
("-V", "Pass `-V name` through to `compadd` (named unsorted group)."),
("-1", "Pass `-1` through to `compadd`."),
("-2", "Pass `-2` through to `compadd`."),
("-n", "Pass `-n` through to `compadd` (no inserted suffix)."),
("-f", "Pass `-f` through to `compadd` (mark matches as filenames for suffix/cdpath handling)."),
("-X", "Pass `-X explanation` through to `compadd` (custom listing-line message)."),
],
),
(
"_combination",
&[
("-s", "Use `pattern` to split each existing style value into fields (default is comma)."),
],
),
(
"_command_names",
&[
("-e", "Complete only external commands found in `$path` (skip aliases, builtins, functions, reserved words)."),
("-", "Same as `-e`: external-only mode."),
],
),
(
"_completers",
&[
("-p", "List only completer-function names that are valid for use in the `completer` style."),
],
),
(
"_describe",
&[
("-1", "Pass `-1` through to `_next_label`/`compadd`."),
("-2", "Pass `-2` through to `_next_label`/`compadd`."),
("-J", "Pass `-J name` through to `_next_label` (named sorted group)."),
("-V", "Pass `-V name` through to `_next_label` (named unsorted group)."),
("-x", "Show the description even when no matches are added."),
("-o", "Treat each `name1`/`name2` array as describing options (default tag is `options`)."),
("-O", "Like `-o` but each match supports an argument-string suffix after a colon."),
("-t", "Use `tag` instead of the default `values`/`options` tag."),
],
),
(
"_description",
&[
("-x", "Show the description even when no matches are added."),
("-1", "Pass `-1` through to `compadd`."),
("-2", "Pass `-2` through to `compadd`."),
("-V", "Pass `-V name` through to `compadd`."),
("-J", "Pass `-J name` through to `compadd` (groups matches in a named sorted group based on group-name style)."),
],
),
(
"_dir_list",
&[
("-s", "Use `sep` instead of `:` as the directory-list separator."),
("-S", "Keep partially-typed list elements in the completion (allow trailing separator)."),
],
),
(
"_email_addresses",
&[
("-c", "Add only the current-typed prefix as a match (don't expand to full addresses)."),
("-n", "Restrict to entries returned by the named `plugin` backend (`_email-`<plugin>)."),
],
),
(
"_message",
&[
("-r", "Take `descr` literally as the message text (skip the `format` style lookup)."),
("-1", "Show the message even when no matches are added."),
("-2", "Show the message only when matches already exist for some other tag."),
("-V", "Display the message in a named unsorted group `group`."),
("-J", "Display the message in a named sorted group `group`."),
],
),
(
"_multi_parts",
&[
("-i", "Insert matches immediately rather than only when uniquely determined."),
],
),
(
"_next_label",
&[
("-x", "Show the description even when no matches are added."),
("-1", "Pass `-1` through to `compadd`."),
("-2", "Pass `-2` through to `compadd`."),
("-V", "Pass `-V name` through to `compadd`."),
("-J", "Pass `-J name` through to `compadd`."),
],
),
(
"_normal",
&[
("-P", "Treat the current word as following a precommand (`nohup`, `time`, …) — skip to the real command."),
("-p", "Like `-P` but explicitly name the precommand for context lookup."),
],
),
(
"_numbers",
&[
],
),
(
"_pick_variant",
&[
("-b", "Treat `builtin-label` as the variant when the command is a shell builtin."),
("-c", "Run `command` (with its args) to obtain the version string instead of the default `--version` invocation."),
("-r", "Store the matched variant name in the parameter `name`."),
],
),
(
"_requested",
&[
("-x", "Show the description even when no matches are added."),
("-1", "Pass `-1` through to `compadd`."),
("-2", "Pass `-2` through to `compadd`."),
("-V", "Pass `-V name` through to `compadd`."),
("-J", "Pass `-J name` through to `compadd`."),
],
),
(
"_sequence",
&[
("-s", "Use `sep` instead of `,` as the list separator."),
("-n", "Stop accepting more matches once `max` items have been completed in the sequence."),
("-d", "Allow duplicate entries in the sequence (default rejects already-typed values)."),
],
),
(
"_tags",
&[
("-C", "Set the curcontext parameter to `name` while iterating the tag list."),
],
),
(
"_values",
&[
("-O", "Name an array holding extra `compadd` options to pass to every match."),
("-s", "Use `sep` as the value separator between multiple keywords (default is comma)."),
("-S", "Use `sep` as the keyword-and-argument separator (default is `=`)."),
("-w", "Examine other typed arguments as well when computing which value-specs are already used."),
("-C", "Make `curcontext` available to action handlers (must be made local by the caller)."),
],
),
(
"_wanted",
&[
("-x", "Show the description even when no matches are added."),
("-C", "Set the curcontext parameter to `name` while invoking the inner command."),
("-1", "Pass `-1` through to `compadd`."),
("-2", "Pass `-2` through to `compadd`."),
("-V", "Pass `-V name` through to `compadd`."),
("-J", "Pass `-J name` through to `compadd`."),
],
),
(
"_widgets",
&[
("-g", "Restrict completions to widget names matching shell pattern `pattern`."),
],
),
];
const COMPSYS_FN_DOCS: &[(&str, &str)] = &[
(
"_main_complete",
"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`.",
),
(
"_directories",
"Complete directory names only. Equivalent to `_files -/`. Honors `path-files` zstyle and respects `GLOB_DOTS`. Rust impl in `crate::compsys::files::directories_execute`.",
),
(
"_cargo",
"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.",
),
(
"_docker",
"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.",
),
(
"_git",
"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.",
),
(
"_kubectl",
"Completion for `kubectl` — subcommands, resource kinds, resource names (queried via `kubectl get`), context/namespace names from kubeconfig.",
),
(
"_terraform",
"Completion for `terraform` — subcommands, workspace names, state-file paths, providers, modules, variable names from the loaded HCL.",
),
(
"_ls",
"Completion for `ls` — flags + file paths. Baseline stub that delegates path completion to `_files` and option completion to a static spec.",
),
(
"_cd",
"Completion for `cd` — directory paths from `$PWD`, `$cdpath`, and the `dirs` stack. Honors `AUTO_CD` and `CDABLE_VARS`.",
),
(
"_cp",
"Completion for `cp` — flags + file paths. Source paths exclude the destination; destination directory is offered as the final candidate.",
),
(
"_mv",
"Completion for `mv` — flags + file/directory paths. Source/destination split identical to `_cp`.",
),
(
"_rm",
"Completion for `rm` — flags + file paths. `-r` enables directory completion; without it, directories are filtered out.",
),
(
"_cat",
"Completion for `cat` — file paths only. No subcommands; flags pass through to `_files`.",
),
(
"_grep",
"Completion for `grep` (GNU/BSD-flavor-aware) — flags then file paths. First positional argument is the pattern (no completion offered for free-text patterns).",
),
];
const EXT_BUILTIN_DOCS: &[(&str, &str)] = &[
("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."),
("arch", "Print the machine architecture (uname -m equivalent): `x86_64`, `arm64`, `aarch64`, etc."),
("async", "Spawn a background task on the persistent worker pool. `async name { body }` queues the body for parallel execution. Pair with `await name` to join."),
("await", "Block until a previously-spawned `async` task completes. `await name` returns the task's exit status; `await` with no args waits for all in-flight tasks."),
("barrier", "Synchronization point for the parallel worker pool. Waits until every running `async`/`peach` task has finished before continuing."),
("base64", "Encode / decode Base64. `-d` decodes; `-w0` no line wrap. coreutils drop-in."),
("basename", "Strip leading directories and an optional suffix. `basename /a/b.txt .txt` → `b`. coreutils drop-in."),
("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."),
("cat", "Concatenate files to stdout. `-n` numbers lines, `-A` shows tabs/EOLs. coreutils drop-in."),
("cdreplay", "Replay the directory stack into the named directory. Reverses recent `cd` history without traversing the parent chain."),
("cksum", "Print CRC32 checksum + byte count of each file. coreutils drop-in."),
("comm", "Compare two sorted files line-by-line. `-1` / `-2` / `-3` suppress columns. coreutils drop-in."),
("compdef", "Register a completion function for one or more commands. `compdef _git git`. Backed by the SQLite compsys cache; lookups are O(log n)."),
("compgen", "Bash-compatible word generator. `compgen -W 'foo bar baz' fo` → `foo`. Used by bash-completion scripts ported to zshrs."),
("compinit", "Initialize the completion system. Walks `$fpath` in parallel via rayon, populates the SQLite cache, marks every `_*` as autoloaded. Default mode skips `.zcompdump` entirely."),
("complete", "Bash-compatible `complete` command — register a completion spec for a command. zshrs bridges to compsys internally."),
("compopt", "Bash-compatible `compopt` — modify completion options at runtime."),
("cut", "Extract fields or character ranges. `-d':' -f1,3` / `-c5-10`. coreutils drop-in."),
("date", "Print or set the system date. `+%FORMAT` strftime; `-d 'rel'` parse relative; `-u` UTC. coreutils drop-in."),
("dbview", "Dump the local zshrs SQLite caches (autoload bodies, completion cache, history FTS). `dbview --table autoloads` filters by table."),
("dircolors", "Emit `LS_COLORS` from a `.dircolors` file. coreutils drop-in."),
("dirname", "Strip the last path component. `dirname /a/b/c` → `/a/b`. coreutils drop-in."),
("doctor", "Diagnostic report of shell health — cache stats, autoload coverage, fpath sanity, daemon presence, memory footprint, recent error summary. zshrs-only."),
("env", "Run a command in a modified environment, or print the current environment. `env -i` empties; `env VAR=val cmd` sets. coreutils drop-in."),
("expand", "Convert tabs to spaces. `-t N` sets tab width. coreutils drop-in."),
("expr", "Evaluate an arithmetic / string expression. `expr 2 + 3` → `5`. Prefer `$(( … ))` in zshrs scripts; provided for POSIX compatibility."),
("factor", "Print prime factors. `factor 60` → `60: 2 2 3 5`. coreutils drop-in."),
("find", "Walk the filesystem and print / act on matches. Supports `-name`/`-type`/`-mtime`/`-exec`. coreutils drop-in (subset)."),
("fold", "Wrap each input line to a width. `-w N` width, `-s` break at spaces. coreutils drop-in."),
("groups", "Print groups the user (or named user) belongs to. coreutils drop-in."),
("head", "Print the first N lines (`-n N`) or bytes (`-c N`) of each file. coreutils drop-in."),
("help", "Print help for a builtin. `help cd` shows the cd usage. zshrs-only."),
("hostname", "Print the system hostname. `-s` short, `-f` FQDN."),
("id", "Print user / group IDs. `-u` user only, `-g` group only, `-n` names. coreutils drop-in."),
("intercept", "Register an AOP intercept. `intercept before|after|around <cmd> { body }` runs `body` around every invocation of `<cmd>`. Bytecode-compiled at registration; no per-call interpreter overhead. zshrs-only."),
("intercept_proceed", "Inside an `around` intercept body, invoke the underlying command. Required so the intercept doesn't shadow the call permanently."),
("link", "Create a hard link. `link src dst`. coreutils drop-in."),
("logname", "Print the user's login name. coreutils drop-in."),
("mkfifo", "Create named pipes (FIFOs). `mkfifo path …`. coreutils drop-in."),
("mktemp", "Create a temp file or directory with a unique name. `-d` directory, `-p DIR` parent. coreutils drop-in."),
("nice", "Run a command with adjusted scheduling priority. `nice -n 10 cmd`. coreutils drop-in."),
("nl", "Number lines. `-b a` numbers all, `-w N` field width. coreutils drop-in."),
("nproc", "Print the number of processing units available. `--all` ignores affinity."),
("paste", "Merge corresponding lines of files. `-d DELIM` separator. coreutils drop-in."),
("peach", "Parallel-for-each — run a block once per element of an array across the worker pool. `peach arr { print $it }`. Returns when all workers finish. zshrs-only."),
("pgrep", "Print PIDs of processes matching a pattern. `-f` matches full command line."),
("pmap", "Display the memory map of one or more processes. `pmap PID`."),
("printenv", "Print the value of one or more environment variables, or all if none given. coreutils drop-in."),
("profile", "CPU / wall-time profile a command and emit a flamegraph. `profile cmd …` → SVG path printed on stdout. Backed by the same sampler as `zprof`."),
("realpath", "Resolve symlinks and `.` / `..` to a canonical absolute path. coreutils drop-in."),
("rev", "Reverse each input line character-by-character. coreutils drop-in."),
("seq", "Print a sequence of numbers. `seq 1 10` / `seq 1 2 10` / `seq -w 1 10`. coreutils drop-in."),
("sha256sum", "Print or check SHA-256 digests. `-c FILE` checks. coreutils drop-in."),
("shuf", "Shuffle input lines. `-n N` limit, `-e ITEM…` shuffle args, `-i LO-HI` shuffle range. coreutils drop-in."),
("sleep", "Pause for the given duration. `sleep 1`, `sleep 0.5`, `sleep 1m`. coreutils drop-in."),
("sort", "Sort lines. `-n` numeric, `-r` reverse, `-k N` by field, `-u` unique. coreutils drop-in."),
("sum", "BSD/sysv checksum + 1K-block count. coreutils drop-in."),
("tac", "Concatenate files in reverse line order. coreutils drop-in."),
("tail", "Print the last N lines (`-n N`) or follow appends (`-f`). coreutils drop-in."),
("tee", "Copy stdin to stdout AND to each named file. `-a` append. coreutils drop-in."),
("touch", "Create a file or update its mtime. `-d STR` set time, `-r REF` copy from REF. coreutils drop-in."),
("tput", "Terminal-capability query. `tput cols`, `tput setaf 1`. Reads `$TERM` via terminfo."),
("tr", "Translate / squeeze / delete characters. `tr a-z A-Z` uppercases. coreutils drop-in."),
("tsort", "Topological sort of partial-order pairs read from stdin. coreutils drop-in."),
("tty", "Print the controlling terminal device path, or `not a tty` if stdin isn't one."),
("uname", "Print system info. `-a` all, `-s` kernel, `-m` machine, `-r` release. coreutils drop-in."),
("unexpand", "Convert leading spaces to tabs. `-a` all spaces. coreutils drop-in."),
("uniq", "Filter adjacent matching lines. `-c` prefix count, `-d` only duplicates. coreutils drop-in."),
("unlink", "Remove a single file via the `unlink(2)` syscall (no `-r`, no prompts). coreutils drop-in."),
("users", "Print the login names of users currently logged in."),
("wc", "Count newlines, words, bytes. `-l` lines, `-w` words, `-c` bytes. coreutils drop-in."),
("whoami", "Print the effective user name. coreutils drop-in."),
("yes", "Repeatedly output a line. `yes` prints `y` forever; `yes STR` prints STR. coreutils drop-in."),
("zbuild", "Bytecode-compile a zsh source file ahead of time. `zbuild script.zsh` writes `script.zwc` next to it; subsequent `source`s skip the lexer/parser. Same on-disk format as `zcompile` but uses fusevm bytecode."),
("zask", "Send an ask-style request to the daemon and print the JSON response. Used by tools/agents that want a single synchronous query against the shared catalog."),
("zcache", "Read / write / list the per-shell cache namespace. `zcache get K` / `zcache set K V [TTL]` / `zcache del K` / `zcache list [PREFIX]`. Backed by the daemon's in-memory KV with optional SQLite persistence."),
("zcmd-result", "Push the exit status + output of a just-completed command to the daemon's command-history catalog. Used by `precmd` hooks to populate the cross-shell `zhistory` index."),
("zcomplete", "Push a completion candidate to the daemon's shared completion cache. Other shells running compinit will see it without re-walking fpath."),
("zd", "Daemon HTTP client. In-process when invoked from inside zshrs (Unix socket); same args as the standalone `zd` binary. `zd ping` / `zd ops` / `zd cache get K`. Maps 1:1 to `POST /op/<NAME>`."),
("zhistory", "Query the daemon's federated command-history catalog. Spans every shell that pushed via `zcmd-result`. SQLite FTS5-backed; `zhistory search 'pattern'`."),
("zid", "Print the current shell's federated ID — the stable `shell_id` (`bash` / `zsh` / `zshrs` / …) and the per-process `bundle_id` the daemon uses to scope state."),
("zjob", "Manage background jobs through the daemon: `zjob submit -- cmd …` queues, `zjob status ID`, `zjob output ID`, `zjob wait ID`, `zjob kill ID`. Jobs survive shell exit because the daemon owns them."),
("zlock", "Acquire / release / try a named cross-shell lock. `zlock acquire NAME [TIMEOUT]` / `zlock release NAME TOKEN` / `zlock try NAME` / `zlock do NAME -- cmd …`. PID-tagged so the daemon GCs stale entries."),
("zlog", "Append a structured log entry to the daemon's log catalog. `zlog 'message' [key=val …]`. Queryable later via `zhistory` / `dbview`."),
("zls", "List entries in the daemon's federated catalog (aliases, functions, env vars, etc.). `zls --kind alias --shell-id bash`. The cross-shell mirror of `alias`/`functions`/`typeset`."),
("znotify", "Send a desktop / system notification through the daemon. Routes to `osascript` (macOS), `notify-send` (Linux), or the in-shell UI when no platform notifier is available."),
("zping", "Round-trip latency probe against the daemon. Prints the RTT in microseconds; non-zero exit if the daemon is unreachable."),
("zpublish", "Publish a JSON event to a pubsub topic. `zpublish topic.name '{\"key\":\"val\"}'`. Subscribers receive via `zsubscribe`."),
("zsend", "Send a one-shot message to another shell (by `shell_id` or `bundle_id`). Like `znotify` but targets a specific shell, not the user's desktop."),
("zsource", "Push a sourced-file event to the daemon's federated catalog. Used by `source`/`.` hooks so the daemon knows which rc files have been loaded by which shells."),
("zsubscribe", "Subscribe to a pubsub topic and stream incoming messages to stdout as SSE-style JSON lines. `zsubscribe 'shell:*.build_done'`."),
("zsuggest", "Query the daemon's suggestion engine for the next command, given the current cwd + history. Used by ZLE's autosuggestion widget when the local history can't supply a candidate."),
("zsync", "Force a flush of the daemon's in-memory state to the SQLite catalog. Normally happens in the background; `zsync` makes it synchronous so a snapshot is consistent."),
("ztag", "Tag the current shell session with one or more labels. `ztag prod-deploy`. Other shells can filter by tag via `zls --tag prod-deploy`."),
("zunsubscribe", "Cancel a `zsubscribe` stream. `zunsubscribe TOPIC` or `zunsubscribe --all`."),
("zuntag", "Remove a tag from the current shell session. Inverse of `ztag`."),
("zwhere", "Locate which shell / bundle / cwd defined a given alias / function / env var in the federated catalog. `zwhere alias ll` → list of every shell that set `ll`."),
];
const OPTION_DOCS_FALLBACK: &[(&str, &str)] = &[
(
"RESTRICTED",
"Restricted-shell mode (equivalent to invoking zsh as `rzsh` or with `-r`).\
\n\nDisables: `cd`, modifying `$PATH` / `$ENV` / `$SHELL`, `>` / `>>` redirects,\
creating functions with the `function` keyword, `exec`-ing commands containing `/`,\
`kill`-ing by pid, and several `setopt` toggles. Designed for sandboxed login shells\
where the user must stay inside a curated command set. Once set, cannot be cleared\
within the running shell.",
),
];
fn document_symbols(state: &State, params: &Value) -> Value {
let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
let text = match state.docs.get(uri) {
Some(t) => t,
None => return Value::Array(vec![]),
};
let mut syms = Vec::new();
for (name, kind, _detail) in scan_symbols(text) {
let mut line_no = 0usize;
for (i, l) in text.lines().enumerate() {
if l.contains(&name) {
line_no = i;
break;
}
}
let lsp_kind: u8 = match kind {
"function" => 12,
"variable" => 13,
_ => 1,
};
syms.push(json!({
"name": name,
"kind": lsp_kind,
"range": {
"start": { "line": line_no, "character": 0 },
"end": { "line": line_no, "character": 0 },
},
"selectionRange": {
"start": { "line": line_no, "character": 0 },
"end": { "line": line_no, "character": 0 },
},
}));
}
Value::Array(syms)
}
fn scan_symbols(text: &str) -> Vec<(String, &'static str, &'static str)> {
let mut out = Vec::new();
for line in text.lines() {
let t = line.trim_start();
if t.starts_with('#') {
continue;
}
if let Some(rest) = t
.strip_prefix("function ")
.or_else(|| t.strip_prefix("function\t"))
{
if let Some(name) = first_ident(rest) {
out.push((name, "function", "function"));
continue;
}
}
if let Some(idx) = t.find("()") {
let head = &t[..idx];
if let Some(name) = first_ident(head) {
if !head.contains(' ') && !head.contains('\t') {
out.push((name, "function", "function"));
continue;
}
}
}
if let Some(rest) = t.strip_prefix("alias ") {
if let Some(name) = first_ident(rest) {
out.push((name, "alias", "alias"));
continue;
}
}
for prefix in &[
"local ",
"typeset ",
"declare ",
"readonly ",
"export ",
"integer ",
"float ",
] {
if let Some(rest) = t.strip_prefix(prefix) {
if let Some(name) = first_ident(rest) {
out.push((name, "variable", "variable"));
break;
}
}
}
}
out
}
fn first_ident(s: &str) -> Option<String> {
let s = s.trim_start();
let mut end = 0;
for c in s.chars() {
if c == '_' || c.is_alphanumeric() {
end += c.len_utf8();
} else {
break;
}
}
if end == 0 {
None
} else {
Some(s[..end].to_string())
}
}
fn folding_ranges(state: &State, params: &Value) -> Value {
let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
let text = match state.docs.get(uri) {
Some(t) => t,
None => return Value::Array(vec![]),
};
let mut out = Vec::new();
let mut brace_stack: Vec<usize> = Vec::new();
let mut block_stack: Vec<(usize, &str)> = Vec::new();
let mut comment_run_start: Option<usize> = None;
for (i, line) in text.lines().enumerate() {
let t = line.trim_start();
if t.starts_with('#') {
if comment_run_start.is_none() {
comment_run_start = Some(i);
}
} else {
if let Some(start) = comment_run_start.take() {
if i - 1 >= start + 2 {
out.push(json!({
"startLine": start, "endLine": i - 1, "kind": "comment"
}));
}
}
}
for c in line.chars() {
if c == '{' {
brace_stack.push(i);
} else if c == '}' {
if let Some(start) = brace_stack.pop() {
if i > start {
out.push(json!({ "startLine": start, "endLine": i, "kind": "region" }));
}
}
}
}
for tok in t.split_whitespace() {
match tok {
"do" | "then" => block_stack.push((i, tok)),
"done" | "fi" => {
if let Some((start, _)) = block_stack.pop() {
if i > start {
out.push(json!({ "startLine": start, "endLine": i, "kind": "region" }));
}
}
}
"case" => block_stack.push((i, "case")),
"esac" => {
if let Some((start, _)) = block_stack.pop() {
if i > start {
out.push(json!({ "startLine": start, "endLine": i, "kind": "region" }));
}
}
}
_ => {}
}
}
}
Value::Array(out)
}
fn definition(state: &State, params: &Value) -> Value {
let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
let text = match state.docs.get(uri) {
Some(t) => t,
None => return Value::Null,
};
let word = match word_at(text, line_no, col) {
Some(w) if !w.is_empty() => w,
_ => return Value::Null,
};
let bare = word.strip_prefix('$').unwrap_or(&word);
if let Some(v) = definition_via_ast(state, &word, bare) {
return v;
}
for (i, l) in text.lines().enumerate() {
let t = l.trim_start();
let is_def = t.starts_with(&format!("function {}", word))
|| t.starts_with(&format!("{}()", word))
|| t.starts_with(&format!("{} ()", word));
if is_def {
let start_col = l.find(&word).unwrap_or(0);
return json!({
"uri": uri,
"range": {
"start": { "line": i, "character": start_col },
"end": { "line": i, "character": start_col + word.len() },
},
});
}
}
Value::Null
}
fn definition_via_ast(state: &State, word: &str, bare: &str) -> Option<Value> {
use crate::lsp_symbols::{find_ast_occurrences, SymbolKind};
let kind = if word.starts_with('$') {
SymbolKind::Global
} else {
SymbolKind::Func
};
let mut hits: Vec<Value> = Vec::new();
for (uri, src) in state.all_docs() {
let lines = find_ast_occurrences(&src, bare, kind.clone());
for line in lines {
if !line_is_decl(&src, line, bare, &kind) {
continue;
}
if let Some((start, end)) = find_first_word_col(&src, line, bare) {
hits.push(json!({
"uri": uri,
"range": {
"start": { "line": line, "character": start },
"end": { "line": line, "character": end },
},
}));
}
}
}
if hits.is_empty() {
let alt = if matches!(kind, SymbolKind::Global) {
SymbolKind::Func
} else {
SymbolKind::Global
};
for (uri, src) in state.all_docs() {
let lines = find_ast_occurrences(&src, bare, alt.clone());
for line in lines {
if !line_is_decl(&src, line, bare, &alt) {
continue;
}
if let Some((start, end)) = find_first_word_col(&src, line, bare) {
hits.push(json!({
"uri": uri,
"range": {
"start": { "line": line, "character": start },
"end": { "line": line, "character": end },
},
}));
}
}
}
}
match hits.len() {
0 => None,
1 => Some(hits.into_iter().next().unwrap()),
_ => Some(Value::Array(hits)),
}
}
fn line_is_decl(src: &str, line: u32, name: &str, kind: &crate::lsp_symbols::SymbolKind) -> bool {
let l = match src.lines().nth(line as usize) {
Some(l) => l,
None => return false,
};
let t = l.trim_start();
use crate::lsp_symbols::SymbolKind;
match kind {
SymbolKind::Func => {
t.starts_with(&format!("function {}", name))
|| t.starts_with(&format!("function {} ", name))
|| t.starts_with(&format!("{}()", name))
|| t.starts_with(&format!("{} ()", name))
}
SymbolKind::Global | SymbolKind::Local => {
let prefixes = [
format!("{}=", name),
format!("{}+=", name),
format!("local {}", name),
format!("typeset {}", name),
format!("declare {}", name),
format!("private {}", name),
format!("export {}", name),
format!("readonly {}", name),
format!("integer {}", name),
format!("float {}", name),
];
prefixes.iter().any(|p| t.starts_with(p.as_str()))
}
}
}
fn find_first_word_col(src: &str, line: u32, name: &str) -> Option<(u32, u32)> {
let l = src.lines().nth(line as usize)?;
let mut start = 0;
while let Some(p) = l[start..].find(name) {
let abs = start + p;
let before = l[..abs].chars().last();
let after = l[abs + name.len()..].chars().next();
let ok_b = before
.map(|c| !(c.is_alphanumeric() || c == '_' || c == '-'))
.unwrap_or(true);
let ok_a = after
.map(|c| !(c.is_alphanumeric() || c == '_' || c == '-'))
.unwrap_or(true);
if ok_b && ok_a && !line_position_inside_string_or_comment(l, abs) {
return Some((abs as u32, (abs + name.len()) as u32));
}
start = abs + name.len();
}
None
}
fn find_all_word_cols(line_text: &str, name: &str) -> Vec<(u32, u32)> {
find_all_word_cols_kinded(line_text, name, false)
}
fn find_all_word_cols_kinded(
line_text: &str,
name: &str,
is_variable_ref: bool,
) -> Vec<(u32, u32)> {
let mut out = Vec::new();
let mut start = 0;
while let Some(p) = line_text[start..].find(name) {
let abs = start + p;
let before = line_text[..abs].chars().last();
let after = line_text[abs + name.len()..].chars().next();
let ok_b = before
.map(|c| !(c.is_alphanumeric() || c == '_' || c == '-'))
.unwrap_or(true);
let ok_a = after
.map(|c| !(c.is_alphanumeric() || c == '_' || c == '-'))
.unwrap_or(true);
let masked = if is_variable_ref {
line_position_inside_uninterpolating_context(line_text, abs)
} else {
line_position_inside_string_or_comment(line_text, abs)
};
if ok_b && ok_a && !masked {
out.push((abs as u32, (abs + name.len()) as u32));
}
start = abs + name.len();
}
out
}
fn references(state: &State, params: &Value) -> Value {
let active_uri = params["textDocument"]["uri"]
.as_str()
.unwrap_or("")
.to_string();
let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
let active_text = match state
.docs
.get(&active_uri)
.cloned()
.or_else(|| state.workspace_files.get(&active_uri).cloned())
{
Some(t) => t,
None => return Value::Array(vec![]),
};
let word = match word_at(&active_text, line_no, col) {
Some(w) if !w.is_empty() => w,
_ => return Value::Array(vec![]),
};
match references_via_ast(state, &active_uri, &active_text, line_no as u32, &word) {
Some(v) => v,
None => {
tracing::warn!(
target: "zshrs::lsp::references",
uri = %active_uri,
%word,
line = line_no,
col,
"AST-walk returned no resolution \
(parse failure or cursor not on a declared symbol); \
returning empty rather than falling back to text-search",
);
Value::Array(vec![])
}
}
}
fn references_via_ast(
state: &State,
active_uri: &str,
active_text: &str,
cursor_line: u32,
cursor_word: &str,
) -> Option<Value> {
use crate::lsp_symbols::{find_ast_occurrences, SymbolKind, SymbolTable};
let bare = cursor_word.strip_prefix('$').unwrap_or(cursor_word);
let active_table = SymbolTable::build(active_text)?;
let (name, kind) = match active_table
.symbol_at(cursor_line, bare)
.and_then(|id| active_table.symbols.iter().find(|s| s.id == id))
{
Some(sym) => (sym.name.clone(), sym.kind.clone()),
None => {
let mut found: Option<SymbolKind> = None;
'outer: for (other_uri, src) in state.all_docs() {
if other_uri == active_uri {
continue;
}
let Some(t) = SymbolTable::build(&src) else {
continue;
};
for s in &t.symbols {
if s.name == bare
&& matches!(s.kind, SymbolKind::Func | SymbolKind::Global)
{
found = Some(s.kind.clone());
break 'outer;
}
}
}
let default_kind = if cursor_word.starts_with('$') {
SymbolKind::Global
} else {
SymbolKind::Func
};
(bare.to_string(), found.unwrap_or(default_kind))
}
};
let mut out: Vec<Value> = Vec::new();
let is_var = matches!(kind, SymbolKind::Global | SymbolKind::Local);
let active_lines: Vec<&str> = active_text.lines().collect();
if let Some(id) = active_table.symbol_at(cursor_line, &name) {
for (line, n) in active_table.occurrences(id) {
if let Some(lt) = active_lines.get(line as usize) {
for (s, e) in find_all_word_cols_kinded(lt, &n, is_var) {
out.push(json!({
"uri": active_uri,
"range": {
"start": { "line": line, "character": s },
"end": { "line": line, "character": e },
},
}));
}
}
}
} else {
let lines = find_ast_occurrences(active_text, &name, kind.clone());
for line in lines {
if let Some(lt) = active_lines.get(line as usize) {
for (s, e) in find_all_word_cols_kinded(lt, &name, is_var) {
out.push(json!({
"uri": active_uri,
"range": {
"start": { "line": line, "character": s },
"end": { "line": line, "character": e },
},
}));
}
}
}
}
if !matches!(kind, SymbolKind::Local) {
let mut walked: std::collections::HashSet<String> =
std::collections::HashSet::new();
walked.insert(active_uri.to_string());
for (uri, src) in state.all_docs() {
if uri == active_uri {
continue;
}
walked.insert(uri.clone());
let lines = find_ast_occurrences(&src, &name, kind.clone());
let src_lines: Vec<&str> = src.lines().collect();
for line in lines {
if let Some(lt) = src_lines.get(line as usize) {
for (s, e) in find_all_word_cols_kinded(lt, &name, is_var) {
out.push(json!({
"uri": uri,
"range": {
"start": { "line": line, "character": s },
"end": { "line": line, "character": e },
},
}));
}
}
}
}
let mut queue: Vec<String> = vec![active_uri.to_string()];
for (uri, _) in state.all_docs() {
queue.push(uri);
}
const MAX_FILES: usize = 256;
while let Some(uri) = queue.pop() {
if walked.len() >= MAX_FILES {
tracing::warn!(
target: "zshrs::lsp::references_ast",
walked = walked.len(),
"source-chain hit MAX_FILES cap; stopping BFS",
);
break;
}
let parent_text = state
.docs
.get(&uri)
.cloned()
.or_else(|| state.workspace_files.get(&uri).cloned())
.or_else(|| {
file_uri_to_path(&uri).and_then(|p| std::fs::read_to_string(p).ok())
});
let Some(parent_text) = parent_text else { continue };
let parent_dir = file_uri_to_path(&uri)
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
.unwrap_or_else(|| std::path::PathBuf::from("."));
for sourced_path in crate::lsp_symbols::collect_sourced_paths(&parent_text, &parent_dir) {
let sourced_uri = format!("file://{}", sourced_path.display());
if walked.contains(&sourced_uri) {
continue;
}
walked.insert(sourced_uri.clone());
let Ok(sourced_text) = std::fs::read_to_string(&sourced_path) else { continue };
let lines = find_ast_occurrences(&sourced_text, &name, kind.clone());
let src_lines: Vec<&str> = sourced_text.lines().collect();
for line in lines {
if let Some(lt) = src_lines.get(line as usize) {
for (s, e) in find_all_word_cols_kinded(lt, &name, is_var) {
out.push(json!({
"uri": sourced_uri,
"range": {
"start": { "line": line, "character": s },
"end": { "line": line, "character": e },
},
}));
}
}
}
queue.push(sourced_uri);
}
}
tracing::debug!(
target: "zshrs::lsp::references_ast",
files_walked = walked.len(),
"source-chain BFS done",
);
}
tracing::debug!(
target: "zshrs::lsp::references_ast",
%name,
?kind,
n_results = out.len(),
"AST-resolved",
);
Some(Value::Array(out))
}
fn document_highlights(state: &State, params: &Value) -> Value {
let refs = references(state, params);
let arr = refs.as_array().cloned().unwrap_or_default();
Value::Array(
arr.into_iter()
.map(|r| json!({ "range": r["range"], "kind": 1 }))
.collect(),
)
}
fn prepare_rename(state: &State, params: &Value) -> Value {
let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
let line_no = params["position"]["line"].as_u64().unwrap_or(0) as usize;
let col = params["position"]["character"].as_u64().unwrap_or(0) as usize;
let text = match state.docs.get(uri) {
Some(t) => t,
None => {
tracing::debug!(
target: "zshrs::lsp::prepareRename",
line = line_no, col,
"no_doc_for_uri",
);
return Value::Null;
}
};
let line_text = text.lines().nth(line_no).unwrap_or("");
if line_starts_comment_before(line_text, col) {
tracing::debug!(
target: "zshrs::lsp::prepareRename",
line = line_no, col,
"gated_comment",
);
return Value::Null;
}
if let Some(word) = word_at(text, line_no, col) {
if !word.is_empty() {
if let Some(line) = text.lines().nth(line_no) {
if let Some(s) = line.find(&word) {
tracing::debug!(
target: "zshrs::lsp::prepareRename",
%word, line = line_no, "accepted",
);
return json!({
"start": { "line": line_no, "character": s },
"end": { "line": line_no, "character": s + word.len() },
"placeholder": word,
});
}
}
}
}
tracing::debug!(
target: "zshrs::lsp::prepareRename",
line = line_no, col,
"no_identifier",
);
Value::Null
}
fn rename(state: &State, params: &Value) -> Value {
let new_name_raw = params["newName"].as_str().unwrap_or("").to_string();
if new_name_raw.is_empty() {
tracing::warn!(target: "zshrs::lsp::rename", "rejecting empty new_name");
return Value::Null;
}
let new_name = match new_name_raw.rfind("::") {
Some(idx) => {
let bare = new_name_raw[idx + 2..].to_string();
tracing::warn!(
target: "zshrs::lsp::rename",
%new_name_raw, %bare,
"stripping `::` qualifier from new_name",
);
bare
}
None => new_name_raw,
};
let refs = references(state, params);
let arr = refs.as_array().cloned().unwrap_or_default();
let mut buckets: HashMap<String, Vec<Value>> = HashMap::new();
let mut total = 0usize;
for r in arr {
let uri = r["uri"].as_str().unwrap_or("").to_string();
if uri.is_empty() {
continue;
}
buckets
.entry(uri)
.or_default()
.push(json!({ "range": r["range"], "newText": new_name }));
total += 1;
}
tracing::info!(
target: "zshrs::lsp::rename",
%new_name,
n_files = buckets.len(),
n_edits = total,
"applied",
);
let mut changes = serde_json::Map::new();
for (uri, edits) in buckets {
changes.insert(uri, Value::Array(edits));
}
json!({ "changes": Value::Object(changes) })
}
const SEMANTIC_TOKEN_TYPES: &[&str] = &[
"comment", "string", "number", "keyword", "operator", "function", "variable", "parameter", "type", "macro", "property", "regexp", "zshrsExtension", "zshrsCompsys", ];
fn semantic_tokens(state: &State, params: &Value) -> Value {
let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
let text = match state.docs.get(uri) {
Some(t) => t,
None => return json!({ "data": [] }),
};
let mut data: Vec<u32> = Vec::new();
let mut last_line: u32 = 0;
let mut last_col: u32 = 0;
for (i, line) in text.lines().enumerate() {
let ln = i as u32;
let mut col = 0usize;
let bytes = line.as_bytes();
while col < bytes.len() {
let rest = &line[col..];
if rest.starts_with('#') {
push_tok(
&mut data,
&mut last_line,
&mut last_col,
ln,
col as u32,
rest.len() as u32,
0,
);
break;
}
if rest.starts_with('"') || rest.starts_with('\'') || rest.starts_with('`') {
let q = rest.as_bytes()[0] as char;
let bb = rest.as_bytes();
let mut close = 1;
while close < bb.len() {
let c = bb[close] as char;
if c == '\\' && q != '\'' && close + 1 < bb.len() {
close += 2;
continue;
}
if c == q {
close += 1;
break;
}
close += 1;
}
if q == '\'' {
push_tok(
&mut data,
&mut last_line,
&mut last_col,
ln,
col as u32,
close as u32,
1,
);
col += close;
continue;
}
let mut seg_start = 0usize;
let mut p = 1usize;
let inner_end = if close > 0 && close <= bb.len() && bb.get(close - 1) == Some(&(q as u8)) {
close - 1
} else {
close
};
let flush_string =
|data: &mut Vec<u32>, last_line: &mut u32, last_col: &mut u32,
col: usize, seg_start: usize, seg_end: usize| {
if seg_end > seg_start {
push_tok(
data,
last_line,
last_col,
ln,
(col + seg_start) as u32,
(seg_end - seg_start) as u32,
1, );
}
};
while p < inner_end {
let c = bb[p] as char;
if c == '\\' && q != '\'' && p + 1 < inner_end {
p += 2;
continue;
}
if c == '$' {
flush_string(&mut data, &mut last_line, &mut last_col, col, seg_start, p);
let var_start = p;
let mut q2 = p + 1;
if q2 < inner_end && bb[q2] == b'{' {
let mut depth = 1i32;
q2 += 1;
while q2 < inner_end && depth > 0 {
match bb[q2] {
b'{' => depth += 1,
b'}' => depth -= 1,
_ => {}
}
q2 += 1;
}
} else if q2 < inner_end && bb[q2] == b'(' {
let mut depth = 1i32;
q2 += 1;
while q2 < inner_end && depth > 0 {
match bb[q2] {
b'(' => depth += 1,
b')' => depth -= 1,
_ => {}
}
q2 += 1;
}
} else {
while q2 < inner_end {
let cc = bb[q2] as char;
if cc.is_alphanumeric() || cc == '_' {
q2 += 1;
} else {
break;
}
}
if q2 == p + 1 && q2 < inner_end {
let cc = bb[q2] as char;
if "?!$#*@-_0123456789".contains(cc) {
q2 += 1;
}
}
}
if q2 > var_start + 1 {
push_tok(
&mut data,
&mut last_line,
&mut last_col,
ln,
(col + var_start) as u32,
(q2 - var_start) as u32,
6,
);
seg_start = q2;
p = q2;
continue;
}
p += 1;
continue;
}
p += 1;
}
flush_string(&mut data, &mut last_line, &mut last_col, col, seg_start, close);
col += close;
continue;
}
if rest.starts_with('$') {
let mut end = 1;
let b = rest.as_bytes();
if end < b.len() && b[end] == b'{' {
end += 1;
while end < b.len() && b[end] != b'}' {
end += 1;
}
if end < b.len() {
end += 1;
}
} else {
while end < b.len() {
let c = b[end] as char;
if c.is_alphanumeric() || c == '_' {
end += 1;
} else {
break;
}
}
if end == 1 {
if end < b.len() {
let c = b[end] as char;
if "?!$#*@-_0123456789".contains(c) {
end += 1;
}
}
}
}
push_tok(
&mut data,
&mut last_line,
&mut last_col,
ln,
col as u32,
end as u32,
6,
);
col += end;
continue;
}
const OPERATORS: &[&str] = &[
";;&", "<<<", "<<-",
"&&", "||", "|&", "<<", ">>", "&>", ">|", ">!",
">&", "<&", "<>", "==", "!=", "=~", "+=", "-=", ":=", "?=",
"[[", "]]", "((", "))", ";;", ";|",
"|", "&", ">", "<",
];
let mut op_len = 0usize;
for op in OPERATORS {
if rest.starts_with(op) {
op_len = op.len();
break;
}
}
if op_len > 0 {
push_tok(
&mut data,
&mut last_line,
&mut last_col,
ln,
col as u32,
op_len as u32,
4, );
col += op_len;
continue;
}
let c0 = rest.as_bytes()[0] as char;
if c0.is_ascii_digit() {
let mut end = 0;
let b = rest.as_bytes();
while end < b.len() && (b[end] as char).is_ascii_digit() {
end += 1;
}
push_tok(
&mut data,
&mut last_line,
&mut last_col,
ln,
col as u32,
end as u32,
2,
);
col += end;
continue;
}
use crate::ported::ztype_h::{ialnum, iident, iuser};
let leading_sigil = iuser(c0 as u8)
&& !iident(c0 as u8) && rest.as_bytes().get(1).map_or(false, |b| iident(*b));
let extra_sigil = !leading_sigil
&& matches!(c0, '+' | '@' | ':' | '^')
&& rest.as_bytes().get(1).map_or(false, |b| iident(*b));
let is_sigil = leading_sigil || extra_sigil;
if iident(c0 as u8) || is_sigil {
let b = rest.as_bytes();
let mut end = if is_sigil { 1 } else { 0 };
while end < b.len() {
let c = b[end];
if ialnum(c) || c == b'_' {
end += 1;
} else if matches!(c, b'-' | b'.' | b':')
&& end + 1 < b.len()
&& (ialnum(b[end + 1]) || b[end + 1] == b'_')
{
end += 1;
} else {
break;
}
}
let w = &rest[..end];
let kind = if KEYWORDS.contains(&w) {
3u32
} else if crate::ext_builtins::EXT_BUILTIN_NAMES.contains(&w)
|| crate::daemon::builtins::ZSHRS_BUILTIN_NAMES.contains(&w)
{
12
} else if crate::compsys::COMPSYS_FN_NAMES.contains(&w) {
13
} else if BUILTINS.contains(&w) {
5
} else {
6
};
push_tok(
&mut data,
&mut last_line,
&mut last_col,
ln,
col as u32,
end as u32,
kind,
);
col += end;
continue;
}
col += 1;
}
}
json!({ "data": data })
}
fn push_tok(
out: &mut Vec<u32>,
last_line: &mut u32,
last_col: &mut u32,
line: u32,
col: u32,
len: u32,
ty: u32,
) {
let delta_line = line - *last_line;
let delta_col = if delta_line == 0 {
col - *last_col
} else {
col
};
out.push(delta_line);
out.push(delta_col);
out.push(len);
out.push(ty);
out.push(0);
*last_line = line;
*last_col = col;
}
fn code_actions(state: &State, params: &Value) -> Value {
let uri = params["textDocument"]["uri"].as_str().unwrap_or("").to_string();
let text = match state.docs.get(&uri).cloned() {
Some(t) => t,
None => return Value::Array(vec![]),
};
let r = ¶ms["range"];
let start_line = r["start"]["line"].as_u64().unwrap_or(0) as u32;
let start_char = r["start"]["character"].as_u64().unwrap_or(0) as u32;
let end_line = r["end"]["line"].as_u64().unwrap_or(0) as u32;
let end_char = r["end"]["character"].as_u64().unwrap_or(0) as u32;
let mut actions: Vec<Value> = Vec::new();
let same_line = start_line == end_line;
let nonempty = start_line != end_line || start_char != end_char;
if !same_line {
if let Some(action) = make_extract_function_multiline(&uri, &text, start_line, end_line) {
actions.push(action);
}
return Value::Array(actions);
}
let line_text = match text.lines().nth(start_line as usize) {
Some(l) => l,
None => return Value::Array(vec![]),
};
let leading_ws: String = line_text
.chars()
.take_while(|c| c.is_whitespace())
.collect();
let line_has_content = !line_text.trim().is_empty();
let whole_line_selected =
nonempty && selection_covers_whole_line(line_text, start_char, end_char);
if line_has_content && (whole_line_selected || !nonempty) {
let body = if whole_line_selected {
utf16_slice(line_text, start_char, end_char)
.map(str::trim_end)
.unwrap_or_else(|| line_text.trim())
} else {
line_text.trim()
};
actions.push(make_extract_function_singleline(
&uri,
&leading_ws,
start_line,
body,
));
}
let (eff_start_char, eff_end_char) = if !nonempty {
match snap_to_word_at_cursor(line_text, start_char) {
Some((s, e)) => (s, e),
None => return Value::Array(actions),
}
} else {
(start_char, end_char)
};
if eff_end_char <= eff_start_char {
return Value::Array(actions);
}
let sel = match utf16_slice(line_text, eff_start_char, eff_end_char) {
Some(s) if !s.trim().is_empty() => s,
_ => return Value::Array(actions),
};
let eff_range = json!({
"start": { "line": start_line, "character": eff_start_char },
"end": { "line": start_line, "character": eff_end_char },
});
let in_string = same_line_inside_interpolating_string(line_text, eff_start_char);
let rhs = if in_string && needs_string_wrap_for_extraction(sel) {
format!("\"{}\"", escape_for_double_quoted(sel))
} else {
sel.to_string()
};
actions.push(make_extract_action(
&uri,
&leading_ws,
start_line,
&eff_range,
&rhs,
"EXTRACTED",
"local",
"Extract to variable (`local NAME=…`)",
));
actions.push(make_extract_action(
&uri,
&leading_ws,
start_line,
&eff_range,
&rhs,
"EXTRACTED",
"readonly",
"Extract to constant (`readonly NAME=…`)",
));
Value::Array(actions)
}
fn selection_covers_whole_line(line_text: &str, start_col: u32, end_col: u32) -> bool {
let mut prefix_byte = 0;
let mut suffix_byte = line_text.len();
let mut u16_seen = 0u32;
for (i, ch) in line_text.char_indices() {
if u16_seen == start_col {
prefix_byte = i;
}
u16_seen += ch.len_utf16() as u32;
if u16_seen == end_col {
suffix_byte = i + ch.len_utf8();
}
}
line_text[..prefix_byte].chars().all(char::is_whitespace)
&& line_text[suffix_byte..].chars().all(char::is_whitespace)
}
fn make_extract_function_singleline(
uri: &str,
leading_ws: &str,
line: u32,
body: &str,
) -> Value {
let name = "extracted_function";
let decl = format!("{leading_ws}{name}() {{\n{leading_ws} {body}\n{leading_ws}}}\n");
let insert_range = json!({
"start": { "line": line, "character": 0 },
"end": { "line": line, "character": 0 },
});
let replace_range = json!({
"start": { "line": line, "character": 0 },
"end": { "line": line + 1, "character": 0 },
});
let replacement = format!("{leading_ws}{name}\n");
let changes = json!({
uri: [
{ "range": insert_range, "newText": decl },
{ "range": replace_range, "newText": replacement },
]
});
json!({
"title": "Extract to function (`name() { … }`)",
"kind": "refactor.extract",
"edit": { "changes": changes },
})
}
fn make_extract_function_multiline(
uri: &str,
text: &str,
start_line: u32,
end_line: u32,
) -> Option<Value> {
let lines: Vec<&str> = text.lines().collect();
if (start_line as usize) >= lines.len() {
return None;
}
let last = (end_line as usize).min(lines.len() - 1);
let block = &lines[start_line as usize..=last];
if block.iter().all(|l| l.trim().is_empty()) {
return None;
}
let common_indent = block
.iter()
.filter(|l| !l.trim().is_empty())
.map(|l| l.chars().take_while(|c| c.is_whitespace()).count())
.min()
.unwrap_or(0);
let leading_ws: String = block
.iter()
.find(|l| !l.trim().is_empty())
.map(|l| l.chars().take(common_indent).collect())
.unwrap_or_default();
let name = "extracted_function";
let mut decl = String::new();
decl.push_str(&format!("{leading_ws}{name}() {{\n"));
for l in block {
if l.trim().is_empty() {
decl.push('\n');
} else {
let stripped = if l.chars().take(common_indent).all(|c| c.is_whitespace()) {
&l[l
.char_indices()
.nth(common_indent)
.map(|(i, _)| i)
.unwrap_or(l.len())..]
} else {
l.trim_start()
};
decl.push_str(&format!("{leading_ws} {stripped}\n"));
}
}
decl.push_str(&format!("{leading_ws}}}\n"));
let insert_range = json!({
"start": { "line": start_line, "character": 0 },
"end": { "line": start_line, "character": 0 },
});
let replace_range = json!({
"start": { "line": start_line, "character": 0 },
"end": { "line": last as u32 + 1, "character": 0 },
});
let replacement = format!("{leading_ws}{name}\n");
let changes = json!({
uri: [
{ "range": insert_range, "newText": decl },
{ "range": replace_range, "newText": replacement },
]
});
Some(json!({
"title": "Extract to function (`name() { … }`)",
"kind": "refactor.extract",
"edit": { "changes": changes },
}))
}
fn make_extract_action(
uri: &str,
leading_ws: &str,
line: u32,
selection_range: &Value,
rhs: &str,
name: &str,
decl_keyword: &str,
title: &str,
) -> Value {
let decl_line = format!("{leading_ws}{decl_keyword} {name}={rhs}\n");
let insert_range = json!({
"start": { "line": line, "character": 0 },
"end": { "line": line, "character": 0 },
});
let changes = json!({
uri: [
{ "range": insert_range, "newText": decl_line },
{ "range": selection_range, "newText": format!("${name}") },
]
});
json!({
"title": title,
"kind": "refactor.extract",
"edit": { "changes": changes },
})
}
fn utf16_slice(line_text: &str, start: u32, end: u32) -> Option<&str> {
let mut u16_seen = 0u32;
let mut s_byte: Option<usize> = None;
let mut e_byte: Option<usize> = None;
for (i, ch) in line_text.char_indices() {
if u16_seen == start {
s_byte = Some(i);
}
u16_seen += ch.len_utf16() as u32;
if u16_seen == end {
e_byte = Some(i + ch.len_utf8());
break;
}
}
let s = s_byte?;
let e = e_byte.unwrap_or(line_text.len());
line_text.get(s..e)
}
fn same_line_inside_interpolating_string(line_text: &str, col: u32) -> bool {
let mut byte_cutoff = line_text.len();
let mut u16_seen = 0u32;
for (i, ch) in line_text.char_indices() {
if u16_seen >= col {
byte_cutoff = i;
break;
}
u16_seen += ch.len_utf16() as u32;
}
let mut in_dq = false;
let mut in_sq = false;
let mut in_bt = false;
let mut chars = line_text[..byte_cutoff].chars().peekable();
while let Some(c) = chars.next() {
match c {
'\\' => {
chars.next();
}
'"' if !in_sq && !in_bt => in_dq = !in_dq,
'\'' if !in_dq && !in_bt => in_sq = !in_sq,
'`' if !in_dq && !in_sq => in_bt = !in_bt,
_ => {}
}
}
in_dq || in_bt
}
fn needs_string_wrap_for_extraction(selection: &str) -> bool {
let t = selection.trim();
if t.is_empty() {
return false;
}
if (t.starts_with('"') && t.ends_with('"'))
|| (t.starts_with('\'') && t.ends_with('\''))
{
return false;
}
if let Some(rest) = t.strip_prefix('$') {
let body = rest.strip_prefix('{').and_then(|r| r.strip_suffix('}')).unwrap_or(rest);
if !body.is_empty()
&& body.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return false;
}
}
true
}
fn escape_for_double_quoted(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
_ => out.push(c),
}
}
out
}
fn snap_to_word_at_cursor(line_text: &str, cursor_col: u32) -> Option<(u32, u32)> {
let mut byte_cur = line_text.len();
let mut u16_seen = 0u32;
for (i, ch) in line_text.char_indices() {
if u16_seen >= cursor_col {
byte_cur = i;
break;
}
u16_seen += ch.len_utf16() as u32;
}
let is_word_char = |c: char| c.is_ascii_alphanumeric() || c == '_';
if same_line_inside_interpolating_string(line_text, cursor_col) {
let prev_char = line_text[..byte_cur].chars().next_back();
let cur_char = line_text[byte_cur..].chars().next();
if matches!(prev_char, Some('$')) || matches!(cur_char, Some('$')) {
let mut start_byte = byte_cur;
for (i, c) in line_text[..byte_cur].char_indices().rev() {
if c == '$' {
start_byte = i;
break;
}
if !is_word_char(c) {
break;
}
start_byte = i;
}
if cur_char == Some('$') {
start_byte = byte_cur;
}
let mut end_byte = start_byte;
let mut iter = line_text[start_byte..].char_indices();
if let Some((_, first)) = iter.next() {
if first == '$' {
end_byte = start_byte + first.len_utf8();
for (i, c) in iter {
if !is_word_char(c) {
break;
}
end_byte = start_byte + i + c.len_utf8();
}
}
}
if end_byte > start_byte {
return Some((
byte_to_utf16_col(line_text, start_byte),
byte_to_utf16_col(line_text, end_byte),
));
}
}
let mut start_byte = byte_cur;
for (i, c) in line_text[..byte_cur].char_indices().rev() {
if !is_word_char(c) {
break;
}
start_byte = i;
}
let mut end_byte = byte_cur;
for (i, c) in line_text[byte_cur..].char_indices() {
if !is_word_char(c) {
break;
}
end_byte = byte_cur + i + c.len_utf8();
}
if end_byte > start_byte {
return Some((
byte_to_utf16_col(line_text, start_byte),
byte_to_utf16_col(line_text, end_byte),
));
}
return None;
}
let mut start_byte = byte_cur;
for (i, c) in line_text[..byte_cur].char_indices().rev() {
if !is_word_char(c) {
break;
}
start_byte = i;
}
let mut end_byte = byte_cur;
for (i, c) in line_text[byte_cur..].char_indices() {
if !is_word_char(c) {
break;
}
end_byte = byte_cur + i + c.len_utf8();
}
if start_byte > 0 {
if let Some((idx, '$')) = line_text[..start_byte].char_indices().next_back() {
let standalone = match line_text[..idx].chars().next_back() {
None => true,
Some(c) => !is_word_char(c),
};
if standalone {
start_byte = idx;
}
}
}
if end_byte > start_byte {
Some((
byte_to_utf16_col(line_text, start_byte),
byte_to_utf16_col(line_text, end_byte),
))
} else {
None
}
}
fn byte_to_utf16_col(line_text: &str, byte_idx: usize) -> u32 {
line_text[..byte_idx.min(line_text.len())]
.encode_utf16()
.count() as u32
}
fn formatting(state: &State, params: &Value) -> Value {
let uri = params["textDocument"]["uri"].as_str().unwrap_or("");
let text = match state.docs.get(uri) {
Some(t) => t.clone(),
None => return Value::Array(vec![]),
};
let opts = ¶ms["options"];
let tab_size = opts["tabSize"].as_u64().unwrap_or(4) as usize;
let insert_spaces = opts["insertSpaces"].as_bool().unwrap_or(true);
let formatted = simple_format(&text, tab_size, insert_spaces);
if formatted == text {
return Value::Array(vec![]);
}
let last_line = text.lines().count().saturating_sub(1);
let last_col = text.lines().last().map(|l| l.len()).unwrap_or(0);
Value::Array(vec![json!({
"range": {
"start": { "line": 0, "character": 0 },
"end": { "line": last_line, "character": last_col },
},
"newText": formatted,
})])
}
fn simple_format(text: &str, tab_size: usize, insert_spaces: bool) -> String {
let mut out = String::with_capacity(text.len());
for line in text.lines() {
let trimmed_end = line.trim_end();
let leading_spaces: usize = trimmed_end
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.map(|c| if c == '\t' { tab_size } else { 1 })
.sum();
let rest = trimmed_end.trim_start();
if insert_spaces {
for _ in 0..leading_spaces {
out.push(' ');
}
} else {
for _ in 0..(leading_spaces / tab_size) {
out.push('\t');
}
for _ in 0..(leading_spaces % tab_size) {
out.push(' ');
}
}
out.push_str(rest);
out.push('\n');
}
out
}
fn word_at(text: &str, line_no: usize, col: usize) -> Option<String> {
let line = text.lines().nth(line_no)?;
if col > line.len() {
return None;
}
let bytes = line.as_bytes();
let mut start = col;
while start > 0 {
let c = bytes[start - 1] as char;
if c == '_' || c.is_alphanumeric() || c == '$' {
start -= 1;
} else {
break;
}
}
let mut end = col;
while end < bytes.len() {
let c = bytes[end] as char;
if c == '_' || c.is_alphanumeric() {
end += 1;
} else {
break;
}
}
if start == end {
return None;
}
let is_dollar_var = bytes[start] == b'$';
let in_braced = start > 0 && bytes[start - 1] == b'{';
if !is_dollar_var && !in_braced {
while end < bytes.len() && bytes[end] == b'-' {
let mut p = end + 1;
while p < bytes.len() {
let c = bytes[p] as char;
if c == '_' || c.is_alphanumeric() {
p += 1;
} else {
break;
}
}
if p > end + 1 {
end = p;
} else {
break;
}
}
while start > 1 && bytes[start - 1] == b'-' {
let mut p = start - 1;
while p > 0 {
let c = bytes[p - 1] as char;
if c == '_' || c.is_alphanumeric() {
p -= 1;
} else {
break;
}
}
if p < start - 1 {
start = p;
} else {
break;
}
}
}
Some(line[start..end].to_string())
}
const KEYWORDS: &[&str] = &[
"if",
"then",
"else",
"elif",
"fi",
"for",
"foreach",
"while",
"until",
"do",
"done",
"case",
"esac",
"select",
"repeat",
"function",
"local",
"typeset",
"declare",
"export",
"readonly",
"integer",
"float",
"private",
"break",
"continue",
"return",
"exit",
"in",
"time",
"coproc",
"always",
"nocorrect",
"noglob",
];
const BUILTINS: &[&str] = &[
"cd",
"pwd",
"pushd",
"popd",
"dirs",
"alias",
"unalias",
"setopt",
"unsetopt",
"zstyle",
"zmodload",
"autoload",
"bindkey",
"compdef",
"compinit",
"zcompile",
"zparseopts",
"source",
".",
"eval",
"exec",
"trap",
"echo",
"print",
"printf",
"read",
"readarray",
"mapfile",
"test",
"[",
"[[",
"]]",
"umask",
"ulimit",
"wait",
"kill",
"jobs",
"fg",
"bg",
"suspend",
"disown",
"history",
"fc",
"hash",
"unhash",
"rehash",
"command",
"type",
"which",
"whence",
"where",
"builtin",
"enable",
"shift",
"unset",
"unfunction",
"set",
"true",
"false",
":",
"stat",
"zstat",
"zsocket",
"zsystem",
"let",
"getopts",
];
const OPTIONS: &[&str] = &[
"EXTENDED_GLOB",
"NULL_GLOB",
"GLOB_DOTS",
"NUMERIC_GLOB_SORT",
"NOMATCH",
"BAD_PATTERN",
"PIPE_FAIL",
"NO_CLOBBER",
"CORRECT",
"CORRECT_ALL",
"HIST_IGNORE_DUPS",
"HIST_IGNORE_ALL_DUPS",
"HIST_SAVE_NO_DUPS",
"HIST_REDUCE_BLANKS",
"SHARE_HISTORY",
"APPEND_HISTORY",
"INC_APPEND_HISTORY",
"EXTENDED_HISTORY",
"AUTO_CD",
"AUTO_PUSHD",
"PUSHD_SILENT",
"PUSHD_TO_HOME",
"PUSHD_IGNORE_DUPS",
"INTERACTIVE_COMMENTS",
"RC_QUOTES",
"RC_EXPAND_PARAM",
"PROMPT_SUBST",
"PROMPT_BANG",
"PROMPT_PERCENT",
"TRANSIENT_RPROMPT",
"COMPLETE_IN_WORD",
"ALWAYS_TO_END",
"AUTO_MENU",
"MENU_COMPLETE",
"NO_BEEP",
"NOTIFY",
"MONITOR",
"BG_NICE",
"HUP",
"CHECK_JOBS",
"MULTIOS",
"CSH_NULL_GLOB",
"ERR_RETURN",
"ERR_EXIT",
"VERBOSE",
"XTRACE",
"TYPESET_SILENT",
"WARN_CREATE_GLOBAL",
"WARN_NESTED_VAR",
];
const KEYWORD_DOCS: &[(&str, &str)] = &[
(
"if",
"Conditional. `if cmd; then …; elif cmd; then …; else …; fi`",
),
(
"for",
"Loop. `for var in words; do …; done` or `for ((init; cond; step)); do …; done`",
),
(
"while",
"Loop. `while cmd; do …; done` — runs the body while `cmd` succeeds.",
),
(
"until",
"Loop. `until cmd; do …; done` — runs the body while `cmd` fails.",
),
(
"case",
"Pattern match. `case word in pat1) …;; pat2) …;; esac`",
),
(
"select",
"Interactive menu. `select var in items; do …; done`",
),
("repeat", "Counted loop. `repeat N; do …; done`"),
("then", "Body separator for `if`/`elif`. `if cmd; then body; fi`"),
("else", "Alternative branch for `if`. `if cmd; then a; else b; fi`"),
("elif", "Alternative test in an `if` chain. `if a; then …; elif b; then …; fi`"),
("do", "Body-introducer for `for`/`while`/`until`/`select`/`repeat`. `for v in …; do body; done`"),
("esac", "Closes a `case` statement. `case word in pat) …;; esac`"),
("in", "Word-list introducer for `for` and `case`. `for v in a b c; do …; done`"),
(
"{",
"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.",
),
(
"}",
"Command-group close brace. Pairs with `{ … }`. Reserved word — preceded by `;` or newline.",
),
(
"!",
"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).",
),
(
"fi",
"Closes an `if` block. `if cmd; then body; fi`. Required terminator — without it the parser keeps reading until EOF.",
),
(
"done",
"Closes a `for` / `foreach` / `while` / `until` / `select` / `repeat` loop body. `for v in a b c; do echo $v; done`. Required terminator.",
),
(
"end",
"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.",
),
(
"declare",
"Alias for `typeset`. Set variable attributes. `-a` array, `-A` assoc, `-i` integer, `-r` readonly.",
),
(
"function",
"Function declaration. `function foo { body }` or `foo() { body }`",
),
(
"local",
"Declare a function-scope variable. `local var=value` or `local -i var=42`",
),
(
"typeset",
"Set variable attributes. `-a` array, `-A` assoc, `-i` integer, `-r` readonly.",
),
("export", "Mark a variable for export to the environment."),
("readonly", "Mark a variable as read-only."),
("integer", "Shorthand for `typeset -i`."),
("float", "Shorthand for `typeset -F` (floating point)."),
(
"return",
"Return from a function or sourced script with the given status.",
),
(
"break",
"Exit the innermost loop, or N levels up with `break N`.",
),
(
"continue",
"Skip to the next iteration of the innermost loop.",
),
("exit", "Exit the shell with the given status."),
("time", "Time the execution of the following pipeline."),
(
"coproc",
"Run a command as a coprocess (background, attached I/O).",
),
];
const BUILTIN_DOCS: &[(&str, &str)] = &[
("cd", "Change the working directory."),
("pwd", "Print the working directory."),
(
"pushd",
"Push the current directory onto the stack and `cd`.",
),
("popd", "Pop a directory off the stack and `cd` to it."),
("alias", "Define a command alias. `alias name=value`"),
("setopt", "Turn on a zsh option. `setopt EXTENDED_GLOB`"),
("unsetopt", "Turn off a zsh option."),
(
"zstyle",
"Set a context-aware style (used by compsys, prompts, etc.).",
),
(
"zmodload",
"Load a zsh binary module (e.g. `zsh/datetime`, `zsh/stat`).",
),
(
"autoload",
"Mark a function to be loaded from `fpath` on first call.",
),
("bindkey", "Bind a key sequence to a ZLE widget."),
("compdef", "Register a completion function for a command."),
(
"source",
"Execute a file in the current shell context. Same as `.`.",
),
("eval", "Concatenate args and execute them as shell code."),
(
"exec",
"Replace the current process with the given command.",
),
("trap", "Set a signal or pseudo-signal handler."),
(
"echo",
"Print arguments separated by spaces, with a trailing newline.",
),
(
"print",
"zsh-extended print. `-r` raw, `-n` no newline, `-l` one per line.",
),
("printf", "C-style formatted print."),
("read", "Read a line into a variable. `read -r var`"),
(
"test",
"Evaluate a conditional. Same as `[`. Prefer `[[ … ]]` in zsh.",
),
("kill", "Send a signal to a job or pid."),
("jobs", "List background jobs."),
("fg", "Bring a job to the foreground."),
("bg", "Resume a stopped job in the background."),
("hash", "Print or modify the command hash table."),
(
"unhash",
"Remove an entry from the hash / alias / function table.",
),
("history", "Show the command history."),
("fc", "List, edit, or re-execute history entries."),
(
"command",
"Bypass aliases and functions to run the named command.",
),
(
"type",
"Show how a name would be interpreted (alias / builtin / function / file).",
),
("whence", "Same as `type` but with more formatting options."),
(
"builtin",
"Run the named builtin, bypassing any function / alias.",
),
("set", "Set positional parameters or options."),
("unset", "Remove a variable."),
(
"getopts",
"Parse positional parameters in the style of GNU getopt.",
),
("let", "Evaluate an arithmetic expression. `let count++`"),
(
":",
"Null command. Returns true. Side-effects of argument expansion still happen.",
),
(
"[",
"Alias for `test`. `[ expr ]` — POSIX conditional. Prefer `[[ expr ]]` in zsh.",
),
("bye", "Alias for `exit`. Exit the shell with the given status."),
("chdir", "Alias for `cd`. Change the working directory."),
(
"compctl",
"Old completion control (compctl mechanism). Largely superseded by `compdef` / compsys.",
),
("declare", "Alias for `typeset`. Set variable attributes."),
(
"hashinfo",
"Print internal hash-table statistics. Debug builtin in `zsh/parameter`-adjacent code.",
),
(
"mem",
"Print zsh memory-allocator statistics. Debug builtin compiled only with `--enable-zsh-mem`.",
),
(
"noglob",
"Precommand modifier. Disable filename generation for the next command. `noglob ls *.tmp`",
),
(
"patdebug",
"Print pattern-matcher internals for a glob/regex. Debug builtin from `zsh/pattern`.",
),
("r", "Re-execute the previous command. Shorthand for `fc -e -`."),
(
"unfunction",
"Remove a function definition. Equivalent to `unhash -f` / `unset -f name`.",
),
("zf_chgrp", "zftp: change group of remote files. Mirrors `chgrp(1)`."),
("zf_chmod", "zftp: change mode of remote files. Mirrors `chmod(1)`."),
("zf_chown", "zftp: change owner of remote files. Mirrors `chown(1)`."),
("zf_ln", "zftp: link / rename remote files. Mirrors `ln(1)`."),
("zf_mkdir", "zftp: create remote directories. Mirrors `mkdir(1)`."),
("zf_mv", "zftp: move / rename remote files. Mirrors `mv(1)`."),
("zf_rm", "zftp: remove remote files. Mirrors `rm(1)`."),
("zf_rmdir", "zftp: remove remote directories. Mirrors `rmdir(1)`."),
("zf_sync", "zftp: flush pending writes on the FTP control channel."),
];
const SPECIAL_VAR_DOCS: &[(&str, &str)] = &[
("$0", "Script name."),
("$?", "Exit status of the last command."),
("$!", "PID of the most recent background command."),
("$$", "PID of the current shell."),
("$#", "Number of positional parameters."),
("$*", "All positional parameters as one word (IFS-joined)."),
("$@", "All positional parameters as separate words."),
("$-", "Currently set option flags."),
("$_", "Last argument of the previous command."),
("$PATH", "Colon-separated command lookup path."),
("$HOME", "User's home directory."),
("$USER", "Current user."),
("$PWD", "Current working directory."),
("$OLDPWD", "Previous working directory (used by `cd -`)."),
("$ZSH_VERSION", "zsh / zshrs version string."),
(
"$RANDOM",
"Each read returns a fresh pseudo-random integer.",
),
("$LINENO", "Current line number in the script."),
("$SECONDS", "Seconds since the shell started."),
("$EPOCHSECONDS", "Unix epoch seconds (zsh/datetime)."),
(
"$EPOCHREALTIME",
"Unix epoch with microsecond precision (zsh/datetime).",
),
(
"$fpath",
"Array of directories searched for autoloaded functions.",
),
("$path", "Array version of $PATH."),
("$argv", "Array of positional parameters (same as $@)."),
("$pipestatus", "Exit statuses of each pipeline element."),
("$SHELL", "Pathname of the login shell. Honored by many tools as the default user shell."),
("$EDITOR", "Preferred editor for tools that invoke an editor (`fc`, `git`, `crontab`, …)."),
("$VISUAL", "Preferred full-screen editor. Takes precedence over `$EDITOR` when set."),
];
pub fn dump_reflection_json() -> String {
let mut all = serde_json::Map::new();
let mut compat = serde_json::Map::new();
for b in crate::ported::builtin::BUILTINS.iter() {
compat.insert(b.node.nam.clone(), Value::String("compat".into()));
all.insert(b.node.nam.clone(), Value::String("compat".into()));
}
let mut keywords = serde_json::Map::new();
for (name, _token) in crate::ported::hashtable::RESWDS {
keywords.insert(name.to_string(), Value::String("keyword".into()));
all.insert(name.to_string(), Value::String("keyword".into()));
}
let mut options = serde_json::Map::new();
for (name, _doc) in crate::zsh_option_docs::OPTION_DOCS {
options.insert((*name).to_string(), Value::String("option".into()));
all.insert((*name).to_string(), Value::String("option".into()));
}
for (alias, _canon) in crate::zsh_option_docs::OPTION_ALIASES {
options.insert((*alias).to_string(), Value::String("option".into()));
all.insert((*alias).to_string(), Value::String("option".into()));
}
let mut special_vars = serde_json::Map::new();
for (name, _doc) in crate::zsh_special_var_docs::SPECIAL_VAR_DOCS {
special_vars.insert((*name).to_string(), Value::String("special".into()));
all.insert((*name).to_string(), Value::String("special".into()));
}
for (alias, _canon) in crate::zsh_special_var_docs::SPECIAL_VAR_ALIASES {
special_vars.insert((*alias).to_string(), Value::String("special".into()));
all.insert((*alias).to_string(), Value::String("special".into()));
}
let mut compsys = serde_json::Map::new();
for n in crate::compsys::COMPSYS_FN_NAMES {
compsys.insert((*n).to_string(), Value::String("compsys".into()));
all.insert((*n).to_string(), Value::String("compsys".into()));
}
let mut extensions = serde_json::Map::new();
for n in crate::ext_builtins::EXT_BUILTIN_NAMES {
extensions.insert((*n).to_string(), Value::String("extension".into()));
all.insert((*n).to_string(), Value::String("extension".into()));
}
for n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES {
extensions.insert((*n).to_string(), Value::String("extension".into()));
all.insert((*n).to_string(), Value::String("extension".into()));
}
let mut operators = serde_json::Map::new();
for (op, _body) in OPERATOR_DOCS {
operators.insert((*op).to_string(), Value::String("operator".into()));
all.insert((*op).to_string(), Value::String("operator".into()));
}
let mut builtins = compat.clone();
for (k, _) in &extensions {
builtins.insert(k.clone(), Value::String("builtin".into()));
}
serde_json::to_string_pretty(&json!({
"all": all,
"builtins": builtins,
"compat": compat,
"keywords": keywords,
"options": options,
"special_vars": special_vars,
"compsys": compsys,
"extensions": extensions,
"operators": operators,
}))
.unwrap_or_else(|_| "{}".into())
}
pub fn all_canonical_names() -> Vec<String> {
use std::collections::BTreeSet;
let mut set: BTreeSet<String> = BTreeSet::new();
for b in crate::ported::builtin::BUILTINS.iter() {
set.insert(b.node.nam.clone());
}
for (n, t) in crate::ported::hashtable::RESWDS {
if *t == crate::ported::zsh_h::TYPESET {
continue;
}
set.insert((*n).to_string());
}
for o in crate::ported::options::ZSH_OPTIONS_SET.iter() {
set.insert((*o).to_string());
}
for (name, _) in crate::zsh_special_var_docs::SPECIAL_VAR_DOCS {
if name.chars().next().map(|c| c.is_ascii_alphabetic() || c == '_').unwrap_or(false) {
set.insert(format!("${}", name));
} else {
set.insert((*name).to_string());
}
}
for n in crate::compsys::COMPSYS_FN_NAMES {
set.insert((*n).to_string());
}
for n in crate::ext_builtins::EXT_BUILTIN_NAMES {
set.insert((*n).to_string());
}
for n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES {
set.insert((*n).to_string());
}
for (op, _) in OPERATOR_DOCS {
set.insert((*op).to_string());
}
set.into_iter().collect()
}
pub fn closest_name(query: &str) -> Option<String> {
let names = all_canonical_names();
let q_bare = query.strip_prefix('$').unwrap_or(query);
let max_dist = std::cmp::max(2, q_bare.len() / 3);
let mut best: Option<(usize, String)> = None;
for n in names {
let n_bare = n.strip_prefix('$').unwrap_or(&n);
let d = edit_distance(q_bare, n_bare);
if d > max_dist {
continue;
}
match best {
None => best = Some((d, n)),
Some((bd, _)) if d < bd => best = Some((d, n)),
_ => {}
}
}
best.map(|(_, n)| n)
}
fn edit_distance(a: &str, b: &str) -> usize {
let av: Vec<char> = a.chars().collect();
let bv: Vec<char> = b.chars().collect();
let m = av.len();
let n = bv.len();
if m == 0 { return n; }
if n == 0 { return m; }
let mut prev: Vec<usize> = (0..=n).collect();
let mut cur: Vec<usize> = vec![0; n + 1];
for i in 1..=m {
cur[0] = i;
for j in 1..=n {
let cost = if av[i - 1] == bv[j - 1] { 0 } else { 1 };
cur[j] = (cur[j - 1] + 1)
.min(prev[j] + 1)
.min(prev[j - 1] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[n]
}
pub fn dump_reference_html() -> String {
use std::fmt::Write;
let mut out = String::new();
let mut compat: Vec<String> = crate::ported::builtin::BUILTINS
.iter()
.map(|b| b.node.nam.clone())
.collect();
compat.sort();
compat.dedup();
write_chapter(
&mut out,
"ch-lsp-compat",
"Compat Builtin Index",
&format!(
"{} entries · zsh-faithful ports from <code>ported::builtin::BUILTINS</code>. \
Each mirrors an upstream <code>Src/Builtins/*.c</code> entry 1:1, with the \
hover body extracted from <code>man zshall</code> yodl. See also: \
<a href=\"#ch-lsp-extensions\">Extension Builtin Index</a> for zshrs-only \
additions.",
compat.len()
),
&compat,
"compat",
);
let keywords: Vec<String> = crate::ported::hashtable::RESWDS
.iter()
.map(|(n, _)| n.to_string())
.collect();
write_chapter(
&mut out,
"ch-lsp-keywords",
"Keyword Index",
&format!(
"{} entries · zsh reserved words from <code>Src/hashtable.c</code> \
<code>reswds[]</code>. Mirrors the <code>man zshmisc</code> \
\"Reserved Words\" section. Declarers (<code>declare</code>, \
<code>export</code>, <code>float</code>, <code>integer</code>, \
<code>local</code>, <code>readonly</code>, <code>typeset</code>) \
are reserved AND also appear in the Builtin Index — they're both.",
keywords.len()
),
&keywords,
"keyword",
);
let mut options: Vec<String> = crate::ported::options::ZSH_OPTIONS_SET
.iter()
.map(|s| s.to_string())
.collect();
options.sort();
write_chapter(
&mut out,
"ch-lsp-options",
"Option Index",
&format!(
"{} entries · the canonical zsh option registry. \
Set / clear via <code>setopt NAME</code> / <code>unsetopt NAME</code>.",
options.len()
),
&options,
"option",
);
let mut specials: Vec<String> = crate::zsh_special_var_docs::SPECIAL_VAR_DOCS
.iter()
.map(|(name, _)| {
if name.chars().next().map(|c| c.is_ascii_alphabetic() || c == '_').unwrap_or(false) {
format!("${}", name)
} else {
(*name).to_string()
}
})
.collect();
specials.sort();
specials.dedup();
write_chapter(
&mut out,
"ch-lsp-specials",
"Special Variable Index",
&format!(
"{} entries · zsh-defined parameters and well-known env vars. \
Includes both scalar (<code>$?</code>) and array (<code>$path</code>) forms.",
specials.len()
),
&specials,
"special",
);
let mut compsys_names: Vec<String> =
crate::compsys::COMPSYS_FN_NAMES.iter().map(|s| s.to_string()).collect();
compsys_names.sort();
write_chapter(
&mut out,
"ch-lsp-compsys",
"Compsys Function Index",
&format!(
"{} entries · the <code>_arguments</code> / <code>_files</code> / \
<code>_describe</code> family of completion functions. Native Rust \
implementations in the <code>compsys</code> crate replace the \
upstream zsh shell-function versions for performance.",
compsys_names.len()
),
&compsys_names,
"compsys",
);
let mut ext_names: Vec<String> = crate::ext_builtins::EXT_BUILTIN_NAMES
.iter()
.map(|s| s.to_string())
.chain(
crate::daemon::builtins::ZSHRS_BUILTIN_NAMES
.iter()
.map(|s| s.to_string()),
)
.collect();
ext_names.sort();
ext_names.dedup();
write_chapter(
&mut out,
"ch-lsp-extensions",
"Extension Builtin Index",
&format!(
"{} entries · zshrs-only builtins with NO upstream zsh counterpart. \
Split across in-process builtins (coreutils drop-ins, <code>async</code>/\
<code>await</code>/<code>barrier</code>, <code>doctor</code>, \
<code>intercept</code>, contrib autoloads) and daemon-backed <code>z*</code> \
builtins (<code>zd</code>, <code>zcache</code>, <code>zls</code>, \
<code>zlock</code>, <code>zpublish</code>, …) that proxy to the local \
<code>zshrs-daemon</code> for cross-shell state.",
ext_names.len()
),
&ext_names,
"extension",
);
let op_names: Vec<String> = OPERATOR_DOCS
.iter()
.map(|(op, _)| (*op).to_string())
.collect();
write_chapter(
&mut out,
"ch-lsp-operators",
"Operator / Punctuation Index",
&format!(
"{} entries · pipelines (<code>|</code>, <code>|&</code>), list ops \
(<code>&&</code>, <code>||</code>, <code>;</code>, <code>&</code>, \
<code>;;</code>), redirects (<code>></code>, <code>>></code>, \
<code><<</code>, <code><<<</code>, <code>&></code>, …), \
conditional/arithmetic openers (<code>[[</code>, <code>]]</code>, <code>((</code>, \
<code>))</code>), substitution forms (<code>$(</code>, <code>${{</code>, \
<code>$((</code>, <code><(</code>, <code>>(</code>), test ops \
(<code>-e</code>, <code>-eq</code>, <code>=~</code>, …), pattern chars \
(<code>*</code>, <code>?</code>, <code>**</code>, <code>~</code>), brace \
expansion (<code>{{a,b,c}}</code>, <code>{{1..10}}</code>), and assignment \
(<code>=</code>, <code>+=</code>). Sourced from <code>man zshmisc</code> \
section prose — these have no per-name yodl <code>item</code> blocks so \
they're hand-curated.",
op_names.len()
),
&op_names,
"operator",
);
out
}
fn write_chapter(
out: &mut String,
id: &str,
title: &str,
meta_html: &str,
names: &[String],
kind: &str,
) {
use std::fmt::Write;
let _ = writeln!(
out,
"\n <!-- ════════════════════════════════════════════════════════════════════ -->\n\
\n <section class=\"tutorial-section\" id=\"{id}\">\n\
\n <h2>{title}</h2>\n\
\n <p class=\"chapter-meta\">{meta_html}</p>",
);
for n in names {
let body = lookup_doc(n);
let body_only = body.split_once("\n\n").map(|(_, b)| b).unwrap_or("");
let anchor = anchor_for(kind, n);
let _ = writeln!(
out,
"\n <article class=\"doc-entry\" id=\"{anchor}\">\n\
\n <h3><code>{}</code> <a class=\"doc-anchor\" href=\"#{anchor}\">¶</a></h3>\n\
{} </article>",
html_escape(n),
md_to_html(body_only),
);
}
out.push_str("\n </section>\n");
}
fn anchor_for(kind: &str, name: &str) -> String {
let mut slug = String::new();
for c in name.chars() {
match c {
c if c.is_ascii_alphanumeric() => slug.push(c),
'_' => slug.push('_'),
'-' => slug.push_str("dash"),
':' => slug.push_str("colon"),
'.' => slug.push_str("dot"),
'[' => slug.push_str("lbracket"),
']' => slug.push_str("rbracket"),
'(' => slug.push_str("lparen"),
')' => slug.push_str("rparen"),
'{' => slug.push_str("lbrace"),
'}' => slug.push_str("rbrace"),
'?' => slug.push_str("qmark"),
'!' => slug.push_str("bang"),
'$' => slug.push_str("dollar"),
'#' => slug.push_str("hash"),
'*' => slug.push_str("star"),
'@' => slug.push_str("at"),
'/' => slug.push_str("slash"),
'+' => slug.push_str("plus"),
'=' => slug.push_str("eq"),
_ => slug.push('-'),
}
}
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
format!("doc-lsp-{}-unnamed", kind)
} else {
format!("doc-lsp-{}-{}", kind, slug)
}
}
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
_ => out.push(c),
}
}
out
}
fn md_to_html(s: &str) -> String {
use std::fmt::Write;
let mut out = String::new();
for para in s.split("\n\n") {
let para = para.trim_matches('\n');
if para.is_empty() {
continue;
}
let joined: String = para
.split('\n')
.map(str::trim_end)
.collect::<Vec<_>>()
.join(" ");
let _ = writeln!(out, " <p>{}</p>", inline_md(&joined));
}
out
}
fn inline_md(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len() + 16);
let mut i = 0;
while i < bytes.len() {
let c = bytes[i] as char;
if c == '`' {
out.push_str("<code>");
i += 1;
while i < bytes.len() && bytes[i] as char != '`' {
let cc = bytes[i] as char;
match cc {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(cc),
}
i += 1;
}
out.push_str("</code>");
if i < bytes.len() {
i += 1; }
continue;
}
if c == '*' && i + 1 < bytes.len() && bytes[i + 1] as char == '*' {
if let Some(end) = find_close(bytes, i + 2, b"**") {
out.push_str("<strong>");
out.push_str(&inline_md(
std::str::from_utf8(&bytes[i + 2..end]).unwrap_or(""),
));
out.push_str("</strong>");
i = end + 2;
continue;
}
}
if c == '_'
&& (i == 0 || !(bytes[i - 1] as char).is_alphanumeric())
&& i + 1 < bytes.len()
&& !(bytes[i + 1] as char).is_whitespace()
{
if let Some(end) = find_close(bytes, i + 1, b"_") {
let after_ok = end + 1 >= bytes.len() || !(bytes[end + 1] as char).is_alphanumeric();
if after_ok {
out.push_str("<em>");
out.push_str(&inline_md(
std::str::from_utf8(&bytes[i + 1..end]).unwrap_or(""),
));
out.push_str("</em>");
i = end + 1;
continue;
}
}
}
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(c),
}
i += 1;
}
out
}
fn find_close(bytes: &[u8], start: usize, needle: &[u8]) -> Option<usize> {
let mut i = start;
while i + needle.len() <= bytes.len() {
if &bytes[i..i + needle.len()] == needle {
return Some(i);
}
i += 1;
}
None
}
#[allow(dead_code)]
fn _hush() {
let _ = std::mem::size_of::<Mutex<()>>();
}
#[derive(Serialize, Deserialize, Default, Debug)]
struct _Placeholder {
_x: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compsys_flag_table_covers_wanted_with_all_six_flags() {
let flags = extract_builtin_flags("_wanted");
let names: Vec<&str> = flags.iter().map(|(f, _)| f.as_str()).collect();
assert_eq!(
names,
vec!["-x", "-C", "-1", "-2", "-V", "-J"],
"_wanted flag set drifted from man zshcompsys signature",
);
for (f, d) in &flags {
assert!(!d.is_empty(), "flag {f} has no description");
}
}
#[test]
fn compsys_flag_table_overrides_bullet_scraper_for_arguments() {
let flags = extract_builtin_flags("_arguments");
assert!(
flags.len() >= 12,
"expected _arguments to surface >=12 flags from man-derived table, got {}",
flags.len()
);
let names: std::collections::HashSet<&str> =
flags.iter().map(|(f, _)| f.as_str()).collect();
for must_have in ["-n", "-s", "-w", "-W", "-C", "-R", "-S", "-A", "-O", "-M"] {
assert!(
names.contains(must_have),
"_arguments missing canonical flag {must_have}"
);
}
}
#[test]
fn every_compsys_fn_in_man_is_in_inventory() {
for name in [
"_call_program",
"_canonical_paths",
"_combination",
"_command_names",
"_completers",
"_dir_list",
"_email_addresses",
"_multi_parts",
"_numbers",
"_pick_variant",
"_sequence",
"_tags",
"_widgets",
] {
assert!(
crate::compsys::COMPSYS_FN_NAMES.contains(&name),
"{name}: missing from COMPSYS_FN_NAMES inventory",
);
}
assert!(is_known_builtin_with_flag_docs("_canonical_paths"));
assert!(is_known_builtin_with_flag_docs("_widgets"));
assert!(is_known_builtin_with_flag_docs("_call_program"));
}
#[test]
fn word_at_middle_of_identifier() {
let _g = crate::test_util::global_state_lock();
let src = "cd /tmp\nlocal x=1\n";
assert_eq!(word_at(src, 0, 1), Some("cd".into()));
assert_eq!(word_at(src, 0, 2), Some("cd".into()));
}
#[test]
fn word_at_includes_dollar_prefix() {
let _g = crate::test_util::global_state_lock();
let src = "echo $HOME\n";
assert_eq!(word_at(src, 0, 6), Some("$HOME".into()));
}
#[test]
fn word_at_extends_through_hyphen_for_function_name() {
let _g = crate::test_util::global_state_lock();
let src = "daemon-ping arg\n";
assert_eq!(word_at(src, 0, 0), Some("daemon-ping".into()));
assert_eq!(word_at(src, 0, 3), Some("daemon-ping".into()));
assert_eq!(word_at(src, 0, 7), Some("daemon-ping".into()));
assert_eq!(word_at(src, 0, 10), Some("daemon-ping".into()));
}
#[test]
fn word_at_extends_through_multiple_hyphens() {
let _g = crate::test_util::global_state_lock();
let src = "daemon-job-submit -- cmd\n";
assert_eq!(word_at(src, 0, 8), Some("daemon-job-submit".into()));
assert_eq!(word_at(src, 0, 13), Some("daemon-job-submit".into()));
}
#[test]
fn word_at_dollar_var_does_not_extend_through_hyphen() {
let _g = crate::test_util::global_state_lock();
let src = "echo $x-y suffix\n";
assert_eq!(word_at(src, 0, 6), Some("$x".into()));
}
#[test]
fn word_at_braced_var_does_not_extend_through_hyphen() {
let _g = crate::test_util::global_state_lock();
let src = "echo ${x-default}\n";
assert_eq!(word_at(src, 0, 7), Some("x".into()));
}
#[test]
fn word_at_returns_none_off_word() {
let _g = crate::test_util::global_state_lock();
let src = "echo hi\n";
assert!(matches!(word_at(src, 0, 5), None | Some(_)));
assert_eq!(word_at(src, 0, 999), None);
}
#[test]
fn scan_symbols_finds_function_keyword_form() {
let _g = crate::test_util::global_state_lock();
let src = "function greet {\n print hi\n}\n";
let s = scan_symbols(src);
assert!(s.iter().any(|(n, k, _)| n == "greet" && *k == "function"));
}
#[test]
fn scan_symbols_finds_paren_form() {
let _g = crate::test_util::global_state_lock();
let src = "foo() {\n :\n}\n";
let s = scan_symbols(src);
assert!(s.iter().any(|(n, k, _)| n == "foo" && *k == "function"));
}
#[test]
fn scan_symbols_finds_locals_and_aliases() {
let _g = crate::test_util::global_state_lock();
let src = "local x=1\nalias ll='ls -la'\nexport PATH=/bin\n";
let s = scan_symbols(src);
assert!(s.iter().any(|(n, k, _)| n == "x" && *k == "variable"));
assert!(s.iter().any(|(n, k, _)| n == "ll" && *k == "alias"));
assert!(s.iter().any(|(n, k, _)| n == "PATH" && *k == "variable"));
}
#[test]
fn scan_symbols_ignores_comments() {
let _g = crate::test_util::global_state_lock();
let src = "# function fake { }\n# alias evil=rm\n: real\n";
let s = scan_symbols(src);
assert!(s.is_empty(), "scan_symbols leaked comment content: {:?}", s);
}
#[test]
fn lookup_doc_returns_markdown_for_known_builtin() {
let _g = crate::test_util::global_state_lock();
let doc = lookup_doc("cd");
assert!(doc.starts_with("**cd**"), "got: {}", doc);
assert!(
doc.contains("Change the current directory"),
"expected upstream cd prose; got: {}",
doc
);
}
#[test]
fn lookup_doc_handles_keywords_and_special_vars() {
let _g = crate::test_util::global_state_lock();
assert!(
lookup_doc("if").contains("zero exit status"),
"expected upstream if prose; got: {}",
lookup_doc("if")
);
assert!(
lookup_doc("$?").contains("exit status"),
"expected $? doc; got: {}",
lookup_doc("$?")
);
}
#[test]
fn lookup_doc_handles_pure_symbolic_specials() {
let _g = crate::test_util::global_state_lock();
let s = lookup_doc("$");
assert!(
!s.is_empty() && s.contains("process ID"),
"lookup_doc('$') should return the PID doc; got: {:?}",
s,
);
for sym in &["$?", "$*", "$#", "$@", "$-", "$_", "$!"] {
let card = lookup_doc(sym);
assert!(
!card.is_empty(),
"lookup_doc({:?}) returned empty; pure-symbolic specials must resolve",
sym,
);
}
}
#[test]
fn lookup_doc_special_var_wins_over_module_builtin_entry() {
let _g = crate::test_util::global_state_lock();
for name in &["prompt", "path", "aliases", "jobdirs", "jobstates", "commands", "modules", "widgets"] {
let card = lookup_doc(name);
assert!(
card.contains("special variable"),
"lookup_doc({:?}) classified as: {:?} — expected 'special variable'",
name,
card.lines().take(3).collect::<Vec<_>>(),
);
}
for name in &["cd", "echo", "set", "shift", "unset", "functions", "history"] {
let card = lookup_doc(name);
assert!(
card.contains("zsh builtin") || card.contains("zshrs"),
"lookup_doc({:?}) lost its builtin classification: {:?}",
name,
card.lines().take(3).collect::<Vec<_>>(),
);
}
}
#[test]
fn lookup_doc_empty_for_unknown() {
let _g = crate::test_util::global_state_lock();
assert_eq!(lookup_doc("definitely_not_a_zsh_thing_xx"), "");
}
#[test]
fn diagnose_clean_file_returns_no_diagnostics() {
let _g = crate::test_util::global_state_lock();
let src = "if [[ -d /tmp ]]; then\n echo ok\nfi\n";
let d = diagnose(src);
assert!(d.is_empty(), "diagnose flagged clean file: {:?}", d);
}
#[test]
fn diagnose_flags_unmatched_brace() {
let _g = crate::test_util::global_state_lock();
let src = "function broken {\n echo missing close\n";
let d = diagnose(src);
assert!(
d.iter()
.any(|v| v["message"].as_str().unwrap_or("").contains("unclosed `{`")),
"expected unclosed-brace diagnostic, got: {:?}",
d
);
}
#[test]
fn diagnose_flags_unclosed_if_block() {
let _g = crate::test_util::global_state_lock();
let src = "if true\nthen\necho\n";
let d = diagnose(src);
assert!(
d.iter().any(|v| v["message"]
.as_str()
.unwrap_or("")
.contains("unclosed `if`")),
"expected unclosed-if diagnostic, got: {:?}",
d
);
}
#[test]
fn diagnose_ignores_braces_inside_strings() {
let _g = crate::test_util::global_state_lock();
let src = "echo \"a } b\" '{ }' \n";
let d = diagnose(src);
assert!(
d.is_empty(),
"string-internal braces tripped diagnose: {:?}",
d
);
}
#[test]
fn diagnose_does_not_flag_dollar_hash_as_comment() {
let _g = crate::test_util::global_state_lock();
let src = "[[ $# -gt 0 ]] && echo args\n";
let d = diagnose(src);
assert!(
d.is_empty(),
"`$#` mis-handled as comment marker: {:?}",
d
);
}
#[test]
fn diagnose_does_not_flag_param_length_as_comment() {
let _g = crate::test_util::global_state_lock();
let src = "echo ${#args}\nif [[ ${#arr} -gt 0 ]]; then echo nonempty; fi\n";
let d = diagnose(src);
assert!(
d.is_empty(),
"`${{#var}}` mis-handled as comment marker: {:?}",
d
);
}
#[test]
fn diagnose_handles_double_bracket_as_pair() {
let _g = crate::test_util::global_state_lock();
let src = "[[ -n \"$x\" ]]\n";
let d = diagnose(src);
assert!(
d.is_empty(),
"`[[ ]]` mis-handled as two `[`s: {:?}",
d
);
}
#[test]
fn diagnose_handles_arithmetic_double_paren_as_pair() {
let _g = crate::test_util::global_state_lock();
let src = "(( i++ ))\n";
let d = diagnose(src);
assert!(
d.is_empty(),
"`(( ))` mis-handled as two `(`s: {:?}",
d
);
}
#[test]
fn diagnose_does_not_flag_case_arm_paren_as_unmatched() {
let _g = crate::test_util::global_state_lock();
let src = "case \"$x\" in\n -h|--help) echo usage ;;\n *) echo other ;;\nesac\n";
let d = diagnose(src);
assert!(
d.is_empty(),
"case-arm `)` flagged as unmatched: {:?}",
d
);
}
#[test]
fn diagnose_still_flags_unmatched_paren_outside_case() {
let _g = crate::test_util::global_state_lock();
let src = "echo bare )\n";
let d = diagnose(src);
assert!(
d.iter()
.any(|v| v["message"]
.as_str()
.unwrap_or("")
.contains("unmatched `)`")),
"real unmatched `)` was not flagged: {:?}",
d
);
}
#[test]
fn simple_format_strips_trailing_whitespace() {
let _g = crate::test_util::global_state_lock();
let src = "echo hi \n echo bye\t\n";
let out = simple_format(src, 4, true);
assert_eq!(out, "echo hi\n echo bye\n");
}
#[test]
fn simple_format_ensures_trailing_newline() {
let _g = crate::test_util::global_state_lock();
let src = "echo hi";
let out = simple_format(src, 4, true);
assert!(out.ends_with('\n'));
}
#[test]
fn dump_reflection_json_is_valid_and_has_builtins() {
let _g = crate::test_util::global_state_lock();
let s = dump_reflection_json();
let v: Value = serde_json::from_str(&s).expect("valid JSON");
assert!(v["builtins"].is_object());
assert!(v["keywords"].is_object());
assert!(v["options"].is_object());
assert!(v["special_vars"].is_object());
assert!(v["builtins"]["cd"].is_string());
assert!(v["keywords"]["if"].is_string());
assert!(v["options"]["EXTENDED_GLOB"].is_string());
for want in ["PS1", "PS2", "PS3", "PS4", "psvar", "PROMPT2"] {
assert!(
v["special_vars"][want].is_string(),
"special_vars missing `{}` — reflection should source from SPECIAL_VAR_DOCS",
want,
);
}
assert!(
v["options"].as_object().unwrap().len() >= 700,
"dump_reflection options regressed to {}; expected full OPTION_DOCS + aliases (~750)",
v["options"].as_object().unwrap().len(),
);
assert!(
v["special_vars"].as_object().unwrap().len() >= 250,
"dump_reflection special_vars regressed to {}; expected full SPECIAL_VAR_DOCS (~280)",
v["special_vars"].as_object().unwrap().len(),
);
}
#[test]
fn completion_offers_builtins_for_short_prefix() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "cd".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 2 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(
items.iter().any(|i| i["label"] == "cd"),
"items: {:?}",
items
);
}
#[test]
fn completion_offers_daemon_z_star_builtins() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "zwh".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 3 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(
items.iter().any(|i| i["label"] == "zwhere"),
"no `zwhere` in completion items for `zwh` prefix: {:?}",
items
.iter()
.map(|i| i["label"].as_str().unwrap_or("?"))
.collect::<Vec<_>>(),
);
}
#[test]
fn completion_offers_ext_builtins_and_compsys_fns() {
let _g = crate::test_util::global_state_lock();
for (input, want) in &[("dat", "date"), ("_argu", "_arguments"), ("vare", "vared")] {
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), (*input).into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": input.len() },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(
items.iter().any(|i| i["label"] == *want),
"no `{}` in completion for `{}` prefix: {:?}",
want,
input,
items
.iter()
.map(|i| i["label"].as_str().unwrap_or("?"))
.collect::<Vec<_>>(),
);
}
}
#[test]
fn completion_offers_snippet_templates() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "if".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 2 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
let snippet = items
.iter()
.find(|i| i["label"].as_str() == Some("if …"))
.unwrap_or_else(|| panic!("no `if …` snippet in items: {:?}", items));
assert_eq!(snippet["kind"], 15, "snippet kind must be 15 (Snippet)");
assert_eq!(
snippet["insertTextFormat"], 2,
"snippet insertTextFormat must be 2 (Snippet — placeholders honored)"
);
let body = snippet["insertText"].as_str().unwrap();
assert!(body.contains("then") && body.contains("fi"), "snippet body wrong: {}", body);
}
#[test]
fn completion_suppressed_inside_double_quoted_literal() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), r#"echo "hello if"#.into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 14 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(
items.is_empty(),
"expected 0 items inside dq literal, got {}: {:?}",
items.len(),
items.iter().take(5).map(|i| i["label"].as_str().unwrap_or("?")).collect::<Vec<_>>(),
);
}
#[test]
fn completion_active_inside_command_substitution_inside_dq() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), r#"echo "x $(cd"#.into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 12 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(
items.iter().any(|i| i["label"] == "cd"),
"expected `cd` to surface inside $() within dq: {:?}",
items.iter().take(5).map(|i| i["label"].as_str().unwrap_or("?")).collect::<Vec<_>>(),
);
}
#[test]
fn completion_active_inside_backticks_inside_dq() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "echo \"x `cd".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 11 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(
items.iter().any(|i| i["label"] == "cd"),
"expected `cd` to surface inside backticks within dq",
);
}
#[test]
fn completion_active_inside_param_expansion_inside_dq() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), r#"echo "x ${P"#.into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 11 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(
!items.is_empty(),
"expected non-empty items inside ${{...}} within dq",
);
}
#[test]
fn completion_suppressed_inside_single_quoted_literal() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "echo 'hello if".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 14 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(items.is_empty(), "expected 0 items inside sq literal");
}
#[test]
fn completion_suppressed_inside_comment() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "# todo: if".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 10 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(items.is_empty(), "expected 0 items inside comment");
}
#[test]
fn completion_active_after_closing_double_quote() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), r#"echo "x" if"#.into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 11 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(
items.iter().any(|i| i["label"] == "if"),
"expected `if` to surface after closed dq string"
);
}
#[test]
fn completion_param_flags_inside_dollar_brace_paren() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "echo ${(".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 8 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
for want in &["L", "U", "@", "#", "f", "F", "j", "s", "q"] {
assert!(
items.iter().any(|i| i["label"] == *want),
"missing param flag `{}` in completion; got {:?}",
want,
items.iter().map(|i| i["label"].as_str().unwrap_or("?")).collect::<Vec<_>>(),
);
}
assert!(
!items.iter().any(|i| i["label"] == "cd" || i["label"] == "if"),
"param-flag context leaked normal completion: {:?}",
items.iter().take(20).map(|i| i["label"].as_str().unwrap_or("?")).collect::<Vec<_>>(),
);
}
#[test]
fn completion_param_flags_after_partial_flag() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "echo ${(b".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 9 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(
items.len() >= 40,
"expected full flag table (40+), got {}",
items.len(),
);
}
#[test]
fn completion_param_flags_inside_nested_dollar_brace() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "echo ${${(".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 10 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(items.iter().any(|i| i["label"] == "L"), "missing `L`");
}
#[test]
fn completion_no_param_flag_when_paren_already_closed() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "echo ${(b)var".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 13 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
let single_char_only = !items.is_empty()
&& items.iter().all(|i| i["label"].as_str().unwrap_or("").chars().count() == 1);
assert!(!single_char_only, "param-flag table leaked past closing `)`");
}
#[test]
fn completion_glob_qualifier_after_star_paren() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "ls *(".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 5 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
for want in &["/", ".", "@", "*", "r", "w", "x", "U", "G"] {
assert!(
items.iter().any(|i| i["label"] == *want),
"missing glob qualifier `{}`; got {:?}",
want,
items.iter().take(20).map(|i| i["label"].as_str().unwrap_or("?")).collect::<Vec<_>>(),
);
}
assert!(
!items.iter().any(|i| i["label"] == "cd" || i["label"] == "if"),
"glob-qualifier context leaked normal completion",
);
}
#[test]
fn completion_glob_qualifier_after_question_mark() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "ls ?(".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 5 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(items.iter().any(|i| i["label"] == "."));
}
#[test]
fn completion_no_glob_qualifier_for_plain_subshell() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "echo (".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 6 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
let has_normal = items.iter().any(|i| i["label"] == "cd" || i["label"] == "if");
let single_char_only = !items.is_empty()
&& items.iter().all(|i| i["label"].as_str().unwrap_or("").chars().count() == 1);
assert!(has_normal || !single_char_only, "subshell `(` mis-triggered glob qualifier table");
}
#[test]
fn completion_param_flag_table_has_50_entries() {
assert!(
PARAM_FLAG_DOCS.len() >= 49,
"PARAM_FLAG_DOCS dropped below 49 entries: {}",
PARAM_FLAG_DOCS.len()
);
}
#[test]
fn completion_history_designator_after_bang_at_word_start() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "!".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 1 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
for want in &["!", "$", "^", "*", "#"] {
assert!(
items.iter().any(|i| i["label"] == *want),
"missing history designator `{}`; got {:?}",
want,
items.iter().map(|i| i["label"].as_str().unwrap_or("?")).collect::<Vec<_>>(),
);
}
assert!(!items.iter().any(|i| i["label"] == "cd" || i["label"] == "if"));
}
#[test]
fn completion_history_designator_after_bang_midline() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "vim !".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 5 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(items.iter().any(|i| i["label"] == "$"));
}
#[test]
fn completion_no_history_designator_inside_arithmetic() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "(( a !".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 6 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(
!items.iter().any(|i| i["label"] == "?str?"),
"history table leaked into `((…))` arithmetic context",
);
}
#[test]
fn completion_no_history_designator_after_alnum() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "foo!".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 4 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(
!items.iter().any(|i| i["label"] == "?str?"),
"history table fired after alnum-preceded `!`",
);
}
#[test]
fn completion_param_modifier_after_colon_in_dollar_brace() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "echo ${var:".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 11 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
for want in &["h", "t", "r", "e", "-", "=", "+", "?", "s", "gs", "q", "Q"] {
assert!(
items.iter().any(|i| i["label"] == *want),
"missing modifier `{}`; got {:?}",
want,
items.iter().map(|i| i["label"].as_str().unwrap_or("?")).collect::<Vec<_>>(),
);
}
}
#[test]
fn completion_param_modifier_after_partial_modifier() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "echo ${var:h".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 12 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(items.len() >= 25, "expected full modifier table; got {}", items.len());
}
#[test]
fn completion_param_modifier_after_history_bang_colon() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "vim !!:".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 7 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
assert!(items.iter().any(|i| i["label"] == "h"));
assert!(items.iter().any(|i| i["label"] == "t"));
}
#[test]
fn completion_no_param_modifier_outside_dollar_brace() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "foo:".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 4 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
let has_normal = items.iter().any(|i| i["label"] == "cd" || i["label"] == "if");
let modifier_only = !items.is_empty()
&& items.iter().all(|i| {
let l = i["label"].as_str().unwrap_or("");
l.chars().count() <= 2
});
assert!(
has_normal || !modifier_only,
"bare `:` mis-triggered modifier table",
);
}
#[test]
fn completion_history_designator_table_has_9_entries() {
assert!(
HISTORY_DESIGNATOR_DOCS.len() >= 9,
"HISTORY_DESIGNATOR_DOCS dropped below 9: {}",
HISTORY_DESIGNATOR_DOCS.len()
);
}
fn complete_at(input: &str, col: usize) -> Vec<Value> {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), input.into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": col },
});
completion(&state, ¶ms)["items"].as_array().cloned().unwrap_or_default()
}
#[test]
fn completion_setopt_surfaces_options_only() {
let items = complete_at("setopt extend", 13);
assert!(items.iter().any(|i| i["label"] == "extended_glob"
|| i["label"] == "extendedglob"
|| i["label"] == "EXTENDED_GLOB"), "no extended_glob variant");
assert!(!items.iter().any(|i| i["label"] == "cd" || i["label"] == "if"));
}
#[test]
fn completion_unsetopt_surfaces_options_only() {
let items = complete_at("unsetopt nul", 12);
assert!(items.iter().any(|i| i["label"].as_str().unwrap_or("").to_lowercase().contains("null")));
assert!(!items.iter().any(|i| i["label"] == "if"));
}
#[test]
fn completion_set_dash_o_surfaces_options() {
let items = complete_at("set -o errex", 12);
assert!(items.iter().any(|i| i["label"].as_str().unwrap_or("").to_lowercase().contains("err")));
}
#[test]
fn completion_kill_dash_surfaces_signals() {
let items = complete_at("kill -", 6);
for want in &["HUP", "INT", "TERM", "KILL", "USR1"] {
assert!(items.iter().any(|i| i["label"] == *want), "missing signal `{}`", want);
}
}
#[test]
fn completion_trap_surfaces_signals() {
let items = complete_at("trap 'cmd' ", 11);
for want in &["INT", "TERM", "EXIT", "ZERR", "DEBUG"] {
assert!(items.iter().any(|i| i["label"] == *want), "missing signal `{}`", want);
}
}
#[test]
fn completion_zmodload_surfaces_modules() {
let items = complete_at("zmodload ", 9);
for want in &["zsh/zle", "zsh/datetime", "zsh/system", "zsh/parameter", "zsh/mathfunc"] {
assert!(items.iter().any(|i| i["label"] == *want), "missing module `{}`", want);
}
}
#[test]
fn completion_bindkey_dash_M_surfaces_keymaps() {
let items = complete_at("bindkey -M ", 11);
for want in &["emacs", "vicmd", "viins", "viopp"] {
assert!(items.iter().any(|i| i["label"] == *want), "missing keymap `{}`", want);
}
}
#[test]
fn completion_bindkey_bare_surfaces_widgets() {
let items = complete_at("bindkey '^A' acce", 17);
assert!(items.iter().any(|i| i["label"] == "accept-line"));
assert!(items.iter().any(|i| i["label"] == "accept-and-hold"));
}
#[test]
fn completion_zle_surfaces_widgets() {
let items = complete_at("zle for", 7);
assert!(items.iter().any(|i| i["label"] == "forward-char"));
assert!(items.iter().any(|i| i["label"] == "forward-word"));
}
#[test]
fn completion_typeset_dash_surfaces_flags() {
let items = complete_at("typeset -", 9);
for want in &["-a", "-A", "-i", "-g", "-r", "-x", "-U", "-f"] {
assert!(items.iter().any(|i| i["label"] == *want), "missing flag `{}`", want);
}
}
#[test]
fn completion_typeset_no_flag_falls_through() {
let items = complete_at("typeset FOO", 11);
assert!(!items.iter().any(|i| i["label"] == "-a"));
}
#[test]
fn completion_zstyle_surfaces_contexts() {
let items = complete_at("zstyle ", 7);
assert!(items.iter().any(|i| i["label"] == ":completion:*"));
assert!(items.iter().any(|i| i["label"] == ":vcs_info:*"));
}
#[test]
fn completion_compdef_surfaces_compsys_fns() {
let items = complete_at("compdef ", 8);
assert!(items.iter().any(|i| i["label"].as_str().unwrap_or("").starts_with('_')));
}
#[test]
fn completion_inside_double_bracket_surfaces_test_ops() {
let items = complete_at("[[ -f /tmp/x && -", 17);
for want in &["-f", "-d", "-e", "-z", "-n", "=~"] {
assert!(items.iter().any(|i| i["label"] == *want), "missing test op `{}`", want);
}
}
#[test]
fn completion_inside_double_paren_surfaces_math_fns() {
let items = complete_at("(( x = sq", 9);
for want in &["sqrt", "sin", "cos", "log", "exp"] {
assert!(items.iter().any(|i| i["label"] == *want), "missing math fn `{}`", want);
}
}
#[test]
fn completion_inside_dollar_double_paren_surfaces_math_fns() {
let items = complete_at("echo $(( ab", 11);
assert!(items.iter().any(|i| i["label"] == "abs"));
}
#[test]
fn completion_inside_pattern_modifier_paren() {
let items = complete_at("ls *.(#", 7);
for want in &["i", "l", "b", "m", "a", "s", "e"] {
assert!(
items.iter().any(|i| i["label"] == *want),
"missing pattern mod `{}` — got {:?}",
want,
items.iter().take(20).map(|i| i["label"].as_str().unwrap_or("?")).collect::<Vec<_>>(),
);
}
}
#[test]
fn completion_inside_subscript_flag_paren() {
let items = complete_at("echo ${arr[(", 12);
for want in &["i", "I", "r", "R", "e", "n"] {
assert!(items.iter().any(|i| i["label"] == *want), "missing subscript flag `{}`", want);
}
}
#[test]
fn completion_signal_table_has_30_entries() {
assert!(SIGNAL_NAMES.len() >= 30, "SIGNAL_NAMES: {}", SIGNAL_NAMES.len());
}
#[test]
fn completion_module_table_has_30_entries() {
assert!(ZSH_MODULE_NAMES.len() >= 30, "ZSH_MODULE_NAMES: {}", ZSH_MODULE_NAMES.len());
}
#[test]
fn completion_keymap_table_has_10_entries() {
assert!(KEYMAP_NAMES.len() >= 10, "KEYMAP_NAMES: {}", KEYMAP_NAMES.len());
}
#[test]
fn completion_widget_table_has_100_entries() {
assert!(ZLE_WIDGET_NAMES.len() >= 100, "ZLE_WIDGET_NAMES: {}", ZLE_WIDGET_NAMES.len());
}
#[test]
fn completion_typeset_flag_table_has_20_entries() {
assert!(TYPESET_FLAGS.len() >= 20, "TYPESET_FLAGS: {}", TYPESET_FLAGS.len());
}
#[test]
fn completion_test_op_table_has_30_entries() {
assert!(TEST_OPERATORS.len() >= 30, "TEST_OPERATORS: {}", TEST_OPERATORS.len());
}
#[test]
fn completion_math_fn_table_has_40_entries() {
assert!(MATH_FUNCTIONS.len() >= 40, "MATH_FUNCTIONS: {}", MATH_FUNCTIONS.len());
}
#[test]
fn completion_zstyle_context_table_has_15_entries() {
assert!(ZSTYLE_CONTEXTS.len() >= 15, "ZSTYLE_CONTEXTS: {}", ZSTYLE_CONTEXTS.len());
}
#[test]
fn completion_pattern_modifier_table_has_10_entries() {
assert!(PATTERN_MODIFIERS.len() >= 10, "PATTERN_MODIFIERS: {}", PATTERN_MODIFIERS.len());
}
#[test]
fn completion_subscript_flag_table_has_10_entries() {
assert!(SUBSCRIPT_FLAGS.len() >= 10, "SUBSCRIPT_FLAGS: {}", SUBSCRIPT_FLAGS.len());
}
#[test]
fn completion_keywords_includes_canonical_reswds() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "e".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 1 },
});
let items = completion(&state, ¶ms)["items"].as_array().unwrap().clone();
let labels: Vec<&str> = items.iter().map(|i| i["label"].as_str().unwrap_or("")).collect();
assert!(
labels.iter().any(|l| *l == "end"),
"RESWDS `end` not surfaced — labels: {:?}",
labels.iter().filter(|l| l.starts_with('e')).take(10).collect::<Vec<_>>(),
);
}
#[test]
fn completion_builtin_flag_print_dash_tab() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "print -".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 7 },
});
let items = completion(&state, ¶ms)["items"].as_array().unwrap().clone();
let labels: Vec<&str> = items.iter().map(|i| i["label"].as_str().unwrap_or("")).collect();
for want in &["-a", "-b", "-c", "-n", "-N", "-o", "-O", "-P", "-r", "-R", "-z"] {
assert!(
labels.iter().any(|l| l == want),
"missing `print -` flag `{}` — got {:?}",
want,
labels.iter().take(20).collect::<Vec<_>>(),
);
}
assert!(
items.len() >= 15,
"expected ≥15 print flags, got {}",
items.len(),
);
}
#[test]
fn completion_dollar_prefix_matches_canonical_specials() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), "$HIST".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 5 },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
let labels: Vec<&str> = items
.iter()
.map(|i| i["label"].as_str().unwrap_or(""))
.collect();
for want in &["$HISTCMD", "$HISTNO", "$HISTCHARS"] {
assert!(
labels.iter().any(|l| l == want),
"missing `{}` for `$HIST` prefix",
want,
);
}
}
#[test]
fn completion_special_vars_includes_full_canonical_set() {
let _g = crate::test_util::global_state_lock();
for prefix in &["PS", "PROMPT", "psvar"] {
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), (*prefix).into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": prefix.len() },
});
let result = completion(&state, ¶ms);
let items = result["items"].as_array().unwrap();
let labels: Vec<&str> = items
.iter()
.map(|i| i["label"].as_str().unwrap_or(""))
.collect();
match *prefix {
"PS" => {
for want in &["PS1", "PS2", "PS3", "PS4"] {
assert!(
labels.iter().any(|l| l == want),
"missing `{}` for prefix `{}`",
want,
prefix,
);
}
}
"PROMPT" => {
for want in &["PROMPT", "PROMPT2", "PROMPT3", "PROMPT4"] {
assert!(
labels.iter().any(|l| l == want),
"missing `{}` for prefix `{}`",
want,
prefix,
);
}
}
"psvar" => {
assert!(
labels.iter().any(|l| *l == "psvar"),
"missing `psvar`",
);
}
_ => {}
}
}
}
#[test]
fn completion_param_modifier_table_has_30_entries() {
assert!(
PARAM_MODIFIER_DOCS.len() >= 30,
"PARAM_MODIFIER_DOCS dropped below 30: {}",
PARAM_MODIFIER_DOCS.len()
);
}
#[test]
fn completion_glob_qualifier_table_has_30_entries() {
assert!(
GLOB_QUALIFIER_DOCS.len() >= 30,
"GLOB_QUALIFIER_DOCS dropped below 30 entries: {}",
GLOB_QUALIFIER_DOCS.len()
);
}
#[test]
fn completion_snippet_table_has_60_plus_entries() {
assert!(
SNIPPETS.len() >= 60,
"snippet table dropped below 60 entries: {}",
SNIPPETS.len()
);
}
#[test]
fn folding_ranges_finds_brace_and_do_blocks() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert(
"file:///t.zsh".into(),
"function f {\n echo\n}\nfor x in 1 2 3; do\n print $x\ndone\n".into(),
);
let params = json!({ "textDocument": { "uri": "file:///t.zsh" } });
let result = folding_ranges(&state, ¶ms);
let arr = result.as_array().unwrap();
assert!(
arr.iter().any(|r| r["startLine"] == 0 && r["endLine"] == 2),
"missing brace fold: {:?}",
arr
);
assert!(
arr.iter().any(|r| r["startLine"] == 3 && r["endLine"] == 5),
"missing for/do fold: {:?}",
arr
);
}
#[test]
fn references_returns_call_sites() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert(
"file:///t.zsh".into(),
"function greet { echo hi }\ngreet\ngreet world\n".into(),
);
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 9 }, "context": { "includeDeclaration": true },
});
let refs = references(&state, ¶ms);
let arr = refs.as_array().unwrap();
assert_eq!(arr.len(), 3, "expected 3 refs, got: {:?}", arr);
}
#[test]
fn references_follows_source_chain_outside_workspace() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
let tmp = std::env::temp_dir().join(format!(
"zshrs-ref-source-chain-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
));
std::fs::create_dir_all(&tmp).unwrap();
let sourced = tmp.join("helpers.zsh");
std::fs::write(&sourced, "greet world\ngreet again\n").unwrap();
let active_text = format!(
"function greet {{ echo hi }}\nsource {}\n",
sourced.display()
);
state.docs.insert("file:///t.zsh".into(), active_text);
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 9 }, "context": { "includeDeclaration": true },
});
let refs = references(&state, ¶ms);
let arr = refs.as_array().unwrap();
assert!(
arr.len() >= 3,
"source-chain following missed refs, got {}: {:?}",
arr.len(),
arr,
);
let sourced_uri = format!("file://{}", sourced.canonicalize().unwrap().display());
assert!(
arr.iter().any(|r| r["uri"].as_str() == Some(sourced_uri.as_str())),
"no ref pointing at sourced file `{}`: {:?}",
sourced_uri,
arr,
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn line_starts_comment_before_shebang() {
let line = "#!/usr/bin/env zsh";
let pos = line.find("env").unwrap();
assert!(line_starts_comment_before(line, pos));
}
#[test]
fn line_starts_comment_before_inline() {
let line = "echo hi; # call cd later";
let pos = line.find("cd").unwrap();
assert!(line_starts_comment_before(line, pos));
}
#[test]
fn line_starts_comment_before_string_with_hash_is_not_a_comment() {
let line = r#"echo "x #y"; cd"#;
let pos = line.rfind("cd").unwrap();
assert!(
!line_starts_comment_before(line, pos),
"code after a string containing `#` must still be code"
);
}
#[test]
fn line_starts_comment_before_single_quote_with_hash() {
let line = "echo 'x #y'; cd";
let pos = line.rfind("cd").unwrap();
assert!(!line_starts_comment_before(line, pos));
}
#[test]
fn line_starts_comment_before_backtick_with_hash() {
let line = "`echo #foo`; cd";
let pos = line.rfind("cd").unwrap();
assert!(!line_starts_comment_before(line, pos));
}
#[test]
fn line_starts_comment_negative_at_start() {
let line = "cd /tmp";
assert!(!line_starts_comment_before(line, 0));
}
#[test]
fn hover_on_shebang_env_is_suppressed() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert(
"file:///t.zsh".into(),
"#!/usr/bin/env zsh\necho hi\n".into(),
);
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 12 },
});
let h = hover(&state, ¶ms);
assert!(h.is_null(), "hover on shebang `env` must be null, got: {h}");
}
#[test]
fn hover_on_builtin_inside_comment_is_suppressed() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state
.docs
.insert("file:///t.zsh".into(), "echo hi # call cd later\n".into());
let cd_pos = "echo hi # call ".len();
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": cd_pos },
});
let h = hover(&state, ¶ms);
assert!(h.is_null(), "comment-text hover must be null, got: {h}");
}
#[test]
fn hover_on_real_builtin_outside_comment_still_works() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state
.docs
.insert("file:///t.zsh".into(), "cd /tmp\n".into());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 0 },
});
let h = hover(&state, ¶ms);
assert!(!h.is_null(), "real builtin must still hover");
}
#[test]
fn position_inside_double_quoted_string_detected() {
let line = "echo \"cd to dir\"";
let cd_start = line.find("cd").unwrap();
let cd_end = cd_start + 2;
assert!(position_inside_string_literal(line, cd_start, cd_end));
}
#[test]
fn position_inside_single_quoted_string_detected() {
let line = "echo 'cd to dir'";
let cd_start = line.find("cd").unwrap();
let cd_end = cd_start + 2;
assert!(position_inside_string_literal(line, cd_start, cd_end));
}
#[test]
fn position_inside_backtick_string_detected() {
let line = "echo `cd to dir`";
let cd_start = line.find("cd").unwrap();
let cd_end = cd_start + 2;
assert!(position_inside_string_literal(line, cd_start, cd_end));
}
#[test]
fn position_inside_parameter_expansion_is_code() {
let line = "echo \"${HOME}/x\"";
let home_start = line.find("HOME").unwrap();
let home_end = home_start + 4;
assert!(
!position_inside_string_literal(line, home_start, home_end),
"`${{HOME}}` inside double-quotes is code, not string text"
);
}
#[test]
fn position_outside_string_is_code() {
let line = "cd /tmp";
assert!(!position_inside_string_literal(line, 0, 2));
}
#[test]
fn position_after_closing_quote_is_code() {
let line = "echo \"foo\" cd";
let cd_start = line.find(" cd").unwrap() + 1;
let cd_end = cd_start + 2;
assert!(!position_inside_string_literal(line, cd_start, cd_end));
}
#[test]
fn classify_comment_outranks_string() {
let line = "# echo \"cd\"";
let cd_start = line.find("cd").unwrap();
let cd_end = cd_start + 2;
assert_eq!(
classify_hover_position(line, cd_start, cd_end),
HoverGate::Comment
);
}
#[test]
fn classify_string_literal() {
let line = "echo \"cd to dir\"";
let cd_start = line.find("cd").unwrap();
let cd_end = cd_start + 2;
assert_eq!(
classify_hover_position(line, cd_start, cd_end),
HoverGate::StringLiteral
);
}
#[test]
fn classify_bare_code() {
let line = "cd /tmp";
assert_eq!(classify_hover_position(line, 0, 2), HoverGate::Code);
}
#[test]
fn rename_strips_colon_colon_qualifier() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert(
"file:///t.zsh".into(),
"function handle { echo hi }\nhandle\nhandle x\n".into(),
);
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 9 }, "newName": "Demo::handle2",
});
let r = rename(&state, ¶ms);
let changes = r["changes"].as_object().expect("changes");
let edits = changes["file:///t.zsh"].as_array().expect("edits");
assert!(!edits.is_empty(), "expected at least 1 edit, got: {edits:?}");
for e in edits {
assert_eq!(
e["newText"], json!("handle2"),
"qualifier must be stripped; got: {e:?}"
);
}
}
#[test]
fn rename_passes_through_bare_new_name() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert(
"file:///t.zsh".into(),
"function handle { echo hi }\nhandle\n".into(),
);
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 9 },
"newName": "handle2",
});
let r = rename(&state, ¶ms);
let edits = r["changes"]["file:///t.zsh"].as_array().expect("edits");
for e in edits {
assert_eq!(e["newText"], json!("handle2"));
}
}
#[test]
fn rename_function_crosses_files() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert(
"file:///lib.zsh".into(),
"function greet { echo hi }\n".into(),
);
state.docs.insert(
"file:///rc.zsh".into(),
"source lib.zsh\ngreet\ngreet world\n".into(),
);
let params = json!({
"textDocument": { "uri": "file:///lib.zsh" },
"position": { "line": 0, "character": 9 }, "context": { "includeDeclaration": true },
"newName": "salute",
});
let r = rename(&state, ¶ms);
let changes = r["changes"].as_object().expect("rename has changes map");
assert!(
changes.contains_key("file:///lib.zsh"),
"lib.zsh edited: {changes:?}"
);
assert!(
changes.contains_key("file:///rc.zsh"),
"rc.zsh edited: {changes:?}"
);
let lib_edits = changes["file:///lib.zsh"].as_array().unwrap();
let rc_edits = changes["file:///rc.zsh"].as_array().unwrap();
assert_eq!(lib_edits.len(), 1);
assert_eq!(rc_edits.len(), 2);
for e in lib_edits.iter().chain(rc_edits.iter()) {
assert_eq!(e["newText"], "salute");
}
}
#[test]
fn rename_rejects_empty_new_name() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert(
"file:///t.zsh".into(),
"function greet { echo hi }\n".into(),
);
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": 9 },
"context": { "includeDeclaration": true },
"newName": "",
});
let r = rename(&state, ¶ms);
assert!(r.is_null(), "empty new_name must be rejected");
}
#[test]
fn workspace_walk_picks_up_unopened_zsh_files() {
let _g = crate::test_util::global_state_lock();
let tmp = std::env::temp_dir().join(format!(
"zshrs-workspace-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&tmp).unwrap();
let lib_path = tmp.join("lib.zsh");
let rc_path = tmp.join("rc.zsh");
std::fs::write(&lib_path, "function greet { echo hi }\n").unwrap();
std::fs::write(&rc_path, "greet\ngreet world\n").unwrap();
let rc_uri = format!("file://{}", rc_path.display());
let mut state = State::default();
state
.docs
.insert(rc_uri.clone(), "greet\ngreet world\n".into());
let init = json!({ "rootUri": format!("file://{}", tmp.display()) });
ingest_workspace_init(&mut state, &init);
let lib_uri = format!("file://{}", lib_path.display());
assert!(
state.workspace_files.contains_key(&lib_uri),
"workspace walk picked up lib.zsh: keys={:?}",
state.workspace_files.keys().collect::<Vec<_>>(),
);
let params = json!({
"textDocument": { "uri": rc_uri },
"position": { "line": 0, "character": 0 },
"context": { "includeDeclaration": true },
"newName": "salute",
});
let r = rename(&state, ¶ms);
let changes = r["changes"].as_object().expect("changes map");
assert!(
changes.contains_key(&lib_uri),
"lib.zsh (workspace) edited: keys={:?}",
changes.keys().collect::<Vec<_>>(),
);
assert!(
changes.contains_key(&rc_uri),
"rc.zsh (open) edited: keys={:?}",
changes.keys().collect::<Vec<_>>(),
);
assert_eq!(changes[&lib_uri].as_array().unwrap().len(), 1);
assert_eq!(changes[&rc_uri].as_array().unwrap().len(), 2);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn workspace_walk_skips_node_modules_and_git() {
let _g = crate::test_util::global_state_lock();
let tmp = std::env::temp_dir().join(format!(
"zshrs-skip-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(tmp.join(".git")).unwrap();
std::fs::create_dir_all(tmp.join("node_modules")).unwrap();
std::fs::write(tmp.join(".git").join("hooks.zsh"), "should_skip=1\n").unwrap();
std::fs::write(tmp.join("node_modules").join("util.zsh"), "should_skip=1\n").unwrap();
std::fs::write(tmp.join("real.zsh"), "should_pick_up=1\n").unwrap();
let mut state = State::default();
let init = json!({ "rootUri": format!("file://{}", tmp.display()) });
ingest_workspace_init(&mut state, &init);
assert_eq!(
state.workspace_files.len(),
1,
"only real.zsh picked up: keys={:?}",
state.workspace_files.keys().collect::<Vec<_>>(),
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn is_zsh_source_filename_accepts_dotfiles_and_extensions() {
assert!(is_zsh_source_filename("foo.zsh"));
assert!(is_zsh_source_filename("foo.sh"));
assert!(is_zsh_source_filename(".zshrc"));
assert!(is_zsh_source_filename(".zshenv"));
assert!(is_zsh_source_filename(".zsh_aliases"));
assert!(!is_zsh_source_filename("foo.py"));
assert!(!is_zsh_source_filename(".gitignore"));
assert!(!is_zsh_source_filename("README.md"));
}
fn run_code_actions(text: &str, sl: u32, sc: u32, el: u32, ec: u32) -> Vec<Value> {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state.docs.insert("file:///t.zsh".into(), text.to_string());
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"range": {
"start": { "line": sl, "character": sc },
"end": { "line": el, "character": ec },
},
});
match code_actions(&state, ¶ms) {
Value::Array(v) => v,
_ => Vec::new(),
}
}
#[test]
fn code_actions_single_line_offers_var_const_and_function() {
let acts = run_code_actions(" echo hello\n", 0, 4, 0, 14);
let titles: Vec<&str> = acts
.iter()
.map(|a| a["title"].as_str().unwrap_or(""))
.collect();
assert!(
titles.iter().any(|t| t.contains("variable")),
"missing Extract Variable: {:?}",
titles,
);
assert!(
titles.iter().any(|t| t.contains("constant")),
"missing Extract Constant: {:?}",
titles,
);
assert!(
titles.iter().any(|t| t.contains("function")),
"missing Extract Function: {:?}",
titles,
);
}
#[test]
fn code_actions_subexpression_skips_function_extract() {
let acts = run_code_actions("echo hello world\n", 0, 5, 0, 10);
let titles: Vec<&str> = acts
.iter()
.map(|a| a["title"].as_str().unwrap_or(""))
.collect();
assert!(titles.iter().any(|t| t.contains("variable")));
assert!(
!titles.iter().any(|t| t.contains("function")),
"function extract leaked on sub-expression: {:?}",
titles,
);
}
#[test]
fn code_actions_multiline_only_offers_function_extract() {
let text = "if true; then\n echo a\n echo b\nfi\n";
let acts = run_code_actions(text, 1, 0, 3, 0);
let titles: Vec<&str> = acts
.iter()
.map(|a| a["title"].as_str().unwrap_or(""))
.collect();
assert_eq!(acts.len(), 1, "expected exactly one action: {:?}", titles);
assert!(titles[0].contains("function"));
let changes = &acts[0]["edit"]["changes"]["file:///t.zsh"];
let edits = changes.as_array().expect("edits array");
assert_eq!(edits.len(), 2);
let decl = edits[0]["newText"].as_str().unwrap_or("");
assert!(
decl.contains("extracted_function() {")
&& decl.contains("echo a")
&& decl.contains("echo b"),
"decl missing body lines: {:?}",
decl,
);
let call = edits[1]["newText"].as_str().unwrap_or("");
assert!(call.trim() == "extracted_function", "call must be bare: {:?}", call);
}
#[test]
fn code_actions_multiline_preserves_relative_indent() {
let text = "if outer; then\n if inner; then\n echo nested\n fi\nfi\n";
let acts = run_code_actions(text, 1, 0, 3, 0);
assert_eq!(acts.len(), 1);
let decl = acts[0]["edit"]["changes"]["file:///t.zsh"][0]["newText"]
.as_str()
.unwrap_or("");
assert!(
decl.contains(" echo nested"),
"relative indent lost: {:?}",
decl,
);
}
#[test]
fn code_actions_caret_only_snaps_to_word() {
let acts = run_code_actions("echo greeting\n", 0, 8, 0, 8);
assert!(
acts.iter()
.any(|a| a["title"].as_str().unwrap_or("").contains("variable")),
"caret-only didn't snap to a word: {:?}",
acts.iter().map(|a| a["title"].clone()).collect::<Vec<_>>(),
);
}
#[test]
fn code_actions_caret_only_offers_extract_function() {
let acts = run_code_actions("echo greeting\n", 0, 8, 0, 8);
let titles: Vec<&str> = acts
.iter()
.map(|a| a["title"].as_str().unwrap_or(""))
.collect();
assert!(
titles.iter().any(|t| t.contains("function")),
"caret-only must include Extract Function for Cmd-Opt-M: {:?}",
titles,
);
let fn_act = acts
.iter()
.find(|a| a["title"].as_str().unwrap_or("").contains("function"))
.expect("function action present");
let decl = fn_act["edit"]["changes"]["file:///t.zsh"][0]["newText"]
.as_str()
.unwrap_or("");
assert!(
decl.contains("echo greeting"),
"caret-only function extract should wrap the whole line, not just the word: {:?}",
decl,
);
}
#[test]
fn code_actions_caret_on_whitespace_still_offers_function() {
let acts = run_code_actions(" echo hello\n", 0, 2, 0, 2);
let titles: Vec<&str> = acts
.iter()
.map(|a| a["title"].as_str().unwrap_or(""))
.collect();
assert!(
titles.iter().any(|t| t.contains("function")),
"cursor on whitespace must still emit Extract Function: {:?}",
titles,
);
}
#[test]
fn code_actions_caret_on_blank_line_returns_empty() {
let acts = run_code_actions("foo\n\nbar\n", 1, 0, 1, 0);
assert!(
acts.is_empty(),
"blank line should produce no actions: {:?}",
acts.iter().map(|a| a["title"].clone()).collect::<Vec<_>>(),
);
}
#[test]
fn prepare_rename_rejects_in_comment() {
let _g = crate::test_util::global_state_lock();
let mut state = State::default();
state
.docs
.insert("file:///t.zsh".into(), "echo hi # rename me\n".into());
let pos = "echo hi # rename ".len();
let params = json!({
"textDocument": { "uri": "file:///t.zsh" },
"position": { "line": 0, "character": pos },
});
let r = prepare_rename(&state, ¶ms);
assert!(r.is_null(), "prepareRename in comment must reject");
}
}