use crate::linter::diagnostic::Diagnostic;
use crate::linter::rules::{Example, Rule, RuleContext};
pub struct UnresolvedImport;
const ALWAYS_LOADABLE: &[&str] = &["Base", "Core", "Main"];
impl Rule for UnresolvedImport {
fn id(&self) -> &'static str {
"unresolved-import"
}
fn default_enabled(&self) -> bool {
false
}
fn description(&self) -> &'static str {
"Flag a `using`/`import` of a module the enclosing project cannot load: \
a name that is neither in its `Project.toml` `[deps]` nor provided by \
the harvested environment (the standard library included). Julia \
resolves a bare `using Foo` against the active project, so an \
undeclared name raises `ArgumentError: Package Foo not found in \
current path` when the module loads. Relative paths (`using .Sub`), \
interpolated paths, and loads inside quoted code or macro calls are \
exempt, and a name the harvest resolved is always accepted — so a \
transitive dependency goes unreported rather than risking a false \
positive. Off by default: the rule needs the project context that only \
a package source file carries, so the language server enables it for \
workspace member files while the CLI leaves it opt-in."
}
fn examples(&self) -> &'static [Example] {
&[Example {
caption: "The project declares `LinearAlgebra` in `[deps]`, but not \
`Frobnicate`:",
source: "using LinearAlgebra\nusing Frobnicate\n",
}]
}
fn example_declared_deps(&self) -> Option<&'static [&'static str]> {
Some(&["LinearAlgebra"])
}
fn check_file(&self, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(resolution) = &ctx.resolution else {
return;
};
let Some(deps) = &resolution.declared_deps else {
return;
};
let scan = ctx.file_scan();
for load in ctx.model.module_loads() {
if load.path.leading_dots != 0 {
continue;
}
let (Some(name), Some(range)) = (load.path.components.first(), load.root_range) else {
continue;
};
if scan.in_skipped(load.range) {
continue;
}
if ALWAYS_LOADABLE.contains(&name.as_str())
|| deps.contains(name.as_str())
|| resolution.packages.package(name).is_some()
{
continue;
}
sink.push(Diagnostic::new(
self.id(),
range,
format!("`{name}` is not a dependency of this project"),
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::index::DeclaredDeps;
use crate::index::model::{DefLocation, ExportedName, ModuleIndex, PackageIndex, Span};
use crate::linter::rules::ResolutionContext;
use crate::semantic::SemanticModel;
use std::collections::BTreeMap;
use std::sync::Arc;
fn library(names: &[&str]) -> BTreeMap<String, Arc<PackageIndex>> {
names
.iter()
.map(|name| {
let pkg = PackageIndex {
name: (*name).to_string(),
root: ModuleIndex {
name: (*name).to_string(),
bare: false,
loc: DefLocation {
file: "src/x.jl".into(),
range: Span { start: 0, end: 0 },
},
exports: Vec::<ExportedName>::new(),
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(),
};
((*name).to_string(), Arc::new(pkg))
})
.collect()
}
fn findings(src: &str, packages: &[&str], deps: Option<&[&str]>) -> Vec<Diagnostic> {
let lib = library(packages);
let parsed = crate::parser::parse(src);
assert!(parsed.diagnostics.is_empty(), "fixture must parse clean");
let model = SemanticModel::build(&parsed.cst);
let declared: Option<Arc<DeclaredDeps>> =
deps.map(|names| Arc::new(names.iter().map(|n| (*n).to_string()).collect()));
let ctx =
RuleContext::new(None, &parsed.cst, &model).with_resolution(Some(ResolutionContext {
packages: &lib,
workspace: None,
declared_deps: declared,
}));
let mut sink = Vec::new();
UnresolvedImport.check_file(&ctx, &mut sink);
sink
}
fn messages(src: &str, packages: &[&str], deps: Option<&[&str]>) -> Vec<String> {
findings(src, packages, deps)
.into_iter()
.map(|d| d.message.body)
.collect()
}
fn flagged(name: &str) -> String {
format!("`{name}` is not a dependency of this project")
}
#[test]
fn undeclared_package_is_flagged() {
assert_eq!(
messages("using Frobnicate\n", &["Base"], Some(&[])),
vec![flagged("Frobnicate")],
);
}
#[test]
fn import_is_checked_like_using() {
assert_eq!(
messages("import Frobnicate\n", &["Base"], Some(&[])),
vec![flagged("Frobnicate")],
);
}
#[test]
fn every_clause_of_a_comma_list_is_checked() {
assert_eq!(
messages(
"import DataFrames, Frobnicate\n",
&[],
Some(&["DataFrames"])
),
vec![flagged("Frobnicate")],
);
}
#[test]
fn item_list_and_alias_forms_are_checked() {
assert_eq!(
messages("using Frobnicate: thing\n", &[], Some(&[])),
vec![flagged("Frobnicate")],
);
assert_eq!(
messages("import Frobnicate as F\n", &[], Some(&[])),
vec![flagged("Frobnicate")],
);
}
#[test]
fn a_dotted_path_is_judged_by_its_root() {
assert_eq!(
messages("using Frobnicate.Sub\n", &[], Some(&[])),
vec![flagged("Frobnicate")],
);
assert_eq!(
messages("using DataFrames.Sub: x\n", &[], Some(&["DataFrames"])),
Vec::<String>::new(),
);
}
#[test]
fn the_span_covers_the_package_name_alone() {
let src = "using Frobnicate.Sub: thing\n";
let found = findings(src, &[], Some(&[]));
assert_eq!(found.len(), 1);
assert_eq!(&src[found[0].range], "Frobnicate");
}
#[test]
fn declared_dependency_is_silent() {
assert_eq!(
messages("using DataFrames\n", &["Base"], Some(&["DataFrames"])),
Vec::<String>::new(),
);
}
#[test]
fn a_harvested_package_is_silent_even_when_undeclared() {
assert_eq!(
messages("using LinearAlgebra\n", &["LinearAlgebra"], Some(&[])),
Vec::<String>::new(),
);
}
#[test]
fn base_core_and_main_are_always_loadable() {
assert_eq!(
messages(
"using Base.Threads\nimport Core\nusing Main.Helpers\n",
&[],
Some(&[]),
),
Vec::<String>::new(),
);
}
#[test]
fn relative_paths_are_not_dependencies() {
assert_eq!(
messages(
"using .Sub\nimport ..Sibling\nusing .Sub: helper\n",
&[],
Some(&[]),
),
Vec::<String>::new(),
);
}
#[test]
fn an_interpolated_path_has_no_name_to_check() {
assert_eq!(
messages("import $A\n", &[], Some(&[])),
Vec::<String>::new(),
);
}
#[test]
fn quoted_code_and_macro_calls_are_skipped() {
assert_eq!(
messages("ex = :(using Frobnicate)\n", &[], Some(&[])),
Vec::<String>::new(),
);
assert_eq!(
messages("quote\n using Frobnicate\nend\n", &[], Some(&[])),
Vec::<String>::new(),
);
assert_eq!(
messages("@testset using Frobnicate\n", &[], Some(&[])),
Vec::<String>::new(),
);
}
#[test]
fn without_a_declared_dependency_set_the_file_is_silent() {
assert_eq!(
messages("using Frobnicate\n", &["Base"], None),
Vec::<String>::new(),
);
}
#[test]
fn no_resolution_context_is_silent() {
let parsed = crate::parser::parse("using Frobnicate\n");
let model = SemanticModel::build(&parsed.cst);
let ctx = RuleContext::new(None, &parsed.cst, &model);
let mut sink = Vec::new();
UnresolvedImport.check_file(&ctx, &mut sink);
assert!(sink.is_empty());
}
}