use rowan::TextRange;
use crate::ast::{AstNode, AstToken, CallExpr, Expr, MacroCall};
use crate::linter::diagnostic::Diagnostic;
use crate::linter::rules::{Example, Rule, RuleContext};
use crate::project::include_target;
use crate::resolve::{Namespace, Resolution, Resolver, has_unresolvable_using};
use crate::syntax::{SyntaxKind, SyntaxNode};
pub struct UndefinedName;
const MODULE_IMPLICIT: &[&str] = &["eval", "include", "new", "ccall"];
impl Rule for UndefinedName {
fn id(&self) -> &'static str {
"undefined-name"
}
fn default_enabled(&self) -> bool {
false
}
fn description(&self) -> &'static str {
"Flag an identifier that no resolution tier provides: not a local or \
a file binding, not a workspace sibling, not a whole-module \
`using`'s export, and not a Base/Core name. Such a read raises \
`UndefVarError` at runtime. The whole file is skipped when it \
`eval`s, `include`s outside a known workspace, or `using`s a module \
the library cannot resolve — in those cases any name may exist; \
value reads inside macro calls and quoted code are likewise exempt. \
Off by default: the rule needs project context to be sound, so the \
language server enables it for workspace member files, while the CLI \
(resolving against a built-in Base/Core snapshot) leaves it opt-in \
for self-contained scripts."
}
fn examples(&self) -> &'static [Example] {
&[Example {
caption: "`raduis` is a typo; no tier resolves it:",
source: "function area(radius)\n return pi * raduis^2\nend\n",
}]
}
fn check_file(&self, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(resolution) = &ctx.resolution else {
return;
};
if has_unresolvable_using(
ctx.model,
resolution.packages,
resolution.workspace.as_ref(),
) {
return;
}
let scan = FileScan::collect(ctx.root);
if scan.calls_eval {
return;
}
if scan.dynamic_include || (resolution.workspace.is_none() && scan.literal_include) {
return;
}
let resolver = Resolver::new(ctx.model, resolution.packages)
.with_workspace(resolution.workspace.clone());
for ident in ctx.model.idents() {
if ident.binding.is_some() {
continue;
}
if scan.in_skipped(ident.range, ident.is_macro) {
continue;
}
let namespace = if ident.is_macro {
Namespace::Macro
} else {
Namespace::Value
};
if !ident.is_macro
&& (MODULE_IMPLICIT.contains(&ident.name.as_str()) || ident.name == "_")
{
continue;
}
if resolver.resolve(&ident.name, ident.range.start(), namespace)
== Resolution::Unresolved
{
let display = if ident.is_macro {
format!("@{}", ident.name)
} else {
ident.name.to_string()
};
sink.push(Diagnostic::new(
self.id(),
ident.range,
format!("`{display}` is not defined"),
));
}
}
}
}
struct FileScan {
macro_calls: Vec<TextRange>,
quotes: Vec<TextRange>,
calls_eval: bool,
literal_include: bool,
dynamic_include: bool,
}
impl FileScan {
fn collect(root: &SyntaxNode) -> Self {
let mut scan = FileScan {
macro_calls: Vec::new(),
quotes: Vec::new(),
calls_eval: false,
literal_include: false,
dynamic_include: false,
};
for node in root.descendants() {
match node.kind() {
SyntaxKind::MACRO_CALL => {
scan.macro_calls.push(node.text_range());
let name = MacroCall::cast(node)
.and_then(|call| call.name())
.and_then(|name| name.macro_token());
if name.is_some_and(|token| token.text() == "eval") {
scan.calls_eval = true;
}
}
SyntaxKind::QUOTE_EXPR => scan.quotes.push(node.text_range()),
SyntaxKind::CALL_EXPR => {
let Some(call) = CallExpr::cast(node) else {
continue;
};
let Some(Expr::Name(callee)) = call.callee() else {
continue;
};
match callee.ident().map(|ident| ident.text().to_string()) {
Some(name) if name == "eval" => scan.calls_eval = true,
Some(name) if name == "include" => {
if include_target(&call).is_some() {
scan.literal_include = true;
} else {
scan.dynamic_include = true;
}
}
_ => {}
}
}
_ => {}
}
}
scan
}
fn in_skipped(&self, range: TextRange, is_macro: bool) -> bool {
let within = |extents: &[TextRange]| extents.iter().any(|e| e.contains_range(range));
within(&self.quotes) || (!is_macro && within(&self.macro_calls))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::index::model::{
DefLocation, ExportedName, FunctionGroup, ModuleIndex, ModuleUsing, PackageIndex, Span,
Visibility,
};
use crate::linter::rules::ResolutionContext;
use crate::semantic::SemanticModel;
use std::collections::BTreeMap;
use std::sync::Arc;
fn loc() -> DefLocation {
DefLocation {
file: "src/x.jl".into(),
range: Span { start: 0, end: 0 },
}
}
fn base(exports: &[&str]) -> BTreeMap<String, Arc<PackageIndex>> {
let pkg = PackageIndex {
name: "Base".to_string(),
root: ModuleIndex {
name: "Base".to_string(),
bare: false,
loc: loc(),
exports: exports
.iter()
.map(|n| ExportedName {
name: n.to_string(),
visibility: Visibility::Exported,
loc: loc(),
})
.collect(),
functions: Vec::new(),
types: Vec::new(),
consts: Vec::new(),
macros: Vec::new(),
submodules: Vec::new(),
usings: Vec::new(),
imported_names: Vec::new(),
},
members: Vec::new(),
member_modules: Default::default(),
diagnostics: Vec::new(),
};
BTreeMap::from([("Base".to_string(), Arc::new(pkg))])
}
fn workspace(siblings: &[&str]) -> Arc<PackageIndex> {
Arc::new(PackageIndex {
name: "MyPkg".to_string(),
root: ModuleIndex {
name: "MyPkg".to_string(),
bare: false,
loc: loc(),
exports: Vec::new(),
functions: siblings
.iter()
.map(|f| FunctionGroup {
name: f.to_string(),
owner: None,
methods: Vec::new(),
doc: None,
})
.collect(),
types: Vec::new(),
consts: Vec::new(),
macros: Vec::new(),
submodules: Vec::new(),
usings: Vec::new(),
imported_names: Vec::new(),
},
members: Vec::new(),
member_modules: Default::default(),
diagnostics: Vec::new(),
})
}
fn workspace_with_loads(usings: &[&[&str]], imported: &[&str]) -> Arc<PackageIndex> {
let mut pkg = (*workspace(&[])).clone();
pkg.root.usings = usings
.iter()
.map(|components| ModuleUsing {
leading_dots: 0,
components: components.iter().map(|c| c.to_string()).collect(),
})
.collect();
pkg.root.imported_names = imported.iter().map(|n| n.to_string()).collect();
Arc::new(pkg)
}
fn base_plus(name: &str, exports: &[&str]) -> BTreeMap<String, Arc<PackageIndex>> {
let mut lib = base(&[]);
let extra = base(exports);
let mut pkg = (*extra.get("Base").unwrap().clone()).clone();
pkg.name = name.to_string();
pkg.root.name = name.to_string();
lib.insert(name.to_string(), Arc::new(pkg));
lib
}
fn messages(
src: &str,
packages: &BTreeMap<String, Arc<PackageIndex>>,
ws: Option<Arc<PackageIndex>>,
) -> Vec<String> {
let parsed = crate::parser::parse(src);
assert!(parsed.diagnostics.is_empty(), "fixture must parse clean");
let model = SemanticModel::build(&parsed.cst);
let ctx = RuleContext {
path: None,
root: &parsed.cst,
model: &model,
resolution: Some(ResolutionContext {
packages,
workspace: ws.map(|pkg| (pkg, Vec::new())),
}),
includes: &[],
julia_target: None,
};
let mut sink = Vec::new();
UndefinedName.check_file(&ctx, &mut sink);
sink.into_iter().map(|d| d.message.body).collect()
}
#[test]
fn qualified_base_extension_resolves_via_self_export() {
let lib = base(&["Base", "IO", "print", "show"]);
let src = "function Base.show(io::IO, x)\n print(io, x)\nend\n";
assert_eq!(
messages(src, &lib, Some(workspace(&[]))),
Vec::<String>::new()
);
}
#[test]
fn qualified_base_extension_flags_without_self_export() {
let lib = base(&["IO", "print", "show"]);
let src = "function Base.show(io::IO, x)\n print(io, x)\nend\n";
let msgs = messages(src, &lib, Some(workspace(&[])));
assert_eq!(msgs, vec!["`Base` is not defined".to_string()], "{msgs:?}");
}
#[test]
fn workspace_package_self_name_resolves() {
let lib = base(&[]);
let msgs = messages("f() = MyPkg.helper()\n", &lib, Some(workspace(&["helper"])));
assert_eq!(msgs, Vec::<String>::new(), "{msgs:?}");
}
#[test]
fn workspace_sibling_resolves() {
let lib = base(&[]);
let msgs = messages(
"f() = helper() + helprr()\n",
&lib,
Some(workspace(&["helper"])),
);
assert_eq!(msgs.len(), 1, "{msgs:?}");
assert!(msgs[0].contains("helprr"));
}
#[test]
fn sibling_using_export_resolves() {
let lib = base_plus("SparseArrays", &["SparseMatrixCSC"]);
let ws = workspace_with_loads(&[&["SparseArrays"]], &[]);
assert_eq!(
messages("f(::SparseMatrixCSC) = 1\n", &lib, Some(ws)),
Vec::<String>::new(),
);
}
#[test]
fn sibling_imported_name_resolves() {
let lib = base(&[]);
let ws = workspace_with_loads(&[], &["Foo"]);
assert_eq!(
messages("g() = Foo.helper()\n", &lib, Some(ws)),
Vec::<String>::new(),
);
}
#[test]
fn unresolvable_sibling_using_bails_the_file() {
let lib = base(&[]);
let ws = workspace_with_loads(&[&["Unharvested"]], &[]);
assert_eq!(
messages("f() = mystery()\n", &lib, Some(ws)),
Vec::<String>::new(),
);
}
#[test]
fn without_workspace_a_sibling_read_would_flag() {
let lib = base(&[]);
let msgs = messages("f() = helper() + helprr()\n", &lib, None);
assert_eq!(msgs.len(), 2, "{msgs:?}");
}
#[test]
fn literal_include_bails_only_without_a_workspace() {
let lib = base(&[]);
let src = "include(\"other.jl\")\nf() = mystery()\n";
assert_eq!(messages(src, &lib, None), Vec::<String>::new());
let msgs = messages(src, &lib, Some(workspace(&["helper"])));
assert_eq!(msgs.len(), 1, "{msgs:?}");
assert!(msgs[0].contains("mystery"));
}
#[test]
fn dynamic_include_bails_even_with_a_workspace() {
let lib = base(&[]);
let src = "include(joinpath(root, \"gen.jl\"))\nf() = mystery()\n";
assert_eq!(
messages(src, &lib, Some(workspace(&[]))),
Vec::<String>::new()
);
}
#[test]
fn quoted_code_is_not_read() {
let lib = base(&[]);
let msgs = messages(
"ex = :(alpha + beta)\nblock = quote\n gamma(delta)\nend\n",
&lib,
Some(workspace(&[])),
);
assert_eq!(msgs, Vec::<String>::new());
}
#[test]
fn no_resolution_context_is_silent() {
let parsed = crate::parser::parse("f() = mystery()\n");
let model = SemanticModel::build(&parsed.cst);
let ctx = RuleContext {
path: None,
root: &parsed.cst,
model: &model,
resolution: None,
includes: &[],
julia_target: None,
};
let mut sink = Vec::new();
UndefinedName.check_file(&ctx, &mut sink);
assert!(sink.is_empty());
}
}