use super::*;
use shuck_ast::{Command, CompoundCommand, Position, Span};
use shuck_indexer::Indexer;
use shuck_parser::parser::{Parser, ShellDialect};
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::tempdir;
fn model(source: &str) -> SemanticModel {
model_with_dialect(source, ShellDialect::Bash)
}
fn model_with_dialect(source: &str, dialect: ShellDialect) -> SemanticModel {
let output = Parser::with_dialect(source, dialect).parse().unwrap();
let indexer = Indexer::new(source, &output);
SemanticModel::build(&output.file, source, &indexer)
}
fn model_with_profile(source: &str, profile: ShellProfile) -> SemanticModel {
let output = Parser::with_profile(source, profile.clone())
.parse()
.unwrap();
let indexer = Indexer::new(source, &output);
SemanticModel::build_with_options(
&output.file,
source,
&indexer,
SemanticBuildOptions {
shell_profile: Some(profile),
..SemanticBuildOptions::default()
},
)
}
fn span_for_nth(source: &str, needle: &str, index: usize) -> Span {
let start_offset = source
.match_indices(needle)
.nth(index)
.map(|(offset, _)| offset)
.unwrap();
let start = Position::new().advanced_by(&source[..start_offset]);
Span::from_positions(start, start.advanced_by(needle))
}
fn function_binding_id(model: &SemanticModel, name: &str, index: usize) -> BindingId {
model.function_definitions(&Name::from(name))[index]
}
fn command_id_starting_with(
model: &SemanticModel,
source: &str,
prefix: &str,
) -> Option<CommandId> {
model.commands().iter().copied().find(|id| {
model
.command_syntax_span(*id)
.slice(source)
.starts_with(prefix)
})
}
fn command_id_containing(model: &SemanticModel, source: &str, needle: &str) -> Option<CommandId> {
model.commands().iter().copied().find(|id| {
model
.command_syntax_span(*id)
.slice(source)
.contains(needle)
})
}
fn model_at_path_with_parse_dialect(path: &Path, dialect: ShellDialect) -> SemanticModel {
let source = fs::read_to_string(path).unwrap();
let output = Parser::with_dialect(&source, dialect).parse().unwrap();
let indexer = Indexer::new(&source, &output);
let mut observer = NoopTraversalObserver;
build_with_observer_at_path_with_resolver(
&output.file,
&source,
&indexer,
&mut observer,
Some(path),
None,
)
}
fn model_at_path(path: &Path) -> SemanticModel {
model_at_path_with_resolver(path, None)
}
fn model_at_path_with_resolver(
path: &Path,
source_path_resolver: Option<&(dyn SourcePathResolver + Send + Sync)>,
) -> SemanticModel {
let source = fs::read_to_string(path).unwrap();
let output = Parser::new(&source).parse().unwrap();
let indexer = Indexer::new(&source, &output);
let mut observer = NoopTraversalObserver;
build_with_observer_at_path_with_resolver(
&output.file,
&source,
&indexer,
&mut observer,
Some(path),
source_path_resolver,
)
}
fn model_at_path_with_plugin_resolver(
path: &Path,
plugin_resolver: &(dyn PluginResolver + Send + Sync),
) -> SemanticModel {
let source = fs::read_to_string(path).unwrap();
let output = Parser::with_dialect(&source, ShellDialect::Zsh)
.parse()
.unwrap();
let indexer = Indexer::new(&source, &output);
SemanticModel::build_with_options(
&output.file,
&source,
&indexer,
SemanticBuildOptions {
source_path: Some(path),
plugin_resolver: Some(plugin_resolver),
shell_profile: Some(ShellProfile::native(shuck_parser::ShellDialect::Zsh)),
..SemanticBuildOptions::default()
},
)
}
fn reportable_unused_names(model: &SemanticModel) -> Vec<Name> {
let analysis = model.analysis();
analysis
.unused_assignments()
.iter()
.filter_map(|binding| {
let binding = model.binding(*binding);
matches!(
binding.kind,
BindingKind::Assignment
| BindingKind::ArrayAssignment
| BindingKind::LoopVariable
| BindingKind::ReadTarget
| BindingKind::MapfileTarget
| BindingKind::PrintfTarget
| BindingKind::GetoptsTarget
| BindingKind::ArithmeticAssignment
)
.then_some(binding.name.clone())
})
.collect()
}
fn assert_unused_assignment_parity(model: &SemanticModel) {
let analysis = model.analysis();
let precise = analysis.unused_assignments().to_vec();
let exact = analysis
.dataflow()
.unused_assignments
.iter()
.map(|unused| unused.binding)
.collect::<Vec<_>>();
assert_eq!(precise, exact);
}
fn binding_names(model: &SemanticModel, ids: &[BindingId]) -> Vec<String> {
ids.iter()
.map(|binding_id| model.binding(*binding_id).name.to_string())
.collect()
}
fn binding_for_name<'a>(model: &'a SemanticModel, name: &str) -> &'a Binding {
let ids = model.bindings_for(&Name::from(name));
assert_eq!(ids.len(), 1, "expected one binding for {name}, got {ids:?}");
model.binding(ids[0])
}
fn block_with_reference(cfg: &ControlFlowGraph, reference: ReferenceId) -> BlockId {
cfg.blocks()
.iter()
.find(|block| block.references.contains(&reference))
.map(|block| block.id)
.expect("reference should be assigned to a CFG block")
}
fn unresolved_names(model: &SemanticModel) -> Vec<String> {
model
.unresolved_references()
.iter()
.map(|reference| model.reference(*reference).name.to_string())
.collect()
}
fn uninitialized_names(model: &SemanticModel) -> Vec<String> {
let analysis = model.analysis();
let references = analysis
.uninitialized_references()
.iter()
.map(|reference| reference.reference)
.collect::<Vec<_>>();
references
.iter()
.map(|reference| model.reference(*reference).name.to_string())
.collect()
}
fn uninitialized_details(model: &SemanticModel) -> Vec<(String, UninitializedCertainty)> {
let references = model.analysis().uninitialized_references().to_vec();
references
.iter()
.map(|reference| {
(
model.reference(reference.reference).name.to_string(),
reference.certainty,
)
})
.collect()
}
fn assert_names_absent(names: &[&str], actual: &[String]) {
for name in names {
assert!(
!actual.iter().any(|actual_name| actual_name == name),
"did not expect `{name}` in {actual:?}"
);
}
}
fn assert_names_present(names: &[&str], actual: &[String]) {
for name in names {
assert!(
actual.iter().any(|actual_name| actual_name == name),
"expected `{name}` in {actual:?}"
);
}
}
fn arithmetic_read_count(model: &SemanticModel, name: &str) -> usize {
model
.references()
.iter()
.filter(|reference| {
reference.kind == ReferenceKind::ArithmeticRead && reference.name == name
})
.count()
}
fn arithmetic_write_count(model: &SemanticModel, name: &str) -> usize {
model
.bindings()
.iter()
.filter(|binding| binding.kind == BindingKind::ArithmeticAssignment && binding.name == name)
.count()
}
fn assert_arithmetic_usage(
model: &SemanticModel,
name: &str,
expected_reads: usize,
expected_writes: usize,
) {
assert_eq!(
arithmetic_read_count(model, name),
expected_reads,
"unexpected arithmetic read count for {name}"
);
assert_eq!(
arithmetic_write_count(model, name),
expected_writes,
"unexpected arithmetic write count for {name}"
);
}
fn common_runtime_source(shebang: &str) -> String {
format!(
"{shebang}\nprintf '%s\\n' \"$IFS\" \"$USER\" \"$HOME\" \"$SHELL\" \"$PWD\" \"$TERM\" \"$PATH\" \"$CDPATH\" \"$LANG\" \"$LC_ALL\" \"$LC_TIME\" \"$SUDO_USER\" \"$DOAS_USER\"\n"
)
}
fn bash_runtime_source(shebang: &str) -> String {
format!(
"{shebang}\nprintf '%s\\n' \"$LINENO\" \"$FUNCNAME\" \"${{BASH_SOURCE[0]}}\" \"${{BASH_LINENO[0]}}\" \"$RANDOM\" \"${{BASH_REMATCH[0]}}\" \"$READLINE_LINE\" \"$BASH_VERSION\" \"${{BASH_VERSINFO[0]}}\" \"$OSTYPE\" \"$HISTCONTROL\" \"$HISTSIZE\"\n"
)
}
fn zsh_runtime_source(shebang: &str) -> String {
format!(
"{shebang}\nprintf '%s\\n' \"${{options[xtrace]}}\" \"${{functions[typeset]}}\" \"${{aliases[ls]}}\" \"${{commands[printf]}}\" \"${{parameters[path]}}\" \"${{termcap[ku]}}\" \"${{terminfo[kcuu1]}}\" \"${{path[1]}}\" \"${{pipestatus[1]}}\" \"${{funcstack[1]}}\" \"${{funcfiletrace[1]}}\" \"${{funcsourcetrace[1]}}\" \"${{psvar[1]}}\" \"${{widgets[widget]}}\" \"${{zsh_eval_context[1]}}\" \"${{module_path[1]}}\" \"${{manpath[1]}}\" \"${{mailpath[1]}}\" \"${{historywords[1]}}\" \"${{jobdirs[1]}}\" \"${{jobstates[1]}}\" \"${{jobtexts[1]}}\" \"${{signals[1]}}\" \"${{nameddirs[project]}}\" \"$MATCH\" \"$MBEGIN\" \"$MEND\" \"$BUFFER\" \"$LBUFFER\" \"$RBUFFER\" \"$CURSOR\" \"$WIDGET\" \"$KEYS\" \"$NUMERIC\" \"$POSTDISPLAY\" \"$histchars\" \"$region_highlight\" \"$LINES\" \"$COLUMNS\" \"$ZSH_VERSION\" \"$ZSH_NAME\" \"$ZSH_PATCHLEVEL\" \"$ZSH_SUBSHELL\" \"$ZSH_ARGZERO\"\n"
)
}
#[test]
fn creates_file_and_function_scopes_and_resolves_local_shadowing() {
let source = "VAR=global\nf() { local VAR=local; echo $VAR; }\n";
let model = model(source);
assert!(matches!(model.scope_kind(ScopeId(0)), ScopeKind::File));
assert!(model.scopes().iter().any(|scope| {
matches!(
&scope.kind,
ScopeKind::Function(function) if function.contains_name_str("f")
)
}));
let local_binding = model
.bindings()
.iter()
.find(|binding| {
binding.name == "VAR"
&& matches!(
binding.kind,
BindingKind::Declaration(DeclarationBuiltin::Local)
)
})
.unwrap();
assert!(matches!(
model.scope_kind(local_binding.scope),
ScopeKind::Function(function) if function.contains_name_str("f")
));
let reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "VAR")
.unwrap();
let resolved = model.resolved_binding(reference.id).unwrap();
assert_eq!(resolved.id, local_binding.id);
}
#[test]
fn brace_fd_redirect_target_resolves_before_new_fd_binding() {
let source = "\
#!/bin/bash
fd='1 2'
exec {fd}>&$fd
printf '%s\\n' \"$fd\"
";
let model = model(source);
let fd_bindings = model.bindings_for(&Name::from("fd"));
assert_eq!(
fd_bindings.len(),
2,
"expected scalar and brace-fd bindings"
);
let initial_binding = model.binding(fd_bindings[0]);
let brace_fd_binding = model.binding(fd_bindings[1]);
assert!(
brace_fd_binding
.attributes
.contains(BindingAttributes::INTEGER)
);
let fd_refs = model
.references()
.iter()
.filter(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "fd")
.collect::<Vec<_>>();
assert_eq!(fd_refs.len(), 2);
let redirect_target_ref = fd_refs
.iter()
.find(|reference| reference.span.start.line == 3)
.unwrap();
let later_ref = fd_refs
.iter()
.find(|reference| reference.span.start.line == 4)
.unwrap();
assert_eq!(
model.resolved_binding(redirect_target_ref.id).unwrap().id,
initial_binding.id
);
assert_eq!(
model.resolved_binding(later_ref.id).unwrap().id,
brace_fd_binding.id
);
}
#[test]
fn declare_plus_g_stays_local_inside_functions() {
let source = "\
#!/bin/bash
f() {
declare +g scoped=1
printf '%s\\n' \"$scoped\"
}
printf '%s\\n' \"$scoped\"
";
let model = model(source);
let scoped_binding = binding_for_name(&model, "scoped");
let ScopeKind::Function(function_scope) = model.scope_kind(scoped_binding.scope) else {
panic!("expected declare +g binding to live in the function scope");
};
assert!(function_scope.contains_name_str("f"));
let scoped_refs = model
.references()
.iter()
.filter(|reference| {
reference.kind == ReferenceKind::Expansion && reference.name == "scoped"
})
.collect::<Vec<_>>();
assert_eq!(scoped_refs.len(), 2);
let inner_ref = scoped_refs
.iter()
.find(|reference| reference.span.start.line == 4)
.unwrap();
let outer_ref = scoped_refs
.iter()
.find(|reference| reference.span.start.line == 6)
.unwrap();
assert_eq!(
model.resolved_binding(inner_ref.id).unwrap().id,
scoped_binding.id
);
assert!(model.resolved_binding(outer_ref.id).is_none());
}
#[test]
fn analysis_maps_function_binding_to_body_scope() {
let model = model(
"\
x=1
f() {
:
}
",
);
let function_binding = binding_for_name(&model, "f");
let assignment_binding = binding_for_name(&model, "x");
let analysis = model.analysis();
let function_scope = analysis
.function_scope_for_binding(function_binding.id)
.expect("expected function body scope for function binding");
let ScopeKind::Function(function_scope_kind) = model.scope_kind(function_scope) else {
panic!("expected function binding to map to a function scope");
};
assert!(function_scope_kind.contains_name_str("f"));
assert_eq!(
analysis.function_scope_for_binding(assignment_binding.id),
None
);
}
#[test]
fn zsh_anonymous_functions_create_function_scoped_locals() {
let source = "function { local scoped=1; echo \"$scoped\" \"$1\"; } arg\necho \"$scoped\"\n";
let model = model_with_dialect(source, ShellDialect::Zsh);
let local_binding = model
.bindings()
.iter()
.find(|binding| {
binding.name == "scoped"
&& matches!(
binding.kind,
BindingKind::Declaration(DeclarationBuiltin::Local)
)
})
.unwrap();
let ScopeKind::Function(function_scope) = model.scope_kind(local_binding.scope) else {
panic!("expected local binding to live in a function scope");
};
assert!(function_scope.is_anonymous());
let scoped_refs = model
.references()
.iter()
.filter(|reference| {
reference.kind == ReferenceKind::Expansion && reference.name == "scoped"
})
.collect::<Vec<_>>();
assert_eq!(scoped_refs.len(), 2);
let inner_ref = scoped_refs
.iter()
.find(|reference| reference.span.start.line == 1)
.unwrap();
let outer_ref = scoped_refs
.iter()
.find(|reference| reference.span.start.line == 2)
.unwrap();
assert_eq!(
model.resolved_binding(inner_ref.id).unwrap().id,
local_binding.id
);
assert!(model.resolved_binding(outer_ref.id).is_none());
}
#[test]
fn zsh_multi_name_functions_bind_each_static_alias() {
let source = "function music itunes() { local track=1; }\n";
let model = model_with_dialect(source, ShellDialect::Zsh);
let music_defs = model.function_definitions(&Name::from("music"));
let itunes_defs = model.function_definitions(&Name::from("itunes"));
assert_eq!(music_defs.len(), 1);
assert_eq!(itunes_defs.len(), 1);
assert_eq!(model.binding(music_defs[0]).span.slice(source), "music");
assert_eq!(model.binding(itunes_defs[0]).span.slice(source), "itunes");
let local_binding = model
.bindings()
.iter()
.find(|binding| {
binding.name == "track"
&& matches!(
binding.kind,
BindingKind::Declaration(DeclarationBuiltin::Local)
)
})
.unwrap();
let ScopeKind::Function(function_scope) = model.scope_kind(local_binding.scope) else {
panic!("expected local binding to live in a function scope");
};
assert!(function_scope.contains_name_str("music"));
assert!(function_scope.contains_name_str("itunes"));
assert_eq!(function_scope.static_names().len(), 2);
}
#[test]
fn function_definition_binding_lookup_uses_the_command_span() {
let source = "build() { :; }\nbuild() { echo later; }\n";
let model = model(source);
let name = Name::from("build");
let definitions = model.function_definitions(&name);
assert_eq!(definitions.len(), 2);
let function_commands = model
.commands()
.iter()
.copied()
.filter(|command| model.command_kind(*command) == CommandKind::Function)
.collect::<Vec<_>>();
assert_eq!(function_commands.len(), 2);
for (command, definition) in function_commands
.into_iter()
.zip(definitions.iter().copied())
{
assert_eq!(
model.function_definition_binding_for_command_span(model.command_span(command)),
Some(definition)
);
}
}
#[test]
fn binding_lookup_uses_the_definition_span() {
let source = "foo=1\nbar=2\n";
let model = model(source);
for name in [Name::from("foo"), Name::from("bar")] {
let binding_id = model.bindings_for(&name)[0];
assert_eq!(
model.binding_for_definition_span(model.binding(binding_id).span),
Some(binding_id)
);
}
}
#[test]
fn zsh_multi_name_function_lookup_works_through_any_alias() {
let source = "flag=1\nfunction music itunes() { echo \"$flag\"; }\nitunes\n";
let model = model_with_dialect(source, ShellDialect::Zsh);
assert_eq!(model.call_sites_for(&Name::from("itunes")).len(), 1);
assert!(model.call_graph().reachable.contains(&Name::from("itunes")));
assert!(
!reportable_unused_names(&model)
.into_iter()
.any(|name| name == "flag")
);
}
#[test]
fn semantic_analysis_exposes_function_scope_and_call_arity_bindings() {
let source = "greet ok\ngreet() { echo \"$1\"; }\ngreet\n";
let model = model(source);
let analysis = model.analysis();
let name = Name::from("greet");
let binding = model.function_definitions(&name)[0];
let function_scope = analysis
.function_scope_for_binding(binding)
.expect("expected function body scope");
assert!(matches!(
model.scope_kind(function_scope),
ScopeKind::Function(_)
));
let arity_sites = analysis
.function_call_arity_sites(&name)
.map(|(site, binding_id)| {
(
site.name_span.slice(source).to_owned(),
site.arg_count,
binding_id,
)
})
.collect::<Vec<_>>();
assert_eq!(
arity_sites,
vec![
("greet".to_owned(), 1, binding),
("greet".to_owned(), 0, binding)
]
);
}
#[test]
fn semantic_analysis_exposes_case_cli_dispatch_reachability() {
let source = "\
#!/bin/sh
start() { echo hi; }
case \"$1\" in
start) \"$1\" ;;
esac
exit $?
late() { echo later; }
";
let output = Parser::with_dialect(source, ShellDialect::Bash)
.parse()
.unwrap();
let indexer = Indexer::new(source, &output);
let model = SemanticModel::build(&output.file, source, &indexer);
let analysis = model.analysis();
let start_binding = model.function_definitions(&Name::from("start"))[0];
let start_scope = analysis
.function_scope_for_binding(start_binding)
.expect("expected start function scope");
let late_binding = model.function_definitions(&Name::from("late"))[0];
let late_scope = analysis
.function_scope_for_binding(late_binding)
.expect("expected late function scope");
let dispatches = analysis.case_cli_dispatches(&output.file, source);
assert_eq!(dispatches.len(), 1);
assert_eq!(dispatches[0].function_scope(), start_scope);
assert_eq!(dispatches[0].dispatcher_span().slice(source), "\"$1\"");
let reachable = analysis.case_cli_reachable_function_scopes(&output.file, &dispatches);
assert!(reachable.contains(&start_scope));
assert!(!reachable.contains(&late_scope));
}
#[test]
fn zsh_parameter_modifiers_still_register_references() {
let model = model_with_profile("print ${(m)foo}\n", ShellProfile::native(ShellDialect::Zsh));
let unresolved = unresolved_names(&model);
assert_names_present(&["foo"], &unresolved);
}
#[test]
fn bash_profile_ignores_zsh_parameter_modifier_references() {
let model = model_with_dialect("printf '%s\\n' ${=zsh_only} ${plain}\n", ShellDialect::Bash);
let unresolved = unresolved_names(&model);
assert_names_present(&["plain"], &unresolved);
assert_names_absent(&["zsh_only"], &unresolved);
}
#[test]
fn zsh_equals_parameter_expansion_registers_reference() {
let model = model_with_profile(
"strategies=(${=ZSH_AUTOSUGGEST_STRATEGY})\n",
ShellProfile::native(ShellDialect::Zsh),
);
let unresolved = unresolved_names(&model);
assert_names_present(&["ZSH_AUTOSUGGEST_STRATEGY"], &unresolved);
}
#[test]
fn zsh_parameter_operations_walk_operand_references_conservatively() {
let model = model_with_profile(
"print ${(m)foo#${needle}} ${(S)foo/$pattern/$replacement} ${(m)foo:$offset:${length}}\n",
ShellProfile::native(ShellDialect::Zsh),
);
let unresolved = unresolved_names(&model);
assert_names_present(
&[
"foo",
"needle",
"pattern",
"replacement",
"offset",
"length",
],
&unresolved,
);
}
#[test]
fn zsh_for_loops_bind_all_targets() {
let source = "\
for key value in a b c d; do
print -r -- \"$key:$value\"
done
for 1 2 3; do
print -r -- \"$1|$2|$3\"
done
";
let model = model_with_dialect(source, ShellDialect::Zsh);
let loop_bindings = model
.bindings()
.iter()
.filter(|binding| binding.kind == BindingKind::LoopVariable)
.map(|binding| {
(
binding.name.to_string(),
binding.span.slice(source).to_string(),
)
})
.collect::<Vec<_>>();
assert_eq!(
loop_bindings,
vec![
("key".to_owned(), "key".to_owned()),
("value".to_owned(), "value".to_owned()),
("1".to_owned(), "1".to_owned()),
("2".to_owned(), "2".to_owned()),
("3".to_owned(), "3".to_owned()),
]
);
for name in ["key", "value", "1", "2", "3"] {
let reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == name)
.unwrap_or_else(|| panic!("expected expansion reference for {name}"));
let binding = model
.resolved_binding(reference.id)
.unwrap_or_else(|| panic!("expected {name} to resolve to a loop binding"));
assert_eq!(binding.kind, BindingKind::LoopVariable);
assert_eq!(binding.name, name);
}
}
#[test]
fn classifies_assignment_and_function_binding_origins() {
let source = "\
literal=plain
copy=$literal
fallback=${literal:-alt}
lower=${literal,}
quoted=${literal@Q}
name=literal
indirect=${!name}
build() { :; }
";
let model = model(source);
assert!(matches!(
binding_for_name(&model, "literal").origin,
BindingOrigin::Assignment {
value: AssignmentValueOrigin::StaticLiteral,
..
}
));
assert!(matches!(
binding_for_name(&model, "copy").origin,
BindingOrigin::Assignment {
value: AssignmentValueOrigin::PlainScalarAccess,
..
}
));
assert!(matches!(
binding_for_name(&model, "fallback").origin,
BindingOrigin::Assignment {
value: AssignmentValueOrigin::ParameterOperator,
..
}
));
assert!(matches!(
binding_for_name(&model, "lower").origin,
BindingOrigin::Assignment {
value: AssignmentValueOrigin::ParameterOperator,
..
}
));
assert!(matches!(
binding_for_name(&model, "quoted").origin,
BindingOrigin::Assignment {
value: AssignmentValueOrigin::Transformation,
..
}
));
assert!(matches!(
binding_for_name(&model, "indirect").origin,
BindingOrigin::Assignment {
value: AssignmentValueOrigin::IndirectExpansion,
..
}
));
assert!(matches!(
binding_for_name(&model, "build").origin,
BindingOrigin::FunctionDefinition { .. }
));
}
#[test]
fn assignment_definition_span_keeps_quoted_associative_subscript_text() {
let source = "\
declare -A a
a[\"]=x\"]=1
";
let model = model(source);
let binding = model
.bindings()
.iter()
.find(|binding| binding.name == "a" && binding.kind == BindingKind::ArrayAssignment)
.expect("expected subscripted assignment binding");
let BindingOrigin::Assignment {
definition_span, ..
} = binding.origin
else {
panic!("expected assignment origin");
};
assert_eq!(definition_span.slice(source), "a[\"]=x\"]");
}
#[test]
fn classifies_loop_and_parameter_default_origins() {
let source = "\
for size in 16 32; do
:
done
name=world
for item in $name; do
:
done
for arg; do
:
done
: \"${created:=default}\"
";
let model = model(source);
assert!(matches!(
binding_for_name(&model, "size").origin,
BindingOrigin::LoopVariable {
items: LoopValueOrigin::StaticWords,
..
}
));
assert!(matches!(
binding_for_name(&model, "item").origin,
BindingOrigin::LoopVariable {
items: LoopValueOrigin::ExpandedWords,
..
}
));
assert!(matches!(
binding_for_name(&model, "arg").origin,
BindingOrigin::LoopVariable {
items: LoopValueOrigin::ImplicitArgv,
..
}
));
assert!(matches!(
binding_for_name(&model, "created").origin,
BindingOrigin::ParameterDefaultAssignment { .. }
));
}
#[test]
fn classifies_builtin_target_and_imported_origins() {
let source = "\
read reply
mapfile lines
printf -v rendered '%s' hi
while getopts 'a' opt; do
:
done
printf '%s\\n' \"$pkgname\"
";
let output = Parser::new(source).parse().unwrap();
let indexer = Indexer::new(source, &output);
let model = SemanticModel::build_with_options(
&output.file,
source,
&indexer,
SemanticBuildOptions {
file_entry_contract: Some(FileContract {
required_reads: Vec::new(),
provided_bindings: vec![ProvidedBinding::new(
Name::from("pkgname"),
ProvidedBindingKind::Variable,
ContractCertainty::Definite,
)],
provided_functions: Vec::new(),
externally_consumed_bindings: false,
externally_consumed_binding_names: Vec::new(),
externally_consumed_binding_prefixes: Vec::new(),
}),
..SemanticBuildOptions::default()
},
);
assert!(matches!(
binding_for_name(&model, "reply").origin,
BindingOrigin::BuiltinTarget {
kind: BuiltinBindingTargetKind::Read,
..
}
));
assert!(matches!(
binding_for_name(&model, "lines").origin,
BindingOrigin::BuiltinTarget {
kind: BuiltinBindingTargetKind::Mapfile,
..
}
));
assert!(matches!(
binding_for_name(&model, "rendered").origin,
BindingOrigin::BuiltinTarget {
kind: BuiltinBindingTargetKind::Printf,
..
}
));
assert!(matches!(
binding_for_name(&model, "opt").origin,
BindingOrigin::BuiltinTarget {
kind: BuiltinBindingTargetKind::Getopts,
..
}
));
assert!(matches!(
binding_for_name(&model, "pkgname").origin,
BindingOrigin::Imported { .. }
));
}
#[test]
fn escaped_declarations_create_name_bindings() {
let source = "\\typeset result temp_flags\nprintf '%s\\n' \"$result\"\n";
let model = model(source);
let unused = model.analysis().unused_assignments().to_vec();
assert!(!binding_for_name(&model, "result").references.is_empty());
assert_eq!(binding_names(&model, &unused), vec!["temp_flags"]);
assert!(matches!(
binding_for_name(&model, "temp_flags").kind,
BindingKind::Declaration(DeclarationBuiltin::Typeset)
));
}
#[test]
fn zsh_declaration_builtins_with_options_create_initialized_bindings() {
let source = "\
#!/bin/zsh
typeset -g global_count=1
typeset -gr readonly_global=2
typeset -gi integer_global=3
print $global_count $readonly_global $integer_global
f() {
local -i local_count=4
typeset implicit_local=5
typeset -i implicit_local_integer=6
typeset -g promoted=7
integer integer_local=8
integer +i string_local=9
print $local_count $implicit_local $implicit_local_integer
print $promoted $integer_local $string_local
}
f
print $promoted
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let cases = [
(
"global_count",
BindingAttributes::DECLARATION_INITIALIZED,
BindingAttributes::LOCAL | BindingAttributes::INTEGER | BindingAttributes::READONLY,
),
(
"readonly_global",
BindingAttributes::DECLARATION_INITIALIZED | BindingAttributes::READONLY,
BindingAttributes::LOCAL | BindingAttributes::INTEGER,
),
(
"integer_global",
BindingAttributes::DECLARATION_INITIALIZED | BindingAttributes::INTEGER,
BindingAttributes::LOCAL | BindingAttributes::READONLY,
),
(
"local_count",
BindingAttributes::DECLARATION_INITIALIZED
| BindingAttributes::LOCAL
| BindingAttributes::INTEGER,
BindingAttributes::READONLY,
),
(
"implicit_local",
BindingAttributes::DECLARATION_INITIALIZED | BindingAttributes::LOCAL,
BindingAttributes::INTEGER | BindingAttributes::READONLY,
),
(
"implicit_local_integer",
BindingAttributes::DECLARATION_INITIALIZED
| BindingAttributes::LOCAL
| BindingAttributes::INTEGER,
BindingAttributes::READONLY,
),
(
"promoted",
BindingAttributes::DECLARATION_INITIALIZED,
BindingAttributes::LOCAL | BindingAttributes::INTEGER | BindingAttributes::READONLY,
),
(
"integer_local",
BindingAttributes::DECLARATION_INITIALIZED
| BindingAttributes::LOCAL
| BindingAttributes::INTEGER,
BindingAttributes::READONLY,
),
(
"string_local",
BindingAttributes::DECLARATION_INITIALIZED | BindingAttributes::LOCAL,
BindingAttributes::INTEGER | BindingAttributes::READONLY,
),
];
let declaration_names = cases.map(|(name, _, _)| name);
for (name, required, forbidden) in cases {
let binding = binding_for_name(&model, name);
assert!(
matches!(
binding.kind,
BindingKind::Declaration(DeclarationBuiltin::Typeset)
| BindingKind::Declaration(DeclarationBuiltin::Local)
),
"expected declaration binding for {name}, got {:?}",
binding.kind
);
assert!(
binding.attributes.contains(required),
"expected {name} to contain {required:?}, got {:?}",
binding.attributes
);
assert!(
!binding.attributes.intersects(forbidden),
"expected {name} not to contain {forbidden:?}, got {:?}",
binding.attributes
);
assert!(!binding.references.is_empty(), "expected {name} to be read");
}
let uninitialized = uninitialized_names(&model);
assert!(
declaration_names
.iter()
.filter(|name| **name != "promoted")
.all(|name| !uninitialized
.iter()
.any(|uninitialized| uninitialized == name)),
"zsh declaration reads should resolve, got {uninitialized:?}"
);
let unused = binding_names(&model, model.analysis().unused_assignments());
assert!(
declaration_names
.iter()
.all(|name| !unused.iter().any(|unused| unused == name)),
"zsh declaration assignments should be live, got {unused:?}"
);
}
#[test]
fn escaped_exported_declarations_keep_exported_attributes() {
let source = "\\typeset -x rvm_hook\nrvm_hook=after_update\n";
let model = model(source);
let bindings = model.bindings_for(&Name::from("rvm_hook"));
assert!(
bindings.iter().any(|binding_id| {
model
.binding(*binding_id)
.attributes
.contains(BindingAttributes::EXPORTED)
}),
"expected an exported rvm_hook declaration"
);
}
#[test]
fn wrapped_read_creates_read_target_bindings() {
let source = "builtin read -${flag} 1 -s -r anykey\n";
let model = model(source);
let anykey = binding_for_name(&model, "anykey");
assert!(matches!(anykey.kind, BindingKind::ReadTarget));
assert!(matches!(
anykey.origin,
BindingOrigin::BuiltinTarget {
kind: BuiltinBindingTargetKind::Read,
..
}
));
}
#[test]
fn command_wrapped_printf_v_creates_target_binding() {
let source = "command printf -v rendered '%s' value\n";
let model = model(source);
let rendered = binding_for_name(&model, "rendered");
assert!(matches!(rendered.kind, BindingKind::PrintfTarget));
assert!(matches!(
rendered.origin,
BindingOrigin::BuiltinTarget {
kind: BuiltinBindingTargetKind::Printf,
..
}
));
}
#[test]
fn zsh_zstyle_query_creates_named_target_binding() {
let source = "#!/bin/zsh\nzstyle -a ':prezto:load' pmodule-dirs user_pmodule_dirs\nprint $user_pmodule_dirs\n";
let model = model_with_dialect(source, ShellDialect::Zsh);
let binding = binding_for_name(&model, "user_pmodule_dirs");
assert_eq!(binding.span.slice(source), "user_pmodule_dirs");
assert!(matches!(binding.kind, BindingKind::ReadTarget));
assert!(matches!(
binding.origin,
BindingOrigin::BuiltinTarget {
kind: BuiltinBindingTargetKind::Zstyle,
..
}
));
assert!(binding.attributes.contains(BindingAttributes::ARRAY));
assert_eq!(binding.references.len(), 1);
}
#[test]
fn zsh_zstyle_scalar_and_boolean_queries_create_named_targets() {
let source = "\
#!/bin/zsh
zstyle -s ':prezto:load' prompt prompt_theme
zstyle -b ':prezto:load' verbose verbose_enabled
print $prompt_theme $verbose_enabled
";
let model = model_with_dialect(source, ShellDialect::Zsh);
for name in ["prompt_theme", "verbose_enabled"] {
let binding = binding_for_name(&model, name);
assert!(matches!(binding.kind, BindingKind::ReadTarget));
assert!(matches!(
binding.origin,
BindingOrigin::BuiltinTarget {
kind: BuiltinBindingTargetKind::Zstyle,
..
}
));
assert!(!binding.attributes.contains(BindingAttributes::ARRAY));
assert_eq!(binding.references.len(), 1);
}
}
#[test]
fn zsh_zstyle_listing_mode_does_not_create_named_target() {
let source = "#!/bin/zsh\nzstyle -L -a ':prezto:load' pmodule-dirs user_pmodule_dirs\nprint $user_pmodule_dirs\n";
let model = model_with_dialect(source, ShellDialect::Zsh);
assert!(
model
.bindings_for(&Name::from("user_pmodule_dirs"))
.is_empty()
);
assert_eq!(unresolved_names(&model), vec!["user_pmodule_dirs"]);
}
#[test]
fn zsh_zstyle_other_modes_do_not_create_named_targets() {
for option in ["-g", "-d", "-m", "-t"] {
let source = format!(
"#!/bin/zsh\nzstyle {option} -a ':prezto:load' pmodule-dirs user_pmodule_dirs\nprint $user_pmodule_dirs\n"
);
let model = model_with_dialect(&source, ShellDialect::Zsh);
assert!(
model
.bindings_for(&Name::from("user_pmodule_dirs"))
.is_empty(),
"unexpected target binding for {option}"
);
assert_eq!(unresolved_names(&model), vec!["user_pmodule_dirs"]);
}
}
#[test]
fn zsh_zstyle_array_query_preserves_existing_associative_target() {
let source = "\
#!/bin/zsh
typeset -A style_map
zstyle -a ':prezto:load' pmodule-dirs style_map
print -r -- ${style_map[key]}
";
let model = model_with_dialect(source, ShellDialect::Zsh);
let bindings = model.bindings_for(&Name::from("style_map"));
assert_eq!(bindings.len(), 2);
let binding = model.binding(*bindings.last().expect("zstyle target binding"));
assert!(binding.attributes.contains(BindingAttributes::ARRAY));
assert!(binding.attributes.contains(BindingAttributes::ASSOC));
assert_names_absent(&["key"], &unresolved_names(&model));
}
#[test]
fn zsh_describe_consumes_named_array_operand() {
let source =
"#!/bin/zsh\ncmds=(git:'run git')\n_describe -t commands 'external command' cmds\n";
let model = model_with_dialect(source, ShellDialect::Zsh);
let binding = binding_for_name(&model, "cmds");
assert_eq!(binding.references.len(), 1);
assert_eq!(
model.reference(binding.references[0]).span.slice(source),
"cmds"
);
}
#[test]
fn zsh_describe_consumes_optional_second_array_operand() {
let source = "\
#!/bin/zsh
values=(git)
descriptions=(git:'run git')
_describe 'external command' values descriptions
";
let model = model_with_dialect(source, ShellDialect::Zsh);
for name in ["values", "descriptions"] {
let binding = binding_for_name(&model, name);
assert_eq!(binding.references.len(), 1);
assert_eq!(
model.reference(binding.references[0]).span.slice(source),
name
);
}
}
#[test]
fn zsh_describe_consumes_array_operands_after_dynamic_description() {
let source = "\
#!/bin/zsh
desc='external command'
values=(git)
descriptions=(git:'run git')
_describe \"$desc\" values descriptions
";
let model = model_with_dialect(source, ShellDialect::Zsh);
for name in ["values", "descriptions"] {
let binding = binding_for_name(&model, name);
assert_eq!(binding.references.len(), 1);
assert_eq!(
model.reference(binding.references[0]).span.slice(source),
name
);
}
}
#[test]
fn zsh_describe_skips_descriptor_after_dynamic_no_value_options() {
let source = "\
#!/bin/zsh
opts='-o'
desc=(not:target)
values=(git)
_describe \"$opts\" desc values
";
let model = model_with_dialect(source, ShellDialect::Zsh);
assert_eq!(binding_for_name(&model, "desc").references.len(), 0);
let binding = binding_for_name(&model, "values");
assert_eq!(binding.references.len(), 1);
assert_eq!(
model.reference(binding.references[0]).span.slice(source),
"values"
);
}
#[test]
fn zsh_describe_consumes_array_operands_after_group_separator() {
let source = "\
#!/bin/zsh
values=(git)
more_values=(hg)
more_descriptions=(hg:'run hg')
_describe 'external command' values -- more_values more_descriptions
";
let model = model_with_dialect(source, ShellDialect::Zsh);
for name in ["values", "more_values", "more_descriptions"] {
let binding = binding_for_name(&model, name);
assert_eq!(binding.references.len(), 1);
assert_eq!(
model.reference(binding.references[0]).span.slice(source),
name
);
}
}
#[test]
fn zsh_describe_consumes_named_array_operand_after_options_and_separator() {
let source = "\
#!/bin/zsh
cmds=(git:'run git')
other_cmds=(hg:'run hg')
_describe -O 'external command' cmds
_describe -- 'other command' other_cmds
";
let model = model_with_dialect(source, ShellDialect::Zsh);
for name in ["cmds", "other_cmds"] {
let binding = binding_for_name(&model, name);
assert_eq!(binding.references.len(), 1);
assert_eq!(
model.reference(binding.references[0]).span.slice(source),
name
);
}
}
#[test]
fn shflags_define_commands_create_flag_bindings() {
let source = "DEFINE_boolean show_commands false 'show commands' s\n";
let model = model(source);
let binding = binding_for_name(&model, "FLAGS_show_commands");
assert_eq!(binding.span.slice(source), "show_commands");
assert!(matches!(
binding.kind,
BindingKind::Declaration(DeclarationBuiltin::Declare)
));
}
#[test]
fn isolates_subshell_bindings_from_parent_resolution() {
let source = "VAR=outer\n( VAR=inner )\necho $VAR\n";
let model = model(source);
let reference = model.references().last().unwrap();
let binding = model.resolved_binding(reference.id).unwrap();
assert_eq!(binding.span.slice(source), "VAR");
}
#[test]
fn declare_g_in_command_substitution_stays_in_that_execution_scope() {
let source = "\
#!/bin/bash
printf '%s\\n' \"$(
f() { declare -gA assoc=([key]=1); }
f
printf '%s\\n' \"${assoc[key]}\"
)\"
printf '%s\\n' \"${assoc[key]}\"
";
let model = model(source);
let assoc_binding = binding_for_name(&model, "assoc");
assert!(assoc_binding.attributes.contains(BindingAttributes::ARRAY));
assert!(assoc_binding.attributes.contains(BindingAttributes::ASSOC));
assert!(matches!(
model.scope_kind(assoc_binding.scope),
ScopeKind::CommandSubstitution
));
}
#[test]
fn records_pipeline_segment_scopes() {
let source = "a | b | c\n";
let model = model(source);
let pipeline_scopes = model
.scopes()
.iter()
.filter(|scope| matches!(scope.kind, ScopeKind::Pipeline))
.count();
assert_eq!(pipeline_scopes, 3);
}
#[test]
fn zsh_final_pipeline_segment_uses_enclosing_scope() {
let source = "\
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let pipeline_assignment = model
.bindings()
.iter()
.find(|binding| binding.span.start.offset == source.find("value=new").unwrap())
.unwrap();
assert!(!matches!(
model.scope_kind(pipeline_assignment.scope),
ScopeKind::Pipeline
));
}
#[test]
fn zsh_sh_emulation_keeps_pipeline_tail_in_pipeline_scope() {
let source = "\
emulate sh
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let pipeline_assignment = model
.bindings()
.iter()
.find(|binding| binding.span.start.offset == source.find("value=new").unwrap())
.unwrap();
assert!(matches!(
model.scope_kind(pipeline_assignment.scope),
ScopeKind::Pipeline
));
}
#[test]
fn zsh_initial_sh_emulation_keeps_pipeline_tail_in_pipeline_scope() {
let source = "\
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
let model = model_with_profile(
source,
ShellProfile::with_zsh_options(
ShellDialect::Zsh,
ZshOptionState::for_emulate(ZshEmulationMode::Sh),
),
);
let pipeline_assignment = model
.bindings()
.iter()
.find(|binding| binding.span.start.offset == source.find("value=new").unwrap())
.unwrap();
assert!(matches!(
model.scope_kind(pipeline_assignment.scope),
ScopeKind::Pipeline
));
}
#[test]
fn zsh_compound_sh_emulation_keeps_later_pipeline_tail_in_pipeline_scope() {
let source = "\
{ emulate sh; }
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let pipeline_assignment = model
.bindings()
.iter()
.find(|binding| binding.span.start.offset == source.find("value=new").unwrap())
.unwrap();
assert!(matches!(
model.scope_kind(pipeline_assignment.scope),
ScopeKind::Pipeline
));
}
#[test]
fn zsh_if_condition_sh_emulation_keeps_later_pipeline_tail_in_pipeline_scope() {
let source = "\
if emulate sh; then :; fi
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let pipeline_assignment = model
.bindings()
.iter()
.find(|binding| binding.span.start.offset == source.find("value=new").unwrap())
.unwrap();
assert!(matches!(
model.scope_kind(pipeline_assignment.scope),
ScopeKind::Pipeline
));
}
#[test]
fn zsh_logical_left_sh_emulation_keeps_later_pipeline_tail_in_pipeline_scope() {
let source = "\
emulate sh && :
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let pipeline_assignment = model
.bindings()
.iter()
.find(|binding| binding.span.start.offset == source.find("value=new").unwrap())
.unwrap();
assert!(matches!(
model.scope_kind(pipeline_assignment.scope),
ScopeKind::Pipeline
));
}
#[test]
fn zsh_pipeline_tail_sh_emulation_keeps_later_pipeline_tail_in_pipeline_scope() {
let source = "\
print setup | emulate sh
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let pipeline_assignment = model
.bindings()
.iter()
.find(|binding| binding.span.start.offset == source.find("value=new").unwrap())
.unwrap();
assert!(matches!(
model.scope_kind(pipeline_assignment.scope),
ScopeKind::Pipeline
));
}
#[test]
fn zsh_always_body_sh_emulation_keeps_later_pipeline_tail_in_pipeline_scope() {
let source = "\
{ emulate sh; } always { :; }
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let pipeline_assignment = model
.bindings()
.iter()
.find(|binding| binding.span.start.offset == source.find("value=new").unwrap())
.unwrap();
assert!(matches!(
model.scope_kind(pipeline_assignment.scope),
ScopeKind::Pipeline
));
}
#[test]
fn zsh_always_cleanup_sh_emulation_keeps_later_pipeline_tail_in_pipeline_scope() {
let source = "\
{ :; } always { emulate sh; }
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let pipeline_assignment = model
.bindings()
.iter()
.find(|binding| binding.span.start.offset == source.find("value=new").unwrap())
.unwrap();
assert!(matches!(
model.scope_kind(pipeline_assignment.scope),
ScopeKind::Pipeline
));
}
#[test]
fn zsh_emulation_restores_native_pipeline_tail_scope() {
let source = "\
emulate sh
emulate zsh
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let pipeline_assignment = model
.bindings()
.iter()
.find(|binding| binding.span.start.offset == source.find("value=new").unwrap())
.unwrap();
assert!(!matches!(
model.scope_kind(pipeline_assignment.scope),
ScopeKind::Pipeline
));
}
#[test]
fn zsh_nonfinal_pipeline_segments_keep_pipeline_scope() {
let source = "\
value=old
{ value=left; print x; } | { value=middle; print y; } | cat
print -r -- \"$value\"
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
for assignment in ["value=left", "value=middle"] {
let binding = model
.bindings()
.iter()
.find(|binding| binding.span.start.offset == source.find(assignment).unwrap())
.unwrap();
assert!(matches!(
model.scope_kind(binding.scope),
ScopeKind::Pipeline
));
}
}
#[test]
fn indexed_scope_lookup_matches_linear_scan_for_all_offsets() {
let source = "\
outer() {
local current=1
(
printf '%s\\n' \"$(
printf '%s\\n' \"$current\" | tr a b
)\"
)
inner() { echo \"$current\"; }
}
outer
";
let model = model(source);
for offset in 0..=source.len() {
assert_eq!(
model.scope_at(offset),
linear_scope_at(model.scopes(), offset),
"offset {offset}"
);
}
}
#[test]
fn command_topology_exposes_parent_child_relationships() {
let source = "if foo; then bar | baz; fi\n";
let model = model(source);
let if_id = command_id_starting_with(&model, source, "if foo").unwrap();
let foo_id = command_id_starting_with(&model, source, "foo").unwrap();
let pipeline_id = command_id_containing(&model, source, "bar | baz").unwrap();
let bar_id = command_id_starting_with(&model, source, "bar").unwrap();
let baz_id = command_id_starting_with(&model, source, "baz").unwrap();
assert_eq!(model.command_parent_id(foo_id), Some(if_id));
assert_eq!(model.command_parent_id(pipeline_id), Some(if_id));
assert_eq!(model.command_parent_id(bar_id), Some(pipeline_id));
assert_eq!(model.command_parent_id(baz_id), Some(pipeline_id));
assert!(model.command_children(if_id).contains(&foo_id));
assert!(model.command_children(if_id).contains(&pipeline_id));
}
#[test]
fn command_topology_attaches_nested_function_body_to_nearest_function() {
let source = "\
outer() {
inner() {
echo inner
}
}
";
let model = model(source);
let inner_function_id = command_id_starting_with(&model, source, "inner()").unwrap();
let inner_body_id = model
.commands()
.iter()
.copied()
.filter(|id| {
model.command_kind(*id) == CommandKind::Compound(CompoundCommandKind::BraceGroup)
&& model
.command_syntax_span(*id)
.slice(source)
.contains("echo inner")
})
.min_by_key(|id| {
let span = model.command_syntax_span(*id);
span.end.offset - span.start.offset
})
.unwrap();
assert_eq!(
model.command_parent_id(inner_body_id),
Some(inner_function_id)
);
assert!(
model
.command_children(inner_function_id)
.contains(&inner_body_id)
);
}
#[test]
fn command_topology_excludes_nested_substitutions_from_structural_commands() {
let source = "echo \"$(printf '%s\\n' hi)\"\n";
let model = model(source);
let echo_id = command_id_starting_with(&model, source, "echo").unwrap();
let printf_id = command_id_starting_with(&model, source, "printf").unwrap();
assert_eq!(model.command_parent_id(printf_id), Some(echo_id));
assert!(model.structural_commands().contains(&echo_id));
assert!(!model.structural_commands().contains(&printf_id));
}
#[test]
fn command_topology_skips_synthetic_parents_for_syntax_backed_queries() {
let source = "case \"$x\" in $(echo \"$v\")) ;; esac\n";
let model = model(source);
let case_id = command_id_starting_with(&model, source, "case").unwrap();
let echo_id = command_id_starting_with(&model, source, "echo").unwrap();
assert_eq!(
model.syntax_backed_command_parent_id(echo_id),
Some(case_id)
);
assert!(
model
.syntax_backed_command_children(case_id)
.contains(&echo_id)
);
}
#[test]
fn command_lookup_by_span_and_kind_skips_synthetic_commands() {
let source = "case \"$x\" in $(echo a)) ;; esac\n";
let model = model(source);
let case_span = model
.commands()
.iter()
.copied()
.map(|id| model.command_syntax_span(id))
.find(|span| span.slice(source).starts_with("case"))
.unwrap();
assert!(
model
.command_by_span_and_kind(case_span, CommandKind::Compound(CompoundCommandKind::Case))
.is_some()
);
let case_id = model.command_by_span(case_span).unwrap();
assert_eq!(
model.command_kind(case_id),
CommandKind::Compound(CompoundCommandKind::Case)
);
let innermost_case_id = model.innermost_command_id_at(source.find("case").unwrap());
assert_eq!(innermost_case_id, Some(case_id));
}
#[test]
fn commands_iter_filters_synthetic_ids_so_command_kind_is_safe() {
let source = "case \"$x\" in $(echo a)) ;; esac\n";
let model = model(source);
for id in model.commands().iter().copied() {
let _ = model.command_kind(id);
}
}
#[test]
fn command_topology_preserves_nested_region_immediate_parents() {
let source = "echo >\"$(a | b)\"\n";
let model = model(source);
let echo_id = command_id_starting_with(&model, source, "echo").unwrap();
let pipeline_span = model.pipeline_commands()[0].span;
let pipeline_id = model
.command_by_span_and_kind(pipeline_span, CommandKind::Binary)
.unwrap();
let a_id = command_id_starting_with(&model, source, "a").unwrap();
let b_id = command_id_starting_with(&model, source, "b").unwrap();
assert_eq!(model.command_parent_id(pipeline_id), Some(echo_id));
assert_eq!(model.command_parent_id(a_id), Some(pipeline_id));
assert_eq!(model.command_parent_id(b_id), Some(pipeline_id));
}
#[test]
fn command_topology_finds_innermost_command_at_offsets() {
let source = "if foo; then bar; fi\n";
let model = model(source);
let if_id = command_id_starting_with(&model, source, "if foo").unwrap();
let foo_id = command_id_starting_with(&model, source, "foo").unwrap();
let bar_id = command_id_starting_with(&model, source, "bar").unwrap();
assert_eq!(
model.innermost_command_id_at(source.find("if").unwrap()),
Some(if_id)
);
assert_eq!(
model.innermost_command_id_at(source.find("foo").unwrap()),
Some(foo_id)
);
assert_eq!(
model.innermost_command_id_at(source.find("bar").unwrap()),
Some(bar_id)
);
}
#[test]
fn command_topology_finds_innermost_command_containing_arbitrary_offsets() {
let source = "if foo; then bar; fi\n";
let model = model(source);
let if_id = command_id_starting_with(&model, source, "if foo").unwrap();
let foo_id = command_id_starting_with(&model, source, "foo").unwrap();
let bar_id = command_id_starting_with(&model, source, "bar").unwrap();
assert_eq!(
model.innermost_command_id_containing_offset(source.find("if").unwrap() + 1),
Some(if_id)
);
assert_eq!(
model.innermost_command_id_containing_offset(source.find("foo").unwrap() + 1),
Some(foo_id)
);
assert_eq!(
model.innermost_command_id_containing_offset(source.find("bar").unwrap() + 1),
Some(bar_id)
);
}
#[test]
fn command_topology_keeps_syntax_and_statement_containment_separate() {
let source = "if foo; then bar; fi >out\n";
let model = model(source);
let if_id = command_id_starting_with(&model, source, "if foo").unwrap();
let redirect_target_offset = source.find("out").unwrap() + 2;
assert_eq!(model.innermost_command_id_at(redirect_target_offset), None);
assert_eq!(
model.innermost_command_id_containing_offset(redirect_target_offset),
Some(if_id)
);
}
#[test]
fn arithmetic_plain_assignment_is_write_only() {
let model = model("(( i = 0 ))\n");
assert_arithmetic_usage(&model, "i", 0, 1);
}
#[test]
fn arithmetic_compound_assignment_is_read_write() {
let model = model("(( i += 2 ))\n");
assert_arithmetic_usage(&model, "i", 1, 1);
}
#[test]
fn arithmetic_prefix_update_is_read_write() {
let model = model("(( ++i ))\n");
assert_arithmetic_usage(&model, "i", 1, 1);
}
#[test]
fn arithmetic_postfix_update_is_read_write() {
let model = model("(( i++ ))\n");
assert_arithmetic_usage(&model, "i", 1, 1);
}
#[test]
fn arithmetic_assignment_reads_index_expressions() {
let model = model("(( a[i++] = 1 ))\n");
assert_arithmetic_usage(&model, "a", 0, 1);
assert_arithmetic_usage(&model, "i", 1, 1);
}
#[test]
fn arithmetic_conditional_tracks_branch_reads_and_writes() {
let model = model("(( x ? y++ : (z = 1) ))\n");
assert_arithmetic_usage(&model, "x", 1, 0);
assert_arithmetic_usage(&model, "y", 1, 1);
assert_arithmetic_usage(&model, "z", 0, 1);
}
#[test]
fn arithmetic_comma_walks_each_expression_in_order() {
let model = model("(( x = 1, y += x, z ))\n");
assert_arithmetic_usage(&model, "x", 1, 1);
assert_arithmetic_usage(&model, "y", 1, 1);
assert_arithmetic_usage(&model, "z", 1, 0);
}
#[test]
fn arithmetic_shell_words_still_walk_nested_expansions() {
let model = model("echo $(( $(printf '%s' \"$x\") + y ))\n");
assert!(
model.references().iter().any(|reference| {
reference.kind == ReferenceKind::Expansion && reference.name == "x"
})
);
assert_arithmetic_usage(&model, "y", 1, 0);
}
#[test]
fn substring_offset_arithmetic_tracks_postfix_updates() {
let source = "\
#!/bin/bash
spinner() {
local chars=\"/-\\\\|\"
local spin_i=0
while true; do
printf '%s\\n' \"${chars:spin_i++%${#chars}:1}\"
done
}
";
let model = model(source);
assert!(model.references().iter().any(|reference| {
reference.kind == ReferenceKind::ParameterSliceArithmetic && reference.name == "spin_i"
}));
assert_eq!(
arithmetic_write_count(&model, "spin_i"),
1,
"unexpected arithmetic write count for spin_i"
);
let unused = model
.analysis()
.unused_assignments()
.iter()
.map(|binding| model.binding(*binding).name.as_str())
.collect::<Vec<_>>();
assert!(!unused.contains(&"spin_i"), "unused bindings: {:?}", unused);
}
#[test]
fn classifies_nameref_and_source_directives() {
let source = "\
declare -n ref=target
# shellcheck source=lib.sh
source \"$x\"
";
let model = model(source);
let nameref = model
.bindings()
.iter()
.find(|binding| binding.name == "ref")
.unwrap();
assert!(matches!(nameref.kind, BindingKind::Nameref));
assert!(nameref.attributes.contains(BindingAttributes::NAMEREF));
assert_eq!(
model.source_refs()[0].kind,
SourceRefKind::Directive("lib.sh".into())
);
assert_eq!(
model.source_refs()[0].resolution,
SourceRefResolution::Unchecked
);
}
#[test]
fn source_directive_applies_across_contiguous_own_line_comments() {
let source = "\
# shellcheck source=lib.sh
# shellcheck disable=SC2154
source \"$x\"
";
let model = model(source);
assert_eq!(
model.source_refs()[0].kind,
SourceRefKind::Directive("lib.sh".into())
);
}
#[test]
fn dev_null_source_directive_persists_until_overridden() {
let source = "\
# shellcheck source=/dev/null
foo() { echo hi; }
source \"$a\"
source \"$b\"
# shellcheck source=./helper.sh
source \"$c\"
source \"$d\"
";
let model = model(source);
assert_eq!(model.source_refs().len(), 4);
assert_eq!(model.source_refs()[0].kind, SourceRefKind::DirectiveDevNull);
assert_eq!(model.source_refs()[1].kind, SourceRefKind::DirectiveDevNull);
assert_eq!(
model.source_refs()[2].kind,
SourceRefKind::Directive("./helper.sh".into())
);
assert_eq!(model.source_refs()[3].kind, SourceRefKind::Dynamic);
}
#[test]
fn escaped_dot_source_builtin_still_records_dynamic_source_refs() {
let source = "\
#!/bin/bash
\\. \"$rvm_environments_path/$1\"
";
let model = model(source);
assert_eq!(model.source_refs().len(), 1);
assert_eq!(model.source_refs()[0].kind, SourceRefKind::Dynamic);
assert_eq!(
model.source_refs()[0].diagnostic_class,
SourceRefDiagnosticClass::DynamicPath
);
}
#[test]
fn command_wrappers_do_not_create_source_refs() {
let source = "\
#!/bin/bash
command . \"$file\"
builtin source \"$file\"
noglob . \"$file\"
";
let model = model(source);
assert!(model.source_refs().is_empty());
}
#[test]
fn parameter_expansion_roots_with_static_path_tails_are_untracked_source_refs() {
for source in [
"#!/bin/bash\nsource \"${ROOT?}/helper.sh\"\n",
"#!/bin/bash\nsource \"${ROOT:-$HOME/.config}/helper.sh\"\n",
"#!/bin/bash\nsource \"${ROOT+vendor}/helper.sh\"\n",
"#!/bin/bash\nsource \"${#ROOT}/helper.sh\"\n",
"#!/bin/bash\nsource \"${ROOT%/*}/helper.sh\"\n",
"#!/bin/bash\nsource \"${ROOT/file/repl}/helper.sh\"\n",
"#!/bin/bash\nsource \"${!ROOT}/helper.sh\"\n",
] {
let model = model(source);
assert_eq!(model.source_refs().len(), 1, "{source}");
assert_eq!(
model.source_refs()[0].kind,
SourceRefKind::Dynamic,
"{source}"
);
assert_eq!(
model.source_refs()[0].diagnostic_class,
SourceRefDiagnosticClass::UntrackedFile,
"{source}"
);
}
}
#[test]
fn command_substitution_roots_with_static_path_tails_are_untracked_source_refs() {
for source in [
"#!/bin/bash\nsource \"$(git --exec-path)/git-sh-setup\"\n",
"#!/bin/sh\n. \"$(dirname \"$0\")/autopause-fcns.sh\"\n",
"#!/bin/ksh\nsource \"$(cd \"$(dirname \"${0}\")\"; pwd)/../nb\"\n",
] {
let model = model(source);
assert_eq!(model.source_refs().len(), 1, "{source}");
assert_eq!(
model.source_refs()[0].kind,
SourceRefKind::Dynamic,
"{source}"
);
assert_eq!(
model.source_refs()[0].diagnostic_class,
SourceRefDiagnosticClass::UntrackedFile,
"{source}"
);
}
}
#[test]
fn literal_leading_backslashes_do_not_create_source_refs() {
for source in [
"#!/bin/bash\n\"\\\\.\" \"$rvm_environments_path/$1\"\n",
"#!/bin/bash\n'\\source' \"$rvm_environments_path/$1\"\n",
"#!/bin/bash\n\\\\. \"$rvm_environments_path/$1\"\n",
] {
let model = model(source);
assert!(
model.source_refs().is_empty(),
"unexpected source refs for {source:?}: {:?}",
model.source_refs()
);
}
}
#[test]
fn builds_transitive_call_graph_and_overwritten_functions() {
let source = "\
f() { g; }
g() { echo hi; }
f
f() { echo again; }
";
let model = model(source);
assert!(model.call_graph().reachable.contains("f"));
assert!(model.call_graph().reachable.contains("g"));
assert_eq!(model.call_graph().overwritten.len(), 1);
assert_eq!(model.call_graph().overwritten[0].name, "f");
}
#[test]
fn call_graph_does_not_root_calls_inside_uncalled_functions() {
let source = "\
f() { g; }
g() { echo hi; }
";
let model = model(source);
assert!(!model.call_graph().reachable.contains("f"));
assert!(!model.call_graph().reachable.contains("g"));
}
#[test]
fn precise_overwritten_functions_track_real_overwrites() {
let source = "\
f() { echo hi; }
f() { echo again; }
";
let model = model(source);
let analysis = model.analysis();
let overwritten = analysis.overwritten_functions();
assert_eq!(overwritten.len(), 1);
assert_eq!(overwritten[0].name, "f");
assert!(!overwritten[0].first_called);
}
#[test]
fn precise_overwritten_functions_preserve_calls_before_redefinition() {
let source = "\
f() { echo hi; }
f
f() { echo again; }
";
let model = model(source);
let analysis = model.analysis();
let overwritten = analysis.overwritten_functions();
assert_eq!(overwritten.len(), 1);
assert!(overwritten[0].first_called);
}
#[test]
fn precise_overwritten_functions_count_nested_calls_from_invoked_wrappers() {
let source = "\
run_case() {
helper
}
helper() { echo hi; }
run_case
helper() { echo again; }
";
let model = model(source);
let analysis = model.analysis();
let overwritten = analysis.overwritten_functions();
assert_eq!(overwritten.len(), 1);
assert!(overwritten[0].first_called);
}
#[test]
fn precise_overwritten_functions_do_not_count_shadowed_nested_calls() {
let source = "\
run_case() {
helper() { echo local; }
helper
}
helper() { echo hi; }
run_case
helper() { echo again; }
";
let model = model(source);
let analysis = model.analysis();
let overwritten = analysis.overwritten_functions();
assert_eq!(overwritten.len(), 1);
assert!(!overwritten[0].first_called);
}
#[test]
fn precise_overwritten_functions_ignore_mutually_exclusive_branches() {
let source = "\
if cond; then
helper() { return 0; }
else
helper() { return 1; }
fi
helper
";
let model = model(source);
assert!(model.analysis().overwritten_functions().is_empty());
}
#[test]
fn precise_overwritten_functions_ignore_conditional_redefinition_after_default() {
let source = "\
helper() { return 0; }
if cond; then
helper() { return 1; }
fi
helper
";
let model = model(source);
assert!(model.analysis().overwritten_functions().is_empty());
}
#[test]
fn precise_overwritten_functions_allow_terminating_paths_before_redefinition() {
let source = "\
helper() { return 0; }
maybe || exit 1
helper() { return 1; }
";
let model = model(source);
let analysis = model.analysis();
let overwritten = analysis.overwritten_functions();
assert_eq!(overwritten.len(), 1);
assert_eq!(overwritten[0].name, "helper");
assert!(!overwritten[0].first_called);
}
#[test]
fn precise_overwritten_functions_do_not_merge_distinct_helper_scopes() {
let source = "\
factory_one() {
helper() { return 0; }
helper
}
factory_two() {
helper() { return 1; }
helper
}
factory_one
factory_two
";
let model = model(source);
assert!(model.analysis().overwritten_functions().is_empty());
}
#[test]
fn unreached_functions_report_definitions_before_script_termination() {
let source = "f() { echo hi; }\nexit 0\n";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions();
assert_eq!(unreached.len(), 1);
assert_eq!(unreached[0].name, "f");
assert_eq!(
unreached[0].reason,
UnreachedFunctionReason::ScriptTerminates
);
}
#[test]
fn unreached_functions_report_definitions_before_terminating_function_call() {
let source = "\
f() { echo hi; }
die() { exit 0; }
die
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions();
assert_eq!(unreached.len(), 1);
assert_eq!(unreached[0].name, "f");
assert_eq!(
unreached[0].reason,
UnreachedFunctionReason::ScriptTerminates
);
}
#[test]
fn unreached_functions_ignore_definitions_at_plain_eof() {
let source = "f() { echo hi; }\n";
let model = model(source);
assert!(model.analysis().unreached_functions().is_empty());
}
#[test]
fn unreached_functions_report_nested_plain_eof_only_with_compat_option() {
let source = "\
outer() {
inner() { :; }
}
";
let model = model(source);
let analysis = model.analysis();
assert!(analysis.unreached_functions().is_empty());
let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
report_unreached_nested_definitions: true,
});
assert_eq!(unreached.len(), 1);
assert_eq!(unreached[0].name, "inner");
assert_eq!(
unreached[0].reason,
UnreachedFunctionReason::EnclosingFunctionUnreached
);
}
#[test]
fn unreached_functions_report_subshell_definitions_with_compat_option() {
let source = "( inner() { :; }; )\n";
let model = model(source);
let analysis = model.analysis();
assert!(analysis.unreached_functions().is_empty());
let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
report_unreached_nested_definitions: true,
});
assert_eq!(unreached.len(), 1);
assert_eq!(unreached[0].name, "inner");
assert_eq!(
unreached[0].reason,
UnreachedFunctionReason::EnclosingFunctionUnreached
);
}
#[test]
fn unreached_functions_ignore_nested_definition_when_enclosing_function_is_called() {
let source = "\
outer() {
inner() { :; }
}
outer
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
report_unreached_nested_definitions: true,
});
assert!(unreached.is_empty());
}
#[test]
fn unreached_functions_do_not_count_indirect_enclosing_function_calls() {
let source = "\
outer() {
inner() { :; }
}
name=outer
$name
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
report_unreached_nested_definitions: true,
});
assert_eq!(unreached.len(), 1);
assert_eq!(unreached[0].name, "inner");
}
#[test]
fn unreached_functions_count_same_scope_nested_calls_before_enclosing_scope_exits() {
let source = "\
outer() {
inner() { :; }
inner
}
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
report_unreached_nested_definitions: true,
});
assert!(unreached.is_empty());
}
#[test]
fn unreached_functions_count_same_scope_command_substitution_calls() {
let source = "\
outer() {
inner() { :; }
value=$(inner)
}
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
report_unreached_nested_definitions: true,
});
assert!(unreached.is_empty());
}
#[test]
fn unreached_functions_ignore_inner_shadow_inside_command_substitution() {
let source = "\
outer() {
inner() { :; }
value=$(inner() { :; }; inner)
}
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
report_unreached_nested_definitions: true,
});
assert_eq!(unreached.len(), 1);
assert_eq!(unreached[0].name, "inner");
}
#[test]
fn transient_call_sites_include_function_scopes_under_command_substitution() {
let source = "\
printf '%s\\n' \"$(
caller() {
target
}
caller
)\"
";
let model = model(source);
let analysis = model.analysis();
let site = &model.call_sites_for(&Name::from("target"))[0];
assert!(analysis.call_site_runs_in_transient_context(site.scope));
}
#[test]
fn function_scope_boundary_helpers_ignore_outer_transient_ancestors() {
let source = "\
printf '%s\\n' \"$(
caller() {
printf '%s\\n' \"$1\"
}
caller
)\"
";
let model = model(source);
let scope = model.scope_at(
span_for_nth(source, "printf '%s\\n' \"$1\"", 0)
.start
.offset,
);
assert!(
model
.transient_ancestor_scopes_within_function(scope)
.next()
.is_none()
);
assert!(
model
.innermost_transient_scope_within_function(scope)
.is_none()
);
assert_eq!(
model.enclosing_function_scope_without_transient_boundary(scope),
model.enclosing_function_scope(scope)
);
}
#[test]
fn function_scope_boundary_helpers_stop_at_in_function_transient_scopes() {
let source = "\
caller() {
value=\"$(
printf '%s\\n' \"$1\"
)\"
}
";
let model = model(source);
let scope = model.scope_at(
span_for_nth(source, "printf '%s\\n' \"$1\"", 0)
.start
.offset,
);
let transients = model
.transient_ancestor_scopes_within_function(scope)
.collect::<Vec<_>>();
assert_eq!(transients.len(), 1);
assert!(matches!(
model.scope_kind(transients[0]),
ScopeKind::CommandSubstitution
));
assert_eq!(
model.innermost_transient_scope_within_function(scope),
Some(transients[0])
);
assert_eq!(
model.enclosing_function_scope_without_transient_boundary(scope),
None
);
}
#[test]
fn function_call_reachability_finds_direct_call_before_cutoff() {
let source = "\
target() { :; }
target
exit
";
let model = model(source);
let binding = function_binding_id(&model, "target", 0);
let cutoff = span_for_nth(source, "exit", 0).start.offset;
let analysis = model.analysis();
let mut reachability = analysis.direct_function_call_reachability(Vec::new());
assert!(reachability.binding_has_reachable_direct_call(
binding,
DirectFunctionCallWindow::before_offset(cutoff)
));
}
#[test]
fn function_call_reachability_respects_between_offsets() {
let source = "\
target() { :; }
echo before
target
exit
";
let model = model(source);
let binding = function_binding_id(&model, "target", 0);
let after = span_for_nth(source, "echo", 0).start.offset;
let before = span_for_nth(source, "exit", 0).start.offset;
let early_before = span_for_nth(source, "target", 1).start.offset - 1;
let analysis = model.analysis();
let mut reachability = analysis.direct_function_call_reachability(Vec::new());
assert!(reachability.binding_has_reachable_direct_call(
binding,
DirectFunctionCallWindow::between_offsets(after, before)
));
assert!(!reachability.binding_has_reachable_direct_call(
binding,
DirectFunctionCallWindow::between_offsets(after, early_before)
));
}
#[test]
fn function_call_reachability_persistent_query_excludes_transient_calls() {
let source = "\
target() { :; }
value=$(target)
";
let model = model(source);
let binding = function_binding_id(&model, "target", 0);
let analysis = model.analysis();
let mut reachability = analysis.direct_function_call_reachability(Vec::new());
assert!(reachability.binding_has_reachable_direct_call(
binding,
DirectFunctionCallWindow::before_offset(source.len())
));
assert!(!reachability.binding_has_reachable_direct_call(
binding,
DirectFunctionCallWindow::before_offset(source.len()).persistent()
));
}
#[test]
fn function_call_reachability_follows_nested_function_activation() {
let source = "\
target() { :; }
wrapper() {
target
}
wrapper
";
let model = model(source);
let binding = function_binding_id(&model, "target", 0);
let analysis = model.analysis();
let mut reachability = analysis.direct_function_call_reachability(Vec::new());
assert!(reachability.binding_has_reachable_direct_call(
binding,
DirectFunctionCallWindow::before_offset(source.len())
));
}
#[test]
fn function_call_reachability_blocks_prior_shadowed_binding() {
let source = "\
target() { : old; }
target() { : new; }
target
";
let model = model(source);
let first = function_binding_id(&model, "target", 0);
let second = function_binding_id(&model, "target", 1);
let analysis = model.analysis();
let mut reachability = analysis.direct_function_call_reachability(Vec::new());
assert!(!reachability.binding_has_reachable_direct_call(
first,
DirectFunctionCallWindow::before_offset(source.len())
));
assert!(reachability.binding_has_reachable_direct_call(
second,
DirectFunctionCallWindow::before_offset(source.len())
));
}
#[test]
fn function_call_reachability_blocks_prior_binding_called_from_later_wrapper() {
let source = "\
target() { : old; }
wrapper() {
target
}
target() { : new; }
wrapper
";
let model = model(source);
let first = function_binding_id(&model, "target", 0);
let second = function_binding_id(&model, "target", 1);
let analysis = model.analysis();
let mut reachability = analysis.direct_function_call_reachability(Vec::new());
assert!(!reachability.binding_has_reachable_direct_call(
first,
DirectFunctionCallWindow::before_offset(source.len())
));
assert!(reachability.binding_has_reachable_direct_call(
second,
DirectFunctionCallWindow::before_offset(source.len())
));
}
#[test]
fn function_call_reachability_uses_supplemental_call_candidates() {
let source = "\
target() { :; }
command target
";
let model = model(source);
let binding = function_binding_id(&model, "target", 0);
let target_span = span_for_nth(source, "target", 1);
let command_span = span_for_nth(source, "command target", 0);
let supplemental = vec![FunctionCallCandidate {
callee: Name::from("target"),
scope: model.scope_at(target_span.start.offset),
name_span: target_span,
command_span,
}];
let analysis = model.analysis();
let mut without_supplemental = analysis.direct_function_call_reachability(Vec::new());
assert!(!without_supplemental.binding_has_reachable_direct_call(
binding,
DirectFunctionCallWindow::before_offset(source.len())
));
let mut with_supplemental = analysis.direct_function_call_reachability(supplemental);
assert!(with_supplemental.binding_has_reachable_direct_call(
binding,
DirectFunctionCallWindow::before_offset(source.len())
));
}
#[test]
fn unreached_functions_count_transitive_nested_calls_before_enclosing_scope_exits() {
let source = "\
outer() {
inner() { :; }
wrapper() {
inner
}
wrapper
}
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
report_unreached_nested_definitions: true,
});
assert!(unreached.is_empty());
}
#[test]
fn unreached_functions_follow_nested_direct_call_chains() {
let source = "\
outer() {
wrapper() {
inner() { :; }
}
wrapper
}
outer
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
report_unreached_nested_definitions: true,
});
assert!(unreached.is_empty());
}
#[test]
fn unreached_functions_count_nested_definitions_installed_before_later_body_call() {
let source = "\
provider() {
helper() { :; }
}
consumer() {
provider
helper
}
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
report_unreached_nested_definitions: true,
});
assert!(unreached.is_empty());
}
#[test]
fn unreached_functions_report_nested_definitions_when_provider_call_follows_use() {
let source = "\
provider() {
helper() { :; }
}
consumer() {
helper
provider
}
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
report_unreached_nested_definitions: true,
});
assert_eq!(unreached.len(), 1);
assert_eq!(unreached[0].name, "helper");
}
#[test]
fn unreached_functions_do_not_count_provider_call_when_consumer_runs_before_definition() {
let source = "\
consumer() {
provider
helper
}
consumer
provider() {
helper() { :; }
}
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
report_unreached_nested_definitions: true,
});
assert_eq!(unreached.len(), 1);
assert_eq!(unreached[0].name, "helper");
}
#[test]
fn unreached_functions_ignore_unreachable_consumer_calls_before_provider_definition() {
let source = "\
consumer() {
provider
helper
}
guard() {
return
consumer
}
guard
provider() {
helper() { :; }
}
consumer
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
report_unreached_nested_definitions: true,
});
assert!(unreached.is_empty());
}
#[test]
fn unreached_functions_report_nested_definitions_when_intermediate_scope_is_not_called() {
let source = "\
outer() {
wrapper() {
inner() { :; }
}
}
outer
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
report_unreached_nested_definitions: true,
});
assert_eq!(unreached.len(), 1);
assert_eq!(unreached[0].name, "inner");
}
#[test]
fn unreached_functions_do_not_count_calls_before_enclosing_definition() {
let source = "\
outer
outer() {
inner() { :; }
}
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
report_unreached_nested_definitions: true,
});
assert_eq!(unreached.len(), 1);
assert_eq!(unreached[0].name, "inner");
}
#[test]
fn unreached_functions_ignore_direct_calls_before_termination() {
let source = "f() { echo hi; }\nf\nexit 0\n";
let model = model(source);
assert!(model.analysis().unreached_functions().is_empty());
}
#[test]
fn unreached_functions_count_direct_calls_that_terminate() {
let source = "f() { exit 1; }\nf\n";
let model = model(source);
assert!(model.analysis().unreached_functions().is_empty());
}
#[test]
fn unreached_functions_report_unreachable_definitions() {
let source = "exit 0\nf() { echo hi; }\n";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions();
assert_eq!(unreached.len(), 1);
assert_eq!(unreached[0].name, "f");
assert_eq!(
unreached[0].reason,
UnreachedFunctionReason::UnreachableDefinition
);
}
#[test]
fn unreached_functions_ignore_non_guaranteed_termination() {
let source = "f() { echo hi; }\nif cond; then exit 0; fi\n";
let model = model(source);
assert!(model.analysis().unreached_functions().is_empty());
}
#[test]
fn unreached_functions_count_transitive_direct_calls_before_termination() {
let source = "\
run_case() {
helper
}
helper() { echo hi; }
run_case
exit 0
";
let model = model(source);
assert!(model.analysis().unreached_functions().is_empty());
}
#[test]
fn unreached_functions_ignore_body_calls_when_enclosing_function_runs_before_definition() {
let source = "\
helper() { :; }
main() {
late
}
main
late() {
helper
}
exit 0
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions();
let names = unreached
.iter()
.map(|function| function.name.as_str())
.collect::<Vec<_>>();
assert_eq!(names, ["helper", "late"]);
}
#[test]
fn unreached_functions_count_nested_substitution_calls_before_termination() {
let source = "\
run_case() {
output=\"$(echo value | helper)\"
}
helper() { echo hi; }
run_case
exit 0
";
let model = model(source);
assert!(model.analysis().unreached_functions().is_empty());
}
#[test]
fn unreached_functions_ignore_shadowed_command_substitution_calls_before_termination() {
let source = "\
helper() { echo hi; }
output=$(helper() { :; }; helper)
exit 0
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions();
assert_eq!(unreached.len(), 1);
assert_eq!(unreached[0].name, "helper");
assert_eq!(
unreached[0].reason,
UnreachedFunctionReason::ScriptTerminates
);
}
#[test]
fn unreached_functions_ignore_shadowed_test_argument_command_substitution_calls() {
let source = "\
helper() { echo hi; }
if [ \"$(helper() { :; }; helper)\" = hi ]; then
echo yes
fi
exit 0
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions();
assert_eq!(unreached.len(), 1);
assert_eq!(unreached[0].name, "helper");
assert_eq!(
unreached[0].reason,
UnreachedFunctionReason::ScriptTerminates
);
}
#[test]
fn unreached_functions_ignore_command_substitution_paths_before_later_termination() {
let source = "\
f() { echo hi; }
output=$(echo value)
exit 0
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions();
assert_eq!(unreached.len(), 1);
assert_eq!(unreached[0].name, "f");
assert_eq!(
unreached[0].reason,
UnreachedFunctionReason::ScriptTerminates
);
}
#[test]
fn unreached_functions_ignore_pipeline_paths_before_later_termination() {
let source = "\
f() { echo hi; }
echo value | cat
exit 0
";
let model = model(source);
let analysis = model.analysis();
let unreached = analysis.unreached_functions();
assert_eq!(unreached.len(), 1);
assert_eq!(unreached[0].name, "f");
assert_eq!(
unreached[0].reason,
UnreachedFunctionReason::ScriptTerminates
);
}
#[test]
fn unreached_functions_count_negated_condition_calls_before_termination() {
let source = "\
die() { exit 1; }
check() { :; }
validate() {
if ! check \"$value\"; then
die
fi
}
validate
exit 0
";
let model = model(source);
let analysis = model.analysis();
let unreached_names = analysis
.unreached_functions()
.iter()
.map(|unreached| unreached.name.as_str())
.collect::<Vec<_>>();
assert!(!unreached_names.contains(&"check"));
}
#[test]
fn unreached_functions_count_calls_to_branch_selected_definitions() {
let source = "\
if use_first; then
helper() { echo first; }
else
helper() { echo second; }
fi
run_case() {
helper
}
run_case
exit 0
";
let model = model(source);
assert!(model.analysis().unreached_functions().is_empty());
}
#[test]
fn unreached_functions_count_late_bound_wrapper_calls_newer_definition() {
let source = "\
helper() { echo old; }
run_case() {
helper
}
helper() { echo new; }
run_case
exit 0
";
let model = model(source);
let analysis = model.analysis();
let unreached_names = analysis
.unreached_functions()
.iter()
.map(|unreached| unreached.name.as_str())
.collect::<Vec<_>>();
assert!(!unreached_names.contains(&"helper"));
}
#[test]
fn tracks_flow_context_for_conditions_and_loops() {
let source = "\
if cmd; then
echo ok
fi
for x in 1 2; do
break
done
";
let output = Parser::new(source).parse().unwrap();
let indexer = Indexer::new(source, &output);
let model = SemanticModel::build(&output.file, source, &indexer);
let Command::Compound(CompoundCommand::If(if_command)) = &output.file.body[0].command else {
panic!("expected if command");
};
let condition_span = match &if_command.condition[0].command {
Command::Simple(command) => command.span,
other => panic!("unexpected condition command: {other:?}"),
};
let condition_context = model.flow_context_at(&condition_span).unwrap();
assert!(condition_context.exit_status_checked);
let Command::Compound(CompoundCommand::For(for_command)) = &output.file.body[1].command else {
panic!("expected for command");
};
let break_span = match &for_command.body[0].command {
Command::Builtin(shuck_ast::BuiltinCommand::Break(command)) => command.span,
other => panic!("unexpected loop body command: {other:?}"),
};
let break_context = model.flow_context_at(&break_span).unwrap();
assert_eq!(break_context.loop_depth, 1);
}
#[test]
fn brace_group_effect_scan_preserves_sibling_flow_context() {
let source = "\
{ emulate sh; }
echo ok
";
let output = Parser::new(source).parse().unwrap();
let indexer = Indexer::new(source, &output);
let model = SemanticModel::build(&output.file, source, &indexer);
let Command::Compound(CompoundCommand::BraceGroup(commands)) = &output.file.body[0].command
else {
panic!("expected brace group");
};
let Command::Simple(inner_command) = &commands[0].command else {
panic!("expected inner simple command");
};
let inner_context = model.flow_context_at(&inner_command.span).unwrap();
assert!(inner_context.in_block);
let Command::Simple(sibling_command) = &output.file.body[1].command else {
panic!("expected sibling simple command");
};
let sibling_context = model.flow_context_at(&sibling_command.span).unwrap();
assert!(!sibling_context.in_block);
}
#[test]
fn command_contexts_track_nested_words_and_condition_roles() {
let source = "\
echo $(if probe; then inner; fi)
cat < <(process)
if outer; then ok; elif alt; then fallback; fi
while guard; do break; done
until stop; do done_cmd; done
while if nested_guard; then :; elif nested_alt; then :; fi; do :; done
cat <<EOF
$(heredoc_cmd)
EOF
";
let model = model(source);
let echo = model
.command_context(command_id_starting_with(&model, source, "echo").unwrap())
.unwrap();
assert!(!echo.is_nested_word_command());
assert_eq!(echo.nested_word_command_depth(), 0);
assert!(echo.is_structural());
assert_eq!(echo.condition_role(), None);
let nested_if = model
.command_context(command_id_starting_with(&model, source, "if probe").unwrap())
.unwrap();
assert!(nested_if.is_nested_word_command());
assert_eq!(nested_if.nested_word_command_depth(), 1);
assert_eq!(nested_if.condition_role(), None);
let probe = model
.command_context(command_id_starting_with(&model, source, "probe").unwrap())
.unwrap();
assert!(probe.is_nested_word_command());
assert_eq!(probe.nested_word_command_depth(), 1);
assert!(probe.is_in_if_condition());
assert!(!probe.is_in_elif_condition());
assert_eq!(probe.condition_role(), Some(CommandConditionRole::If));
let process = model
.command_context(command_id_starting_with(&model, source, "process").unwrap())
.unwrap();
assert!(process.is_nested_word_command());
assert_eq!(process.nested_word_command_depth(), 1);
let outer = model
.command_context(command_id_starting_with(&model, source, "outer").unwrap())
.unwrap();
assert!(!outer.is_nested_word_command());
assert_eq!(outer.nested_word_command_depth(), 0);
assert!(outer.is_in_if_condition());
assert_eq!(outer.condition_role(), Some(CommandConditionRole::If));
let alt = model
.command_context(command_id_starting_with(&model, source, "alt").unwrap())
.unwrap();
assert!(alt.is_in_if_condition());
assert!(alt.is_in_elif_condition());
assert_eq!(alt.condition_role(), Some(CommandConditionRole::Elif));
let guard = model
.command_context(command_id_starting_with(&model, source, "guard").unwrap())
.unwrap();
assert_eq!(guard.condition_role(), Some(CommandConditionRole::While));
let stop = model
.command_context(command_id_starting_with(&model, source, "stop").unwrap())
.unwrap();
assert_eq!(stop.condition_role(), Some(CommandConditionRole::Until));
let nested_guard = model
.command_context(command_id_starting_with(&model, source, "nested_guard").unwrap())
.unwrap();
assert_eq!(
nested_guard.condition_role(),
Some(CommandConditionRole::If)
);
let nested_alt = model
.command_context(command_id_starting_with(&model, source, "nested_alt").unwrap())
.unwrap();
assert_eq!(
nested_alt.condition_role(),
Some(CommandConditionRole::Elif)
);
let heredoc = model
.command_context(command_id_starting_with(&model, source, "heredoc_cmd").unwrap())
.unwrap();
assert!(heredoc.is_nested_word_command());
assert_eq!(heredoc.nested_word_command_depth(), 1);
}
#[test]
fn condition_contexts_do_not_flow_into_function_bodies() {
let source = "if helper() { echo body; }; then :; fi\n";
let model = model(source);
let helper = model
.command_context(command_id_starting_with(&model, source, "helper").unwrap())
.unwrap();
assert!(helper.is_in_if_condition());
assert_eq!(helper.condition_role(), Some(CommandConditionRole::If));
let body = model
.command_context(command_id_starting_with(&model, source, "echo body").unwrap())
.unwrap();
assert!(!body.is_in_if_condition());
assert!(!body.is_in_elif_condition());
assert_eq!(body.condition_role(), None);
}
#[test]
fn previous_visible_binding_can_ignore_the_current_assignment_span() {
let model = model(
"\
#!/bin/bash
value=outer
f() {
local value=inner
value=next
}
",
);
let binding_ids = model.bindings_for(&Name::from("value"));
let local = model.binding(binding_ids[1]);
let current = model.binding(binding_ids[2]);
assert_eq!(
model
.visible_binding(&Name::from("value"), current.span)
.map(|binding| binding.id),
Some(current.id)
);
assert_eq!(
model
.previous_visible_binding(&Name::from("value"), current.span, Some(current.span))
.map(|binding| binding.id),
Some(local.id)
);
}
#[test]
fn visible_candidate_bindings_for_reference_returns_visible_scope_chain() {
let source = "\
#!/bin/bash
value=outer
f() {
local value=inner
printf '%s\\n' \"$value\"
}
";
let model = model(source);
let reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "value")
.unwrap();
let candidates = model.visible_candidate_bindings_for_reference(reference);
let value_bindings = model.bindings_for(&Name::from("value"));
assert_eq!(candidates, vec![value_bindings[1], value_bindings[0]]);
}
#[test]
fn visible_candidate_bindings_for_reference_falls_back_to_prior_outer_scope_bindings() {
let source = "\
#!/bin/bash
first() {
target=(one two)
}
second() {
printf '%s\\n' \"$target\"
}
";
let model = model(source);
let reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "target")
.unwrap();
let candidates = model.visible_candidate_bindings_for_reference(reference);
assert_eq!(candidates, model.bindings_for(&Name::from("target")));
}
#[test]
fn assoc_lookup_binding_prefers_visible_assoc_declarations_and_respects_local_shadowing() {
let model = model(
"\
#!/bin/bash
declare -A map
helper() {
map[$key]=1
}
shadow() {
local map
map[$key]=2
}
",
);
let helper_scope = model
.scopes()
.iter()
.find_map(|scope| match &scope.kind {
ScopeKind::Function(FunctionScopeKind::Named(names))
if names.iter().any(|name| name == "helper") =>
{
Some(scope.id)
}
_ => None,
})
.expect("expected helper scope");
let shadow_scope = model
.scopes()
.iter()
.find_map(|scope| match &scope.kind {
ScopeKind::Function(FunctionScopeKind::Named(names))
if names.iter().any(|name| name == "shadow") =>
{
Some(scope.id)
}
_ => None,
})
.expect("expected shadow scope");
let helper_assignment = model
.bindings_for(&Name::from("map"))
.iter()
.copied()
.find(|binding_id| {
let binding = model.binding(*binding_id);
binding.scope == helper_scope && binding.kind == BindingKind::ArrayAssignment
})
.expect("expected helper assignment binding");
let shadow_local = model
.bindings_for(&Name::from("map"))
.iter()
.copied()
.find(|binding_id| {
let binding = model.binding(*binding_id);
binding.scope == shadow_scope
&& matches!(binding.kind, BindingKind::Declaration(_))
&& binding.attributes.contains(BindingAttributes::LOCAL)
})
.expect("expected shadowing local binding");
let shadow_assignment = model
.bindings_for(&Name::from("map"))
.iter()
.copied()
.find(|binding_id| {
let binding = model.binding(*binding_id);
binding.scope == shadow_scope && binding.kind == BindingKind::ArrayAssignment
})
.expect("expected shadow assignment binding");
assert!(
model
.visible_binding_for_assoc_lookup(
&Name::from("map"),
helper_scope,
model.binding(helper_assignment).span,
)
.is_some_and(|binding| binding.attributes.contains(BindingAttributes::ASSOC))
);
assert_eq!(
model
.visible_binding_for_assoc_lookup(
&Name::from("map"),
shadow_scope,
model.binding(shadow_assignment).span,
)
.map(|binding| binding.id),
Some(shadow_local)
);
}
#[test]
fn imported_entry_bindings_insert_in_visibility_order() {
let source = "\
#!/bin/bash
value=local
echo $value
";
let output = Parser::new(source).parse().unwrap();
let indexer = Indexer::new(source, &output);
let model = SemanticModel::build_with_options(
&output.file,
source,
&indexer,
SemanticBuildOptions {
file_entry_contract: Some(FileContract {
required_reads: Vec::new(),
provided_bindings: vec![ProvidedBinding::new(
Name::from("value"),
ProvidedBindingKind::Variable,
ContractCertainty::Definite,
)],
provided_functions: Vec::new(),
externally_consumed_bindings: false,
externally_consumed_binding_names: Vec::new(),
externally_consumed_binding_prefixes: Vec::new(),
}),
..SemanticBuildOptions::default()
},
);
let value_bindings = model.bindings_for(&Name::from("value"));
assert_eq!(value_bindings.len(), 2);
assert!(matches!(
model.binding(value_bindings[0]).kind,
BindingKind::Imported
));
assert!(matches!(
model.binding(value_bindings[1]).kind,
BindingKind::Assignment
));
let value_reference = model
.references()
.iter()
.find(|reference| {
reference.name.as_str() == "value" && reference.span.slice(source) == "$value"
})
.expect("expected value reference");
assert!(matches!(
model.visible_binding(&Name::from("value"), value_reference.span),
Some(binding) if binding.kind == BindingKind::Assignment
));
}
#[test]
fn name_only_export_consumes_existing_binding() {
let source = "foo=1\nexport foo\n";
let model = model(source);
let foo_bindings = model
.bindings()
.iter()
.filter(|binding| binding.name == "foo")
.collect::<Vec<_>>();
assert_eq!(foo_bindings.len(), 1);
assert!(
foo_bindings[0]
.attributes
.contains(BindingAttributes::EXPORTED)
);
let declaration_reference = model
.references()
.iter()
.find(|reference| {
reference.kind == ReferenceKind::DeclarationName && reference.name == "foo"
})
.unwrap();
let resolved = model.resolved_binding(declaration_reference.id).unwrap();
assert_eq!(resolved.id, foo_bindings[0].id);
}
#[test]
fn name_only_local_creates_a_binding_for_later_reads() {
let source = "f() { local VAR; echo \"$VAR\"; }\n";
let model = model(source);
let local_binding = model
.bindings()
.iter()
.find(|binding| {
binding.name == "VAR"
&& matches!(
binding.kind,
BindingKind::Declaration(DeclarationBuiltin::Local)
)
})
.unwrap();
assert!(
!local_binding
.attributes
.contains(BindingAttributes::DECLARATION_INITIALIZED)
);
let reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "VAR")
.unwrap();
let resolved = model.resolved_binding(reference.id).unwrap();
assert_eq!(resolved.id, local_binding.id);
let reference_id = reference.id;
let analysis = model.analysis();
let uninitialized = analysis.uninitialized_references();
assert_eq!(uninitialized.len(), 1);
assert_eq!(uninitialized[0].reference, reference_id);
assert_eq!(uninitialized[0].certainty, UninitializedCertainty::Definite);
}
#[test]
fn declaration_function_name_operands_do_not_create_variable_bindings() {
let source = "\
declare -f -F config_unset >/dev/null
export -f helper
readonly -f locked
typeset -f typed
";
let model = model(source);
let function_operand_names = ["config_unset", "helper", "locked", "typed"];
for name in function_operand_names {
assert!(
model.bindings().iter().all(|binding| binding.name != name),
"{name} should be treated as a function name, not a variable binding"
);
assert!(
model.references().iter().all(|reference| {
reference.name != name || reference.kind != ReferenceKind::DeclarationName
}),
"{name} should not create a declaration-name variable reference"
);
}
}
#[test]
fn declaration_plus_function_flags_keep_name_operands_as_variables() {
let source = "\
declare +f config_unset
declare +F config_maybe
declare -f +f config_after_toggle
declare +f config_before -f helper_function
declare -f hidden_function +f config_after
typeset +f typed
";
let model = model(source);
let variable_operand_names = [
"config_unset",
"config_maybe",
"config_after_toggle",
"config_before",
"config_after",
"typed",
];
for name in variable_operand_names {
assert!(
model.bindings().iter().any(|binding| binding.name == name
&& matches!(binding.kind, BindingKind::Declaration(_))),
"{name} should be treated as a variable declaration"
);
}
let function_operand_names = ["helper_function", "hidden_function"];
for name in function_operand_names {
assert!(
model.bindings().iter().all(|binding| binding.name != name),
"{name} should be treated as a function name, not a variable binding"
);
assert!(
model.references().iter().all(|reference| {
reference.name != name || reference.kind != ReferenceKind::DeclarationName
}),
"{name} should not create a declaration-name variable reference"
);
}
}
#[test]
fn repeated_name_only_local_reuses_existing_same_scope_binding() {
let source = "f() { local foo=1; local foo; echo \"$foo\"; }\n";
let model = model(source);
let foo_bindings = model
.bindings()
.iter()
.filter(|binding| binding.name == "foo")
.collect::<Vec<_>>();
assert_eq!(foo_bindings.len(), 1);
assert!(
foo_bindings[0]
.attributes
.contains(BindingAttributes::LOCAL)
);
assert!(
foo_bindings[0]
.attributes
.contains(BindingAttributes::DECLARATION_INITIALIZED)
);
let declaration_reference = model
.references()
.iter()
.find(|reference| {
reference.kind == ReferenceKind::DeclarationName && reference.name == "foo"
})
.unwrap();
let resolved_declaration = model.resolved_binding(declaration_reference.id).unwrap();
assert_eq!(resolved_declaration.id, foo_bindings[0].id);
let expansion_reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "foo")
.unwrap();
let resolved_expansion = model.resolved_binding(expansion_reference.id).unwrap();
assert_eq!(resolved_expansion.id, foo_bindings[0].id);
let analysis = model.analysis();
assert!(analysis.uninitialized_references().is_empty());
assert!(analysis.unused_assignments().is_empty());
}
#[test]
fn name_only_local_after_local_write_reuses_existing_local_state() {
let source = "f() { local foo=1; foo=2; local foo; echo \"$foo\"; }\n";
let model = model(source);
let foo_bindings = model
.bindings()
.iter()
.filter(|binding| binding.name == "foo")
.collect::<Vec<_>>();
assert_eq!(foo_bindings.len(), 2);
assert!(
foo_bindings[0]
.attributes
.contains(BindingAttributes::LOCAL)
);
assert!(
foo_bindings[1]
.attributes
.contains(BindingAttributes::LOCAL)
);
let declaration_reference = model
.references()
.iter()
.find(|reference| {
reference.kind == ReferenceKind::DeclarationName && reference.name == "foo"
})
.unwrap();
let resolved_declaration = model.resolved_binding(declaration_reference.id).unwrap();
assert_eq!(resolved_declaration.id, foo_bindings[1].id);
let expansion_reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "foo")
.unwrap();
let resolved_expansion = model.resolved_binding(expansion_reference.id).unwrap();
assert_eq!(resolved_expansion.id, foo_bindings[1].id);
let unused_binding_ids = model.analysis().dataflow().unused_assignment_ids().to_vec();
assert_eq!(unused_binding_ids, vec![foo_bindings[0].id]);
assert!(model.analysis().uninitialized_references().is_empty());
}
#[test]
fn name_only_local_after_conditional_unset_reuses_existing_local_state() {
let source = "\
f() {
local foo=1
if cond; then
unset foo
fi
local foo
echo \"$foo\"
}
";
let model = model(source);
let function_scope = model
.scopes()
.iter()
.find_map(|scope| match &scope.kind {
ScopeKind::Function(FunctionScopeKind::Named(names))
if names.iter().any(|name| name == "f") =>
{
Some(scope.id)
}
_ => None,
})
.expect("expected function scope");
let foo_bindings = model
.bindings()
.iter()
.filter(|binding| binding.name == "foo" && binding.scope == function_scope)
.collect::<Vec<_>>();
assert_eq!(foo_bindings.len(), 1);
assert!(
foo_bindings[0]
.attributes
.contains(BindingAttributes::LOCAL)
);
let expansion_reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "foo")
.expect("expected foo expansion");
let resolved_expansion = model.resolved_binding(expansion_reference.id).unwrap();
assert_eq!(resolved_expansion.id, foo_bindings[0].id);
let analysis = model.analysis();
assert!(analysis.uninitialized_references().is_empty());
assert!(analysis.unused_assignments().is_empty());
}
#[test]
fn name_only_local_after_invalid_unset_flag_reuses_existing_local_state() {
let source = "\
f() {
local foo=1
unset -z foo
local foo
echo \"$foo\"
}
";
let model = model(source);
let function_scope = model
.scopes()
.iter()
.find_map(|scope| match &scope.kind {
ScopeKind::Function(FunctionScopeKind::Named(names))
if names.iter().any(|name| name == "f") =>
{
Some(scope.id)
}
_ => None,
})
.expect("expected function scope");
let foo_bindings = model
.bindings()
.iter()
.filter(|binding| binding.name == "foo" && binding.scope == function_scope)
.collect::<Vec<_>>();
assert_eq!(foo_bindings.len(), 1);
assert!(
foo_bindings[0]
.attributes
.contains(BindingAttributes::LOCAL)
);
let expansion_reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "foo")
.expect("expected foo expansion");
let resolved_expansion = model.resolved_binding(expansion_reference.id).unwrap();
assert_eq!(resolved_expansion.id, foo_bindings[0].id);
let analysis = model.analysis();
assert!(analysis.uninitialized_references().is_empty());
assert!(analysis.unused_assignments().is_empty());
}
#[test]
fn name_only_local_after_conflicting_unset_flags_reuses_existing_local_state() {
let source = "\
f() {
local foo=1
unset -vf foo
local foo
echo \"$foo\"
}
";
let model = model(source);
let function_scope = model
.scopes()
.iter()
.find_map(|scope| match &scope.kind {
ScopeKind::Function(FunctionScopeKind::Named(names))
if names.iter().any(|name| name == "f") =>
{
Some(scope.id)
}
_ => None,
})
.expect("expected function scope");
let foo_bindings = model
.bindings()
.iter()
.filter(|binding| binding.name == "foo" && binding.scope == function_scope)
.collect::<Vec<_>>();
assert_eq!(foo_bindings.len(), 1);
assert!(
foo_bindings[0]
.attributes
.contains(BindingAttributes::LOCAL)
);
let expansion_reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "foo")
.expect("expected foo expansion");
let resolved_expansion = model.resolved_binding(expansion_reference.id).unwrap();
assert_eq!(resolved_expansion.id, foo_bindings[0].id);
let analysis = model.analysis();
assert!(analysis.uninitialized_references().is_empty());
assert!(analysis.unused_assignments().is_empty());
}
#[test]
fn name_only_local_after_split_conflicting_unset_flags_reuses_existing_local_state() {
let source = "\
f() {
local foo=1
unset -f -v foo
local foo
echo \"$foo\"
}
";
let model = model(source);
let function_scope = model
.scopes()
.iter()
.find_map(|scope| match &scope.kind {
ScopeKind::Function(FunctionScopeKind::Named(names))
if names.iter().any(|name| name == "f") =>
{
Some(scope.id)
}
_ => None,
})
.expect("expected function scope");
let foo_bindings = model
.bindings()
.iter()
.filter(|binding| binding.name == "foo" && binding.scope == function_scope)
.collect::<Vec<_>>();
assert_eq!(foo_bindings.len(), 1);
assert!(
foo_bindings[0]
.attributes
.contains(BindingAttributes::LOCAL)
);
let expansion_reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "foo")
.expect("expected foo expansion");
let resolved_expansion = model.resolved_binding(expansion_reference.id).unwrap();
assert_eq!(resolved_expansion.id, foo_bindings[0].id);
let analysis = model.analysis();
assert!(analysis.uninitialized_references().is_empty());
assert!(analysis.unused_assignments().is_empty());
}
#[test]
fn unset_n_clears_nameref_binding_state_before_name_only_local() {
let source = "\
f() {
local foo=1
local -n ref=foo
unset -n ref
local ref
}
";
let model = model(source);
let function_scope = model
.scopes()
.iter()
.find_map(|scope| match &scope.kind {
ScopeKind::Function(FunctionScopeKind::Named(names))
if names.iter().any(|name| name == "f") =>
{
Some(scope.id)
}
_ => None,
})
.expect("expected function scope");
let ref_bindings = model
.bindings()
.iter()
.filter(|binding| binding.name == "ref" && binding.scope == function_scope)
.collect::<Vec<_>>();
assert!(
ref_bindings
.iter()
.any(|binding| matches!(binding.kind, BindingKind::Nameref))
);
let redeclared_local = ref_bindings
.iter()
.find(|binding| {
matches!(
binding.kind,
BindingKind::Declaration(DeclarationBuiltin::Local)
)
})
.expect("expected fresh local binding after unset -n");
assert!(
!redeclared_local
.attributes
.contains(BindingAttributes::NAMEREF)
);
}
#[test]
fn name_only_local_after_unset_n_plain_variable_reuses_existing_local_state() {
let source = "\
f() {
local foo=1
unset -n foo
local foo
echo \"$foo\"
}
";
let model = model(source);
let function_scope = model
.scopes()
.iter()
.find_map(|scope| match &scope.kind {
ScopeKind::Function(FunctionScopeKind::Named(names))
if names.iter().any(|name| name == "f") =>
{
Some(scope.id)
}
_ => None,
})
.expect("expected function scope");
let foo_bindings = model
.bindings()
.iter()
.filter(|binding| binding.name == "foo" && binding.scope == function_scope)
.collect::<Vec<_>>();
assert_eq!(foo_bindings.len(), 1);
assert!(
foo_bindings[0]
.attributes
.contains(BindingAttributes::LOCAL)
);
let expansion_reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "foo")
.expect("expected foo expansion");
let resolved_expansion = model.resolved_binding(expansion_reference.id).unwrap();
assert_eq!(resolved_expansion.id, foo_bindings[0].id);
let analysis = model.analysis();
assert!(analysis.uninitialized_references().is_empty());
assert!(analysis.unused_assignments().is_empty());
}
#[test]
fn name_only_local_after_dynamic_unset_option_word_reuses_existing_local_state() {
let source = "\
f() {
local foo=1
local mode=-f
unset \"$mode\" foo
local foo
echo \"$foo\"
}
";
let model = model(source);
let function_scope = model
.scopes()
.iter()
.find_map(|scope| match &scope.kind {
ScopeKind::Function(FunctionScopeKind::Named(names))
if names.iter().any(|name| name == "f") =>
{
Some(scope.id)
}
_ => None,
})
.expect("expected function scope");
let foo_bindings = model
.bindings()
.iter()
.filter(|binding| binding.name == "foo" && binding.scope == function_scope)
.collect::<Vec<_>>();
assert_eq!(foo_bindings.len(), 1);
assert!(
foo_bindings[0]
.attributes
.contains(BindingAttributes::LOCAL)
);
let expansion_reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "foo")
.expect("expected foo expansion");
let resolved_expansion = model.resolved_binding(expansion_reference.id).unwrap();
assert_eq!(resolved_expansion.id, foo_bindings[0].id);
let analysis = model.analysis();
assert!(analysis.uninitialized_references().is_empty());
assert!(analysis.unused_assignments().is_empty());
}
#[test]
fn name_only_local_after_unset_creates_fresh_non_assoc_binding() {
let source = "\
main() {
local key=name
declare -A map
unset map
local map
map[$key]=1
}
";
let model = model(source);
let main_scope = model
.scopes()
.iter()
.find_map(|scope| match &scope.kind {
ScopeKind::Function(FunctionScopeKind::Named(names))
if names.iter().any(|name| name == "main") =>
{
Some(scope.id)
}
_ => None,
})
.expect("expected main scope");
let redeclared_local = model
.bindings()
.iter()
.find(|binding| {
binding.name == "map"
&& binding.scope == main_scope
&& matches!(
binding.kind,
BindingKind::Declaration(DeclarationBuiltin::Local)
)
})
.expect("expected redeclared local binding");
let array_assignment = model
.bindings()
.iter()
.find(|binding| {
binding.name == "map"
&& binding.scope == main_scope
&& binding.kind == BindingKind::ArrayAssignment
})
.expect("expected array assignment binding");
assert!(
!redeclared_local
.attributes
.contains(BindingAttributes::ASSOC)
);
assert_eq!(
model
.visible_binding_for_assoc_lookup(
&Name::from("map"),
main_scope,
array_assignment.span,
)
.map(|binding| binding.id),
Some(redeclared_local.id)
);
}
#[test]
fn special_command_targets_store_name_only_spans() {
let source = "\
read -r read_target
read -ra read_array_target read_array_remainder
read -aattached_target
read -ar
mapfile mapfile_target
readarray readarray_target
printf -v printf_target '%s' value
getopts 'ab' getopts_target
";
let model = model(source);
let read_target = model
.bindings()
.iter()
.find(|binding| {
binding.name == "read_target" && matches!(binding.kind, BindingKind::ReadTarget)
})
.unwrap();
assert_eq!(read_target.span.slice(source), "read_target");
assert!(!read_target.attributes.contains(BindingAttributes::ARRAY));
let read_array_target = model
.bindings()
.iter()
.find(|binding| {
binding.name == "read_array_target" && matches!(binding.kind, BindingKind::ReadTarget)
})
.unwrap();
assert_eq!(read_array_target.span.slice(source), "read_array_target");
assert!(
read_array_target
.attributes
.contains(BindingAttributes::ARRAY)
);
assert!(!model.bindings().iter().any(|binding| {
binding.name == "read_array_remainder" && matches!(binding.kind, BindingKind::ReadTarget)
}));
let attached_read_target = model
.bindings()
.iter()
.find(|binding| {
binding.name == "attached_target" && matches!(binding.kind, BindingKind::ReadTarget)
})
.unwrap();
assert_eq!(attached_read_target.span.slice(source), "attached_target");
let short_attached_read_target = model
.bindings()
.iter()
.find(|binding| binding.name == "r" && matches!(binding.kind, BindingKind::ReadTarget))
.unwrap();
assert_eq!(short_attached_read_target.span.slice(source), "r");
let mapfile_target = model
.bindings()
.iter()
.find(|binding| {
binding.name == "mapfile_target" && matches!(binding.kind, BindingKind::MapfileTarget)
})
.unwrap();
assert_eq!(mapfile_target.span.slice(source), "mapfile_target");
assert!(mapfile_target.attributes.contains(BindingAttributes::ARRAY));
let readarray_target = model
.bindings()
.iter()
.find(|binding| {
binding.name == "readarray_target" && matches!(binding.kind, BindingKind::MapfileTarget)
})
.unwrap();
assert_eq!(readarray_target.span.slice(source), "readarray_target");
assert!(
readarray_target
.attributes
.contains(BindingAttributes::ARRAY)
);
let printf_target = model
.bindings()
.iter()
.find(|binding| {
binding.name == "printf_target" && matches!(binding.kind, BindingKind::PrintfTarget)
})
.unwrap();
assert_eq!(printf_target.span.slice(source), "printf_target");
let getopts_target = model
.bindings()
.iter()
.find(|binding| {
binding.name == "getopts_target" && matches!(binding.kind, BindingKind::GetoptsTarget)
})
.unwrap();
assert_eq!(getopts_target.span.slice(source), "getopts_target");
}
#[test]
fn special_command_target_parsing_skips_option_operands_and_tracks_implicit_mapfile() {
let source = "\
delimiter=:
callback=cb
read -d delimiter -a read_array_target read_array_remainder <<<\":\"
mapfile -C callback -c 1 mapfile_target < <(printf '%s\\n' value)
mapfile
";
let model = model(source);
let read_targets = model
.bindings()
.iter()
.filter(|binding| matches!(binding.kind, BindingKind::ReadTarget))
.collect::<Vec<_>>();
assert!(
read_targets
.iter()
.any(|binding| binding.name == "read_array_target")
);
assert!(
!read_targets
.iter()
.any(|binding| binding.name == "read_array_remainder")
);
assert!(
!read_targets
.iter()
.any(|binding| binding.name == "delimiter")
);
let mapfile_targets = model
.bindings()
.iter()
.filter(|binding| matches!(binding.kind, BindingKind::MapfileTarget))
.collect::<Vec<_>>();
assert!(
mapfile_targets
.iter()
.any(|binding| binding.name == "mapfile_target")
);
assert!(mapfile_targets.iter().any(|binding| {
binding.name == "MAPFILE"
&& binding.attributes.contains(BindingAttributes::ARRAY)
&& matches!(
binding.origin,
BindingOrigin::BuiltinTarget {
definition_span,
..
} if definition_span == binding.span
)
}));
assert!(
!mapfile_targets
.iter()
.any(|binding| binding.name == "callback")
);
}
#[test]
fn zparseopts_targets_record_array_bindings_in_zsh() {
let source = "\
zparseopts -D -E -F -a all -A assoc -- \\
h=help -help=help \\
v+:=verbose -verbose+:=verbose
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let target = |name: &str| {
model
.bindings()
.iter()
.find(|binding| {
binding.name == name && matches!(binding.kind, BindingKind::ZparseoptsTarget)
})
.unwrap_or_else(|| panic!("missing zparseopts target {name}"))
};
let all = target("all");
assert_eq!(all.span.slice(source), "all");
assert!(all.attributes.contains(BindingAttributes::ARRAY));
assert!(!all.attributes.contains(BindingAttributes::ASSOC));
assert!(matches!(
all.origin,
BindingOrigin::BuiltinTarget {
kind: BuiltinBindingTargetKind::Zparseopts,
..
}
));
let assoc = target("assoc");
assert_eq!(assoc.span.slice(source), "assoc");
assert!(assoc.attributes.contains(BindingAttributes::ARRAY));
assert!(assoc.attributes.contains(BindingAttributes::ASSOC));
for name in ["help", "verbose"] {
let binding = target(name);
assert_eq!(binding.span.slice(source), name);
assert!(binding.attributes.contains(BindingAttributes::ARRAY));
assert!(!binding.attributes.contains(BindingAttributes::ASSOC));
}
}
#[test]
fn compinit_initializes_completion_tables_in_zsh() {
let source = "\
#!/bin/zsh
autoload -Uz compinit
compinit
print -r -- ${_comps[(I)-value-*]}
";
let model = model_with_dialect(source, ShellDialect::Zsh);
let binding = binding_for_name(&model, "_comps");
assert!(matches!(binding.kind, BindingKind::Imported));
assert!(binding.attributes.contains(BindingAttributes::ARRAY));
assert!(binding.attributes.contains(BindingAttributes::ASSOC));
assert!(matches!(
binding.origin,
BindingOrigin::BuiltinTarget {
kind: BuiltinBindingTargetKind::Compinit,
..
}
));
assert_eq!(binding.references.len(), 1);
assert_eq!(
model.reference(binding.references[0]).name.as_str(),
"_comps"
);
assert_names_absent(&["_comps"], &uninitialized_names(&model));
}
#[test]
fn zparseopts_attached_and_separator_targets_are_precise() {
let source = "zparseopts -aall -Aassoc -- -long=long_target s=short_target\n";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let targets = model
.bindings()
.iter()
.filter(|binding| matches!(binding.kind, BindingKind::ZparseoptsTarget))
.map(|binding| {
(
binding.name.as_str().to_owned(),
binding.span.slice(source).to_owned(),
binding.attributes.contains(BindingAttributes::ASSOC),
)
})
.collect::<Vec<_>>();
assert_eq!(
targets,
vec![
("all".to_owned(), "all".to_owned(), false),
("assoc".to_owned(), "assoc".to_owned(), true),
("long_target".to_owned(), "long_target".to_owned(), false),
("short_target".to_owned(), "short_target".to_owned(), false),
]
);
}
#[test]
fn zparseopts_control_options_are_not_stacked() {
let source = "zparseopts -- -DEK=dest\n";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let binding = model
.bindings()
.iter()
.find(|binding| {
binding.name == "dest" && matches!(binding.kind, BindingKind::ZparseoptsTarget)
})
.expect("expected -DEK spec target");
assert_eq!(binding.span.slice(source), "dest");
assert!(binding.attributes.contains(BindingAttributes::ARRAY));
}
#[test]
fn zparseopts_escaped_equals_in_spec_name_is_not_a_target_separator() {
let source = "zparseopts -a opts -- foo\\=bar foo\\=baz=dest\n";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let targets = model
.bindings()
.iter()
.filter(|binding| matches!(binding.kind, BindingKind::ZparseoptsTarget))
.map(|binding| binding.name.as_str().to_owned())
.collect::<Vec<_>>();
assert_eq!(targets, vec!["opts".to_owned(), "dest".to_owned()]);
}
#[test]
fn zparseopts_mapping_does_not_bind_targets_that_name_specs() {
let source = "zparseopts -A bar -M a=foo b+: c:=b\n";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let targets = model
.bindings()
.iter()
.filter(|binding| matches!(binding.kind, BindingKind::ZparseoptsTarget))
.map(|binding| {
(
binding.name.as_str().to_owned(),
binding.attributes.contains(BindingAttributes::ASSOC),
)
})
.collect::<Vec<_>>();
assert_eq!(
targets,
vec![("bar".to_owned(), true), ("foo".to_owned(), false)]
);
}
#[test]
fn zparseopts_dynamic_targets_do_not_create_static_bindings() {
let source = "zparseopts -a$aggregate -- x=$target\n";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
assert!(
!model
.bindings()
.iter()
.any(|binding| matches!(binding.kind, BindingKind::ZparseoptsTarget))
);
}
#[test]
fn zparseopts_targets_are_zsh_only() {
let source = "zparseopts -- x=target\n";
let model = model_with_dialect(source, ShellDialect::Bash);
assert!(
!model
.bindings()
.iter()
.any(|binding| matches!(binding.kind, BindingKind::ZparseoptsTarget))
);
}
#[test]
fn zsh_function_reply_assignment_is_marked_externally_consumed() {
let source = "\
#!/bin/zsh
helper() {
REPLY=value
}
";
let model = model_with_dialect(source, ShellDialect::Zsh);
let binding = binding_for_name(&model, "REPLY");
assert!(
binding
.attributes
.contains(BindingAttributes::EXTERNALLY_CONSUMED),
"{binding:?}"
);
assert!(
!binding_names(&model, model.analysis().unused_assignments()).contains(&"REPLY".to_owned())
);
}
#[test]
fn zsh_local_reply_assignment_stays_local() {
let source = "\
#!/bin/zsh
helper() {
local REPLY=value
}
";
let model = model_with_dialect(source, ShellDialect::Zsh);
let binding = binding_for_name(&model, "REPLY");
assert!(
!binding
.attributes
.contains(BindingAttributes::EXTERNALLY_CONSUMED)
);
}
#[test]
fn zsh_reply_assignment_stays_local_until_local_is_cleared() {
let source = "\
#!/bin/zsh
helper() {
local REPLY
REPLY=value
unset REPLY
}
";
let model = model_with_dialect(source, ShellDialect::Zsh);
let reply_bindings = model.bindings_for(&Name::from("REPLY"));
let assignment = model.binding(*reply_bindings.last().expect("assignment binding"));
assert!(
!assignment
.attributes
.contains(BindingAttributes::EXTERNALLY_CONSUMED)
);
assert!(
binding_names(&model, model.analysis().unused_assignments()).contains(&"REPLY".to_owned())
);
}
#[test]
fn mapfile_missing_option_operand_does_not_panic() {
let source = "mapfile -u\n";
let model = model(source);
assert!(
!model
.bindings()
.iter()
.any(|binding| matches!(binding.kind, BindingKind::MapfileTarget))
);
}
#[test]
fn read_header_bindings_consumed_in_loop_body_are_live() {
let source = "\
printf '%s\n' 'service safe ok yes' | while read UNIT EXPOSURE PREDICATE HAPPY; do
printf '%s %s %s %s\n' \"$UNIT\" \"$EXPOSURE\" \"$PREDICATE\" \"$HAPPY\"
done
";
let model = model(source);
let unused = reportable_unused_names(&model);
for name in ["UNIT", "EXPOSURE", "PREDICATE", "HAPPY"] {
assert!(
!unused.contains(&Name::from(name)),
"unused bindings: {:?}",
unused
);
}
}
#[test]
fn command_prefix_assignments_do_not_create_shell_bindings() {
let source = "\
base_flags=1
CFLAGS=\"$base_flags\" make
echo \"$CFLAGS\"
";
let model = model(source);
assert!(
model
.bindings()
.iter()
.all(|binding| binding.name != "CFLAGS")
);
let cflags_reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "CFLAGS")
.unwrap();
assert!(model.resolved_binding(cflags_reference.id).is_none());
assert!(model.unresolved_references().contains(&cflags_reference.id));
}
#[test]
fn indirect_expansion_keeps_dynamic_target_arrays_live() {
let source = "\
#!/bin/bash
apache_args=(--apache)
nginx_args=(--nginx)
apache_args+=(--common)
nginx_args+=(--common)
web_server=apache
args_var=\"${web_server}_args[@]\"
printf '%s\\n' \"${!args_var}\"
";
let model = model(source);
model.analysis().dataflow();
let unused = model
.analysis()
.unused_assignments()
.iter()
.map(|binding| model.binding(*binding).name.as_str())
.collect::<Vec<_>>();
assert!(!unused.contains(&"apache_args"));
assert!(!unused.contains(&"nginx_args"));
let carrier = model
.bindings()
.iter()
.find(|binding| binding.name == "args_var")
.unwrap();
let reference = model
.references()
.iter()
.find(|reference| {
reference.kind == ReferenceKind::IndirectExpansion && reference.name == "args_var"
})
.unwrap();
let mut carrier_targets = binding_names(&model, model.indirect_targets_for_binding(carrier.id));
carrier_targets.sort();
carrier_targets.dedup();
assert_eq!(carrier_targets, vec!["apache_args", "nginx_args"]);
let mut reference_targets =
binding_names(&model, model.indirect_targets_for_reference(reference.id));
reference_targets.sort();
reference_targets.dedup();
assert_eq!(reference_targets, vec!["apache_args", "nginx_args"]);
}
#[test]
fn append_assignments_contribute_to_later_array_expansion() {
let source = "\
#!/bin/bash
arr=(--first)
arr+=(--second)
printf '%s\\n' \"${arr[@]}\"
";
let model = model(source);
model.analysis().dataflow();
let unused = model
.analysis()
.unused_assignments()
.iter()
.map(|binding| model.binding(*binding).name.as_str())
.collect::<Vec<_>>();
assert!(!unused.contains(&"arr"));
}
#[test]
fn process_substitution_reads_keep_outer_assignments_live() {
let source = "\
#!/bin/bash
f() {
local opts
case \"$1\" in
a) opts=alpha ;;
*) opts=beta ;;
esac
while IFS= read -r line; do :; done < <(printf '%s\\n' \"$opts\")
}
f a
";
let model = model(source);
let unused = reportable_unused_names(&model);
assert!(
!unused.contains(&Name::from("opts")),
"unused: {:?}",
unused
);
assert!(unused.contains(&Name::from("line")), "unused: {:?}", unused);
}
#[test]
fn overwritten_empty_initializers_do_not_report_the_placeholder_assignment() {
let plain = model(
"\
#!/bin/bash
foo=
foo=bar
",
);
let plain_unused = plain.analysis().unused_assignments().to_vec();
assert_eq!(plain_unused.len(), 1);
assert_eq!(plain.binding(plain_unused[0]).span.start.line, 3);
let local = model(
"\
#!/bin/bash
f() {
local foo=
foo=bar
}
f
",
);
let local_unused = local.analysis().unused_assignments().to_vec();
assert_eq!(local_unused.len(), 1);
assert_eq!(local.binding(local_unused[0]).span.start.line, 4);
}
#[test]
fn associative_compound_declaration_marks_binding_assoc_and_array() {
let model = model("#!/bin/bash\ndeclare -A assoc=(one [foo]=bar [bar]+=baz)\n");
let assoc = model
.bindings()
.iter()
.find(|binding| binding.name == "assoc")
.expect("expected assoc binding");
assert!(assoc.attributes.contains(BindingAttributes::ARRAY));
assert!(assoc.attributes.contains(BindingAttributes::ASSOC));
}
#[test]
fn read_implicitly_consumes_visible_ifs_binding() {
let source = "\
#!/bin/bash
f() {
local IFS=$'\\n'
local unused=1
read -d '' -ra reply < <(printf 'alpha\\nbeta\\0')
printf '%s\\n' \"${reply[@]}\"
}
f
";
let model = model(source);
model.analysis().dataflow();
assert!(model.references().iter().any(|reference| {
reference.name == "IFS" && matches!(reference.kind, ReferenceKind::ImplicitRead)
}));
let unused = model
.analysis()
.unused_assignments()
.iter()
.map(|binding| model.binding(*binding).name.as_str())
.collect::<Vec<_>>();
assert!(!unused.contains(&"IFS"));
assert!(unused.contains(&"unused"));
}
#[test]
fn reaching_bindings_lookup_picks_later_same_name_reference() {
let source = "\
#!/bin/bash
foo=1
printf '%s\\n' \"$foo\"
foo=2
printf '%s\\n' \"$foo\"
";
let model = model(source);
let foo_bindings = model
.bindings()
.iter()
.filter(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
.map(|binding| binding.id)
.collect::<Vec<_>>();
assert_eq!(foo_bindings.len(), 2);
let target_reference = model
.references()
.iter()
.rev()
.find(|reference| reference.name == "foo")
.expect("expected foo reference");
let reaching = model
.analysis()
.reaching_bindings_for_name(&target_reference.name, target_reference.span);
assert_eq!(reaching, vec![foo_bindings[1]]);
}
#[test]
fn semantic_analysis_exposes_binding_and_reference_flow_blocks() {
let source = "\
#!/bin/bash
foo=1
printf '%s\\n' \"$foo\"
";
let model = model(source);
let binding = model
.bindings()
.iter()
.find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
.expect("expected foo binding");
let reference = model
.references()
.iter()
.find(|reference| reference.name == "foo")
.expect("expected foo reference");
let analysis = model.analysis();
assert_eq!(
analysis.reference_id_for_name_at(&reference.name, reference.span),
Some(reference.id)
);
assert!(analysis.block_for_binding(binding.id).is_some());
assert!(analysis.block_for_reference_id(reference.id).is_some());
assert!(analysis.binding_dominates_reference_from_flow_entry(
binding.id,
&reference.name,
reference.span,
true
));
}
#[test]
fn semantic_block_coverage_detects_uncovered_branch_paths() {
let source = "\
#!/bin/bash
if cond; then
foo=1
fi
printf '%s\\n' \"$foo\"
";
let model = model(source);
let binding = model
.bindings()
.iter()
.find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
.expect("expected foo binding");
let reference = model
.references()
.iter()
.find(|reference| reference.name == "foo")
.expect("expected foo reference");
let analysis = model.analysis();
let binding_block = analysis
.block_for_binding(binding.id)
.expect("expected binding block");
let reference_block = analysis
.block_for_reference_id(reference.id)
.expect("expected reference block");
let entry =
analysis.flow_entry_block_for_binding_scopes(&[binding.scope], reference.span.start.offset);
let cover_blocks = FxHashSet::from_iter([binding_block]);
assert!(!analysis.blocks_cover_all_paths_to_block(entry, reference_block, &cover_blocks));
assert!(!analysis.binding_dominates_reference_from_flow_entry(
binding.id,
&reference.name,
reference.span,
true
));
}
#[test]
fn value_flow_returns_real_reference_reaching_value_bindings() {
let source = "\
#!/bin/bash
foo=1
foo=2
printf '%s\\n' \"$foo\"
";
let model = model(source);
let bindings = model
.bindings()
.iter()
.filter(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
.map(|binding| binding.id)
.collect::<Vec<_>>();
let reference = model
.references()
.iter()
.find(|reference| reference.name == "foo")
.expect("expected foo reference");
let analysis = model.analysis();
let value_flow = analysis.value_flow();
assert_eq!(
value_flow.reaching_value_bindings_for_name(&reference.name, reference.span),
vec![bindings[1]]
);
}
#[test]
fn value_flow_returns_synthetic_use_site_value_bindings() {
let source = "\
#!/bin/bash
foo=1
if cond; then
foo=2
fi
printf done
";
let model = model(source);
let printf = command_id_starting_with(&model, source, "printf").expect("expected printf");
let bindings = model
.bindings()
.iter()
.filter(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
.map(|binding| binding.id)
.collect::<Vec<_>>();
let analysis = model.analysis();
let value_flow = analysis.value_flow();
assert_eq!(
value_flow.reaching_value_bindings_for_name(&Name::from("foo"), model.command_span(printf)),
bindings
);
}
#[test]
fn value_flow_can_bypass_one_reaching_binding() {
let source = "\
#!/bin/bash
foo=1
if cond; then
foo=2
fi
printf '%s\\n' \"$foo\"
";
let model = model(source);
let bindings = model
.bindings()
.iter()
.filter(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
.map(|binding| binding.id)
.collect::<Vec<_>>();
let reference = model
.references()
.iter()
.find(|reference| reference.name == "foo")
.expect("expected foo reference");
let analysis = model.analysis();
let value_flow = analysis.value_flow();
assert_eq!(
value_flow.reaching_value_bindings_bypassing(&reference.name, bindings[1], reference.span),
vec![bindings[0]]
);
}
#[test]
fn value_flow_returns_ancestor_bindings_before_scope_site() {
let source = "\
#!/bin/bash
foo=1
use() {
printf '%s\\n' \"$foo\"
}
";
let model = model(source);
let binding = model
.bindings()
.iter()
.find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
.expect("expected foo binding");
let reference = model
.references()
.iter()
.find(|reference| reference.name == "foo")
.expect("expected foo reference");
let analysis = model.analysis();
let value_flow = analysis.value_flow();
assert_eq!(
value_flow.ancestor_value_bindings_before(&reference.name, reference.scope, reference.span),
vec![binding.id]
);
}
#[test]
fn value_flow_returns_nonlocal_bindings_from_called_functions() {
let source = "\
#!/bin/bash
setfoo() {
foo=1
}
use() {
setfoo
printf '%s\\n' \"$foo\"
}
";
let model = model(source);
let binding = model
.bindings()
.iter()
.find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
.expect("expected foo binding");
let reference = model
.references()
.iter()
.find(|reference| reference.name == "foo")
.expect("expected foo reference");
let analysis = model.analysis();
let mut value_flow = analysis.value_flow();
assert_eq!(
value_flow.nonlocal_value_bindings_from_called_functions_before(
&reference.name,
reference.scope,
reference.span,
),
vec![binding.id]
);
}
#[test]
fn value_flow_ignores_conditionally_installed_called_functions() {
let source = "\
#!/bin/bash
if cond; then
setfoo() {
foo=1
}
fi
use() {
setfoo
printf '%s\\n' \"$foo\"
}
";
let model = model(source);
let reference = model
.references()
.iter()
.find(|reference| reference.name == "foo")
.expect("expected foo reference");
let analysis = model.analysis();
let mut value_flow = analysis.value_flow();
assert!(
value_flow
.nonlocal_value_bindings_from_called_functions_before(
&reference.name,
reference.scope,
reference.span,
)
.is_empty()
);
}
#[test]
fn value_flow_uses_visible_top_level_functions_from_function_bodies() {
let source = "\
#!/bin/bash
use() {
setfoo
printf '%s\\n' \"$foo\"
}
setfoo() {
foo=1
}
use
";
let model = model(source);
let binding = model
.bindings()
.iter()
.find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
.expect("expected foo binding");
let reference = model
.references()
.iter()
.find(|reference| reference.name == "foo")
.expect("expected foo reference");
let analysis = model.analysis();
let mut value_flow = analysis.value_flow();
assert_eq!(
value_flow.nonlocal_value_bindings_from_called_functions_before(
&reference.name,
reference.scope,
reference.span,
),
vec![binding.id]
);
}
#[test]
fn value_flow_resolves_multi_name_function_alias_fallbacks() {
let source = "\
#!/bin/zsh
use() {
itunes
print $foo
}
function music itunes() {
foo=1
}
use
";
let model = model_with_dialect(source, ShellDialect::Zsh);
let binding = model
.bindings()
.iter()
.find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
.expect("expected foo binding");
let reference = model
.references()
.iter()
.find(|reference| reference.name == "foo")
.expect("expected foo reference");
let analysis = model.analysis();
let mut value_flow = analysis.value_flow();
assert_eq!(
value_flow.nonlocal_value_bindings_from_called_functions_before(
&reference.name,
reference.scope,
reference.span,
),
vec![binding.id]
);
}
#[test]
fn value_flow_returns_helper_bindings_visible_at_reference() {
let source = "\
#!/bin/bash
setfoo() {
foo=1
}
use() {
setfoo
printf '%s\\n' \"$foo\"
}
";
let model = model(source);
let binding = model
.bindings()
.iter()
.find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
.expect("expected foo binding");
let reference = model
.references()
.iter()
.find(|reference| reference.name == "foo")
.expect("expected foo reference");
let analysis = model.analysis();
let mut value_flow = analysis.value_flow();
assert_eq!(
value_flow.helper_value_bindings_before(&reference.name, reference.span),
vec![binding.id]
);
}
#[test]
fn value_flow_named_function_call_sites_resolve_alias_fallbacks() {
let source = "\
#!/bin/zsh
use() {
itunes
print $foo
}
function music itunes() {
foo=1
}
use
";
let model = model_with_dialect(source, ShellDialect::Zsh);
let helper_scope = model
.bindings()
.iter()
.find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
.map(|binding| binding.scope)
.expect("expected helper scope");
let analysis = model.analysis();
let value_flow = analysis.value_flow();
let call_sites = value_flow.named_function_call_sites(helper_scope);
assert_eq!(call_sites.len(), 1);
assert_eq!(call_sites[0].callee.as_str(), "itunes");
}
#[test]
fn value_flow_tracks_transitive_called_function_scopes_before() {
let source = "\
#!/bin/bash
first() {
second
}
second() {
foo=1
}
first
printf '%s\\n' \"$foo\"
";
let model = model(source);
let reference = model
.references()
.iter()
.find(|reference| reference.name == "foo")
.expect("expected foo reference");
let helper_scope = model
.bindings()
.iter()
.find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
.map(|binding| binding.scope)
.expect("expected helper scope");
let analysis = model.analysis();
let value_flow = analysis.value_flow();
let caller_scope = model.scope_at(reference.span.start.offset);
let callee_scopes = value_flow
.transitively_called_function_scopes_before(caller_scope, reference.span.start.offset);
assert!(callee_scopes.contains(&helper_scope));
}
#[test]
fn value_flow_uses_path_covering_alternate_function_definitions() {
let source = "\
#!/bin/bash
if cond; then
setfoo() {
foo=1
}
else
setfoo() {
foo=2
}
fi
use() {
setfoo
printf '%s\\n' \"$foo\"
}
";
let model = model(source);
let bindings = model
.bindings()
.iter()
.filter(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
.map(|binding| binding.id)
.collect::<Vec<_>>();
let reference = model
.references()
.iter()
.find(|reference| reference.name == "foo")
.expect("expected foo reference");
let analysis = model.analysis();
let mut value_flow = analysis.value_flow();
assert_eq!(
value_flow.nonlocal_value_bindings_from_called_functions_before(
&reference.name,
reference.scope,
reference.span,
),
bindings
);
}
#[test]
fn value_flow_detects_binding_paths_do_not_cover_span() {
let source = "\
#!/bin/bash
if cond; then
foo=1
fi
printf '%s\\n' \"$foo\"
";
let model = model(source);
let binding = model
.bindings()
.iter()
.find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
.expect("expected foo binding");
let printf = command_id_starting_with(&model, source, "printf").expect("expected printf");
let analysis = model.analysis();
let value_flow = analysis.value_flow();
assert!(
!value_flow
.value_bindings_cover_all_paths_to_span(&[binding.id], model.command_span(printf))
);
}
#[test]
fn ifs_assignments_are_treated_as_implicitly_used() {
let source = "\
#!/bin/bash
IFS=$'\\n\\t'
unused=1
echo ok
";
let model = model(source);
model.analysis().dataflow();
let unused = model
.analysis()
.unused_assignments()
.iter()
.map(|binding| model.binding(*binding).name.as_str())
.collect::<Vec<_>>();
assert!(!unused.contains(&"IFS"));
assert!(unused.contains(&"unused"));
}
#[test]
fn shell_runtime_assignments_are_treated_as_implicitly_used() {
let source = "\
#!/bin/sh
PATH=$PATH:/opt/custom
CDPATH=/tmp
LANG=C
LC_ALL=C
LC_TIME=C
unused=1
echo ok
";
let model = model(source);
model.analysis().dataflow();
let unused = model
.analysis()
.unused_assignments()
.iter()
.map(|binding| model.binding(*binding).name.as_str())
.collect::<Vec<_>>();
for name in ["PATH", "CDPATH", "LANG", "LC_ALL", "LC_TIME"] {
assert!(!unused.contains(&name), "unused bindings: {:?}", unused);
}
assert!(unused.contains(&"unused"));
}
#[test]
fn special_runtime_assignments_are_treated_as_implicitly_used() {
let source = "\
#!/bin/bash
HOME=/tmp/home
SHELL=/bin/bash
TERM=xterm-256color
USER=builder
PWD=/tmp/work
HISTFILE=/tmp/history
HISTFILESIZE=unlimited
HISTIGNORE='ls:bg:fg:history'
HISTSIZE=-1
HISTTIMEFORMAT='%F %T '
COMP_WORDBREAKS=\"${COMP_WORDBREAKS//:/}\"
PROMPT_COMMAND='history -a'
BASH_ENV=/dev/null
BASH_XTRACEFD=9
ENV=/dev/null
INPUTRC=/tmp/inputrc
MAIL=/tmp/mail
OLDPWD=/tmp/old
PROMPT_DIRTRIM=2
SECONDS=0
TIMEFORMAT='%R'
TMOUT=30
PS1='prompt> '
PS2='continuation> '
PS3=''
PS4='+ '
COLUMNS=1
READLINE_POINT=0
unused=1
";
let model = model(source);
model.analysis().dataflow();
let unused = model
.analysis()
.unused_assignments()
.iter()
.map(|binding| model.binding(*binding).name.as_str())
.collect::<Vec<_>>();
for name in [
"HOME",
"SHELL",
"TERM",
"USER",
"PWD",
"OPTIND",
"OPTARG",
"OPTERR",
"HISTFILE",
"HISTFILESIZE",
"HISTIGNORE",
"HISTSIZE",
"HISTTIMEFORMAT",
"COMP_WORDBREAKS",
"PROMPT_COMMAND",
"BASH_ENV",
"BASH_XTRACEFD",
"ENV",
"INPUTRC",
"MAIL",
"OLDPWD",
"PROMPT_DIRTRIM",
"SECONDS",
"TIMEFORMAT",
"TMOUT",
"PS1",
"PS2",
"PS3",
"PS4",
"COLUMNS",
"READLINE_POINT",
] {
assert!(!unused.contains(&name), "unused bindings: {:?}", unused);
}
assert!(unused.contains(&"unused"));
}
#[test]
fn escaped_ps4_prompt_references_are_read_at_the_assignment_site() {
let source = "\
#!/bin/bash
export PS4=\"+ \\${BASH_SOURCE##\\${rvm_path:-}} > \"
p=\"$rvm_path\"
";
let model = model(source);
let analysis = model.analysis();
let prompt_reference = analysis
.uninitialized_references()
.iter()
.map(|uninitialized| model.reference(uninitialized.reference))
.find(|reference| reference.name == "rvm_path" && reference.span.slice(source) == "PS4");
assert!(prompt_reference.is_some());
}
#[test]
fn trap_action_references_are_read_at_the_action_word() {
let source = "\
#!/bin/sh
tmpdir=/tmp/example
trap 'ret=$?; rmdir \"$tmpdir/d\" \"$tmpdir\" 2>/dev/null; exit $ret' 0
";
let model = model(source);
let analysis = model.analysis();
let trap_reference = analysis
.uninitialized_references()
.iter()
.map(|uninitialized| model.reference(uninitialized.reference))
.find(|reference| {
reference.name == "ret"
&& reference.kind == ReferenceKind::TrapAction
&& reference.span.slice(source)
== "'ret=$?; rmdir \"$tmpdir/d\" \"$tmpdir\" 2>/dev/null; exit $ret'"
});
assert!(trap_reference.is_some());
}
#[test]
fn bash_completion_runtime_vars_are_treated_as_live() {
let source = "\
#!/bin/bash
_pyenv() {
COMPREPLY=()
local word=\"${COMP_WORDS[COMP_CWORD]}\"
COMPREPLY=( $(compgen -W \"$(printf 'a b')\" -- \"$word\") )
}
complete -F _pyenv pyenv
";
let model = model(source);
model.analysis().dataflow();
let unused = model
.analysis()
.unused_assignments()
.iter()
.map(|binding| model.binding(*binding).name.as_str())
.collect::<Vec<_>>();
assert!(!unused.contains(&"COMPREPLY"));
let uninitialized = uninitialized_names(&model);
assert!(!uninitialized.contains(&"COMP_WORDS".to_string()));
assert!(!uninitialized.contains(&"COMP_CWORD".to_string()));
}
#[test]
fn exact_indirect_expansion_does_not_keep_unrelated_array_live() {
let source = "\
#!/bin/bash
apache_args=(--apache)
unused_args=(--unused)
args_var=apache_args[@]
printf '%s\\n' \"${!args_var}\"
";
let model = model(source);
model.analysis().dataflow();
let unused = model
.analysis()
.unused_assignments()
.iter()
.map(|binding| model.binding(*binding).name.as_str())
.collect::<Vec<_>>();
assert!(!unused.contains(&"apache_args"));
assert!(unused.contains(&"unused_args"));
let reference = model
.references()
.iter()
.find(|reference| {
reference.kind == ReferenceKind::IndirectExpansion && reference.name == "args_var"
})
.unwrap();
let targets = binding_names(&model, model.indirect_targets_for_reference(reference.id));
assert_eq!(targets, vec!["apache_args"]);
}
#[test]
fn exact_indirect_target_resolution_tracks_underlying_binding() {
let source = "\
#!/bin/bash
target=ok
name=target
printf '%s\\n' \"${!name}\"
";
let model = model(source);
let carrier = model
.bindings()
.iter()
.find(|binding| binding.name == "name")
.unwrap();
let reference = model
.references()
.iter()
.find(|reference| {
reference.kind == ReferenceKind::IndirectExpansion && reference.name == "name"
})
.unwrap();
assert_eq!(
binding_names(&model, model.indirect_targets_for_binding(carrier.id)),
vec!["target"]
);
assert_eq!(
binding_names(&model, model.indirect_targets_for_reference(reference.id)),
vec!["target"]
);
}
#[test]
fn unused_assignment_flags_indirect_only_targets_by_default() {
let source = "\
#!/bin/bash
target=ok
name=target
other=1
printf '%s\\n' \"${!name}\"
";
let model = model(source);
let default_unused = binding_names(&model, model.analysis().unused_assignments());
let live_indirect_target_unused = binding_names(
&model,
model
.analysis()
.unused_assignments_with_options(UnusedAssignmentAnalysisOptions {
treat_indirect_expansion_targets_as_used: true,
report_unreachable_assignments: false,
}),
);
assert_eq!(default_unused, vec!["target", "other"]);
assert_eq!(live_indirect_target_unused, vec!["other"]);
}
#[test]
fn unused_assignment_options_keep_array_like_indirect_targets_live() {
let source = "\
#!/bin/bash
apache_args=(--apache)
unused_args=(--unused)
args_var=apache_args[@]
printf '%s\\n' \"${!args_var}\"
";
let model = model(source);
let default_unused = binding_names(&model, model.analysis().unused_assignments());
let compat_unused = binding_names(
&model,
model
.analysis()
.unused_assignments_with_options(UnusedAssignmentAnalysisOptions {
treat_indirect_expansion_targets_as_used: false,
report_unreachable_assignments: false,
}),
);
assert_eq!(default_unused, vec!["unused_args"]);
assert_eq!(compat_unused, vec!["unused_args"]);
}
#[test]
fn unused_assignment_options_preserve_heuristic_fast_path_without_indirect_targets() {
let model = model("unused=1\n");
let analysis = model.analysis();
let compat_unused = analysis
.unused_assignments_with_options(UnusedAssignmentAnalysisOptions {
treat_indirect_expansion_targets_as_used: false,
report_unreachable_assignments: false,
})
.to_vec();
assert!(analysis.exact_variable_dataflow.get().is_none());
assert!(analysis.dataflow.get().is_none());
assert_eq!(binding_names(&model, &compat_unused), vec!["unused"]);
}
#[test]
fn call_graph_and_zsh_option_analysis_are_lazy_until_queried() {
let model = model_with_profile("echo hi\n", ShellProfile::native(ShellDialect::Zsh));
assert!(model.call_graph.get().is_none());
assert!(model.zsh_option_analysis.get().is_none());
model.call_graph();
assert!(model.call_graph.get().is_some());
assert!(model.zsh_option_analysis.get().is_none());
model.zsh_options_at(0);
assert!(model.zsh_option_analysis.get().is_some());
}
#[test]
fn unused_assignment_options_can_report_unreachable_assignments() {
let source = "\
#!/bin/bash
f() {
return 1
dead=1
used=1
printf '%s\\n' \"$used\"
}
f
";
let model = model(source);
let default_unused = binding_names(&model, model.analysis().unused_assignments());
let compat_unused = binding_names(
&model,
model
.analysis()
.unused_assignments_with_options(UnusedAssignmentAnalysisOptions {
treat_indirect_expansion_targets_as_used: false,
report_unreachable_assignments: true,
}),
);
assert!(!default_unused.iter().any(|name| name == "dead"));
assert_eq!(compat_unused, vec!["dead"]);
}
#[test]
fn resolved_indirect_expansion_carrier_is_not_marked_uninitialized() {
let source = "\
#!/bin/bash
f() {
local carrier
echo \"${!carrier}\"
}
f
";
let model = model(source);
assert!(uninitialized_names(&model).is_empty());
}
#[test]
fn prefix_name_expansions_do_not_read_the_prefix_as_a_variable() {
let source = "unset \"${!completion_prefix@}\"\nprintf '%s\\n' \"$ordinary_missing\"\n";
let model = model(source);
let uninitialized = uninitialized_names(&model);
assert_names_absent(&["completion_prefix"], &uninitialized);
assert_names_present(&["ordinary_missing"], &uninitialized);
}
#[test]
fn guarded_parameter_expansions_are_not_marked_uninitialized() {
let source = "\
printf '%s\\n' \
\"${missing_default:-fallback}\" \
\"${missing_assign:=value}\" \
\"${missing_replace:+alt}\" \
\"${missing_error:?missing}\"
";
let model = model(source);
let unresolved = unresolved_names(&model);
let uninitialized = uninitialized_names(&model);
assert_names_present(
&[
"missing_default",
"missing_assign",
"missing_replace",
"missing_error",
],
&unresolved,
);
assert_names_absent(
&[
"missing_default",
"missing_assign",
"missing_replace",
"missing_error",
],
&uninitialized,
);
}
#[test]
fn unquoted_guarded_parameter_expansions_are_not_marked_uninitialized() {
let source = "\
eval start-stop-daemon --start \\
${directory:+--chdir} $directory
";
let model = model(source);
let uninitialized = uninitialized_names(&model);
assert_names_absent(&["directory"], &uninitialized);
}
#[test]
fn self_referential_assignments_are_not_marked_uninitialized() {
let source = "\
foo=\"$foo\"
for flag in a b; do
valid_flags=\"${valid_flags} $flag\"
done
foo[$foo]=x
";
let model = model(source);
let uninitialized = uninitialized_names(&model);
assert_names_absent(&["foo", "valid_flags"], &uninitialized);
}
#[test]
fn escaped_declaration_builtins_initialize_dynamic_assignment_operands() {
let source = "\
\\typeset ret=$?
printf '%s\\n' \"$ret\"
";
let model = model_with_dialect(source, ShellDialect::Bash);
let uninitialized = uninitialized_names(&model);
assert_names_absent(&["ret"], &uninitialized);
}
#[test]
fn arithmetic_conditional_literal_operands_are_uninitialized_reads() {
let source = "\
version=1
if [[ $version -eq \"latest\" ]]; then
:
fi
if [[ 1 -ne bare ]]; then
:
fi
";
let model = model(source);
let uninitialized = uninitialized_names(&model);
assert_names_present(&["latest", "bare"], &uninitialized);
}
#[test]
fn let_arithmetic_assignments_initialize_targets() {
let source = "\
#!/bin/bash
let line=\"$number\"+1
printf '%s\\n' \"$line\"
";
let model = model_with_dialect(source, ShellDialect::Bash);
let uninitialized = uninitialized_names(&model);
assert_names_absent(&["line"], &uninitialized);
assert_names_present(&["number"], &uninitialized);
}
#[test]
fn assignment_values_continue_after_escaped_newlines() {
let source = "\
#!/bin/sh
easyrsa_ksh=\\
'value'
[ \"${KSH_VERSION}\" = \"${easyrsa_ksh}\" ] && echo ok
";
let model = model(source);
let uninitialized = uninitialized_names(&model);
assert_names_absent(&["easyrsa_ksh"], &uninitialized);
}
#[test]
fn special_zero_prefix_removal_inside_escaped_quotes_does_not_synthesize_empty_references() {
let source = "\
#!/bin/bash
usage=\"
Terraform:
data \\\"external\\\" \\\"github_repos\\\" {
program = [\\\"/path/to/${0##*/}\\\", \\\"github_repository\\\"]
}
\"
";
let model = model_with_dialect(source, ShellDialect::Bash);
let uninitialized = uninitialized_names(&model);
assert_names_absent(&[""], &uninitialized);
}
#[test]
fn unparsed_indexed_subscript_prefixes_are_uninitialized_reads() {
let source = "\
arr+=([docker:dind]=x [nats-streaming:nanoserver]=y)
";
let model = model(source);
let uninitialized = uninitialized_names(&model);
assert_names_present(&["docker", "nats", "streaming"], &uninitialized);
assert_names_absent(&["dind", "nanoserver"], &uninitialized);
}
#[test]
fn escaped_heredoc_parameter_literals_still_expand_nested_references() {
let source = "\
cat <<EOF
\\${OUTER:-$inner}
EOF
";
let model = model(source);
let uninitialized = uninitialized_names(&model);
assert_names_present(&["inner"], &uninitialized);
assert_names_absent(&["OUTER"], &uninitialized);
}
#[test]
fn assign_default_and_error_operands_are_marked_uninitialized() {
let source = "\
printf '%s\\n' \
\"${missing_default:-$fallback_name}\" \
\"${missing_assign:=$seed_name}\" \
\"${missing_replace:+$replacement_name}\" \
\"${missing_error:?$hint_name}\"
";
let model = model(source);
let unresolved = unresolved_names(&model);
let uninitialized = uninitialized_names(&model);
assert_names_present(
&[
"fallback_name",
"seed_name",
"replacement_name",
"hint_name",
],
&unresolved,
);
assert_names_absent(&["fallback_name", "replacement_name"], &uninitialized);
assert_names_present(&["seed_name", "hint_name"], &uninitialized);
}
#[test]
fn parameter_slice_arithmetic_operands_are_not_uninitialized() {
let source = "\
value=abcdef
printf '%s\\n' \"${value:offset}\" \"${value:1:$length}\"
";
let model = model(source);
let uninitialized = uninitialized_names(&model);
assert_names_absent(&["offset", "length"], &uninitialized);
assert!(model.references().iter().any(|reference| {
reference.kind == ReferenceKind::ParameterSliceArithmetic && reference.name == "offset"
}));
assert!(model.references().iter().any(|reference| {
reference.kind == ReferenceKind::ParameterSliceArithmetic && reference.name == "length"
}));
}
#[test]
fn defaulting_parameter_operand_references_are_marked_for_sc2154_suppression() {
let source = "\
printf '%s\\n' \
\"${missing_default:-$fallback_name}\" \
\"${missing_assign:=$seed_name}\" \
\"${missing_replace:+$replacement_name}\" \
\"${missing_error:?$hint_name}\"
";
let model = model(source);
let suppressed = model
.references()
.iter()
.filter(|reference| model.is_defaulting_parameter_operand_reference(reference.id))
.map(|reference| reference.name.as_str())
.collect::<Vec<_>>();
assert_eq!(
suppressed,
vec![
"fallback_name",
"seed_name",
"replacement_name",
"hint_name",
]
);
}
#[test]
fn guarded_or_defaulting_reference_offsets_are_grouped_by_name() {
let source = "\
printf '%s\\n' \
\"${missing_default:-$fallback_name}\" \
\"${missing_assign:=$seed_name}\" \
\"${missing_replace:+$seed_name}\" \
\"${missing_error:?$hint_name}\" \
\"${missing_error:?$hint_name}\"
";
let model = model(source);
let mut expected = FxHashMap::<String, Vec<usize>>::default();
for reference in model.references() {
if model.is_guarded_parameter_reference(reference.id)
|| model.is_defaulting_parameter_operand_reference(reference.id)
{
expected
.entry(reference.name.as_str().to_owned())
.or_default()
.push(reference.span.start.offset);
}
}
for offsets in expected.values_mut() {
offsets.sort_unstable();
offsets.dedup();
}
let actual = model
.guarded_or_defaulting_reference_offsets_by_name()
.iter()
.map(|(name, offsets)| (name.as_str().to_owned(), offsets.to_vec()))
.collect::<FxHashMap<_, _>>();
assert_eq!(actual, expected);
}
#[test]
fn function_positional_reference_summary_respects_local_transient_resets() {
let source = "\
greet() {
(
set -- inner
printf '%s\\n' \"$1\"
)
}
relay() {
printf '%s\\n' \"$@\"
}
";
let model = model(source);
let greet_use_offset = span_for_nth(source, "printf '%s\\n' \"$1\"", 0)
.start
.offset;
let relay_use_offset = span_for_nth(source, "printf '%s\\n' \"$@\"", 0)
.start
.offset;
let greet_scope = model
.enclosing_function_scope(model.scope_at(greet_use_offset))
.unwrap();
let relay_scope = model
.enclosing_function_scope(model.scope_at(relay_use_offset))
.unwrap();
let without_resets = model.function_positional_reference_summary(&FxHashMap::default());
assert_eq!(
without_resets
.get(&greet_scope)
.copied()
.unwrap()
.required_arg_count(),
1
);
assert!(
without_resets
.get(&greet_scope)
.copied()
.unwrap()
.uses_unprotected_positional_parameters()
);
assert_eq!(
without_resets
.get(&relay_scope)
.copied()
.unwrap()
.required_arg_count(),
0
);
assert!(
without_resets
.get(&relay_scope)
.copied()
.unwrap()
.uses_unprotected_positional_parameters()
);
let transient_scope = model
.innermost_transient_scope_within_function(model.scope_at(greet_use_offset))
.unwrap();
let reset_offset = span_for_nth(source, "set -- inner", 0).start.offset;
let mut local_resets = FxHashMap::default();
local_resets.insert(transient_scope, vec![reset_offset]);
let with_resets = model.function_positional_reference_summary(&local_resets);
assert!(!with_resets.contains_key(&greet_scope));
assert_eq!(
with_resets
.get(&relay_scope)
.copied()
.unwrap()
.required_arg_count(),
0
);
assert!(
with_resets
.get(&relay_scope)
.copied()
.unwrap()
.uses_unprotected_positional_parameters()
);
}
#[test]
fn branch_initialized_names_stay_initialized_inside_command_substitutions() {
let source = "\
if [ \"$1\" = h ]; then
humanreadable=-h
else
humanreadable=-m
fi
value=\"$(free ${humanreadable} | awk '{print $2}')\"
";
let model = model(source);
let uninitialized = uninitialized_names(&model);
assert_names_absent(&["humanreadable"], &uninitialized);
}
#[test]
fn assign_default_parameter_expansion_initializes_later_reads() {
let source = "\
printf '%s\\n' \"${config_path:=/tmp/default}\"
printf '%s\\n' \"$config_path\" \"$still_missing\"
";
let model = model(source);
let uninitialized = uninitialized_names(&model);
assert_names_absent(&["config_path"], &uninitialized);
assert_names_present(&["still_missing"], &uninitialized);
let binding = model
.bindings()
.iter()
.find(|binding| {
binding.name == "config_path"
&& matches!(binding.kind, BindingKind::ParameterDefaultAssignment)
})
.unwrap();
assert_eq!(binding.span.slice(source), "${config_path:=/tmp/default}");
}
#[test]
fn parameter_guard_flow_initializes_later_c006_reads() {
let source = "\
printf '%s\\n' \"${defaulted:-fallback}\"
printf '%s\\n' \"${assigned:=fallback}\"
printf '%s\\n' \"${required:?missing}\"
printf '%s\\n' \"${replacement:+alt}\"
printf '%s\\n' \"$defaulted\" \"$assigned\" \"$required\" \"$replacement\" \"$still_missing\"
";
let model = model(source);
let uninitialized = uninitialized_names(&model);
assert_names_absent(
&["defaulted", "assigned", "required", "replacement"],
&uninitialized,
);
assert_names_present(&["still_missing"], &uninitialized);
}
#[test]
fn parameter_guard_flow_respects_same_command_order() {
let source = "\
printf '%s\\n' \
\"${same_default:-fallback}\" \"$same_default\" \
\"${same_assigned:=fallback}\" \"$same_assigned\" \
\"${same_required:?missing}\" \"$same_required\" \
\"${same_replacement:+alt}\" \"$same_replacement\" \
\"$before_default\" \"${before_default:-fallback}\" \
\"$still_missing\"
";
let model = model(source);
let uninitialized = uninitialized_names(&model);
assert_names_absent(
&[
"same_default",
"same_assigned",
"same_required",
"same_replacement",
],
&uninitialized,
);
assert_names_present(&["before_default", "still_missing"], &uninitialized);
}
#[test]
fn parameter_guard_flow_does_not_escape_conditional_operands() {
let source = "\
printf '%s\\n' \"${outer:+${nested_default:-fallback}}\" \"$outer\" \"$nested_default\"
printf '%s\\n' \"${other:+${nested_replacement:+alt}}\" \"$other\" \"$nested_replacement\"
";
let model = model(source);
let uninitialized = uninitialized_names(&model);
assert_names_absent(&["outer", "other"], &uninitialized);
assert_names_present(&["nested_default", "nested_replacement"], &uninitialized);
}
#[test]
fn parameter_guard_flow_does_not_escape_short_circuit_conditionals() {
let source = "\
flag=
[[ ${left_guard:-fallback} && $flag ]]
printf '%s\\n' \"$left_guard\"
[[ $flag && ${right_and:-fallback} ]]
printf '%s\\n' \"$right_and\"
flag=1
[[ $flag || ${right_or:+alt} ]]
printf '%s\\n' \"$right_or\"
";
let model = model(source);
let uninitialized = uninitialized_names(&model);
assert_names_absent(&["left_guard"], &uninitialized);
assert_names_present(&["right_and", "right_or"], &uninitialized);
}
#[test]
fn non_assigning_parameter_guard_flow_does_not_create_bindings() {
let source = "\
: \"${defaulted:-fallback}\" \"${replacement:+alt}\"
";
let model = model(source);
assert!(model.bindings_for(&Name::from("defaulted")).is_empty());
assert!(model.bindings_for(&Name::from("replacement")).is_empty());
assert!(
model
.analysis()
.summarize_scope_provided_bindings(ScopeId(0))
.is_empty()
);
}
#[test]
fn parameter_reference_spans_exclude_escaped_quotes_in_double_quoted_strings() {
let source = "\
#!/bin/bash
rvm_info=\"
uname: \\\"${_system_info}\\\"
\"
";
let model = model(source);
let reference = model
.references()
.iter()
.find(|reference| reference.name == "_system_info")
.unwrap();
assert_eq!(reference.span.start.line, 3);
assert_eq!(reference.span.start.column, 12);
assert_eq!(reference.span.slice(source), "${_system_info}");
}
#[test]
fn parameter_reference_spans_recover_after_escaped_quotes_and_tabs_in_assignments() {
let source = "\
#!/bin/bash
physmemtotal=\"${physmemtotal//,/.}\"
payload=\"{
\t\\\"client_id\\\": \\\"${uuidinstance}\\\",
\t\\\"events\\\": [
\t\t{
\t\t\\\"name\\\": \\\"LinuxGSM\\\",
\t\t\\\"params\\\": {
\t\t\t\\\"cpuusedmhzroundup\\\": \\\"${cpuusedmhzroundup}\\\",
\t\t\t\\\"diskused\\\": \\\"${serverfilesdu}\\\",
\t\t\t}
\t\t}
\t]
}\"
";
let model = model(source);
let reference = model
.references()
.iter()
.find(|reference| reference.name == "serverfilesdu")
.unwrap();
assert_eq!(reference.span.start.line, 10);
assert_eq!(reference.span.start.column, 20);
assert_eq!(reference.span.slice(source), "${serverfilesdu}");
}
#[test]
fn unbraced_parameter_reference_spans_recover_after_escaped_quotes() {
let source = "\
#!/bin/bash
rvm_info=\"
path: \\\"$rvm_path\\\"
\"
addtimestamp=\"gawk '{ print strftime(\\\\\\\"[$logtimestampformat]\\\\\\\"), \\\\\\$0 }'\"
";
let model = model(source);
let rvm_path = model
.references()
.iter()
.find(|reference| reference.name == "rvm_path")
.unwrap();
let logtimestampformat = model
.references()
.iter()
.find(|reference| reference.name == "logtimestampformat")
.unwrap();
assert_eq!(rvm_path.span.slice(source), "$rvm_path");
assert_eq!(logtimestampformat.span.slice(source), "$logtimestampformat");
}
#[test]
fn parameter_reference_spans_include_nested_parameter_operator_suffixes() {
let source = "\
rvm_ruby_gem_home=\"${rvm_ruby_gem_home%%${rvm_gemset_separator:-\"@\"}*}\"
if [ \"${skiprdeps/${_lf}/}\" != \"${skiprdeps}\" ]; then
:
fi
";
let model = model(source);
let rvm_gem_home = model
.references()
.iter()
.find(|reference| reference.name == "rvm_ruby_gem_home")
.unwrap();
let skiprdeps = model
.references()
.iter()
.find(|reference| reference.name == "skiprdeps")
.unwrap();
assert_eq!(
rvm_gem_home.span.slice(source),
"${rvm_ruby_gem_home%%${rvm_gemset_separator:-\"@\"}*}"
);
assert_eq!(rvm_gem_home.span.end.column, 71);
assert_eq!(skiprdeps.span.slice(source), "${skiprdeps/${_lf}/}");
assert_eq!(skiprdeps.span.end.column, 27);
}
#[test]
fn default_parameter_operand_reads_are_tracked() {
let source = "\
repo_root=$(pwd)
cache_dir=${1:-\"$repo_root/.cache\"}
printf '%s\\n' \"$cache_dir\"
";
let model = model_with_dialect(source, ShellDialect::Posix);
let unused = reportable_unused_names(&model);
assert!(
!unused.contains(&Name::from("repo_root")),
"unused bindings: {:?}",
unused
);
let reference = model
.references()
.iter()
.find(|reference| {
reference.kind == ReferenceKind::Expansion && reference.name == "repo_root"
})
.unwrap();
let binding = model.resolved_binding(reference.id).unwrap();
assert_eq!(binding.name, "repo_root");
}
#[test]
fn self_referential_default_initializers_are_not_reported_unused() {
let source = "\
#!/bin/sh
STATE=\"${STATE:-in_progress}\"
DESCRIPTION=\"${DESCRIPTION:-Deployment metadata updated}\"
";
let model = model_with_dialect(source, ShellDialect::Posix);
let unused = reportable_unused_names(&model);
assert!(
!unused.contains(&Name::from("STATE")),
"unused bindings: {:?}",
unused
);
assert!(
!unused.contains(&Name::from("DESCRIPTION")),
"unused bindings: {:?}",
unused
);
}
#[test]
fn detects_dead_code_after_exit() {
let source = "exit 0\necho dead\n";
let model = model(source);
let analysis = model.analysis();
let dead_code = analysis.dead_code();
assert_eq!(dead_code.len(), 1);
assert_eq!(
dead_code[0].unreachable[0].slice(source).trim_end(),
"echo dead"
);
assert_eq!(dead_code[0].cause.slice(source).trim_end(), "exit 0");
}
#[test]
fn zsh_always_cleanup_after_return_is_reachable() {
let source = "\
#!/bin/zsh
{
return
} always {
printf '%s\\n' cleanup
}
printf '%s\\n' after
";
let model = model_with_dialect(source, ShellDialect::Zsh);
let unreachable = model
.analysis()
.dead_code()
.iter()
.flat_map(|entry| entry.unreachable.iter())
.map(|span| span.slice(source).trim_end().to_owned())
.collect::<Vec<_>>();
assert!(
!unreachable.contains(&"printf '%s\\n' cleanup".to_owned()),
"unreachable spans: {unreachable:?}"
);
assert!(
unreachable.contains(&"printf '%s\\n' after".to_owned()),
"unreachable spans: {unreachable:?}"
);
}
#[test]
fn loop_control_condition_keeps_unreachable_if_tree_causes() {
let source = "\
while true; do
if break; then
printf '%s\\n' after_true
else
printf '%s\\n' after_false
fi
printf '%s\\n' after_if
done
";
let model = model(source);
let analysis = model.analysis();
let dead_code = analysis.dead_code();
let unreachable = dead_code
.iter()
.flat_map(|entry| entry.unreachable.iter())
.map(|span| span.slice(source).trim_end().to_owned())
.collect::<Vec<_>>();
assert!(unreachable.contains(&"printf '%s\\n' after_true".to_owned()));
assert!(unreachable.contains(&"printf '%s\\n' after_false".to_owned()));
assert!(unreachable.contains(&"printf '%s\\n' after_if".to_owned()));
assert!(
dead_code
.iter()
.all(|entry| entry.cause_kind == UnreachableCauseKind::LoopControl),
"dead code: {dead_code:?}"
);
}
#[test]
fn compound_dead_code_reports_outermost_spans() {
let source = "\
return
if true; then
echo one
fi
printf '%s\\n' two
";
let model = model(source);
let analysis = model.analysis();
let dead_code = analysis.dead_code();
let unreachable = dead_code
.iter()
.flat_map(|entry| entry.unreachable.iter())
.map(|span| span.slice(source).trim_end().to_owned())
.collect::<Vec<_>>();
assert!(unreachable.contains(&"if true; then\n echo one\nfi".to_owned()));
assert!(unreachable.contains(&"printf '%s\\n' two".to_owned()));
assert!(!unreachable.contains(&"echo one".to_owned()));
}
#[test]
fn dead_code_after_script_terminating_function_calls_is_detected() {
let source = "\
exit_script() {
exit 0
}
main() {
exit_script
printf '%s\\n' never
}
";
let model = model(source);
let analysis = model.analysis();
let unreachable = analysis
.dead_code()
.iter()
.flat_map(|entry| entry.unreachable.iter())
.map(|span| span.slice(source).trim_end().to_owned())
.collect::<Vec<_>>();
assert!(unreachable.contains(&"printf '%s\\n' never".to_owned()));
}
#[test]
fn resolved_function_calls_feed_dataflow_and_script_termination() {
let source = "\
reader() {
printf '%s\\n' \"$value\"
}
exit_script() {
exit 0
}
main() {
value=ok
reader
exit_script
printf '%s\\n' never
}
main
";
let model = model(source);
let analysis = model.analysis();
let unused = binding_names(&model, analysis.dataflow().unused_assignment_ids());
let unreachable = analysis
.dead_code()
.iter()
.flat_map(|entry| entry.unreachable.iter())
.map(|span| span.slice(source).trim_end().to_owned())
.collect::<Vec<_>>();
assert!(
!unused.iter().any(|name| name == "value"),
"unused bindings: {unused:?}"
);
assert!(
unreachable.contains(&"printf '%s\\n' never".to_owned()),
"unreachable spans: {unreachable:?}"
);
}
#[test]
fn condition_body_after_script_terminating_condition_is_dead() {
let source = "\
if exit 0; then
printf '%s\\n' never
fi
";
let model = model(source);
let unreachable = model
.analysis()
.dead_code()
.iter()
.flat_map(|entry| entry.unreachable.iter())
.map(|span| span.slice(source).trim_end().to_owned())
.collect::<Vec<_>>();
assert!(
unreachable.contains(&"printf '%s\\n' never".to_owned()),
"unreachable spans: {unreachable:?}"
);
}
#[test]
fn case_return_paths_keep_helper_from_being_script_terminating() {
let source = "\
die() {
exit 1
}
helper() {
case \"$1\" in
ok)
return
;;
esac
die
}
main() {
helper ok
printf '%s\\n' still_reachable
}
";
let model = model(source);
assert!(
model.analysis().dead_code().is_empty(),
"dead code: {:?}",
model.analysis().dead_code()
);
}
#[test]
fn loop_return_paths_keep_helper_from_being_script_terminating() {
let source = "\
die() {
exit 1
}
helper() {
for item in 1; do
return
done
die
}
main() {
helper
printf '%s\\n' still_reachable
}
";
let model = model(source);
assert!(
model.analysis().dead_code().is_empty(),
"dead code: {:?}",
model.analysis().dead_code()
);
}
#[test]
fn script_terminating_calls_use_rewritten_statement_spans() {
let sources = [
"\
exit_script() {
exit 0
}
main() {
exit_script >/dev/null
printf '%s\\n' never
}
",
"\
exit_script() {
exit 0
}
main() {
! exit_script
printf '%s\\n' never
}
",
];
for source in sources {
let model = model(source);
let analysis = model.analysis();
let unreachable = analysis
.dead_code()
.iter()
.flat_map(|entry| entry.unreachable.iter())
.map(|span| span.slice(source).trim_end().to_owned())
.collect::<Vec<_>>();
assert!(
unreachable.contains(&"printf '%s\\n' never".to_owned()),
"unreachable spans: {unreachable:?}"
);
}
}
#[test]
fn sourceable_file_return_keeps_helper_exit_calls_non_terminating() {
let source = "\
[ -n \"$loaded\" ] && return
loaded=1
exit_script() {
exit 0
}
main() {
exit_script
printf '%s\\n' still_reachable
}
";
let model = model(source);
assert!(
model.analysis().dead_code().is_empty(),
"dead code: {:?}",
model.analysis().dead_code()
);
}
#[test]
fn sourceable_file_return_before_helper_keeps_helper_exit_calls_non_terminating() {
let source = "\
already_loaded() {
:
}
[ -n \"$loaded\" ] && return
loaded=1
exit_script() {
exit 0
}
main() {
exit_script
printf '%s\\n' still_reachable
}
";
let model = model(source);
assert!(
model.analysis().dead_code().is_empty(),
"dead code: {:?}",
model.analysis().dead_code()
);
}
#[test]
fn sourceable_file_return_after_helper_keeps_helper_exit_calls_terminating() {
let source = "\
main() {
exit_script
printf '%s\\n' never
}
{
exit_script() {
exit 0
}
return
}
";
let model = model(source);
let unreachable = model
.analysis()
.dead_code()
.iter()
.flat_map(|dead_code| dead_code.unreachable.iter())
.map(|span| span.slice(source).to_owned())
.collect::<Vec<_>>();
assert!(
unreachable.contains(&"printf '%s\\n' never\n".to_owned()),
"unreachable spans: {unreachable:?}"
);
}
#[test]
fn brace_group_function_definitions_can_make_later_calls_terminating() {
let source = "\
{
exit_script() {
exit 0
}
}
main() {
exit_script
printf '%s\\n' never
}
";
let model = model(source);
let analysis = model.analysis();
let unreachable = analysis
.dead_code()
.iter()
.flat_map(|entry| entry.unreachable.iter())
.map(|span| span.slice(source).trim_end().to_owned())
.collect::<Vec<_>>();
assert!(unreachable.contains(&"printf '%s\\n' never".to_owned()));
}
#[test]
fn later_parent_scope_function_definitions_can_terminate_later_runtime_calls() {
let source = "\
main() {
exit_script
printf '%s\\n' never
}
exit_script() {
exit 0
}
main
";
let model = model(source);
let analysis = model.analysis();
let unreachable = analysis
.dead_code()
.iter()
.flat_map(|entry| entry.unreachable.iter())
.map(|span| span.slice(source).trim_end().to_owned())
.collect::<Vec<_>>();
assert!(unreachable.contains(&"printf '%s\\n' never".to_owned()));
}
#[test]
fn later_function_definitions_do_not_make_earlier_calls_terminating() {
let source = "\
main() {
exit_script
printf '%s\\n' still_reachable
}
main
exit_script() {
exit 0
}
";
let model = model(source);
assert!(
model.analysis().dead_code().is_empty(),
"dead code: {:?}",
model.analysis().dead_code()
);
}
#[test]
fn transitive_calls_before_parent_definitions_keep_later_code_reachable() {
let source = "\
main() {
helper
}
helper() {
inner
}
inner() {
exit_script
printf '%s\\n' maybe
}
if should_run; then
main
fi
exit_script() {
exit 0
}
main
";
let model = model(source);
assert!(
model.analysis().dead_code().is_empty(),
"dead code: {:?}",
model.analysis().dead_code()
);
}
#[test]
fn top_level_parent_call_before_nested_definition_keeps_later_code_reachable() {
let source = "\
outer() {
inner() {
helper
printf '%s\\n' maybe
}
inner
helper() {
exit 0
}
}
outer
";
let model = model(source);
assert!(
model.analysis().dead_code().is_empty(),
"dead code: {:?}",
model.analysis().dead_code()
);
}
#[test]
fn nested_calls_after_parent_definition_can_use_script_terminating_helpers() {
let source = "\
outer() {
inner() {
helper
printf '%s\\n' never
}
helper() {
exit 0
}
inner
}
outer
";
let model = model(source);
let unreachable = model
.analysis()
.dead_code()
.iter()
.flat_map(|entry| entry.unreachable.iter())
.map(|span| span.slice(source).trim_end().to_owned())
.collect::<Vec<_>>();
assert!(
unreachable.contains(&"printf '%s\\n' never".to_owned()),
"unreachable spans: {unreachable:?}"
);
}
#[test]
fn later_redefinitions_do_not_fall_back_to_stale_terminating_helpers() {
let source = "\
exit_script() {
exit 0
}
main() {
exit_script
printf '%s\\n' maybe
}
if should_run; then
main
fi
exit_script() {
:
}
main
";
let model = model(source);
assert!(
model.analysis().dead_code().is_empty(),
"dead code: {:?}",
model.analysis().dead_code()
);
}
#[test]
fn conditional_function_definitions_do_not_make_calls_terminating() {
let source = "\
if false; then
exit_script() {
exit 0
}
fi
exit_script
printf '%s\\n' still_reachable
";
let model = model(source);
assert!(
model.analysis().dead_code().is_empty(),
"dead code: {:?}",
model.analysis().dead_code()
);
}
#[test]
fn conditional_exit_keeps_or_fallback_reachable() {
let source = "run && exit 0 || echo fallback\n";
let model = model(source);
assert!(
model.analysis().dead_code().is_empty(),
"dead code: {:?}",
model.analysis().dead_code()
);
}
#[test]
fn deferred_function_bodies_resolve_later_file_scope_bindings() {
let source = "f() { echo $X; }\nX=1\nf\n";
let model = model(source);
let reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "X")
.unwrap();
let binding = model.resolved_binding(reference.id).unwrap();
assert_eq!(binding.span.slice(source), "X");
}
#[test]
fn deferred_non_brace_function_bodies_resolve_later_file_scope_bindings() {
let source = "f() if true; then echo $X; fi\nX=1\nf\n";
let model = model(source);
let reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "X")
.unwrap();
let binding = model.resolved_binding(reference.id).unwrap();
assert_eq!(binding.span.slice(source), "X");
}
#[test]
fn top_level_reads_remain_source_order_sensitive() {
let source = "echo $X\nX=1\n";
let model = model(source);
let reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "X")
.unwrap();
assert!(model.resolved_binding(reference.id).is_none());
assert_eq!(model.unresolved_references(), &[reference.id]);
}
#[test]
fn common_runtime_vars_are_not_marked_uninitialized_in_bash_and_sh_scripts() {
let names = [
"IFS",
"USER",
"HOME",
"SHELL",
"PWD",
"TERM",
"PATH",
"CDPATH",
"LANG",
"LC_ALL",
"LC_TIME",
"SUDO_USER",
"DOAS_USER",
];
for shebang in ["#!/bin/bash", "#!/bin/sh"] {
let source = common_runtime_source(shebang);
let model = model(&source);
let unresolved = unresolved_names(&model);
let uninitialized = uninitialized_names(&model);
assert_names_absent(&names, &unresolved);
assert_names_absent(&names, &uninitialized);
}
}
#[test]
fn bash_runtime_vars_are_not_marked_uninitialized_in_bash_scripts() {
let source = bash_runtime_source("#!/bin/bash");
let model = model(&source);
let names = [
"LINENO",
"FUNCNAME",
"BASH_SOURCE",
"BASH_LINENO",
"RANDOM",
"BASH_REMATCH",
"READLINE_LINE",
"BASH_VERSION",
"BASH_VERSINFO",
"OSTYPE",
"HISTCONTROL",
"HISTSIZE",
];
let unresolved = unresolved_names(&model);
let uninitialized = uninitialized_names(&model);
assert_names_absent(&names, &unresolved);
assert_names_absent(&names, &uninitialized);
}
#[test]
fn env_split_bash_shebang_enables_bash_runtime_vars() {
let source = bash_runtime_source("#!/usr/bin/env -S bash -e");
let model = model(&source);
let names = [
"LINENO",
"FUNCNAME",
"BASH_SOURCE",
"BASH_LINENO",
"RANDOM",
"BASH_REMATCH",
"READLINE_LINE",
"BASH_VERSION",
"BASH_VERSINFO",
"OSTYPE",
"HISTCONTROL",
"HISTSIZE",
];
let unresolved = unresolved_names(&model);
let uninitialized = uninitialized_names(&model);
assert_names_absent(&names, &unresolved);
assert_names_absent(&names, &uninitialized);
}
#[test]
fn inferred_profile_honors_env_split_shebang() {
assert_eq!(
infer_parse_dialect_from_source("#!/usr/bin/env -S sh -e\n:\n", None),
ShellDialect::Posix
);
assert_eq!(
infer_parse_dialect_from_source("#!/usr/bin/env -S zsh -f\nprint ok\n", None),
ShellDialect::Zsh
);
}
#[test]
fn bash_runtime_array_references_are_classified() {
let source = "#!/bin/bash\nprintf '%s\\n' \"$BASH_SOURCE\" \"$FUNCNAME\" \"$RANDOM\"\n";
let model = model(source);
for name in ["BASH_SOURCE", "FUNCNAME"] {
let reference = model
.references()
.iter()
.find(|reference| reference.name == name)
.unwrap();
assert!(model.reference_is_predefined_runtime_array(reference.id));
}
let random = model
.references()
.iter()
.find(|reference| reference.name == "RANDOM")
.unwrap();
assert!(!model.reference_is_predefined_runtime_array(random.id));
}
#[test]
fn zsh_runtime_vars_are_not_marked_uninitialized_in_zsh_scripts() {
let source = zsh_runtime_source("#!/bin/zsh");
let model = model(&source);
let names = [
"options",
"functions",
"aliases",
"commands",
"parameters",
"termcap",
"terminfo",
"path",
"pipestatus",
"funcstack",
"funcfiletrace",
"funcsourcetrace",
"psvar",
"widgets",
"zsh_eval_context",
"module_path",
"manpath",
"mailpath",
"historywords",
"jobdirs",
"jobstates",
"jobtexts",
"signals",
"nameddirs",
"MATCH",
"match",
"MBEGIN",
"mbegin",
"MEND",
"mend",
"BUFFER",
"LBUFFER",
"RBUFFER",
"CURSOR",
"WIDGET",
"KEYS",
"NUMERIC",
"POSTDISPLAY",
"histchars",
"region_highlight",
"LINES",
"COLUMNS",
"ZSH_VERSION",
"ZSH_NAME",
"ZSH_PATCHLEVEL",
"ZSH_SUBSHELL",
"ZSH_ARGZERO",
];
let unresolved = unresolved_names(&model);
let uninitialized = uninitialized_names(&model);
assert_names_absent(&names, &unresolved);
assert_names_absent(&names, &uninitialized);
}
#[test]
fn zsh_runtime_array_references_are_classified() {
let source = "\
#!/bin/zsh
printf '%s\\n' \"${path[1]}\" \"${options[xtrace]}\" \"${pipestatus[1]}\" \"${match[1]}\" \"${nameddirs[project]}\" \"$ZSH_VERSION\"
";
let model = model(source);
for name in ["path", "options", "pipestatus", "match", "nameddirs"] {
let reference = model
.references()
.iter()
.find(|reference| reference.name == name)
.unwrap();
assert!(
model.reference_is_predefined_runtime_array(reference.id),
"{name} should be classified as a zsh runtime array"
);
}
let version = model
.references()
.iter()
.find(|reference| reference.name == "ZSH_VERSION")
.unwrap();
assert!(!model.reference_is_predefined_runtime_array(version.id));
}
#[test]
fn zsh_existence_tests_do_not_register_fake_variable_reads() {
let source = "\
#!/bin/zsh
if (( $+commands[git] )); then
:
fi
if (( ${+functions[zdot_warn]} )); then
:
fi
if (( $+ZINIT_CNORM )); then
:
fi
if (( $+commands[$cmd] )); then
:
fi
if (( ${+functions[$fn]} )); then
:
fi
if (( $+arr[i+1] )); then
:
fi
";
let model = model_with_dialect(source, ShellDialect::Zsh);
let unresolved = unresolved_names(&model);
let uninitialized = uninitialized_names(&model);
let unresolved_details = model
.unresolved_references()
.iter()
.map(|id| {
let reference = model.reference(*id);
(reference.name.to_string(), reference.span.slice(source))
})
.collect::<Vec<_>>();
assert_eq!(
unresolved_details,
vec![
("cmd".into(), "$cmd"),
("fn".into(), "$fn"),
("i".into(), "i")
]
);
assert_names_absent(
&[
"+commands",
"git",
"commands",
"+functions",
"zdot_warn",
"functions",
"+ZINIT_CNORM",
"ZINIT_CNORM",
"+cmd",
"+fn",
"+arr",
"arr",
],
&unresolved,
);
assert_names_absent(
&[
"+commands",
"git",
"commands",
"+functions",
"zdot_warn",
"functions",
"+ZINIT_CNORM",
"ZINIT_CNORM",
"+cmd",
"+fn",
"+arr",
"arr",
],
&uninitialized,
);
assert_names_present(&["cmd", "fn", "i"], &unresolved);
assert_names_present(&["cmd", "fn", "i"], &uninitialized);
}
#[test]
fn bash_runtime_vars_remain_unresolved_in_non_bash_scripts() {
let source = bash_runtime_source("#!/bin/sh");
let model = model(&source);
let names = [
"LINENO",
"FUNCNAME",
"BASH_SOURCE",
"BASH_LINENO",
"RANDOM",
"BASH_REMATCH",
"READLINE_LINE",
"BASH_VERSION",
"BASH_VERSINFO",
"OSTYPE",
"HISTCONTROL",
"HISTSIZE",
];
let unresolved = unresolved_names(&model);
let uninitialized = uninitialized_names(&model);
assert_names_present(&names, &unresolved);
assert_names_present(&names, &uninitialized);
}
#[test]
fn deferred_nested_function_bodies_resolve_later_outer_bindings() {
let source = "\
outer() {
inner() { echo $X; }
X=1
inner
}
outer
";
let model = model(source);
let reference = model
.references()
.iter()
.find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "X")
.unwrap();
let binding = model.resolved_binding(reference.id).unwrap();
assert_eq!(binding.span.slice(source).trim(), "X");
assert!(matches!(
model.scope_kind(binding.scope),
ScopeKind::Function(function) if function.contains_name_str("outer")
));
}
#[test]
fn top_level_assignment_read_by_later_function_call_is_live() {
let source = "\
show() { echo \"$flag\"; }
flag=1
show
";
let model = model(source);
let unused = reportable_unused_names(&model);
assert!(unused.is_empty(), "unused: {:?}", unused);
}
#[test]
fn sourced_helper_reads_keep_top_level_assignment_live() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("helper.sh");
fs::write(
&main,
"\
#!/bin/sh
flag=1
. ./helper.sh
",
)
.unwrap();
fs::write(&helper, "echo \"$flag\"\n").unwrap();
let model = model_at_path(&main);
assert!(
model.synthetic_reads.iter().any(|read| read.name == "flag"),
"synthetic reads: {:?}",
model.synthetic_reads
);
}
#[test]
fn bash_source_file_suffix_reads_keep_top_level_assignment_live_transitively() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.bash");
let loader = temp.path().join("loader.bash");
let helper = temp.path().join("loader.bash__dep.bash");
fs::write(
&main,
"\
#!/bin/bash
flag=1
source ./loader.bash
",
)
.unwrap();
fs::write(
&loader,
"\
#!/bin/bash
source \"${BASH_SOURCE[0]}__dep.bash\"
",
)
.unwrap();
fs::write(&helper, "#!/bin/bash\necho \"$flag\"\n").unwrap();
let model = model_at_path(&main);
assert!(
model.synthetic_reads.iter().any(|read| read.name == "flag"),
"synthetic reads: {:?}",
model.synthetic_reads
);
}
#[test]
fn bash_source_double_zero_suffix_reads_keep_top_level_assignment_live_transitively() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.bash");
let loader = temp.path().join("loader.bash");
let helper = temp.path().join("loader.bash__dep.bash");
fs::write(
&main,
"\
#!/bin/bash
flag=1
source ./loader.bash
",
)
.unwrap();
fs::write(
&loader,
"\
#!/bin/bash
source \"${BASH_SOURCE[00]}__dep.bash\"
",
)
.unwrap();
fs::write(&helper, "#!/bin/bash\necho \"$flag\"\n").unwrap();
let model = model_at_path(&main);
assert!(
model.synthetic_reads.iter().any(|read| read.name == "flag"),
"synthetic reads: {:?}",
model.synthetic_reads
);
}
#[test]
fn bash_source_spaced_zero_suffix_reads_keep_top_level_assignment_live_transitively() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.bash");
let loader = temp.path().join("loader.bash");
let helper = temp.path().join("loader.bash__dep.bash");
fs::write(
&main,
"\
#!/bin/bash
flag=1
source ./loader.bash
",
)
.unwrap();
fs::write(
&loader,
"\
#!/bin/bash
source \"${BASH_SOURCE[ 0 ]}__dep.bash\"
",
)
.unwrap();
fs::write(&helper, "#!/bin/bash\necho \"$flag\"\n").unwrap();
let model = model_at_path(&main);
assert!(
model.synthetic_reads.iter().any(|read| read.name == "flag"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(unused.is_empty(), "unused: {:?}", unused);
}
#[test]
fn bash_source_nonzero_suffix_does_not_keep_top_level_assignment_live_transitively() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.bash");
let loader = temp.path().join("loader.bash");
let helper = temp.path().join("loader.bash__dep.bash");
fs::write(
&main,
"\
#!/bin/bash
flag=1
source ./loader.bash
",
)
.unwrap();
fs::write(
&loader,
"\
#!/bin/bash
source \"${BASH_SOURCE[1]}__dep.bash\"
",
)
.unwrap();
fs::write(&helper, "#!/bin/bash\necho \"$flag\"\n").unwrap();
let model = model_at_path(&main);
assert!(
!model.synthetic_reads.iter().any(|read| read.name == "flag"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert_eq!(unused, vec!["flag"]);
}
#[test]
fn bash_source_dirname_reads_keep_top_level_assignment_live_transitively() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.bash");
let loader = temp.path().join("loader.bash");
let helper = temp.path().join("helper.bash");
fs::write(
&main,
"\
#!/bin/bash
flag=1
source ./loader.bash
",
)
.unwrap();
fs::write(
&loader,
"\
#!/bin/bash
source \"$(dirname \"${BASH_SOURCE[0]}\")/helper.bash\"
",
)
.unwrap();
fs::write(&helper, "#!/bin/bash\necho \"$flag\"\n").unwrap();
let model = model_at_path(&main);
assert!(
model.synthetic_reads.iter().any(|read| read.name == "flag"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(unused.is_empty(), "unused: {:?}", unused);
}
#[test]
fn executed_helper_reads_keep_loop_variable_live() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("helper.sh");
fs::write(
&main,
"\
#!/bin/sh
for queryip in 127.0.0.1; do
helper.sh
done
",
)
.unwrap();
fs::write(&helper, "printf '%s\\n' \"$queryip\"\n").unwrap();
let model = model_at_path(&main);
assert!(
model
.synthetic_reads
.iter()
.any(|read| read.name == "queryip"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(
!unused.contains(&Name::from("queryip")),
"unused: {:?}",
unused
);
}
#[test]
fn executed_helper_without_read_does_not_keep_unrelated_assignment_live() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("helper.sh");
fs::write(
&main,
"\
#!/bin/sh
unused=1
helper.sh
",
)
.unwrap();
fs::write(&helper, "printf '%s\\n' ok\n").unwrap();
let model = model_at_path(&main);
let unused = reportable_unused_names(&model);
assert!(
unused.contains(&Name::from("unused")),
"unused: {:?}",
unused
);
}
#[test]
fn loader_function_source_reads_keep_top_level_assignment_live() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("helper.sh");
fs::write(
&main,
"\
#!/bin/sh
load() { . \"$ROOT/$1\"; }
flag=1
load helper.sh
",
)
.unwrap();
fs::write(&helper, "echo \"$flag\"\n").unwrap();
let model = model_at_path(&main);
assert!(
model.synthetic_reads.iter().any(|read| read.name == "flag"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(unused.is_empty(), "unused: {:?}", unused);
}
#[test]
fn wrapped_loader_function_source_reads_keep_top_level_assignment_live() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("helper.sh");
fs::write(
&main,
"\
#!/bin/sh
load() { . \"$ROOT/$1\"; }
flag=1
noglob load helper.sh
",
)
.unwrap();
fs::write(&helper, "echo \"$flag\"\n").unwrap();
let model = model_at_path(&main);
assert!(
model.synthetic_reads.iter().any(|read| read.name == "flag"),
"synthetic reads: {:?}",
model.synthetic_reads
);
}
#[test]
fn source_path_resolver_keeps_helper_reads_generic() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("resolved/helper.sh");
fs::create_dir_all(helper.parent().unwrap()).unwrap();
fs::write(
&main,
"\
#!/bin/sh
flag=1
./helper.sh
",
)
.unwrap();
fs::write(&helper, "echo \"$flag\"\n").unwrap();
let without_resolver = model_at_path(&main);
let unused_without_resolver = reportable_unused_names(&without_resolver);
assert!(
unused_without_resolver.contains(&Name::from("flag")),
"unused without resolver: {:?}",
unused_without_resolver
);
let main_path = main.clone();
let helper_path = helper.clone();
let resolver = move |source_path: &Path, candidate: &str| {
if source_path == main_path.as_path() && candidate == "./helper.sh" {
vec![helper_path.clone()]
} else {
Vec::new()
}
};
let with_resolver = model_at_path_with_resolver(&main, Some(&resolver));
assert!(
with_resolver
.synthetic_reads
.iter()
.any(|read| read.name == "flag"),
"synthetic reads: {:?}",
with_resolver.synthetic_reads
);
let unused_with_resolver = reportable_unused_names(&with_resolver);
assert!(
!unused_with_resolver.contains(&Name::from("flag")),
"unused with resolver: {:?}",
unused_with_resolver
);
}
#[test]
fn missing_literal_source_is_marked_unresolved() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
fs::write(&main, "#!/bin/sh\n. ./missing.sh\n").unwrap();
let model = model_at_path(&main);
assert_eq!(model.source_refs().len(), 1);
assert_eq!(
model.source_refs()[0].resolution,
SourceRefResolution::Unresolved
);
}
#[test]
fn resolved_literal_source_is_marked_resolved() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("helper.sh");
fs::write(&main, "#!/bin/sh\n. ./helper.sh\n").unwrap();
fs::write(&helper, "echo helper\n").unwrap();
let model = model_at_path(&main);
assert_eq!(model.source_refs().len(), 1);
assert_eq!(
model.source_refs()[0].resolution,
SourceRefResolution::Resolved
);
}
#[test]
fn source_path_resolver_can_use_single_variable_static_tails() {
let temp = tempdir().unwrap();
let main = temp.path().join("tests/main.sh");
let helper = temp.path().join("scripts/rvm");
fs::create_dir_all(main.parent().unwrap()).unwrap();
fs::create_dir_all(helper.parent().unwrap()).unwrap();
fs::write(
&main,
"\
#!/bin/sh
flag=1
source \"$rvm_path/scripts/rvm\"
",
)
.unwrap();
fs::write(&helper, "echo \"$flag\"\n").unwrap();
let without_resolver = model_at_path(&main);
let unused_without_resolver = reportable_unused_names(&without_resolver);
assert!(
unused_without_resolver.contains(&Name::from("flag")),
"unused without resolver: {:?}",
unused_without_resolver
);
let main_path = main.clone();
let helper_path = helper.clone();
let resolver = move |source_path: &Path, candidate: &str| {
if source_path == main_path.as_path() && candidate == "scripts/rvm" {
vec![helper_path.clone()]
} else {
Vec::new()
}
};
let with_resolver = model_at_path_with_resolver(&main, Some(&resolver));
assert!(
with_resolver
.synthetic_reads
.iter()
.any(|read| read.name == "flag"),
"synthetic reads: {:?}",
with_resolver.synthetic_reads
);
let unused_with_resolver = reportable_unused_names(&with_resolver);
assert!(
!unused_with_resolver.contains(&Name::from("flag")),
"unused with resolver: {:?}",
unused_with_resolver
);
}
#[test]
fn sourced_helper_exports_definite_imported_binding() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("helper.sh");
fs::write(
&main,
"\
#!/bin/sh
. ./helper.sh
printf '%s\\n' \"$flag\"
",
)
.unwrap();
fs::write(&helper, "flag=1\n").unwrap();
let model = model_at_path(&main);
let imported = model
.bindings()
.iter()
.find(|binding| binding.name == "flag" && binding.kind == BindingKind::Imported)
.unwrap();
assert!(
!imported
.attributes
.contains(BindingAttributes::IMPORTED_POSSIBLE)
);
assert!(model.analysis().uninitialized_references().is_empty());
}
#[test]
fn sourced_helper_exports_possible_imported_binding() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("helper.sh");
fs::write(
&main,
"\
#!/bin/sh
. ./helper.sh
printf '%s\\n' \"$flag\"
",
)
.unwrap();
fs::write(
&helper,
"\
if cond; then
flag=1
fi
",
)
.unwrap();
let model = model_at_path(&main);
let imported_is_possible = model
.bindings()
.iter()
.find(|binding| binding.name == "flag" && binding.kind == BindingKind::Imported)
.map(|binding| {
binding
.attributes
.contains(BindingAttributes::IMPORTED_POSSIBLE)
})
.unwrap();
let details = uninitialized_details(&model);
assert!(imported_is_possible, "uninitialized: {:?}", details);
assert_eq!(
details,
vec![("flag".to_owned(), UninitializedCertainty::Possible)]
);
}
#[test]
fn local_shadowing_can_clear_imported_initialization_before_nested_command_substitutions() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("helper.sh");
fs::write(
&main,
"\
#!/bin/bash
. ./helper.sh
f() {
local flag
printf '%s\\n' \"$(
printf '%s\\n' \"$flag\"
)\"
}
f
",
)
.unwrap();
fs::write(&helper, "flag=1\n").unwrap();
let model = model_at_path(&main);
let uninitialized = uninitialized_names(&model);
assert_names_present(&["flag"], &uninitialized);
}
#[test]
fn scope_entry_loops_preserve_possible_uninitialized_names() {
let source = "\
while command; do
flag=1
done
printf '%s\\n' \"$flag\"
";
let model = model(source);
assert_eq!(
uninitialized_details(&model),
vec![("flag".to_owned(), UninitializedCertainty::Possible)]
);
}
#[test]
fn sourced_helper_function_reads_do_not_keep_assignments_live_until_called() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("helper.sh");
fs::write(
&main,
"\
#!/bin/sh
flag=1
. ./helper.sh
",
)
.unwrap();
fs::write(
&helper,
"\
use_flag() {
printf '%s\\n' \"$flag\"
}
",
)
.unwrap();
let model = model_at_path(&main);
let unused = reportable_unused_names(&model);
assert!(unused.contains(&Name::from("flag")), "unused: {:?}", unused);
}
#[test]
fn quoted_heredoc_body_does_not_report_uninitialized_reads() {
let source = "\
build=\"$(command cat <<\\END
printf '%s\\n' \"$workdir\"
END
)\"
";
let model = model(source);
assert!(model.analysis().uninitialized_references().is_empty());
}
#[test]
fn escaped_dollar_heredoc_body_does_not_report_uninitialized_reads() {
let source = "\
#!/bin/sh
cat <<EOF
\\${devtype} \\${devnum}
EOF
";
let model = model(source);
assert!(model.analysis().uninitialized_references().is_empty());
}
#[test]
fn escaped_dollar_word_does_not_report_uninitialized_reads() {
let source = "\
#!/bin/sh
printf '%s\\n' \"\\$workdir\"
";
let model = model(source);
assert!(model.analysis().uninitialized_references().is_empty());
}
#[test]
fn escaped_parameter_expansion_keeps_nested_default_reads() {
let source = "\
#!/bin/sh
printf '%s\\n' \\${workdir:-$fallback} \\${other:-${inner}}
";
let model = model(source);
let unresolved = unresolved_names(&model);
assert_names_absent(&["workdir", "other"], &unresolved);
assert_names_present(&["fallback", "inner"], &unresolved);
}
#[test]
fn unquoted_heredoc_body_reports_live_uninitialized_reads() {
let source = "\
archname=archive
cat <<EOF > \"$archname\"
#!/bin/sh
ORIG_UMASK=`umask`
if test \"$KEEP_UMASK\" = n; then
umask 077
fi
CRCsum=\"$CRCsum\"
archdirname=\"$archdirname\"
EOF
";
let model = model(source);
let details = uninitialized_details(&model);
assert!(details.iter().any(
|(name, certainty)| name == "CRCsum" && *certainty == UninitializedCertainty::Definite
));
assert!(
details.iter().any(|(name, certainty)| name == "archdirname"
&& *certainty == UninitializedCertainty::Definite)
);
}
#[test]
fn quoted_heredoc_source_text_does_not_keep_assignments_live() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.bash");
fs::write(
&main,
"\
#!/bin/bash
outdir=/tmp
build=\"$(command cat <<\\END
. \\\"$outdir\\\"/build.info
END
)\"
",
)
.unwrap();
let model = model_at_path(&main);
let unused = reportable_unused_names(&model);
assert!(
unused.contains(&Name::from("outdir")),
"unused: {:?}",
unused
);
}
#[test]
fn quoted_heredoc_body_stays_inert_with_source_closure_enabled() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.bash");
fs::write(
&main,
"\
#!/bin/sh
build=\"$(command cat <<\\END
for formula in libiconv cmake git wget; do
if command brew ls --version \"$formula\" >/dev/null; then
command brew upgrade \"$formula\"
else
command brew install \"$formula\"
fi
done
archflag=\"-march\"
nopltflag=\"-fno-plt\"
cflags=\"$archflag=$cpu $nopltflag\"
. \"$outdir\"/build.info
END
)\"
",
)
.unwrap();
let model = model_at_path(&main);
assert!(
model.analysis().uninitialized_references().is_empty(),
"uninitialized: {:?}",
model.analysis().uninitialized_references()
);
}
#[test]
fn posix_quoted_heredoc_body_stays_inert_with_source_closure_enabled() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
fs::write(
&main,
"\
#!/bin/sh
build=\"$(command cat <<\\END
for formula in libiconv cmake git wget; do
if command brew ls --version \"$formula\" >/dev/null; then
command brew upgrade \"$formula\"
else
command brew install \"$formula\"
fi
done
archflag=\"-march\"
nopltflag=\"-fno-plt\"
cflags=\"$archflag=$cpu $nopltflag\"
. \"$outdir\"/build.info
END
)\"
",
)
.unwrap();
let model = model_at_path_with_parse_dialect(&main, ShellDialect::Posix);
assert!(
model.analysis().uninitialized_references().is_empty(),
"uninitialized: {:?}",
model.analysis().uninitialized_references()
);
}
#[test]
fn posix_second_quoted_heredoc_body_stays_inert_with_source_closure_enabled() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
fs::write(
&main,
"\
#!/bin/sh
usage=\"$(command cat <<\\END
Usage
END
)\"
build=\"$(command cat <<\\END
for formula in libiconv cmake git wget; do
if command brew ls --version \"$formula\" >/dev/null; then
command brew upgrade \"$formula\"
else
command brew install \"$formula\"
fi
done
archflag=\"-march\"
nopltflag=\"-fno-plt\"
cflags=\"$archflag=$cpu $nopltflag\"
. \"$outdir\"/build.info
END
)\"
",
)
.unwrap();
let model = model_at_path_with_parse_dialect(&main, ShellDialect::Posix);
assert!(
model.analysis().uninitialized_references().is_empty(),
"uninitialized: {:?}",
model.analysis().uninitialized_references()
);
}
#[test]
fn quoted_heredoc_build_template_executed_later_stays_inert() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("build.info");
fs::write(
&main,
"\
#!/bin/sh
usage=\"$(command cat <<\\END
Usage
END
)\"
build=\"$(command cat <<\\END
outdir=\"$(command pwd)\"
workdir=\"${TMPDIR:-/tmp}/gitstatus-build.tmp.$$\"\n\
for formula in libiconv cmake git wget; do
if command brew ls --version \"$formula\" >/dev/null 2>&1; then
command brew upgrade \"$formula\"
else
command brew install \"$formula\"
fi
done
archflag=\"-march\"
nopltflag=\"-fno-plt\"
cflags=\"$archflag=$cpu $nopltflag\"
. \"$outdir\"/build.info
END
)\"
eval \"$build\"
",
)
.unwrap();
fs::write(&helper, "libgit2_version=1.0\n").unwrap();
let model = model_at_path(&main);
let references = model.analysis().uninitialized_references().to_vec();
let names = references
.iter()
.map(|reference| model.reference(reference.reference).name.clone())
.collect::<Vec<_>>();
assert!(
!names.iter().any(|name| {
matches!(
name.as_str(),
"formula" | "archflag" | "nopltflag" | "outdir"
)
}),
"uninitialized names: {names:?}"
);
}
#[test]
fn eval_scan_keeps_apostrophe_inside_double_quotes_from_hiding_reads() {
let source = "foo=1\neval \"echo \\\"it's \\$foo\\\"\"\n";
let model = model(source);
let unused = reportable_unused_names(&model);
assert!(
!unused.contains(&Name::from("foo")),
"unused bindings: {:?}",
unused
);
}
#[test]
fn eval_scan_keeps_quoted_hash_from_starting_comment() {
let source = "foo=1\neval \"echo \\\"# \\$foo\\\"\"\n";
let model = model(source);
let unused = reportable_unused_names(&model);
assert!(
!unused.contains(&Name::from("foo")),
"unused bindings: {:?}",
unused
);
}
#[test]
fn eval_scan_treats_unquoted_hash_after_space_as_comment() {
let source = "foo=1\neval \"echo # \\$foo\"\n";
let model = model(source);
let unused = reportable_unused_names(&model);
assert!(
unused.contains(&Name::from("foo")),
"unused bindings: {:?}",
unused
);
}
#[test]
fn eval_scan_keeps_source_single_quoted_fragments_inert_for_c001() {
let source = "foo=1\neval 'echo \"$foo\"'\n";
let model = model(source);
let unused = reportable_unused_names(&model);
assert!(
unused.contains(&Name::from("foo")),
"unused bindings: {:?}",
unused
);
}
#[test]
fn eval_scan_treats_pid_prefix_as_special_parameter() {
let source = "foo=1\neval \"echo $$foo\"\n";
let model = model(source);
let unused = reportable_unused_names(&model);
assert!(
unused.contains(&Name::from("foo")),
"unused bindings: {:?}",
unused
);
}
#[test]
fn escaped_dollar_heredoc_body_stays_inert_with_source_closure_enabled() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
fs::write(
&main,
"\
#!/bin/sh
cat <<EOF > ./postinst
if [ \"\\$1\" = \"configure\" ]; then
for ver in 1 current; do
for x in rewriteSystem rewriteURI; do
xmlcatalog --noout --add \\$x http://example.test/xsl/\\$ver
done
done
fi
EOF
",
)
.unwrap();
let model = model_at_path(&main);
assert!(
model.analysis().uninitialized_references().is_empty(),
"uninitialized: {:?}",
model.analysis().uninitialized_references()
);
}
#[test]
fn quoted_heredoc_case_arm_and_nested_same_name_heredoc_stay_inert() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
fs::write(
&main,
"\
#!/bin/sh
build=\"$(command cat <<\\END
case \"$gitstatus_kernel\" in
linux)
for formula in libiconv cmake git wget; do
if command brew ls --version \"$formula\" >/dev/null; then
command brew upgrade \"$formula\"
else
command brew install \"$formula\"
fi
done
;;
esac
command cat >&2 <<-END
\tSUCCESS
\tEND
END
)\"
",
)
.unwrap();
let model = model_at_path(&main);
assert!(
model.analysis().uninitialized_references().is_empty(),
"uninitialized: {:?}",
model.analysis().uninitialized_references()
);
}
#[test]
fn tab_stripped_escaped_dollar_heredoc_body_stays_inert() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
fs::write(
&main,
"\
#!/bin/sh
cat <<- EOF > ./postinst
\tif [ \"\\$1\" = \"configure\" ]; then
\t\tfor ver in 1 current; do
\t\t\tfor x in rewriteSystem rewriteURI; do
\t\t\t\txmlcatalog --noout --add \\$x http://example.test/xsl/\\$ver
\t\t\tdone
\t\tdone
\tfi
\tEOF
",
)
.unwrap();
let model = model_at_path(&main);
assert!(
model.analysis().uninitialized_references().is_empty(),
"uninitialized: {:?}",
model.analysis().uninitialized_references()
);
}
#[test]
fn posix_tab_stripped_escaped_dollar_heredoc_body_stays_inert() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
fs::write(
&main,
"\
#!/bin/sh
cat <<- EOF > ./postinst
\tif [ \"$TERMUX_PACKAGE_FORMAT\" = \"pacman\" ] || [ \"\\$1\" = \"configure\" ]; then
\t\tfor ver in $TERMUX_PKG_VERSION current; do
\t\t\tfor x in rewriteSystem rewriteURI; do
\t\t\t\txmlcatalog --noout --add \\$x http://docbook.sourceforge.net/release/xsl-ns/\\$ver \\
\t\t\t\t\t\"$TERMUX_PREFIX/share/xml/docbook/xsl-stylesheets-$TERMUX_PKG_VERSION\" \\
\t\t\t\t\t\"$TERMUX_PREFIX/etc/xml/catalog\"
\t\t\tdone
\t\tdone
\tfi
\tEOF
",
)
.unwrap();
let model = model_at_path_with_parse_dialect(&main, ShellDialect::Posix);
let references = model.analysis().uninitialized_references().to_vec();
let names = references
.iter()
.map(|reference| model.reference(reference.reference).name.clone())
.collect::<Vec<_>>();
assert!(
!names
.iter()
.any(|name| matches!(name.as_str(), "x" | "ver")),
"uninitialized names: {names:?}"
);
}
#[test]
fn posix_docbook_wrapper_does_not_treat_escaped_placeholders_as_reads() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
fs::write(
&main,
"\
#!/bin/sh
termux_step_create_debscripts() {
\tcat <<- EOF > ./postinst
\t#!$TERMUX_PREFIX/bin/sh
\tif [ \"$TERMUX_PACKAGE_FORMAT\" = \"pacman\" ] || [ \"\\$1\" = \"configure\" ]; then
\t\tfor ver in $TERMUX_PKG_VERSION current; do
\t\t\tfor x in rewriteSystem rewriteURI; do
\t\t\t\txmlcatalog --noout --add \\$x http://cdn.docbook.org/release/xsl/\\$ver \\
\t\t\t\t\t\"$TERMUX_PREFIX/share/xml/docbook/xsl-stylesheets-$TERMUX_PKG_VERSION\" \\
\t\t\t\t\t\"$TERMUX_PREFIX/etc/xml/catalog\"
\
\t\t\t\txmlcatalog --noout --add \\$x http://docbook.sourceforge.net/release/xsl-ns/\\$ver \\
\t\t\t\t\t\"$TERMUX_PREFIX/share/xml/docbook/xsl-stylesheets-$TERMUX_PKG_VERSION\" \\
\t\t\t\t\t\"$TERMUX_PREFIX/etc/xml/catalog\"
\
\t\t\t\txmlcatalog --noout --add \\$x http://docbook.sourceforge.net/release/xsl/\\$ver \\
\t\t\t\t\t\"$TERMUX_PREFIX/share/xml/docbook/xsl-stylesheets-${TERMUX_PKG_VERSION}-nons\" \\
\t\t\t\t\t\"$TERMUX_PREFIX/etc/xml/catalog\"
\t\t\tdone
\t\tdone
\tfi
\tEOF
}
",
)
.unwrap();
let model = model_at_path_with_parse_dialect(&main, ShellDialect::Posix);
let references = model.analysis().uninitialized_references().to_vec();
let names = references
.iter()
.map(|reference| model.reference(reference.reference).name.clone())
.collect::<Vec<_>>();
assert!(
!names
.iter()
.any(|name| matches!(name.as_str(), "x" | "ver")),
"uninitialized names: {names:?}"
);
}
#[test]
fn sourced_helper_function_reads_keep_assignments_live_when_called() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("helper.sh");
fs::write(
&main,
"\
#!/bin/sh
flag=1
. ./helper.sh
use_flag
",
)
.unwrap();
fs::write(
&helper,
"\
use_flag() {
printf '%s\\n' \"$flag\"
}
",
)
.unwrap();
let model = model_at_path(&main);
let unused = reportable_unused_names(&model);
assert!(
!unused.contains(&Name::from("flag")),
"unused: {:?}",
unused
);
}
#[test]
fn sourced_helper_function_exports_definite_imported_binding_when_called() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("helper.sh");
fs::write(
&main,
"\
#!/bin/sh
. ./helper.sh
set_flag
printf '%s\\n' \"$flag\"
",
)
.unwrap();
fs::write(
&helper,
"\
set_flag() {
flag=1
}
",
)
.unwrap();
let model = model_at_path(&main);
assert!(model.analysis().uninitialized_references().is_empty());
}
#[test]
fn sourced_helper_function_exports_possible_imported_binding_when_called() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("helper.sh");
fs::write(
&main,
"\
#!/bin/sh
. ./helper.sh
set_flag
printf '%s\\n' \"$flag\"
",
)
.unwrap();
fs::write(
&helper,
"\
set_flag() {
if cond; then
flag=1
fi
}
",
)
.unwrap();
let model = model_at_path(&main);
assert_eq!(
uninitialized_details(&model),
vec![("flag".to_owned(), UninitializedCertainty::Possible)]
);
}
#[test]
fn unresolved_zsh_runtime_helpers_can_export_reply_bindings_when_called() {
let temp = tempdir().unwrap();
let module_dir = temp.path().join("zdot/modules/apt");
fs::create_dir_all(&module_dir).unwrap();
let main = module_dir.join("apt.zsh");
fs::write(
&main,
"\
#!/bin/zsh
zdot_provides_tool_args ':zdot:apt' op eza
printf '%s\\n' \"${reply[@]}\"
",
)
.unwrap();
let model = model_at_path(&main);
assert!(model.analysis().uninitialized_references().is_empty());
}
#[test]
fn unresolved_zsh_reply_helpers_do_not_seed_non_runtime_paths() {
let temp = tempdir().unwrap();
let main = temp.path().join("script.zsh");
fs::write(
&main,
"\
#!/bin/zsh
zdot_provides_tool_args ':zdot:apt' op eza
printf '%s\\n' \"${reply[@]}\"
",
)
.unwrap();
let model = model_at_path(&main);
assert_eq!(
uninitialized_details(&model),
vec![("reply".to_owned(), UninitializedCertainty::Definite)]
);
}
#[test]
fn layered_source_closure_imports_function_contracts_transitively() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let loader = temp.path().join("loader.sh");
let helper = temp.path().join("helper.sh");
fs::write(
&main,
"\
#!/bin/sh
. ./loader.sh
set_flag
printf '%s\\n' \"$flag\"
",
)
.unwrap();
fs::write(&loader, ". ./helper.sh\n").unwrap();
fs::write(
&helper,
"\
set_flag() {
flag=1
}
",
)
.unwrap();
let model = model_at_path(&main);
assert!(model.analysis().uninitialized_references().is_empty());
}
#[test]
fn late_parent_scope_loader_calls_are_visible_from_nested_functions() {
let source = "\
outer() {
inner() { load_helper ./helper.sh; }
load_helper() { . \"$1\"; }
inner
}
";
let model = model(source);
let name = Name::from("load_helper");
let call = &model.call_sites_for(&name)[0];
assert!(
model
.analysis()
.visible_function_binding_at_call(&name, call.name_span)
.is_some()
);
}
#[test]
fn executed_helper_does_not_import_bindings_back_to_the_caller() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("helper.sh");
fs::write(
&main,
"\
#!/bin/sh
./helper.sh
printf '%s\\n' \"$flag\"
",
)
.unwrap();
fs::write(&helper, "flag=1\n").unwrap();
let model = model_at_path(&main);
assert!(
model
.bindings()
.iter()
.all(|binding| !(binding.name == "flag" && binding.kind == BindingKind::Imported))
);
assert_eq!(
uninitialized_details(&model),
vec![("flag".to_owned(), UninitializedCertainty::Definite)]
);
}
#[test]
fn imported_bindings_do_not_resolve_reads_before_the_import_site() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("helper.sh");
fs::write(
&main,
"\
#!/bin/sh
printf '%s\\n' \"$flag\"
. ./helper.sh
",
)
.unwrap();
fs::write(&helper, "flag=1\n").unwrap();
let model = model_at_path(&main);
let reference = model
.references()
.iter()
.find(|reference| reference.name == "flag")
.unwrap();
assert!(model.resolved_binding(reference.id).is_none());
assert_eq!(
uninitialized_details(&model),
vec![("flag".to_owned(), UninitializedCertainty::Definite)]
);
}
#[test]
fn file_entry_contracts_seed_multiple_first_command_reads_as_imported_bindings() {
let source = "printf '%s\\n' \"$pkgname\" \"$pkgver\" \"$wrksrc\"\n";
let output = Parser::new(source).parse().unwrap();
let indexer = Indexer::new(source, &output);
let model = SemanticModel::build_with_options(
&output.file,
source,
&indexer,
SemanticBuildOptions {
file_entry_contract: Some(FileContract {
required_reads: Vec::new(),
provided_bindings: vec![
ProvidedBinding::new(
Name::from("pkgname"),
ProvidedBindingKind::Variable,
ContractCertainty::Definite,
),
ProvidedBinding::new(
Name::from("pkgver"),
ProvidedBindingKind::Variable,
ContractCertainty::Definite,
),
ProvidedBinding::new(
Name::from("wrksrc"),
ProvidedBindingKind::Variable,
ContractCertainty::Definite,
),
],
provided_functions: Vec::new(),
externally_consumed_bindings: false,
externally_consumed_binding_names: Vec::new(),
externally_consumed_binding_prefixes: Vec::new(),
}),
..SemanticBuildOptions::default()
},
);
for name in ["pkgname", "pkgver", "wrksrc"] {
let reference = model
.references()
.iter()
.find(|reference| reference.name == name)
.unwrap();
let binding = model.resolved_binding(reference.id).unwrap();
assert_eq!(binding.kind, BindingKind::Imported);
assert_eq!(binding.name, name);
assert!(
binding
.attributes
.contains(BindingAttributes::IMPORTED_FILE_ENTRY)
);
}
assert_eq!(
uninitialized_details(&model),
vec![
("pkgname".to_owned(), UninitializedCertainty::Definite),
("pkgver".to_owned(), UninitializedCertainty::Definite),
("wrksrc".to_owned(), UninitializedCertainty::Definite),
]
);
}
#[test]
fn file_entry_contracts_seed_deferred_function_body_reads_as_imported_bindings() {
let source = "\
build() {
printf '%s\\n' \"$pkgname\" \"$pkgver\" \"$wrksrc\"
}
";
let output = Parser::new(source).parse().unwrap();
let indexer = Indexer::new(source, &output);
let model = SemanticModel::build_with_options(
&output.file,
source,
&indexer,
SemanticBuildOptions {
file_entry_contract: Some(FileContract {
required_reads: Vec::new(),
provided_bindings: vec![
ProvidedBinding::new(
Name::from("pkgname"),
ProvidedBindingKind::Variable,
ContractCertainty::Definite,
),
ProvidedBinding::new(
Name::from("pkgver"),
ProvidedBindingKind::Variable,
ContractCertainty::Definite,
),
ProvidedBinding::new(
Name::from("wrksrc"),
ProvidedBindingKind::Variable,
ContractCertainty::Definite,
),
],
provided_functions: Vec::new(),
externally_consumed_bindings: false,
externally_consumed_binding_names: Vec::new(),
externally_consumed_binding_prefixes: Vec::new(),
}),
..SemanticBuildOptions::default()
},
);
for name in ["pkgname", "pkgver", "wrksrc"] {
let reference = model
.references()
.iter()
.find(|reference| reference.name == name && reference.kind == ReferenceKind::Expansion)
.unwrap();
let binding = model.resolved_binding(reference.id).unwrap();
assert_eq!(binding.kind, BindingKind::Imported);
assert_eq!(binding.name, name);
assert!(
binding
.attributes
.contains(BindingAttributes::IMPORTED_FILE_ENTRY)
);
}
assert_eq!(
uninitialized_details(&model),
vec![
("pkgname".to_owned(), UninitializedCertainty::Definite),
("pkgver".to_owned(), UninitializedCertainty::Definite),
("wrksrc".to_owned(), UninitializedCertainty::Definite),
]
);
}
#[test]
fn file_entry_contracts_seed_nested_function_regions_as_imported_bindings() {
let source = "\
hook() {
for f in ${pycompile_dirs}; do
if [ \"${pkgname}\" = \"base-files\" ]; then
echo \"python${pycompile_version}\"
else
printf '%s\\n' \"${pkgver}: ${f}\"
fi
done
}
";
let output = Parser::new(source).parse().unwrap();
let indexer = Indexer::new(source, &output);
let model = SemanticModel::build_with_options(
&output.file,
source,
&indexer,
SemanticBuildOptions {
file_entry_contract: Some(FileContract {
required_reads: Vec::new(),
provided_bindings: vec![
ProvidedBinding::new(
Name::from("pkgname"),
ProvidedBindingKind::Variable,
ContractCertainty::Definite,
),
ProvidedBinding::new(
Name::from("pkgver"),
ProvidedBindingKind::Variable,
ContractCertainty::Definite,
),
ProvidedBinding::new(
Name::from("pycompile_dirs"),
ProvidedBindingKind::Variable,
ContractCertainty::Definite,
),
ProvidedBinding::new(
Name::from("pycompile_version"),
ProvidedBindingKind::Variable,
ContractCertainty::Definite,
),
],
provided_functions: Vec::new(),
externally_consumed_bindings: false,
externally_consumed_binding_names: Vec::new(),
externally_consumed_binding_prefixes: Vec::new(),
}),
..SemanticBuildOptions::default()
},
);
for name in ["pkgname", "pkgver", "pycompile_dirs", "pycompile_version"] {
let reference = model
.references()
.iter()
.find(|reference| reference.name == name)
.unwrap();
let binding = model.resolved_binding(reference.id).unwrap();
assert_eq!(binding.kind, BindingKind::Imported);
assert_eq!(binding.name, name);
assert!(
binding
.attributes
.contains(BindingAttributes::IMPORTED_FILE_ENTRY)
);
}
assert_eq!(
uninitialized_details(&model),
vec![
(
"pycompile_dirs".to_owned(),
UninitializedCertainty::Definite
),
("pkgname".to_owned(), UninitializedCertainty::Definite),
(
"pycompile_version".to_owned(),
UninitializedCertainty::Definite
),
("pkgver".to_owned(), UninitializedCertainty::Definite),
]
);
}
#[test]
fn initialized_file_entry_bindings_suppress_uninitialized_reads() {
let source = "printf '%s\\n' \"$theme_color\"\n";
let output = Parser::new(source).parse().unwrap();
let indexer = Indexer::new(source, &output);
let model = SemanticModel::build_with_options(
&output.file,
source,
&indexer,
SemanticBuildOptions {
file_entry_contract: Some(FileContract {
required_reads: Vec::new(),
provided_bindings: vec![ProvidedBinding::new_file_entry_initialized(
Name::from("theme_color"),
ProvidedBindingKind::Variable,
ContractCertainty::Definite,
)],
provided_functions: Vec::new(),
externally_consumed_bindings: false,
externally_consumed_binding_names: Vec::new(),
externally_consumed_binding_prefixes: Vec::new(),
}),
..SemanticBuildOptions::default()
},
);
let reference = model
.references()
.iter()
.find(|reference| reference.name == "theme_color")
.unwrap();
let binding = model.resolved_binding(reference.id).unwrap();
assert!(
binding
.attributes
.contains(BindingAttributes::IMPORTED_FILE_ENTRY_INITIALIZED)
);
assert!(uninitialized_names(&model).is_empty());
}
#[test]
fn file_entry_contracts_can_mark_assignments_as_caller_consumed() {
let source = "published=1\n";
let output = Parser::new(source).parse().unwrap();
let indexer = Indexer::new(source, &output);
let model = SemanticModel::build_with_options(
&output.file,
source,
&indexer,
SemanticBuildOptions {
file_entry_contract: Some(FileContract {
required_reads: Vec::new(),
provided_bindings: Vec::new(),
provided_functions: Vec::new(),
externally_consumed_bindings: true,
externally_consumed_binding_names: Vec::new(),
externally_consumed_binding_prefixes: Vec::new(),
}),
..SemanticBuildOptions::default()
},
);
let binding = binding_for_name(&model, "published");
assert!(
binding
.attributes
.contains(BindingAttributes::EXTERNALLY_CONSUMED)
);
assert!(model.analysis().unused_assignments().is_empty());
}
#[test]
fn file_entry_contracts_can_mark_exact_nonlocal_assignments_as_caller_consumed() {
let source = "\
published=1
unpublished=1
helper() {
local published=2
}
";
let output = Parser::new(source).parse().unwrap();
let indexer = Indexer::new(source, &output);
let model = SemanticModel::build_with_options(
&output.file,
source,
&indexer,
SemanticBuildOptions {
file_entry_contract: Some(FileContract {
required_reads: Vec::new(),
provided_bindings: Vec::new(),
provided_functions: Vec::new(),
externally_consumed_bindings: false,
externally_consumed_binding_names: vec![Name::from("published")],
externally_consumed_binding_prefixes: Vec::new(),
}),
..SemanticBuildOptions::default()
},
);
let published_bindings = model
.bindings()
.iter()
.filter(|binding| binding.name == "published")
.collect::<Vec<_>>();
assert_eq!(published_bindings.len(), 2);
assert!(
published_bindings[0]
.attributes
.contains(BindingAttributes::EXTERNALLY_CONSUMED),
"{published_bindings:?}"
);
assert!(
!published_bindings[1]
.attributes
.contains(BindingAttributes::EXTERNALLY_CONSUMED),
"{published_bindings:?}"
);
assert!(
!binding_for_name(&model, "unpublished")
.attributes
.contains(BindingAttributes::EXTERNALLY_CONSUMED)
);
assert_eq!(
binding_names(&model, model.analysis().unused_assignments()),
vec!["unpublished", "published"]
);
}
#[test]
fn cyclic_source_closure_does_not_invent_bindings() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let a = temp.path().join("a.sh");
let b = temp.path().join("b.sh");
fs::write(
&main,
"\
#!/bin/sh
. ./a.sh
printf '%s\\n' \"$flag\"
",
)
.unwrap();
fs::write(&a, ". ./b.sh\n").unwrap();
fs::write(&b, ". ./a.sh\n").unwrap();
let model = model_at_path(&main);
assert!(
model
.bindings()
.iter()
.all(|binding| !(binding.name == "flag" && binding.kind == BindingKind::Imported))
);
assert_eq!(
uninitialized_details(&model),
vec![("flag".to_owned(), UninitializedCertainty::Definite)]
);
}
#[test]
fn unsupported_bash_source_alias_fallback_does_not_keep_assignment_live() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.bash");
let loader = temp.path().join("loader.bash");
let helper = temp.path().join("helper.bash");
fs::write(
&main,
"\
#!/bin/bash
flag=1
source ./loader.bash
",
)
.unwrap();
fs::write(
&loader,
"\
#!/bin/bash
SELF=\"${BASH_SOURCE}\"
source \"$(dirname \"${SELF:-$0}\")/helper.bash\"
",
)
.unwrap();
fs::write(&helper, "#!/bin/bash\necho \"$flag\"\n").unwrap();
let model = model_at_path(&main);
assert!(
!model.synthetic_reads.iter().any(|read| read.name == "flag"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(unused.contains(&Name::from("flag")), "unused: {:?}", unused);
}
#[test]
fn escaped_bash_source_template_does_not_import_helper() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.bash");
let helper = temp.path().join("helper.bash");
fs::write(
&main,
"\
#!/bin/bash
flag=1
source \"\\$(dirname \\\"${BASH_SOURCE[0]}\\\")/helper.bash\"
",
)
.unwrap();
fs::write(&helper, "#!/bin/bash\necho \"$flag\"\n").unwrap();
let model = model_at_path(&main);
assert!(
!model.synthetic_reads.iter().any(|read| read.name == "flag"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(unused.contains(&Name::from("flag")), "unused: {:?}", unused);
}
#[test]
fn shellcheck_source_directive_overrides_bash_source_template() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.bash");
let loader = temp.path().join("loader.bash");
let helper = temp.path().join("alt-helper.bash");
fs::write(
&main,
"\
#!/bin/bash
flag=1
source ./loader.bash
",
)
.unwrap();
fs::write(
&loader,
"\
#!/bin/bash
# shellcheck source=alt-helper.bash
source \"$(dirname \"${BASH_SOURCE[0]}\")/missing-helper.bash\"
",
)
.unwrap();
fs::write(&helper, "#!/bin/bash\necho \"$flag\"\n").unwrap();
let model = model_at_path(&main);
assert!(
model.synthetic_reads.iter().any(|read| read.name == "flag"),
"synthetic reads: {:?}",
model.synthetic_reads
);
assert_eq!(
model.source_refs()[0].resolution,
SourceRefResolution::Resolved
);
let unused = reportable_unused_names(&model);
assert!(unused.is_empty(), "unused: {:?}", unused);
}
#[test]
fn configured_zsh_plugin_entrypoint_reads_apply_after_file_assignments() {
struct EntryResolver {
entrypoint: PathBuf,
}
impl PluginResolver for EntryResolver {
fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
vec![PluginRequest {
framework: PluginFramework::ExplicitFilesystem,
kind: PluginRequestKind::Entrypoint,
name: self.entrypoint.to_string_lossy().into_owned(),
span: Span::new(),
explicit: true,
root_hint: None,
}]
}
fn resolve_plugin_request(
&self,
_source_path: &Path,
request: &PluginRequest,
) -> PluginResolution {
if request.kind == PluginRequestKind::Entrypoint {
PluginResolution {
entrypoints: vec![self.entrypoint.clone()],
file_entry_contracts: Vec::new(),
requesting_file_contract: FileContract::default(),
}
} else {
PluginResolution::default()
}
}
}
let temp = tempdir().unwrap();
let main = temp.path().join("main.zsh");
let plugin = temp.path().join("plugin.zsh");
fs::write(
&main,
"#!/bin/zsh\nif true; then\n flag=1\nelse\n flag=2\nfi\nlate_scope() {\n local flag=3\n}\n",
)
.unwrap();
fs::write(&plugin, "echo \"$flag\"\n").unwrap();
let resolver = EntryResolver { entrypoint: plugin };
let model = model_at_path_with_plugin_resolver(&main, &resolver);
assert!(
model.synthetic_reads.iter().any(|read| read.name == "flag"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(
!unused.contains(&Name::from("flag")),
"unused: {:?}",
unused
);
}
#[test]
fn plugin_request_contracts_mark_requesting_file_assignments_consumed() {
struct ContractOnlyResolver;
impl PluginResolver for ContractOnlyResolver {
fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
vec![PluginRequest {
framework: PluginFramework::OhMyZsh,
kind: PluginRequestKind::Plugin,
name: "tmux".to_owned(),
span: Span::new(),
explicit: true,
root_hint: None,
}]
}
fn resolve_plugin_request(
&self,
_source_path: &Path,
_request: &PluginRequest,
) -> PluginResolution {
PluginResolution {
entrypoints: Vec::new(),
file_entry_contracts: Vec::new(),
requesting_file_contract: FileContract {
externally_consumed_binding_prefixes: vec![Name::from("ZSH_TMUX_")],
..FileContract::default()
},
}
}
}
let temp = tempdir().unwrap();
let main = temp.path().join(".zshrc");
fs::write(
&main,
"ZSH_TMUX_AUTOQUIT=true\nZSH_TMUX_DEFAULT_SESSION_NAME=dev\nordinary=1\n",
)
.unwrap();
let model = model_at_path_with_plugin_resolver(&main, &ContractOnlyResolver);
assert_eq!(
binding_names(&model, model.analysis().unused_assignments()),
vec!["ordinary".to_owned()]
);
}
#[test]
fn zsh_plugin_hook_callbacks_make_function_reads_required() {
struct EntryResolver {
entrypoint: PathBuf,
}
impl PluginResolver for EntryResolver {
fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
vec![PluginRequest {
framework: PluginFramework::ExplicitFilesystem,
kind: PluginRequestKind::Entrypoint,
name: self.entrypoint.to_string_lossy().into_owned(),
span: Span::new(),
explicit: true,
root_hint: None,
}]
}
fn resolve_plugin_request(
&self,
_source_path: &Path,
request: &PluginRequest,
) -> PluginResolution {
if request.kind == PluginRequestKind::Entrypoint {
PluginResolution {
entrypoints: vec![self.entrypoint.clone()],
file_entry_contracts: Vec::new(),
requesting_file_contract: FileContract::default(),
}
} else {
PluginResolution::default()
}
}
}
let temp = tempdir().unwrap();
let main = temp.path().join("main.zsh");
let plugin = temp.path().join("plugin.zsh");
fs::write(
&main,
"#!/bin/zsh\nZSH_AUTOSUGGEST_STRATEGY=(match_prev_cmd completion)\n",
)
.unwrap();
fs::write(
&plugin,
"\
add-zsh-hook precmd _plugin_start
_plugin_start() {
_plugin_fetch_suggestion
}
_plugin_fetch_suggestion() {
strategies=(${=ZSH_AUTOSUGGEST_STRATEGY})
}
",
)
.unwrap();
let resolver = EntryResolver { entrypoint: plugin };
let model = model_at_path_with_plugin_resolver(&main, &resolver);
assert!(
model
.synthetic_reads
.iter()
.any(|read| read.name == "ZSH_AUTOSUGGEST_STRATEGY"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(
!unused.contains(&Name::from("ZSH_AUTOSUGGEST_STRATEGY")),
"unused: {:?}",
unused
);
}
#[test]
fn prezto_zstyle_pmodule_loads_static_module_entrypoints() {
struct PreztoResolver {
root: PathBuf,
}
impl PluginResolver for PreztoResolver {
fn resolve_plugin_request(
&self,
_source_path: &Path,
request: &PluginRequest,
) -> PluginResolution {
if request.framework == PluginFramework::Prezto
&& request.kind == PluginRequestKind::Plugin
{
PluginResolution {
entrypoints: vec![
self.root
.join("modules")
.join(&request.name)
.join("init.zsh"),
],
file_entry_contracts: Vec::new(),
requesting_file_contract: FileContract::default(),
}
} else {
PluginResolution::default()
}
}
}
let temp = tempdir().unwrap();
let main = temp.path().join("zpreztorc");
let prezto_root = temp.path().join("prezto");
let module = prezto_root.join("modules/editor/init.zsh");
fs::create_dir_all(module.parent().unwrap()).unwrap();
fs::write(
&main,
"\
#!/bin/zsh
EDITOR_STYLE=1
zstyle ':prezto:load' pmodule \
'editor'
",
)
.unwrap();
fs::write(&module, "print -r -- \"$EDITOR_STYLE\"\n").unwrap();
let resolver = PreztoResolver { root: prezto_root };
let model = model_at_path_with_plugin_resolver(&main, &resolver);
assert!(
model
.synthetic_reads
.iter()
.any(|read| read.name == "EDITOR_STYLE"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(
!unused.contains(&Name::from("EDITOR_STYLE")),
"unused: {unused:?}"
);
}
#[test]
fn prezto_pmodload_loads_static_transitive_module_entrypoints() {
struct PreztoResolver {
root: PathBuf,
}
impl PluginResolver for PreztoResolver {
fn resolve_plugin_request(
&self,
_source_path: &Path,
request: &PluginRequest,
) -> PluginResolution {
if request.framework == PluginFramework::Prezto
&& request.kind == PluginRequestKind::Plugin
{
PluginResolution {
entrypoints: vec![
self.root
.join("modules")
.join(&request.name)
.join("init.zsh"),
],
file_entry_contracts: Vec::new(),
requesting_file_contract: FileContract::default(),
}
} else {
PluginResolution::default()
}
}
}
let temp = tempdir().unwrap();
let main = temp.path().join("zpreztorc");
let prezto_root = temp.path().join("prezto");
let outer = prezto_root.join("modules/outer/init.zsh");
let inner = prezto_root.join("modules/inner/init.zsh");
fs::create_dir_all(outer.parent().unwrap()).unwrap();
fs::create_dir_all(inner.parent().unwrap()).unwrap();
fs::write(
&main,
"\
#!/bin/zsh
INNER_STYLE=1
zstyle ':prezto:load' pmodule 'outer'
",
)
.unwrap();
fs::write(&outer, "pmodload 'inner'\n").unwrap();
fs::write(&inner, "print -r -- \"$INNER_STYLE\"\n").unwrap();
let resolver = PreztoResolver { root: prezto_root };
let model = model_at_path_with_plugin_resolver(&main, &resolver);
assert!(
model
.synthetic_reads
.iter()
.any(|read| read.name == "INNER_STYLE"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(
!unused.contains(&Name::from("INNER_STYLE")),
"unused: {unused:?}"
);
}
#[test]
fn prezto_dynamic_pmodule_styles_do_not_load_modules() {
struct EmptyResolver;
impl PluginResolver for EmptyResolver {
fn resolve_plugin_request(
&self,
_source_path: &Path,
_request: &PluginRequest,
) -> PluginResolution {
panic!("dynamic Prezto module style should not resolve a plugin request");
}
}
let temp = tempdir().unwrap();
let main = temp.path().join("zpreztorc");
fs::write(
&main,
"\
#!/bin/zsh
DYNAMIC_ONLY=1
module=editor
zstyle ':prezto:load' pmodule \"$module\"
",
)
.unwrap();
let model = model_at_path_with_plugin_resolver(&main, &EmptyResolver);
let unused = reportable_unused_names(&model);
assert!(
unused.contains(&Name::from("DYNAMIC_ONLY")),
"unused: {unused:?}"
);
}
#[test]
fn prezto_zstyle_query_forms_do_not_load_target_variables_as_modules() {
struct EmptyResolver;
impl PluginResolver for EmptyResolver {
fn resolve_plugin_request(
&self,
_source_path: &Path,
_request: &PluginRequest,
) -> PluginResolution {
panic!("Prezto zstyle query forms should not resolve plugin requests");
}
}
let temp = tempdir().unwrap();
let main = temp.path().join("zpreztorc");
fs::write(
&main,
"\
#!/bin/zsh
QUERY_ONLY=1
zstyle -a ':prezto:load' pmodule target_modules
zstyle -L -a ':prezto:load' pmodule
",
)
.unwrap();
let model = model_at_path_with_plugin_resolver(&main, &EmptyResolver);
let unused = reportable_unused_names(&model);
assert!(
unused.contains(&Name::from("QUERY_ONLY")),
"unused: {unused:?}"
);
}
#[test]
fn zdot_load_module_loads_static_module_entrypoints() {
struct ZdotResolver {
root: PathBuf,
}
impl PluginResolver for ZdotResolver {
fn resolve_plugin_request(
&self,
_source_path: &Path,
request: &PluginRequest,
) -> PluginResolution {
if request.framework == PluginFramework::Zdot
&& request.kind == PluginRequestKind::Plugin
{
PluginResolution {
entrypoints: vec![
self.root
.join("modules")
.join(&request.name)
.join(format!("{}.zsh", request.name)),
],
file_entry_contracts: Vec::new(),
requesting_file_contract: FileContract::default(),
}
} else {
PluginResolution::default()
}
}
}
let temp = tempdir().unwrap();
let main = temp.path().join(".zshrc");
let zdot_root = temp.path().join("zdot");
let module = zdot_root.join("modules/fzf/fzf.zsh");
fs::create_dir_all(module.parent().unwrap()).unwrap();
fs::write(
&main,
"\
#!/bin/zsh
FZF_STYLE=1
zdot_load_module fzf
",
)
.unwrap();
fs::write(&module, "print -r -- \"$FZF_STYLE\"\n").unwrap();
let resolver = ZdotResolver { root: zdot_root };
let model = model_at_path_with_plugin_resolver(&main, &resolver);
assert!(
model
.synthetic_reads
.iter()
.any(|read| read.name == "FZF_STYLE"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(
!unused.contains(&Name::from("FZF_STYLE")),
"unused: {unused:?}"
);
}
#[test]
fn zdot_dynamic_module_loads_do_not_resolve() {
struct EmptyResolver;
impl PluginResolver for EmptyResolver {
fn resolve_plugin_request(
&self,
_source_path: &Path,
_request: &PluginRequest,
) -> PluginResolution {
panic!("dynamic zdot module load should not resolve a plugin request");
}
}
let temp = tempdir().unwrap();
let main = temp.path().join(".zshrc");
fs::write(
&main,
"\
#!/bin/zsh
DYNAMIC_ZDOT_ONLY=1
module=fzf
zdot_load_module \"$module\"
",
)
.unwrap();
let model = model_at_path_with_plugin_resolver(&main, &EmptyResolver);
let unused = reportable_unused_names(&model);
assert!(
unused.contains(&Name::from("DYNAMIC_ZDOT_ONLY")),
"unused: {unused:?}"
);
}
#[test]
fn zsh_plugin_eval_generated_callbacks_make_function_reads_required() {
struct EntryResolver {
entrypoint: PathBuf,
}
impl PluginResolver for EntryResolver {
fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
vec![PluginRequest {
framework: PluginFramework::ExplicitFilesystem,
kind: PluginRequestKind::Entrypoint,
name: self.entrypoint.to_string_lossy().into_owned(),
span: Span::new(),
explicit: true,
root_hint: None,
}]
}
fn resolve_plugin_request(
&self,
_source_path: &Path,
request: &PluginRequest,
) -> PluginResolution {
if request.kind == PluginRequestKind::Entrypoint {
PluginResolution {
entrypoints: vec![self.entrypoint.clone()],
file_entry_contracts: Vec::new(),
requesting_file_contract: FileContract::default(),
}
} else {
PluginResolution::default()
}
}
}
let temp = tempdir().unwrap();
let main = temp.path().join("main.zsh");
let plugin = temp.path().join("plugin.zsh");
fs::write(
&main,
"#!/bin/zsh\nZSH_AUTOSUGGEST_STRATEGY=(match_prev_cmd completion)\n",
)
.unwrap();
fs::write(
&plugin,
"\
add-zsh-hook precmd _plugin_start
_plugin_start() {
_plugin_bind_widget any modify
}
_plugin_bind_widget() {
local widget=$1
local action=$2
eval '_plugin_widget_$action() { _plugin_$action \"$@\" }'
}
_plugin_modify() {
_plugin_fetch
}
_plugin_fetch() {
_plugin_fetch_suggestion
}
_plugin_fetch_suggestion() {
strategies=(${=ZSH_AUTOSUGGEST_STRATEGY})
}
",
)
.unwrap();
let resolver = EntryResolver { entrypoint: plugin };
let model = model_at_path_with_plugin_resolver(&main, &resolver);
assert!(
model
.synthetic_reads
.iter()
.any(|read| read.name == "ZSH_AUTOSUGGEST_STRATEGY"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(
!unused.contains(&Name::from("ZSH_AUTOSUGGEST_STRATEGY")),
"unused: {:?}",
unused
);
}
#[test]
fn zsh_plugin_generated_callback_inference_ignores_non_eval_text() {
struct EntryResolver {
entrypoint: PathBuf,
}
impl PluginResolver for EntryResolver {
fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
vec![PluginRequest {
framework: PluginFramework::ExplicitFilesystem,
kind: PluginRequestKind::Entrypoint,
name: self.entrypoint.to_string_lossy().into_owned(),
span: Span::new(),
explicit: true,
root_hint: None,
}]
}
fn resolve_plugin_request(
&self,
_source_path: &Path,
request: &PluginRequest,
) -> PluginResolution {
if request.kind == PluginRequestKind::Entrypoint {
PluginResolution {
entrypoints: vec![self.entrypoint.clone()],
file_entry_contracts: Vec::new(),
requesting_file_contract: FileContract::default(),
}
} else {
PluginResolution::default()
}
}
}
let temp = tempdir().unwrap();
let main = temp.path().join("main.zsh");
let plugin = temp.path().join("plugin.zsh");
fs::write(&main, "#!/bin/zsh\nPLUGIN_ONLY=1\n").unwrap();
fs::write(
&plugin,
"\
add-zsh-hook precmd _plugin_start
_plugin_start() {
_plugin_bind_widget any modify
}
_plugin_bind_widget() {
local action=$2
print \"_plugin_$action\"
}
_plugin_modify() {
print \"$PLUGIN_ONLY\"
}
",
)
.unwrap();
let resolver = EntryResolver { entrypoint: plugin };
let model = model_at_path_with_plugin_resolver(&main, &resolver);
assert!(
!model
.synthetic_reads
.iter()
.any(|read| read.name == "PLUGIN_ONLY"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(
unused.contains(&Name::from("PLUGIN_ONLY")),
"unused: {unused:?}"
);
}
#[test]
fn zsh_plugin_generated_callback_inference_ignores_commented_eval_text() {
struct EntryResolver {
entrypoint: PathBuf,
}
impl PluginResolver for EntryResolver {
fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
vec![PluginRequest {
framework: PluginFramework::ExplicitFilesystem,
kind: PluginRequestKind::Entrypoint,
name: self.entrypoint.to_string_lossy().into_owned(),
span: Span::new(),
explicit: true,
root_hint: None,
}]
}
fn resolve_plugin_request(
&self,
_source_path: &Path,
request: &PluginRequest,
) -> PluginResolution {
if request.kind == PluginRequestKind::Entrypoint {
PluginResolution {
entrypoints: vec![self.entrypoint.clone()],
file_entry_contracts: Vec::new(),
requesting_file_contract: FileContract::default(),
}
} else {
PluginResolution::default()
}
}
}
let temp = tempdir().unwrap();
let main = temp.path().join("main.zsh");
let plugin = temp.path().join("plugin.zsh");
fs::write(&main, "#!/bin/zsh\nCOMMENT_ONLY=1\n").unwrap();
fs::write(
&plugin,
"\
add-zsh-hook precmd _plugin_start
_plugin_start() {
_plugin_bind_widget any modify
}
_plugin_bind_widget() {
local action=$2
# eval '_plugin_widget_$action() { _plugin_$action \"$@\" }'
}
_plugin_modify() {
print \"$COMMENT_ONLY\"
}
",
)
.unwrap();
let resolver = EntryResolver { entrypoint: plugin };
let model = model_at_path_with_plugin_resolver(&main, &resolver);
assert!(
!model
.synthetic_reads
.iter()
.any(|read| read.name == "COMMENT_ONLY"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(
unused.contains(&Name::from("COMMENT_ONLY")),
"unused: {unused:?}"
);
}
#[test]
fn zsh_plugin_eval_generated_callbacks_handle_quoted_positional_aliases() {
struct EntryResolver {
entrypoint: PathBuf,
}
impl PluginResolver for EntryResolver {
fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
vec![PluginRequest {
framework: PluginFramework::ExplicitFilesystem,
kind: PluginRequestKind::Entrypoint,
name: self.entrypoint.to_string_lossy().into_owned(),
span: Span::new(),
explicit: true,
root_hint: None,
}]
}
fn resolve_plugin_request(
&self,
_source_path: &Path,
request: &PluginRequest,
) -> PluginResolution {
if request.kind == PluginRequestKind::Entrypoint {
PluginResolution {
entrypoints: vec![self.entrypoint.clone()],
file_entry_contracts: Vec::new(),
requesting_file_contract: FileContract::default(),
}
} else {
PluginResolution::default()
}
}
}
let temp = tempdir().unwrap();
let main = temp.path().join("main.zsh");
let plugin = temp.path().join("plugin.zsh");
fs::write(&main, "#!/bin/zsh\nQUOTED_ALIAS=1\n").unwrap();
fs::write(
&plugin,
"\
add-zsh-hook precmd _plugin_start
_plugin_start() {
_plugin_bind_widget any modify
}
_plugin_bind_widget() {
local action=\"$2\"
eval '_plugin_widget_$action() { _plugin_$action \"$@\" }'
}
_plugin_modify() {
print \"$QUOTED_ALIAS\"
}
",
)
.unwrap();
let resolver = EntryResolver { entrypoint: plugin };
let model = model_at_path_with_plugin_resolver(&main, &resolver);
assert!(
model
.synthetic_reads
.iter()
.any(|read| read.name == "QUOTED_ALIAS"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(
!unused.contains(&Name::from("QUOTED_ALIAS")),
"unused: {unused:?}"
);
}
#[test]
fn zsh_plugin_deferred_file_scope_reads_ignore_declaration_names() {
struct EntryResolver {
entrypoint: PathBuf,
}
impl PluginResolver for EntryResolver {
fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
vec![PluginRequest {
framework: PluginFramework::ExplicitFilesystem,
kind: PluginRequestKind::Entrypoint,
name: self.entrypoint.to_string_lossy().into_owned(),
span: Span::new(),
explicit: true,
root_hint: None,
}]
}
fn resolve_plugin_request(
&self,
_source_path: &Path,
request: &PluginRequest,
) -> PluginResolution {
if request.kind == PluginRequestKind::Entrypoint {
PluginResolution {
entrypoints: vec![self.entrypoint.clone()],
file_entry_contracts: Vec::new(),
requesting_file_contract: FileContract::default(),
}
} else {
PluginResolution::default()
}
}
}
let temp = tempdir().unwrap();
let main = temp.path().join("main.zsh");
let plugin = temp.path().join("plugin.zsh");
fs::write(&main, "#!/bin/zsh\nPLUGIN_DECLARED=1\n").unwrap();
fs::write(
&plugin,
"\
add-zsh-hook precmd _plugin_start
_plugin_start() {
typeset -g PLUGIN_DECLARED
}
",
)
.unwrap();
let resolver = EntryResolver { entrypoint: plugin };
let model = model_at_path_with_plugin_resolver(&main, &resolver);
assert!(
!model
.synthetic_reads
.iter()
.any(|read| read.name == "PLUGIN_DECLARED"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(
unused.contains(&Name::from("PLUGIN_DECLARED")),
"unused: {unused:?}"
);
}
#[test]
fn zsh_plugin_hook_listing_does_not_make_callback_reads_required() {
struct EntryResolver {
entrypoint: PathBuf,
}
impl PluginResolver for EntryResolver {
fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
vec![PluginRequest {
framework: PluginFramework::ExplicitFilesystem,
kind: PluginRequestKind::Entrypoint,
name: self.entrypoint.to_string_lossy().into_owned(),
span: Span::new(),
explicit: true,
root_hint: None,
}]
}
fn resolve_plugin_request(
&self,
_source_path: &Path,
request: &PluginRequest,
) -> PluginResolution {
if request.kind == PluginRequestKind::Entrypoint {
PluginResolution {
entrypoints: vec![self.entrypoint.clone()],
file_entry_contracts: Vec::new(),
requesting_file_contract: FileContract::default(),
}
} else {
PluginResolution::default()
}
}
}
let temp = tempdir().unwrap();
let main = temp.path().join("main.zsh");
let plugin = temp.path().join("plugin.zsh");
fs::write(&main, "#!/bin/zsh\nLISTED_ONLY=1\n").unwrap();
fs::write(
&plugin,
"\
add-zsh-hook -L precmd _plugin_listed
_plugin_listed() {
print \"$LISTED_ONLY\"
}
",
)
.unwrap();
let resolver = EntryResolver { entrypoint: plugin };
let model = model_at_path_with_plugin_resolver(&main, &resolver);
assert!(
!model
.synthetic_reads
.iter()
.any(|read| read.name == "LISTED_ONLY"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(
unused.contains(&Name::from("LISTED_ONLY")),
"unused: {unused:?}"
);
}
#[test]
fn zsh_plugin_hook_registration_collects_later_callbacks() {
struct EntryResolver {
entrypoint: PathBuf,
}
impl PluginResolver for EntryResolver {
fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
vec![PluginRequest {
framework: PluginFramework::ExplicitFilesystem,
kind: PluginRequestKind::Entrypoint,
name: self.entrypoint.to_string_lossy().into_owned(),
span: Span::new(),
explicit: true,
root_hint: None,
}]
}
fn resolve_plugin_request(
&self,
_source_path: &Path,
request: &PluginRequest,
) -> PluginResolution {
if request.kind == PluginRequestKind::Entrypoint {
PluginResolution {
entrypoints: vec![self.entrypoint.clone()],
file_entry_contracts: Vec::new(),
requesting_file_contract: FileContract::default(),
}
} else {
PluginResolution::default()
}
}
}
let temp = tempdir().unwrap();
let main = temp.path().join("main.zsh");
let plugin = temp.path().join("plugin.zsh");
fs::write(&main, "#!/bin/zsh\nSECOND_CALLBACK=1\n").unwrap();
fs::write(
&plugin,
"\
add-zsh-hook precmd _plugin_first _plugin_second
_plugin_first() {
:
}
_plugin_second() {
print \"$SECOND_CALLBACK\"
}
",
)
.unwrap();
let resolver = EntryResolver { entrypoint: plugin };
let model = model_at_path_with_plugin_resolver(&main, &resolver);
assert!(
model
.synthetic_reads
.iter()
.any(|read| read.name == "SECOND_CALLBACK"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(
!unused.contains(&Name::from("SECOND_CALLBACK")),
"unused: {unused:?}"
);
}
#[test]
fn zsh_plugin_deferred_callback_uses_latest_function_definition() {
struct EntryResolver {
entrypoint: PathBuf,
}
impl PluginResolver for EntryResolver {
fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
vec![PluginRequest {
framework: PluginFramework::ExplicitFilesystem,
kind: PluginRequestKind::Entrypoint,
name: self.entrypoint.to_string_lossy().into_owned(),
span: Span::new(),
explicit: true,
root_hint: None,
}]
}
fn resolve_plugin_request(
&self,
_source_path: &Path,
request: &PluginRequest,
) -> PluginResolution {
if request.kind == PluginRequestKind::Entrypoint {
PluginResolution {
entrypoints: vec![self.entrypoint.clone()],
file_entry_contracts: Vec::new(),
requesting_file_contract: FileContract::default(),
}
} else {
PluginResolution::default()
}
}
}
let temp = tempdir().unwrap();
let main = temp.path().join("main.zsh");
let plugin = temp.path().join("plugin.zsh");
fs::write(&main, "#!/bin/zsh\nOLD_CALLBACK=1\nNEW_CALLBACK=1\n").unwrap();
fs::write(
&plugin,
"\
add-zsh-hook precmd _plugin_callback
_plugin_callback() {
print \"$OLD_CALLBACK\"
}
_plugin_callback() {
print \"$NEW_CALLBACK\"
}
",
)
.unwrap();
let resolver = EntryResolver { entrypoint: plugin };
let model = model_at_path_with_plugin_resolver(&main, &resolver);
let unused = reportable_unused_names(&model);
assert!(
unused.contains(&Name::from("OLD_CALLBACK")),
"unused: {unused:?}"
);
assert!(
!unused.contains(&Name::from("NEW_CALLBACK")),
"unused: {unused:?}"
);
}
#[test]
fn zsh_plugin_hook_registration_inside_called_setup_function_is_deferred_root() {
struct EntryResolver {
entrypoint: PathBuf,
}
impl PluginResolver for EntryResolver {
fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
vec![PluginRequest {
framework: PluginFramework::ExplicitFilesystem,
kind: PluginRequestKind::Entrypoint,
name: self.entrypoint.to_string_lossy().into_owned(),
span: Span::new(),
explicit: true,
root_hint: None,
}]
}
fn resolve_plugin_request(
&self,
_source_path: &Path,
request: &PluginRequest,
) -> PluginResolution {
if request.kind == PluginRequestKind::Entrypoint {
PluginResolution {
entrypoints: vec![self.entrypoint.clone()],
file_entry_contracts: Vec::new(),
requesting_file_contract: FileContract::default(),
}
} else {
PluginResolution::default()
}
}
}
let temp = tempdir().unwrap();
let main = temp.path().join("main.zsh");
let plugin = temp.path().join("plugin.zsh");
fs::write(&main, "#!/bin/zsh\nSETUP_CALLBACK=1\n").unwrap();
fs::write(
&plugin,
"\
_plugin_setup() {
add-zsh-hook precmd _plugin_callback
}
_plugin_setup
_plugin_callback() {
print \"$SETUP_CALLBACK\"
}
",
)
.unwrap();
let resolver = EntryResolver { entrypoint: plugin };
let model = model_at_path_with_plugin_resolver(&main, &resolver);
assert!(
model
.synthetic_reads
.iter()
.any(|read| read.name == "SETUP_CALLBACK"),
"synthetic reads: {:?}",
model.synthetic_reads
);
let unused = reportable_unused_names(&model);
assert!(
!unused.contains(&Name::from("SETUP_CALLBACK")),
"unused: {unused:?}"
);
}
#[test]
fn plugin_aware_zsh_source_resolution_imports_framework_entrypoint() {
struct SourceResolver {
entrypoint: PathBuf,
}
impl PluginResolver for SourceResolver {
fn resolve_source_path(&self, _source_path: &Path, candidate: &str) -> Vec<PathBuf> {
if candidate.ends_with("/oh-my-zsh.sh") {
vec![self.entrypoint.clone()]
} else {
Vec::new()
}
}
fn resolve_plugin_request(
&self,
_source_path: &Path,
_request: &PluginRequest,
) -> PluginResolution {
PluginResolution::default()
}
}
let temp = tempdir().unwrap();
let main = temp.path().join("main.zsh");
let entrypoint = temp.path().join("oh-my-zsh.sh");
fs::write(
&main,
"#!/bin/zsh\nZSH=/not-installed/.oh-my-zsh\nflag=1\nsource \"$ZSH/oh-my-zsh.sh\"\n",
)
.unwrap();
fs::write(&entrypoint, "echo \"$flag\"\n").unwrap();
let resolver = SourceResolver { entrypoint };
let model = model_at_path_with_plugin_resolver(&main, &resolver);
assert!(
model.synthetic_reads.iter().any(|read| read.name == "flag"),
"synthetic reads: {:?}",
model.synthetic_reads
);
assert_eq!(
model.source_refs()[0].resolution,
SourceRefResolution::Resolved
);
assert!(model.source_refs()[0].explicitly_provided);
let unused = reportable_unused_names(&model);
assert!(
!unused.contains(&Name::from("flag")),
"unused: {:?}",
unused
);
}
#[test]
fn plugin_source_resolution_does_not_apply_to_non_zsh_sources() {
struct SourceResolver {
entrypoint: PathBuf,
}
impl PluginResolver for SourceResolver {
fn resolve_source_path(&self, _source_path: &Path, _candidate: &str) -> Vec<PathBuf> {
vec![self.entrypoint.clone()]
}
fn resolve_plugin_request(
&self,
_source_path: &Path,
_request: &PluginRequest,
) -> PluginResolution {
PluginResolution::default()
}
}
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let entrypoint = temp.path().join("plugin.sh");
fs::write(
&main,
"#!/bin/sh\nflag=1\nsource /opt/app/plugins/plugin.sh\n",
)
.unwrap();
fs::write(&entrypoint, "echo \"$flag\"\n").unwrap();
let resolver = SourceResolver { entrypoint };
let source = fs::read_to_string(&main).unwrap();
let output = Parser::with_dialect(&source, ShellDialect::Bash)
.parse()
.unwrap();
let indexer = Indexer::new(&source, &output);
let model = SemanticModel::build_with_options(
&output.file,
&source,
&indexer,
SemanticBuildOptions {
source_path: Some(&main),
plugin_resolver: Some(&resolver),
shell_profile: Some(ShellProfile::native(shuck_parser::ShellDialect::Bash)),
..SemanticBuildOptions::default()
},
);
assert_eq!(
model.source_refs()[0].resolution,
SourceRefResolution::Unresolved
);
assert!(!model.source_refs()[0].explicitly_provided);
}
#[test]
fn precise_unused_assignments_match_dataflow_for_source_closure_cases() {
let temp = tempdir().unwrap();
let sourced_main = temp.path().join("sourced-main.sh");
let sourced_helper = temp.path().join("sourced-helper.sh");
fs::write(
&sourced_main,
"\
#!/bin/sh
flag=1
. ./sourced-helper.sh
",
)
.unwrap();
fs::write(&sourced_helper, "echo \"$flag\"\n").unwrap();
let executed_main = temp.path().join("executed-main.sh");
let executed_helper = temp.path().join("executed-helper.sh");
fs::write(
&executed_main,
"\
#!/bin/sh
unused=1
executed-helper.sh
",
)
.unwrap();
fs::write(&executed_helper, "printf '%s\\n' ok\n").unwrap();
let sourced_model = model_at_path(&sourced_main);
assert_unused_assignment_parity(&sourced_model);
let executed_model = model_at_path(&executed_main);
assert_unused_assignment_parity(&executed_model);
}
#[test]
fn non_arithmetic_subscript_reads_are_recorded_in_conditionals_and_declarations() {
let source = "\
#!/bin/bash
[[ -v assoc[\"$key\"] ]]
declare -A map=([\"$other\"]=1)
";
let model = model(source);
let unresolved = unresolved_names(&model);
assert_names_present(&["key", "other"], &unresolved);
let conditional_reference = model
.references()
.iter()
.find(|reference| reference.name == "key")
.expect("expected conditional subscript reference");
assert_eq!(
conditional_reference.kind,
ReferenceKind::ConditionalOperand
);
let declaration_reference = model
.references()
.iter()
.find(|reference| reference.name == "other")
.expect("expected declaration subscript reference");
assert_eq!(declaration_reference.kind, ReferenceKind::Expansion);
}
#[test]
fn associative_subscript_literals_do_not_register_variable_reads() {
let source = "\
#!/bin/bash
declare -A map
map[swift-cmark]=1
printf '%s\\n' \"${map[swift-cmark]}\" \"${map[$dynamic_key]}\"
";
let model = model(source);
let unresolved = unresolved_names(&model);
assert_names_absent(&["swift", "cmark"], &unresolved);
assert_names_present(&["dynamic_key"], &unresolved);
assert!(
model
.bindings()
.iter()
.rev()
.find(|binding| binding.name == "map")
.is_some_and(|binding| binding.attributes.contains(BindingAttributes::ASSOC))
);
}
#[test]
fn associative_arithmetic_subscript_literals_do_not_register_variable_reads() {
let source = "\
#!/bin/bash
declare -A box
printf '%s\\n' \"$((box[m_width]))\" \"$((box[$dynamic_key]))\"
";
let model = model(source);
let unresolved = unresolved_names(&model);
assert_names_absent(&["m_width"], &unresolved);
assert_names_present(&["dynamic_key"], &unresolved);
}
#[test]
fn zsh_associative_key_literals_do_not_register_variable_reads() {
let source = "\
#!/bin/zsh
typeset -A ZINIT ICE
ZINIT[ice-list]=x
ICE[ps-on-update]=x
functions[iterm2_precmd]=x
print -r -- ${functions[iterm2_precmd]}
";
let model = model_with_dialect(source, ShellDialect::Zsh);
let unresolved = unresolved_names(&model);
assert_names_absent(
&["ice", "list", "ps", "on", "update", "iterm2_precmd"],
&unresolved,
);
}
#[test]
fn whole_array_reassignment_preserves_visible_associative_attributes() {
let source = "\
#!/bin/zsh
typeset -A ZINIT
ZINIT=( ${(kv)OTHER[@]} ${(kv)ZINIT[@]} )
ZINIT[bkp-autoload]=${functions[autoload]}
print -r -- ${ZINIT[bkp-autoload]}
";
let model = model_with_dialect(source, ShellDialect::Zsh);
let unresolved = unresolved_names(&model);
assert_names_absent(&["bkp", "autoload"], &unresolved);
assert!(
model
.bindings()
.iter()
.rev()
.find(|binding| binding.name == "ZINIT")
.is_some_and(|binding| binding.attributes.contains(BindingAttributes::ASSOC))
);
}
#[test]
fn explicit_indexed_redeclaration_does_not_preserve_associative_attributes() {
let source = "\
#!/bin/zsh
typeset -A opts
typeset -a opts=(alpha beta)
print -r -- ${opts[$idx]}
";
let model = model_with_dialect(source, ShellDialect::Zsh);
let unresolved = unresolved_names(&model);
assert_names_present(&["idx"], &unresolved);
assert!(
model
.bindings()
.iter()
.rev()
.find(|binding| binding.name == "opts")
.is_some_and(|binding| {
binding.attributes.contains(BindingAttributes::ARRAY)
&& !binding.attributes.contains(BindingAttributes::ASSOC)
})
);
}
#[test]
fn zsh_subscript_declarations_preserve_explicit_array_flags() {
let source = "\
#!/bin/zsh
indexed=scalar
assoc=scalar
plain=scalar
typeset -a indexed[1]=x
typeset -A assoc[key]=x
typeset plain[1]=x
";
let model = model_with_dialect(source, ShellDialect::Zsh);
let indexed = model
.bindings()
.iter()
.rev()
.find(|binding| {
binding.name == "indexed"
&& binding.kind == BindingKind::Declaration(DeclarationBuiltin::Typeset)
})
.expect("expected typed indexed declaration");
assert!(indexed.attributes.contains(BindingAttributes::ARRAY));
assert!(!indexed.attributes.contains(BindingAttributes::ASSOC));
let assoc = model
.bindings()
.iter()
.rev()
.find(|binding| {
binding.name == "assoc"
&& binding.kind == BindingKind::Declaration(DeclarationBuiltin::Typeset)
})
.expect("expected typed associative declaration");
assert!(assoc.attributes.contains(BindingAttributes::ARRAY));
assert!(assoc.attributes.contains(BindingAttributes::ASSOC));
let plain = model
.bindings()
.iter()
.rev()
.find(|binding| {
binding.name == "plain"
&& binding.kind == BindingKind::Declaration(DeclarationBuiltin::Typeset)
})
.expect("expected plain declaration");
assert!(!plain.attributes.contains(BindingAttributes::ARRAY));
assert!(!plain.attributes.contains(BindingAttributes::ASSOC));
}
#[test]
fn arithmetic_indexed_writes_preserve_associative_attributes() {
let source = "\
#!/bin/bash
declare -A box
(( box[key] = 1 ))
printf '%s\\n' \"$((box[m_width]))\" \"$((box[$dynamic_key]))\"
";
let model = model(source);
let unresolved = unresolved_names(&model);
assert_names_absent(&["key", "m_width"], &unresolved);
assert_names_present(&["dynamic_key"], &unresolved);
let arithmetic_binding = model
.bindings()
.iter()
.rev()
.find(|binding| binding.name == "box" && binding.kind == BindingKind::ArithmeticAssignment)
.expect("expected arithmetic box binding");
assert!(
arithmetic_binding
.attributes
.contains(BindingAttributes::ARRAY)
);
assert!(
arithmetic_binding
.attributes
.contains(BindingAttributes::ASSOC)
);
}
#[test]
fn parameter_default_subscript_after_unset_does_not_inherit_associative_attributes() {
let source = "\
#!/bin/bash
declare -A map
unset map
: \"${map[$key]:=}\"
";
let model = model(source);
let binding = model
.bindings()
.iter()
.rev()
.find(|binding| {
binding.name == "map" && binding.kind == BindingKind::ParameterDefaultAssignment
})
.expect("expected parameter-default map binding");
assert!(binding.attributes.contains(BindingAttributes::ARRAY));
assert!(!binding.attributes.contains(BindingAttributes::ASSOC));
}
#[test]
fn parameter_default_subscript_after_function_unset_does_not_inherit_global_assoc() {
let source = "\
#!/bin/bash
declare -A map
f() {
unset map
: \"${map[$key]:=}\"
}
f
";
let model = model(source);
let binding = model
.bindings()
.iter()
.rev()
.find(|binding| {
binding.name == "map" && binding.kind == BindingKind::ParameterDefaultAssignment
})
.expect("expected parameter-default map binding");
assert!(binding.attributes.contains(BindingAttributes::ARRAY));
assert!(!binding.attributes.contains(BindingAttributes::ASSOC));
}
#[test]
fn deferred_parameter_default_after_function_unset_does_not_inherit_later_global_assoc() {
let source = "\
#!/bin/bash
f() {
unset map
: \"${map[$key]:=}\"
}
declare -A map
f
";
let model = model(source);
let binding = model
.bindings()
.iter()
.rev()
.find(|binding| {
binding.name == "map" && binding.kind == BindingKind::ParameterDefaultAssignment
})
.expect("expected parameter-default map binding");
assert!(binding.attributes.contains(BindingAttributes::ARRAY));
assert!(!binding.attributes.contains(BindingAttributes::ASSOC));
}
#[test]
fn deferred_parameter_default_after_global_unset_does_not_inherit_later_global_assoc() {
let source = "\
#!/bin/bash
f() {
: \"${map[$key]:=}\"
}
declare -A map
unset map
f
";
let model = model(source);
let binding = model
.bindings()
.iter()
.rev()
.find(|binding| {
binding.name == "map" && binding.kind == BindingKind::ParameterDefaultAssignment
})
.expect("expected parameter-default map binding");
assert!(binding.attributes.contains(BindingAttributes::ARRAY));
assert!(!binding.attributes.contains(BindingAttributes::ASSOC));
}
#[test]
fn escaped_parameter_replacement_patterns_do_not_register_variable_reads() {
let source = "\
#!/bin/bash
d=lib
origin=/tmp
echo \"${d//\\$ORIGIN/$origin}\"
";
let model = model(source);
let unresolved = unresolved_names(&model);
assert!(
unresolved.is_empty(),
"unexpected unresolved refs: {unresolved:?}"
);
}
#[test]
fn parameter_replacement_pattern_reads_do_not_count_as_uninitialized() {
let source = "\
#!/bin/bash
dir=all/retroarch.cfg
echo \"${dir//$configdir\\/}\"
echo \"${dir##$trim_prefix}\"
find \"$configdir\"
";
let model = model(source);
assert!(model.references().iter().any(|reference| {
reference.name == "configdir"
&& reference.kind == ReferenceKind::ParameterPattern
&& reference.span.slice(source) == "$configdir"
}));
assert!(model.references().iter().any(|reference| {
reference.name == "trim_prefix"
&& reference.kind == ReferenceKind::ParameterPattern
&& reference.span.slice(source) == "$trim_prefix"
}));
let analysis = model.analysis();
let uninitialized = analysis.uninitialized_references();
assert!(uninitialized.iter().any(|uninitialized| {
let reference = model.reference(uninitialized.reference);
reference.name == "configdir" && reference.span.slice(source) == "$configdir"
}));
assert!(!uninitialized.iter().any(|uninitialized| {
let reference = model.reference(uninitialized.reference);
reference.name == "configdir" && reference.kind == ReferenceKind::ParameterPattern
}));
assert!(!uninitialized.iter().any(|uninitialized| {
let reference = model.reference(uninitialized.reference);
reference.name == "trim_prefix" && reference.kind == ReferenceKind::ParameterPattern
}));
}
#[test]
fn redirect_target_references_are_uninitialized_reads() {
let source = "\
#!/bin/bash
{ echo value; } >> \"${missing_target}/out\"
echo \"${ordinary_missing}/out\"
";
let model = model(source);
let redirect_reference = model
.references()
.iter()
.find(|reference| reference.name == "missing_target")
.expect("redirect target reference should be recorded");
block_with_reference(model.analysis().cfg(), redirect_reference.id);
let uninitialized = uninitialized_names(&model);
assert_names_present(&["missing_target", "ordinary_missing"], &uninitialized);
}
#[test]
fn references_in_command_span_filters_to_direct_references_in_the_requested_subspan() {
let source = "\
#!/bin/bash
foo=\"$foo\" cmd \"$foo\" \"$(printf '%s' \"$foo\")\"
";
let output = Parser::new(source).parse().unwrap();
let model = model(source);
let command_id = command_id_starting_with(&model, source, "foo=\"$foo\" cmd").unwrap();
let command_span = model.command_span(command_id);
let Command::Simple(command) = &output.file.body[0].command else {
panic!("expected simple command");
};
let assignment_span = command.assignments[0].span;
let body_word_span = command.args[0].span;
let assignment_refs = model
.references_in_command_span(command_span, assignment_span)
.map(|reference| reference.span.slice(source))
.collect::<Vec<_>>();
let body_refs = model
.references_in_command_span(command_span, body_word_span)
.map(|reference| reference.span.slice(source))
.collect::<Vec<_>>();
let command_refs = model
.references_in_command_span(command_span, command_span)
.map(|reference| reference.span.slice(source))
.collect::<Vec<_>>();
assert_eq!(assignment_refs, vec!["$foo"]);
assert_eq!(body_refs, vec!["$foo"]);
assert_eq!(command_refs, vec!["$foo", "$foo"]);
}
#[test]
fn unreachable_references_are_still_uninitialized_reads() {
let source = "\
#!/bin/bash
load_value() {
return 1
printf '%s\\n' \"$after_return\"
}
load_value
";
let model = model(source);
let uninitialized = uninitialized_names(&model);
assert_names_present(&["after_return"], &uninitialized);
}
#[test]
fn uninitialized_reference_certainty_lookup_matches_analysis_results() {
let source = "\
#!/bin/bash
echo \"$definite\"
case \"$1\" in
yes)
possible=1
;;
esac
echo \"$possible\"
";
let model = model(source);
let analysis = model.analysis();
let references = analysis.uninitialized_references().to_vec();
assert!(
!references.is_empty(),
"expected representative uninitialized references"
);
for uninitialized in references {
let reference = model.reference(uninitialized.reference);
assert_eq!(
analysis.uninitialized_reference_certainty_at(reference.span),
Some(uninitialized.certainty),
"certainty lookup should match analysis results for {}",
reference.name
);
}
}
#[test]
fn zsh_option_analysis_exposes_native_defaults() {
let source = "print $name\n";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let options = model
.zsh_options_at(source.find("print").unwrap())
.expect("expected zsh options");
assert_eq!(options.sh_word_split, OptionValue::Off);
assert_eq!(options.glob, OptionValue::On);
assert_eq!(options.short_loops, OptionValue::On);
}
#[test]
fn zsh_option_analysis_tracks_setopt_updates_by_offset() {
let source = "setopt no_glob\nprint *\n";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let options = model
.zsh_options_at(source.find("print").unwrap())
.expect("expected zsh options");
assert_eq!(options.glob, OptionValue::Off);
}
#[test]
fn zsh_option_analysis_merges_conditionals_to_unknown_on_divergence() {
let source = "if test \"$x\" = y; then\n setopt no_glob\nfi\nprint *\n";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let options = model
.zsh_options_at(source.find("print").unwrap())
.expect("expected zsh options");
assert_eq!(options.glob, OptionValue::Unknown);
}
#[test]
fn zsh_option_analysis_respects_local_options_in_functions() {
let source = "\
fn() {
setopt local_options no_glob
}
fn
print *
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let options = model
.zsh_options_at(source.find("print").unwrap())
.expect("expected zsh options");
assert_eq!(options.glob, OptionValue::On);
}
#[test]
fn zsh_option_analysis_applies_top_level_local_options_to_function_leaks() {
let source = "\
setopt localoptions
fn() {
setopt no_glob
}
fn
print *
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let options = model
.zsh_options_at(source.find("print").unwrap())
.expect("expected zsh options");
assert_eq!(options.glob, OptionValue::On);
}
#[test]
fn zsh_option_analysis_leaks_function_option_updates_by_default() {
let source = "\
fn() {
setopt sh_word_split
}
fn
print $name
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
assert!(
model.scopes[0]
.bindings
.keys()
.any(|name| name.as_str() == "fn"),
"expected top-level function binding for `fn`"
);
assert!(
model.recorded_program.function_body_scopes.len() == 1,
"expected one recorded function body scope"
);
assert!(
model
.recorded_program
.command_infos
.values()
.any(|info_id| {
model
.recorded_program
.command_info(*info_id)
.static_callee
.as_deref()
== Some("fn")
}),
"expected a static callee for the function call"
);
let options = model
.zsh_options_at(source.find("print").unwrap())
.expect("expected zsh options");
assert_eq!(options.sh_word_split, OptionValue::On);
}
#[test]
fn zsh_option_analysis_falls_back_to_ancestor_state_in_uncalled_function_bodies() {
let source = "\
fn() {
print $name
}
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let options = model
.zsh_options_at(source.find("print").unwrap())
.expect("expected inherited zsh options");
assert_eq!(options.sh_word_split, OptionValue::Off);
assert_eq!(options.glob, OptionValue::On);
}
#[test]
fn zsh_option_analysis_merges_function_snapshots_from_multiple_call_contexts() {
let source = "\
fn() {
print $name
}
fn
setopt sh_word_split
fn
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let options = model
.zsh_options_at(source.find("print").unwrap())
.expect("expected merged function zsh options");
assert_eq!(options.sh_word_split, OptionValue::Unknown);
}
#[test]
fn zsh_option_analysis_tracks_wrapped_option_builtins() {
let source = "\
command setopt no_glob
builtin unsetopt short_loops
print *
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let options = model
.zsh_options_at(source.find("print").unwrap())
.expect("expected wrapped zsh option effects");
assert_eq!(options.glob, OptionValue::Off);
assert_eq!(options.short_loops, OptionValue::Off);
}
#[test]
fn zsh_option_analysis_skips_assignment_prefixes_after_wrappers() {
for source in [
"\
command FOO=1 setopt no_glob
print *
",
"\
noglob FOO=1 setopt no_glob
print *
",
] {
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let options = model
.zsh_options_at(source.find("print").unwrap())
.expect("expected wrapped zsh option effects");
assert_eq!(options.glob, OptionValue::Off, "{source}");
}
}
#[test]
fn zsh_option_analysis_tracks_command_repeated_p_wrapper() {
let source = "\
command -pp setopt no_glob
print *
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let options = model
.zsh_options_at(source.find("print").unwrap())
.expect("expected wrapped zsh option effects");
assert_eq!(options.glob, OptionValue::Off);
}
#[test]
fn zsh_option_analysis_tracks_exec_bundled_option_wrappers() {
for source in [
"\
exec -cl setopt no_glob
print *
",
"\
exec -lc setopt no_glob
print *
",
"\
exec -la shuck setopt no_glob
print *
",
] {
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let options = model
.zsh_options_at(source.find("print").unwrap())
.expect("expected wrapped zsh option effects");
assert_eq!(options.glob, OptionValue::Off, "{source}");
}
}
#[test]
fn zsh_option_analysis_ignores_external_command_wrappers() {
for source in [
"\
sudo setopt no_glob
print *
",
"\
find . -exec setopt no_glob \\;
print *
",
"\
command FOO=1 sudo setopt no_glob
print *
",
"\
command FOO=1 find . -exec setopt no_glob \\;
print *
",
] {
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let options = model
.zsh_options_at(source.find("print").unwrap())
.expect("expected wrapped zsh options");
assert_eq!(options.glob, OptionValue::On, "{source}");
}
}
#[test]
fn zsh_option_analysis_ignores_command_lookup_modes() {
for source in [
"\
command -v setopt no_glob
print *
",
"\
command -pv setopt no_glob
print *
",
"\
command -pV setopt no_glob
print *
",
] {
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let options = model
.zsh_options_at(source.find("print").unwrap())
.expect("expected wrapped zsh options");
assert_eq!(options.glob, OptionValue::On, "{source}");
}
}
#[test]
fn zsh_option_analysis_ignores_unsupported_precommand_options() {
for source in [
"\
command -x setopt no_glob
print *
",
"\
builtin -x setopt no_glob
print *
",
"\
exec -x setopt no_glob
print *
",
] {
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let options = model
.zsh_options_at(source.find("print").unwrap())
.expect("expected wrapped zsh options");
assert_eq!(options.glob, OptionValue::On, "{source}");
}
}
#[test]
fn zsh_option_analysis_tracks_ksh_arrays_updates_by_offset() {
for (source, expected) in [
("setopt ksh_arrays\nprint $name\n", OptionValue::On),
("setopt ksharrays\nprint $name\n", OptionValue::On),
("unsetopt no_ksh_arrays\nprint $name\n", OptionValue::On),
("unsetopt no-ksh-arrays\nprint $name\n", OptionValue::On),
("setopt -- ksh_arrays\nprint $name\n", OptionValue::On),
("unsetopt -- no_ksh_arrays\nprint $name\n", OptionValue::On),
("emulate ksh\nprint $name\n", OptionValue::On),
("emulate -R ksh\nprint $name\n", OptionValue::On),
("unsetopt ksh_arrays\nprint $name\n", OptionValue::Off),
("unsetopt ksharrays\nprint $name\n", OptionValue::Off),
("setopt no_ksh_arrays\nprint $name\n", OptionValue::Off),
("setopt no-ksh-arrays\nprint $name\n", OptionValue::Off),
("emulate zsh\nprint $name\n", OptionValue::Off),
("emulate sh\nprint $name\n", OptionValue::Off),
("emulate csh\nprint $name\n", OptionValue::Off),
] {
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let options = model
.zsh_options_at(source.find("print").unwrap())
.expect("expected zsh options");
assert_eq!(options.ksh_arrays, expected, "{source}");
}
}
#[test]
fn zsh_option_analysis_treats_dynamic_ksh_arrays_updates_as_unknown() {
for source in [
"opt=ksh_arrays\nsetopt \"$opt\"\nprint $name\n",
"opt=ksh_arrays\nset -o \"$opt\"\nprint $name\n",
"opt=ksh_arrays\nemulate -o \"$opt\" zsh\nprint $name\n",
"opt=no_ksh_arrays\nunsetopt \"$opt\"\nprint $name\n",
"opt=no_ksh_arrays\nset +o \"$opt\"\nprint $name\n",
"setopt -m 'ksh*'\nprint $name\n",
"unsetopt -m 'no_ksh*'\nprint $name\n",
"mode=ksh\nemulate \"$mode\"\nprint $name\n",
] {
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let options = model
.zsh_options_at(source.find("print").unwrap())
.expect("expected zsh options");
assert_eq!(options.ksh_arrays, OptionValue::Unknown, "{source}");
}
}
fn array_reference_policy_at(model: &SemanticModel, offset: usize) -> ArrayReferencePolicy {
model.shell_behavior_at(offset).array_reference_policy()
}
#[test]
fn semantic_behavior_tracks_subscript_indexing_options() {
let bash_source = "print ${arr[0]}\n";
let bash_model = model_with_profile(bash_source, ShellProfile::native(ShellDialect::Bash));
let bash_offset = bash_source.find("print").unwrap();
assert_eq!(
bash_model
.shell_behavior_at(bash_offset)
.subscript_indexing(),
SubscriptIndexBehavior::ZeroBased,
);
for (source, expected) in [
("print ${arr[0]}\n", SubscriptIndexBehavior::OneBased),
(
"setopt ksh_arrays\nprint ${arr[1]}\n",
SubscriptIndexBehavior::ZeroBased,
),
(
"setopt ksh_arrays ksh_zero_subscript\nprint ${arr[0]}\n",
SubscriptIndexBehavior::ZeroBased,
),
(
"setopt ksh_arrays\nif cond; then setopt ksh_zero_subscript; fi\nprint ${arr[1]}\n",
SubscriptIndexBehavior::ZeroBased,
),
(
"setopt ksh_arrays\nunsetopt ksh_arrays\nprint ${arr[1]}\n",
SubscriptIndexBehavior::OneBased,
),
(
"setopt ksh_zero_subscript\nunsetopt ksh_zero_subscript\nprint ${arr[0]}\n",
SubscriptIndexBehavior::OneBased,
),
(
"setopt ksh_zero_subscript\nprint ${arr[0]}\n",
SubscriptIndexBehavior::OneBasedWithZeroAlias,
),
(
"opt=ksh_zero_subscript\nsetopt \"$opt\"\nprint ${arr[0]}\n",
SubscriptIndexBehavior::Ambiguous,
),
] {
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let offset = source.find("print").unwrap();
assert_eq!(
model.shell_behavior_at(offset).subscript_indexing(),
expected,
"{source}"
);
}
}
#[test]
fn semantic_behavior_tracks_arithmetic_literal_options() {
let bash_source = "print $(( 010 + 0x10 ))\n";
let bash_model = model_with_profile(bash_source, ShellProfile::native(ShellDialect::Bash));
let bash_offset = bash_source.find("print").unwrap();
assert_eq!(
bash_model
.shell_behavior_at(bash_offset)
.arithmetic_literals(),
ArithmeticLiteralBehavior::CStyleAndLeadingZeroOctal,
);
for (source, expected) in [
(
"print $(( 010 + 0x10 ))\n",
ArithmeticLiteralBehavior::DecimalUnlessExplicitBase,
),
(
"setopt c_bases\nprint $(( 010 + 0x10 ))\n",
ArithmeticLiteralBehavior::DecimalUnlessExplicitBase,
),
(
"setopt octal_zeroes\nprint $(( 010 + 0x10 ))\n",
ArithmeticLiteralBehavior::LeadingZeroOctal,
),
(
"setopt c_bases octal_zeroes\nprint $(( 010 + 0x10 ))\n",
ArithmeticLiteralBehavior::LeadingZeroOctal,
),
(
"opt=octal_zeroes\nsetopt \"$opt\"\nprint $(( 010 + 0x10 ))\n",
ArithmeticLiteralBehavior::Ambiguous,
),
] {
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let offset = source.find("print").unwrap();
assert_eq!(
model.shell_behavior_at(offset).arithmetic_literals(),
expected,
"{source}"
);
}
}
#[test]
fn semantic_behavior_detects_real_ksh_array_enables() {
for source in [
"setopt ksh_arrays\nprint $name\n",
"setopt ksharrays\nprint $name\n",
"unsetopt no_ksh_arrays\nprint $name\n",
"unsetopt no-ksh-arrays\nprint $name\n",
"setopt -- ksh_arrays\nprint $name\n",
"unsetopt -- no_ksh_arrays\nprint $name\n",
"emulate ksh\nprint $name\n",
"emulate -L ksh\nprint $name\n",
"emulate -R ksh\nprint $name\n",
"opt=ksh_arrays\nsetopt \"$opt\"\nprint $name\n",
"opt=ksh_arrays\nset -o \"$opt\"\nprint $name\n",
"opt=no_ksh_arrays\nunsetopt \"$opt\"\nprint $name\n",
"opt=no_ksh_arrays\nset +o \"$opt\"\nprint $name\n",
"setopt -m 'ksh*'\nprint $name\n",
"unsetopt -m 'no_ksh*'\nprint $name\n",
"mode=ksh\nemulate \"$mode\"\nprint $name\n",
] {
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let offset = source.find("print").unwrap();
assert_ne!(
array_reference_policy_at(&model, offset),
ArrayReferencePolicy::NativeZshScalar,
"{source}"
);
}
}
#[test]
fn semantic_behavior_ignores_non_effect_text_for_ksh_arrays() {
for source in [
"# emulate ksh in comments should not count\nprint $name\n",
"msg='setopt ksh_arrays'\nprint $name\n",
"setopt no_ksh_arrays\nprint $name\n",
"emulate sh\nprint $name\n",
"emulate zsh\nprint $name\n",
"unsetopt ksh_arrays\nprint $name\n",
] {
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let offset = source.find("print").unwrap();
assert_eq!(
array_reference_policy_at(&model, offset),
ArrayReferencePolicy::NativeZshScalar,
"{source}"
);
}
}
#[test]
fn semantic_shell_behavior_uses_selector_policy_for_non_zsh_profiles() {
let source = "setopt ksh_arrays\nprint $name\n";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Bash));
let offset = source.find("print").unwrap();
assert_eq!(
array_reference_policy_at(&model, offset),
ArrayReferencePolicy::RequiresExplicitSelector,
"{source}"
);
}
#[test]
fn semantic_runtime_ksh_arrays_state_requires_guaranteed_function_local_disable() {
let source = "\
fn() {
if [[ -n $flag ]]; then
emulate -LR zsh
fi
print $name
}
dispatcher=fn
setopt ksh_arrays
$dispatcher
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let offset = source.find("print $name").unwrap();
assert!(
model
.zsh_options_at(offset)
.is_some_and(|options| options.ksh_arrays == OptionValue::Unknown),
"{source}"
);
assert_eq!(
array_reference_policy_at(&model, offset),
ArrayReferencePolicy::Ambiguous,
"{source}"
);
}
#[test]
fn semantic_runtime_ksh_arrays_state_keeps_unconditional_function_local_disable() {
let source = "\
fn() {
emulate -LR zsh
print $name
}
dispatcher=fn
setopt ksh_arrays
$dispatcher
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let offset = source.find("print $name").unwrap();
assert_eq!(
array_reference_policy_at(&model, offset),
ArrayReferencePolicy::NativeZshScalar,
"{source}"
);
}
#[test]
fn semantic_runtime_ksh_arrays_state_tracks_top_level_indirect_calls() {
let source = "\
fn() {
emulate ksh
}
dispatcher=fn
$dispatcher
print $name
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let offset = source.find("print $name").unwrap();
assert!(
model
.zsh_options_at(offset)
.is_some_and(|options| options.ksh_arrays == OptionValue::On),
"{source}"
);
assert_eq!(
array_reference_policy_at(&model, offset),
ArrayReferencePolicy::RequiresExplicitSelector,
"{source}"
);
}
#[test]
fn semantic_runtime_ksh_arrays_state_treats_unresolved_dispatch_as_ambiguous() {
let source = "\
enable_ksh() {
emulate ksh
}
$dispatcher
print $name
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let offset = source.find("print $name").unwrap();
assert!(
model
.zsh_options_at(offset)
.is_some_and(|options| options.ksh_arrays == OptionValue::Unknown),
"{source}"
);
assert_eq!(
array_reference_policy_at(&model, offset),
ArrayReferencePolicy::Ambiguous,
"{source}"
);
}
#[test]
fn semantic_runtime_ksh_arrays_state_recovers_after_top_level_explicit_disable() {
let source = "\
enable_ksh() {
emulate ksh
}
dispatcher=enable_ksh
$dispatcher
unsetopt ksh_arrays
print $name
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let offset = source.find("print $name").unwrap();
assert!(
model
.zsh_options_at(offset)
.is_some_and(|options| options.ksh_arrays == OptionValue::Off),
"{source}"
);
assert_eq!(
array_reference_policy_at(&model, offset),
ArrayReferencePolicy::NativeZshScalar,
"{source}"
);
}
#[test]
fn semantic_runtime_ksh_arrays_state_tracks_wrapped_top_level_indirect_calls() {
let source = "\
enable_ksh() {
emulate ksh
}
dispatcher=enable_ksh
noglob $dispatcher
print $name
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let offset = source.find("print $name").unwrap();
assert!(
model
.zsh_options_at(offset)
.is_some_and(|options| options.ksh_arrays == OptionValue::On),
"{source}"
);
assert_eq!(
array_reference_policy_at(&model, offset),
ArrayReferencePolicy::RequiresExplicitSelector,
"{source}"
);
}
#[test]
fn semantic_runtime_ksh_arrays_state_ignores_command_wrapped_dynamic_calls() {
let source = "\
enable_ksh() {
emulate ksh
}
dispatcher=enable_ksh
command $dispatcher
print $name
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let offset = source.find("print $name").unwrap();
assert_eq!(
array_reference_policy_at(&model, offset),
ArrayReferencePolicy::NativeZshScalar,
"{source}"
);
}
#[test]
fn semantic_runtime_ksh_arrays_state_prefers_latest_visible_dynamic_target() {
let source = "\
fn() {
emulate ksh
}
fn() {
unsetopt ksh_arrays
}
dispatcher=fn
setopt ksh_arrays
$dispatcher
print $name
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let offset = source.find("print $name").unwrap();
assert_eq!(
array_reference_policy_at(&model, offset),
ArrayReferencePolicy::NativeZshScalar,
"{source}"
);
}
#[test]
fn semantic_runtime_ksh_arrays_state_treats_unknown_dispatcher_binding_as_ambiguous() {
let source = "\
enable_ksh() {
emulate ksh
}
run_dispatcher() {
unsetopt ksh_arrays
dispatcher=$1
$dispatcher
print $name
}
run_dispatcher enable_ksh
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let offset = source.find("print $name").unwrap();
assert!(
model
.zsh_options_at(offset)
.is_some_and(|options| options.ksh_arrays == OptionValue::Off),
"{source}"
);
assert_eq!(
array_reference_policy_at(&model, offset),
ArrayReferencePolicy::Ambiguous,
"{source}"
);
}
#[test]
fn semantic_runtime_zsh_option_analysis_reuses_dynamic_function_summaries() {
let mut source = String::from("#!/bin/zsh\n");
for index in 0..12 {
source.push_str(&format!(
"\
f{index}() {{
$dispatcher
}}
"
));
}
source.push_str(
"\
enable_ksh() {
emulate ksh
}
run_dispatcher() {
$dispatcher
print $name
}
run_dispatcher
",
);
let model = model_with_profile(&source, ShellProfile::native(ShellDialect::Zsh));
let offset = source.find("print $name").unwrap();
assert_eq!(
array_reference_policy_at(&model, offset),
ArrayReferencePolicy::Ambiguous
);
}
#[test]
fn semantic_runtime_zsh_option_analysis_records_cached_root_function_snapshots() {
let source = "\
setopt ksh_arrays
unsetopt ksh_arrays
callee() {
print $name
}
caller() {
callee
}
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let caller_call_offset = source.rfind("callee").unwrap();
let callee_print_offset = source.find("print $name").unwrap();
assert_eq!(
array_reference_policy_at(&model, caller_call_offset),
ArrayReferencePolicy::Ambiguous
);
assert!(
model
.shell_behavior_at(callee_print_offset)
.zsh_options()
.is_some_and(|options| options.ksh_arrays == OptionValue::Unknown),
"{source}"
);
assert_eq!(
array_reference_policy_at(&model, callee_print_offset),
ArrayReferencePolicy::Ambiguous
);
}
#[test]
fn semantic_runtime_zsh_option_analysis_keys_summaries_by_recursion_context() {
let source = "\
setopt ksh_arrays
unsetopt ksh_arrays
f() {
g
}
g() {
h
}
h() {
g
print $name
unsetopt ksh_arrays
}
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let f_call_offset = source.find(" g\n").unwrap() + 2;
let h_print_offset = source.find("print $name").unwrap();
assert_eq!(
array_reference_policy_at(&model, f_call_offset),
ArrayReferencePolicy::Ambiguous
);
assert!(
model
.shell_behavior_at(h_print_offset)
.zsh_options()
.is_some_and(|options| options.ksh_arrays == OptionValue::Unknown),
"{source}"
);
assert_eq!(
array_reference_policy_at(&model, h_print_offset),
ArrayReferencePolicy::Ambiguous
);
}
#[test]
fn semantic_runtime_zsh_option_summary_cache_keeps_recursive_context_local() {
let source = "\
unsetopt ksh_arrays
f() {
g
setopt ksh_arrays
}
g() {
f
}
f
unsetopt ksh_arrays
g
print $name
";
let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
let offset = source.find("print $name").unwrap();
assert!(
model
.shell_behavior_at(offset)
.zsh_options()
.is_some_and(|options| options.ksh_arrays == OptionValue::On),
"{source}"
);
assert_eq!(
array_reference_policy_at(&model, offset),
ArrayReferencePolicy::RequiresExplicitSelector
);
}