use std::collections::HashMap;
use crate::extensions::zsh_ast::{
ZshAssign, ZshAssignValue, ZshCommand, ZshList, ZshPipe, ZshProgram, ZshSimple, ZshSublist,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct SymbolId(pub u32);
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SymbolKind {
Func,
Global,
Local,
}
#[derive(Clone, Debug)]
pub struct Symbol {
pub id: SymbolId,
pub name: String,
pub kind: SymbolKind,
pub decl_line: u32,
}
#[derive(Clone, Debug)]
pub struct SymbolRef {
pub symbol: SymbolId,
pub line: u32,
pub name: String,
}
pub struct SymbolTable {
pub symbols: Vec<Symbol>,
pub refs: Vec<SymbolRef>,
}
impl SymbolTable {
pub fn build(src: &str) -> Option<Self> {
let prog = parse_locked(src)?;
let mut builder = Builder::default();
builder.walk_program(&prog);
Some(SymbolTable {
symbols: builder.symbols,
refs: builder.refs,
})
}
pub fn symbol_at(&self, line: u32, needle: &str) -> Option<SymbolId> {
for r in &self.refs {
if r.line == line && r.name == needle {
return Some(r.symbol);
}
}
for s in &self.symbols {
if s.decl_line == line && s.name == needle {
return Some(s.id);
}
}
None
}
pub fn occurrences(&self, id: SymbolId) -> Vec<(u32, String)> {
let mut out: Vec<(u32, String)> = Vec::new();
if let Some(sym) = self.symbols.iter().find(|s| s.id == id) {
out.push((sym.decl_line, sym.name.clone()));
}
for r in &self.refs {
if r.symbol == id {
out.push((r.line, r.name.clone()));
}
}
out
}
}
pub fn find_ast_occurrences(src: &str, name: &str, kind: SymbolKind) -> Vec<u32> {
let Some(prog) = parse_locked(src) else {
return Vec::new();
};
let mut finder = OccurrenceFinder {
target: name,
kind,
out: Vec::new(),
};
finder.walk_program(&prog);
finder.out
}
pub fn collect_sourced_paths(src: &str, base_dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let Some(prog) = parse_locked(src) else {
return Vec::new();
};
let mut finder = SourceFinder {
out: Vec::new(),
};
finder.walk_program(&prog);
let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
let mut resolved: Vec<std::path::PathBuf> = Vec::new();
for raw in finder.out {
let untok = crate::ported::lex::untokenize(&raw);
let trimmed = untok.trim_matches(|c| c == '"' || c == '\'');
if trimmed.is_empty() {
continue;
}
let path = if let Some(rest) = trimmed.strip_prefix("~/") {
match &home {
Some(h) => h.join(rest),
None => continue,
}
} else if trimmed.starts_with('/') {
std::path::PathBuf::from(trimmed)
} else if trimmed.contains('$') {
continue;
} else {
base_dir.join(trimmed)
};
if let Ok(canon) = std::fs::canonicalize(&path) {
resolved.push(canon);
}
}
resolved
}
struct SourceFinder {
out: Vec<String>,
}
impl SourceFinder {
fn walk_program(&mut self, p: &ZshProgram) {
for list in &p.lists {
self.walk_sublist(&list.sublist);
}
}
fn walk_sublist(&mut self, sl: &ZshSublist) {
self.walk_pipe(&sl.pipe);
if let Some((_, next)) = &sl.next {
self.walk_sublist(next);
}
}
fn walk_pipe(&mut self, pipe: &ZshPipe) {
self.walk_command(&pipe.cmd);
if let Some(next) = &pipe.next {
self.walk_pipe(next);
}
}
fn walk_command(&mut self, cmd: &ZshCommand) {
match cmd {
ZshCommand::Simple(s) => {
if let Some(verb) = s.words.first() {
let v = crate::ported::lex::untokenize(verb);
if matches!(v.as_str(), "source" | "." | "zsource") {
if let Some(arg) = s.words.get(1) {
self.out.push(arg.clone());
}
}
}
}
ZshCommand::FuncDef(fd) => self.walk_program(&fd.body),
ZshCommand::Subsh(p) | ZshCommand::Cursh(p) => self.walk_program(p),
ZshCommand::For(f) => self.walk_program(&f.body),
ZshCommand::Case(c) => {
for arm in &c.arms {
self.walk_program(&arm.body);
}
}
ZshCommand::If(iff) => {
self.walk_program(&iff.cond);
self.walk_program(&iff.then);
for (cond, body) in &iff.elif {
self.walk_program(cond);
self.walk_program(body);
}
if let Some(els) = &iff.else_ {
self.walk_program(els);
}
}
ZshCommand::While(w) | ZshCommand::Until(w) => {
self.walk_program(&w.cond);
self.walk_program(&w.body);
}
ZshCommand::Repeat(r) => self.walk_program(&r.body),
ZshCommand::Try(t) => {
self.walk_program(&t.try_block);
self.walk_program(&t.always);
}
ZshCommand::Redirected(inner, _) => self.walk_command(inner),
ZshCommand::Time(Some(sl)) => self.walk_sublist(sl),
ZshCommand::Time(None) | ZshCommand::Cond(_) | ZshCommand::Arith(_) => {}
}
}
}
struct OccurrenceFinder<'a> {
target: &'a str,
kind: SymbolKind,
out: Vec<u32>,
}
impl<'a> OccurrenceFinder<'a> {
fn walk_program(&mut self, p: &ZshProgram) {
for list in &p.lists {
self.walk_sublist(&list.sublist);
}
}
fn walk_sublist(&mut self, sl: &ZshSublist) {
self.walk_pipe(&sl.pipe);
if let Some((_, next)) = &sl.next {
self.walk_sublist(next);
}
}
fn walk_pipe(&mut self, pipe: &ZshPipe) {
let line0 = pipe.lineno.saturating_sub(1) as u32;
self.walk_command(&pipe.cmd, line0);
if let Some(next) = &pipe.next {
self.walk_pipe(next);
}
}
fn walk_command(&mut self, cmd: &ZshCommand, line: u32) {
match cmd {
ZshCommand::FuncDef(fd) => {
if matches!(self.kind, SymbolKind::Func) {
for n in &fd.names {
let nu = crate::ported::lex::untokenize(n);
if nu == self.target {
self.out.push(line);
}
}
}
self.walk_program(&fd.body);
}
ZshCommand::Simple(s) => self.walk_simple(s, line),
ZshCommand::Subsh(p) | ZshCommand::Cursh(p) => self.walk_program(p),
ZshCommand::For(f) => self.walk_program(&f.body),
ZshCommand::Case(c) => {
for arm in &c.arms {
self.walk_program(&arm.body);
}
}
ZshCommand::If(iff) => {
self.walk_program(&iff.cond);
self.walk_program(&iff.then);
for (cond, body) in &iff.elif {
self.walk_program(cond);
self.walk_program(body);
}
if let Some(els) = &iff.else_ {
self.walk_program(els);
}
}
ZshCommand::While(w) | ZshCommand::Until(w) => {
self.walk_program(&w.cond);
self.walk_program(&w.body);
}
ZshCommand::Repeat(r) => self.walk_program(&r.body),
ZshCommand::Try(t) => {
self.walk_program(&t.try_block);
self.walk_program(&t.always);
}
ZshCommand::Redirected(inner, _) => self.walk_command(inner, line),
ZshCommand::Time(Some(sl)) => self.walk_sublist(sl),
ZshCommand::Time(None) | ZshCommand::Cond(_) | ZshCommand::Arith(_) => {}
}
}
fn walk_simple(&mut self, s: &ZshSimple, line: u32) {
if matches!(self.kind, SymbolKind::Global | SymbolKind::Local) {
for a in &s.assigns {
let nu = crate::ported::lex::untokenize(&a.name);
if nu == self.target {
self.out.push(line);
}
if let ZshAssignValue::Scalar(v) = &a.value {
self.scan_dollar_refs(v, line);
} else if let ZshAssignValue::Array(v) = &a.value {
for item in v {
self.scan_dollar_refs(item, line);
}
}
}
let is_local_kw = s
.words
.first()
.map(|w| matches!(w.as_str(), "local" | "typeset" | "declare" | "private"))
.unwrap_or(false);
if is_local_kw {
for w in s.words.iter().skip(1) {
let raw = w.as_str();
if raw.starts_with('-') {
continue;
}
let name_part = match raw.find('=') {
Some(i) => &raw[..i],
None => raw,
};
if name_part == self.target {
self.out.push(line);
}
}
}
}
if matches!(self.kind, SymbolKind::Func) {
let is_alias = s.words.first().map(|w| w.as_str() == "alias").unwrap_or(false);
let is_autoload = s.words.first().map(|w| w.as_str() == "autoload").unwrap_or(false);
if is_alias || is_autoload {
for w in s.words.iter().skip(1) {
let untok_word = crate::ported::lex::untokenize(w);
if untok_word.starts_with('-')
|| (is_autoload && untok_word.starts_with('+'))
{
continue;
}
let name_part = if is_alias {
match untok_word.find('=') {
Some(i) => &untok_word[..i],
None => untok_word.as_str(),
}
} else {
untok_word.as_str()
};
if name_part == self.target {
self.out.push(line);
}
}
}
if let Some(first) = s.words.first() {
let callee = if matches!(
first.as_str(),
"local" | "typeset" | "declare" | "private" | "alias" | "autoload"
) {
None
} else if matches!(
first.as_str(),
"builtin" | "command" | "exec" | "noglob" | "nocorrect"
) {
s.words.get(1)
} else {
Some(first)
};
if let Some(name) = callee {
let nu = crate::ported::lex::untokenize(name);
if nu == self.target {
self.out.push(line);
}
}
}
}
if matches!(self.kind, SymbolKind::Global | SymbolKind::Local) {
for w in &s.words {
self.scan_dollar_refs(w, line);
}
}
}
fn scan_dollar_refs(&mut self, word: &str, line: u32) {
let cleaned = crate::ported::lex::untokenize(word);
let bytes = cleaned.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'$' {
i += 1;
continue;
}
i += 1;
if i >= bytes.len() {
break;
}
let braced = bytes[i] == b'{';
if braced {
i += 1;
if i < bytes.len() && bytes[i] == b'(' {
let mut depth = 1i32;
i += 1;
while i < bytes.len() && depth > 0 {
match bytes[i] {
b'(' => depth += 1,
b')' => depth -= 1,
_ => {}
}
i += 1;
}
}
}
let start = i;
while i < bytes.len() {
let c = bytes[i];
if c.is_ascii_alphanumeric() || c == b'_' {
i += 1;
} else {
break;
}
}
if i > start {
if let Ok(name) = std::str::from_utf8(&bytes[start..i]) {
if name == self.target {
self.out.push(line);
}
}
}
if braced {
while i < bytes.len() && bytes[i] != b'}' {
i += 1;
}
if i < bytes.len() {
i += 1;
}
}
}
}
}
fn parse_locked(src: &str) -> Option<ZshProgram> {
use crate::utils::{errflag, ERRFLAG_ERROR};
use std::sync::atomic::Ordering;
crate::lex::lex_init(src);
let saved = errflag.load(Ordering::Relaxed);
errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
let prog = crate::parse::parse();
let had_err = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
errflag.store(saved, Ordering::Relaxed);
if had_err {
return None;
}
Some(prog)
}
#[derive(Default)]
struct Builder {
symbols: Vec<Symbol>,
refs: Vec<SymbolRef>,
next_sym: u32,
func_index: HashMap<String, SymbolId>,
global_index: HashMap<String, SymbolId>,
local_stack: Vec<HashMap<String, SymbolId>>,
}
impl Builder {
fn fresh_id(&mut self) -> SymbolId {
let id = SymbolId(self.next_sym);
self.next_sym += 1;
id
}
fn declare(&mut self, name: &str, kind: SymbolKind, line: u32) -> SymbolId {
let name = crate::ported::lex::untokenize(name);
let id = self.fresh_id();
self.symbols.push(Symbol {
id,
name: name.clone(),
kind: kind.clone(),
decl_line: line,
});
match kind {
SymbolKind::Func => {
self.func_index.insert(name.clone(), id);
}
SymbolKind::Global => {
self.global_index.insert(name.clone(), id);
}
SymbolKind::Local => {
if let Some(top) = self.local_stack.last_mut() {
top.insert(name, id);
}
}
}
id
}
fn record_ref(&mut self, name: &str, line: u32) {
let name = crate::ported::lex::untokenize(name);
for scope in self.local_stack.iter().rev() {
if let Some(&id) = scope.get(name.as_str()) {
self.refs.push(SymbolRef {
symbol: id,
line,
name,
});
return;
}
}
if let Some(&id) = self.global_index.get(name.as_str()) {
self.refs.push(SymbolRef {
symbol: id,
line,
name,
});
return;
}
if let Some(&id) = self.func_index.get(name.as_str()) {
self.refs.push(SymbolRef {
symbol: id,
line,
name,
});
}
}
fn walk_program(&mut self, p: &ZshProgram) {
for list in &p.lists {
self.walk_list(list);
}
}
fn walk_list(&mut self, list: &ZshList) {
self.walk_sublist(&list.sublist);
}
fn walk_sublist(&mut self, sl: &ZshSublist) {
self.walk_pipe(&sl.pipe);
if let Some((_, next)) = &sl.next {
self.walk_sublist(next);
}
}
fn walk_pipe(&mut self, pipe: &ZshPipe) {
let line0 = pipe.lineno.saturating_sub(1) as u32;
self.walk_command(&pipe.cmd, line0);
if let Some(next) = &pipe.next {
self.walk_pipe(next);
}
}
fn walk_command(&mut self, cmd: &ZshCommand, line: u32) {
match cmd {
ZshCommand::FuncDef(fd) => {
for n in &fd.names {
let _id = self.declare(n, SymbolKind::Func, line);
}
self.local_stack.push(HashMap::new());
self.walk_program(&fd.body);
self.local_stack.pop();
}
ZshCommand::Simple(s) => self.walk_simple(s, line),
ZshCommand::Subsh(p) | ZshCommand::Cursh(p) => self.walk_program(p),
ZshCommand::For(f) => self.walk_program(&f.body),
ZshCommand::Case(c) => {
for arm in &c.arms {
self.walk_program(&arm.body);
}
}
ZshCommand::If(iff) => {
self.walk_program(&iff.cond);
self.walk_program(&iff.then);
for (cond, body) in &iff.elif {
self.walk_program(cond);
self.walk_program(body);
}
if let Some(els) = &iff.else_ {
self.walk_program(els);
}
}
ZshCommand::While(w) | ZshCommand::Until(w) => {
self.walk_program(&w.cond);
self.walk_program(&w.body);
}
ZshCommand::Repeat(r) => self.walk_program(&r.body),
ZshCommand::Try(t) => {
self.walk_program(&t.try_block);
self.walk_program(&t.always);
}
ZshCommand::Redirected(inner, _) => self.walk_command(inner, line),
ZshCommand::Time(Some(sl)) => self.walk_sublist(sl),
ZshCommand::Time(None) | ZshCommand::Cond(_) | ZshCommand::Arith(_) => {}
}
}
fn walk_simple(&mut self, s: &ZshSimple, line: u32) {
let is_local_keyword = s
.words
.first()
.map(|w| matches!(w.as_str(), "local" | "typeset" | "declare" | "private"))
.unwrap_or(false);
let is_alias_keyword = s
.words
.first()
.map(|w| w.as_str() == "alias")
.unwrap_or(false);
let is_autoload_keyword = s
.words
.first()
.map(|w| w.as_str() == "autoload")
.unwrap_or(false);
let in_function = !self.local_stack.is_empty();
if is_alias_keyword {
for w in s.words.iter().skip(1) {
let untok_word = crate::ported::lex::untokenize(w);
if untok_word.starts_with('-') {
continue;
}
let name_part = match untok_word.find('=') {
Some(i) => &untok_word[..i],
None => untok_word.as_str(),
};
if !name_part.is_empty()
&& name_part
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
self.declare(name_part, SymbolKind::Func, line);
}
}
}
if is_autoload_keyword {
for w in s.words.iter().skip(1) {
let raw = w.as_str();
if raw.starts_with('-') || raw.starts_with('+') {
continue;
}
let untok = crate::ported::lex::untokenize(raw);
if !untok.is_empty()
&& untok
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
self.declare(&untok, SymbolKind::Func, line);
}
}
}
for a in &s.assigns {
let kind = if in_function {
SymbolKind::Local
} else {
SymbolKind::Global
};
self.declare(&a.name, kind, line);
if let ZshAssignValue::Scalar(v) = &a.value {
self.scan_dollar_refs(v, line);
} else if let ZshAssignValue::Array(v) = &a.value {
for item in v {
self.scan_dollar_refs(item, line);
}
}
}
if is_local_keyword {
for w in s.words.iter().skip(1) {
let raw = w.as_str();
if raw.starts_with('-') {
continue;
}
let name_part = match raw.find('=') {
Some(i) => &raw[..i],
None => raw,
};
if !name_part.is_empty()
&& name_part
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
self.declare(name_part, SymbolKind::Local, line);
}
if let Some(i) = raw.find('=') {
self.scan_dollar_refs(&raw[i + 1..], line);
}
}
}
if let Some(first) = s.words.first() {
let callee = if is_local_keyword {
None
} else if matches!(
first.as_str(),
"builtin" | "command" | "exec" | "noglob" | "nocorrect"
) {
s.words.get(1)
} else {
Some(first)
};
if let Some(name) = callee {
if !name.is_empty() {
self.record_ref(name, line);
}
}
for w in &s.words {
self.scan_dollar_refs(w, line);
}
}
}
fn scan_dollar_refs(&mut self, word: &str, line: u32) {
let cleaned = crate::ported::lex::untokenize(word);
let bytes = cleaned.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'$' {
i += 1;
continue;
}
i += 1;
if i >= bytes.len() {
break;
}
let braced = bytes[i] == b'{';
if braced {
i += 1;
if i < bytes.len() && bytes[i] == b'(' {
let mut depth = 1i32;
i += 1;
while i < bytes.len() && depth > 0 {
match bytes[i] {
b'(' => depth += 1,
b')' => depth -= 1,
_ => {}
}
i += 1;
}
}
}
let start = i;
while i < bytes.len() {
let c = bytes[i];
if c.is_ascii_alphanumeric() || c == b'_' {
i += 1;
} else {
break;
}
}
if i > start {
if let Ok(name) = std::str::from_utf8(&bytes[start..i]) {
self.record_ref(name, line);
}
}
if braced {
while i < bytes.len() && bytes[i] != b'}' {
i += 1;
}
if i < bytes.len() {
i += 1;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_ast_occurrences_func_decl_with_hyphen() {
let _g = crate::test_util::global_state_lock();
let src = "function daemon-ping() {\n echo hi\n}\n";
let table = SymbolTable::build(src);
let dump: Vec<(String, String)> = table
.as_ref()
.map(|t| {
t.symbols
.iter()
.map(|s| (s.name.clone(), format!("{:?}", s.kind)))
.collect()
})
.unwrap_or_default();
let lines = find_ast_occurrences(src, "daemon-ping", SymbolKind::Func);
assert!(
lines.contains(&0),
"expected line 0 (the FuncDef decl).\n AST symbols: {:?}\n occurrences: {:?}",
dump,
lines
);
}
#[test]
fn alias_decl_recorded_as_func() {
let _g = crate::test_util::global_state_lock();
let src = "alias myalias='echo hello'\nmyalias\n";
let t = SymbolTable::build(src).expect("parse ok");
let dump: Vec<_> = t.symbols.iter().map(|s| (&s.name, &s.kind, s.decl_line)).collect();
let funcs: Vec<_> = t
.symbols
.iter()
.filter(|s| matches!(s.kind, SymbolKind::Func) && s.name == "myalias")
.collect();
assert_eq!(funcs.len(), 1, "alias not recorded. table: {:?}", dump);
assert_eq!(funcs[0].decl_line, 0, "decl line: {:?}", dump);
}
#[test]
fn autoload_decl_recorded_as_func() {
let _g = crate::test_util::global_state_lock();
let src = "autoload -U callback_helper\ncallback_helper\n";
let t = SymbolTable::build(src).expect("parse ok");
let funcs: Vec<_> = t
.symbols
.iter()
.filter(|s| matches!(s.kind, SymbolKind::Func) && s.name == "callback_helper")
.collect();
assert_eq!(funcs.len(), 1);
let refs: Vec<_> = t.refs.iter().filter(|r| r.name == "callback_helper").collect();
assert!(!refs.is_empty(), "no ref recorded for call: {:?}", t.refs);
}
#[test]
fn global_param_refs_resolve_in_dq_string() {
let _g = crate::test_util::global_state_lock();
let src = "FOO=42\necho \"$FOO is set\"\necho \"${FOO:-x}\"\n";
let t = SymbolTable::build(src).expect("parse ok");
let foo_id = t
.symbols
.iter()
.find(|s| s.name == "FOO" && matches!(s.kind, SymbolKind::Global))
.map(|s| s.id)
.expect("FOO declared");
let refs: Vec<_> = t.refs.iter().filter(|r| r.symbol == foo_id).collect();
assert_eq!(refs.len(), 2, "two interp refs expected: {:?}", t.refs);
}
#[test]
fn find_ast_occurrences_global_assignment_and_refs() {
let _g = crate::test_util::global_state_lock();
let src = "DAEMON_URL=http://example.com\n\
echo \"$DAEMON_URL is the URL\"\n\
curl \"$DAEMON_URL/api\"\n";
let lines = find_ast_occurrences(src, "DAEMON_URL", SymbolKind::Global);
assert!(lines.contains(&0), "decl line: {:?}", lines);
assert!(lines.contains(&1), "dq-interp ref: {:?}", lines);
assert!(lines.contains(&2), "second dq-interp ref: {:?}", lines);
}
#[test]
fn find_ast_occurrences_func_kind_ignores_dollar_refs() {
let _g = crate::test_util::global_state_lock();
let src = "function daemon-ping() { :; }\ndaemon-ping arg\n";
let lines = find_ast_occurrences(src, "daemon-ping", SymbolKind::Func);
assert_eq!(lines, vec![0, 1], "{:?}", lines);
}
#[test]
fn find_ast_occurrences_global_kind_ignores_func_calls() {
let _g = crate::test_util::global_state_lock();
let src = "foo=1\nfoo bar\necho $foo\n";
let lines = find_ast_occurrences(src, "foo", SymbolKind::Global);
assert!(lines.contains(&0), "assign: {:?}", lines);
assert!(lines.contains(&2), "$foo ref: {:?}", lines);
assert!(!lines.contains(&1), "func-call leaked: {:?}", lines);
}
#[test]
fn find_ast_occurrences_func_call_with_hyphen() {
let _g = crate::test_util::global_state_lock();
let src = "function daemon-ping() { :; }\ndaemon-ping\ndaemon-ping arg\n";
let lines = find_ast_occurrences(src, "daemon-ping", SymbolKind::Func);
assert!(lines.contains(&0), "decl line: {:?}", lines);
assert!(lines.contains(&1), "call line 1: {:?}", lines);
assert!(lines.contains(&2), "call line 2: {:?}", lines);
}
#[test]
fn func_decl_recorded() {
let _g = crate::test_util::global_state_lock();
let t = SymbolTable::build("function greet { echo hi }\n").expect("parse ok");
let funcs: Vec<_> = t
.symbols
.iter()
.filter(|s| matches!(s.kind, SymbolKind::Func))
.collect();
assert_eq!(funcs.len(), 1);
assert_eq!(funcs[0].name, "greet");
assert_eq!(funcs[0].decl_line, 0);
}
#[test]
fn func_call_is_recorded_as_ref() {
let _g = crate::test_util::global_state_lock();
let src = "function greet { echo hi }\ngreet\ngreet world\n";
let t = SymbolTable::build(src).expect("parse ok");
let func_id = t
.symbols
.iter()
.find(|s| matches!(s.kind, SymbolKind::Func) && s.name == "greet")
.map(|s| s.id)
.expect("greet found");
let refs: Vec<_> = t.refs.iter().filter(|r| r.symbol == func_id).collect();
assert_eq!(refs.len(), 2, "two call sites recorded: {:?}", t.refs);
}
#[test]
fn external_command_is_not_recorded_as_func_ref() {
let _g = crate::test_util::global_state_lock();
let t = SymbolTable::build("echo hi\n").expect("parse ok");
assert!(
t.refs.is_empty(),
"no refs for unknown command: {:?}",
t.refs
);
}
#[test]
fn global_assignment_records_a_global_symbol() {
let _g = crate::test_util::global_state_lock();
let t = SymbolTable::build("my_var=42\n").expect("parse ok");
let g: Vec<_> = t
.symbols
.iter()
.filter(|s| matches!(s.kind, SymbolKind::Global))
.collect();
assert_eq!(g.len(), 1);
assert_eq!(g[0].name, "my_var");
}
#[test]
fn local_inside_function_records_local() {
let _g = crate::test_util::global_state_lock();
let t = SymbolTable::build("function f { local x=1; echo $x }\n").expect("parse ok");
let locals: Vec<_> = t
.symbols
.iter()
.filter(|s| matches!(s.kind, SymbolKind::Local))
.collect();
assert!(
locals.iter().any(|s| s.name == "x"),
"local x recorded: {:?}",
t.symbols
);
}
#[test]
fn dollar_var_ref_is_resolved_to_local() {
let _g = crate::test_util::global_state_lock();
let t = SymbolTable::build("function f { local x=1; echo $x }\n").expect("parse ok");
let x_id = t
.symbols
.iter()
.find(|s| s.name == "x" && matches!(s.kind, SymbolKind::Local))
.map(|s| s.id)
.expect("x found");
assert!(
t.refs.iter().any(|r| r.symbol == x_id && r.name == "x"),
"$x resolved to the local: {:?}",
t.refs
);
}
#[test]
fn symbol_at_finds_decl_line() {
let _g = crate::test_util::global_state_lock();
let src = "function greet { echo hi }\n";
let t = SymbolTable::build(src).expect("parse ok");
let id = t.symbol_at(0, "greet").expect("resolved");
let sym = t.symbols.iter().find(|s| s.id == id).unwrap();
assert_eq!(sym.name, "greet");
assert!(matches!(sym.kind, SymbolKind::Func));
}
#[test]
fn symbol_at_finds_call_site() {
let _g = crate::test_util::global_state_lock();
let src = "function greet { echo hi }\ngreet world\n";
let t = SymbolTable::build(src).expect("parse ok");
let id = t.symbol_at(1, "greet").expect("call resolved");
let sym = t.symbols.iter().find(|s| s.id == id).unwrap();
assert_eq!(sym.name, "greet");
assert!(matches!(sym.kind, SymbolKind::Func));
}
}