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, PackageSource, Resolution, Resolver, module_at};
use crate::semantic::{LoadKind, SemanticModel};
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) {
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"),
));
}
}
}
}
fn has_unresolvable_using(model: &SemanticModel, packages: &dyn PackageSource) -> bool {
model.module_loads().iter().any(|load| {
if load.kind != LoadKind::Using || load.items.is_some() {
return false;
}
if load.path.leading_dots != 0 || load.path.components.is_empty() {
return true;
}
let Some(pkg) = packages.package(&load.path.components[0]) else {
return true;
};
module_at(&pkg.root, &load.path.components[1..]).is_none()
})
}
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, PackageIndex, Span, Visibility,
};
use crate::linter::rules::ResolutionContext;
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(),
},
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(),
},
members: Vec::new(),
member_modules: Default::default(),
diagnostics: Vec::new(),
})
}
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())),
}),
};
let mut sink = Vec::new();
UndefinedName.check_file(&ctx, &mut sink);
sink.into_iter().map(|d| d.message.body).collect()
}
#[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 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,
};
let mut sink = Vec::new();
UndefinedName.check_file(&ctx, &mut sink);
assert!(sink.is_empty());
}
}