use std::collections::{BTreeSet, HashMap};
use std::sync::OnceLock;
use rowan::TextRange;
use rowan::ast::AstNode as _;
use smol_str::SmolStr;
use crate::ast::{Arg, CallExpr, HasArgList as _};
use crate::linter::diagnostic::{Diagnostic, Severity, ViolationData};
use crate::linter::rules::{Example, Rule, RuleContext};
use crate::semantic::spelling::{bounded_chars, candidate_names, one_edit};
use crate::semantic::symbols::unbacktick;
use crate::semantic::{
IdentRef, LoadedPackage, PackageOrigin, ScopeId, implicit_attached_packages,
};
pub struct UndefinedSymbol;
impl Rule for UndefinedSymbol {
fn id(&self) -> &'static str {
"undefined-symbol"
}
fn description(&self) -> &'static str {
"Flag an identifier read that resolves to no in-scope binding and no \
known package export.\n\nGated for safety: the rule stays silent for a \
whole file unless every `library()`-attached package is indexed, since \
an un-indexed package could export the otherwise-unresolved name. In \
an analyzed package, a package-local call argument is checked only \
when its matched formal is proven to evaluate the promise normally; \
capture, opaque forwarding, and ambiguous behavior stay silent.\n\n\
Suggest a visible binding or known attached export only when a single \
name differs by one insertion, deletion, substitution, or adjacent \
transposition. Short names (fewer than four characters), distant or \
ambiguous spellings, and searches exceeding a fixed budget receive no \
hint. Suggestions do not provide an automatic fix."
}
fn examples(&self) -> &'static [Example] {
&[
Example {
caption: "`subtotal` resolves to nothing:",
source: "total <- subtotal\n",
},
Example {
caption: "A unique close spelling receives a hint:",
source: "lenth(1:3)\n",
},
]
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn default_enabled(&self) -> bool {
true
}
fn check_file(&self, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
if ctx.model.attaches_opaque_env() {
return;
}
let mut suggestions = Suggestions::default();
match ctx.resolution {
Some(resolution) => sink.extend(
ctx.model
.idents()
.iter()
.filter(|ident| !ident.data_masked)
.filter(|ident| ctx.model.resolve_local(ident).is_none())
.filter(|ident| ident.name != ".packageName" || !ctx.is_package_r_source())
.filter(|ident| resolution.unresolved.contains(ident.name.as_str()))
.filter(|ident| {
!inside_non_eager_local_argument(
ctx.root,
ctx.model,
ctx.is_package_r_source(),
ident.range,
&resolution.local_functions,
)
})
.map(|ident| {
undefined(
&ident.name,
ident.range,
suggestions.find(ctx, ident, resolution.suggestion_exports.as_ref()),
)
}),
),
None => self.run_standalone(ctx, sink),
}
}
}
fn inside_non_eager_local_argument(
root: &crate::syntax::SyntaxNode,
model: &crate::semantic::SemanticModel,
is_package_r_source: bool,
range: TextRange,
functions: &std::collections::BTreeMap<String, crate::project::FunctionPromiseSummary>,
) -> bool {
let Some(mut node) = root
.covering_element(range)
.into_token()
.and_then(|token| token.parent())
else {
return false;
};
loop {
if let Some(arg) = Arg::cast(node.clone())
&& let Some(call_node) = node.parent().and_then(|list| list.parent())
&& let Some(call) = CallExpr::cast(call_node)
&& let Some(name) = call.callee_name()
&& let Some(summary) = functions.get(name.as_str())
{
if let Some(callee) = call.callee_token()
&& let Some(ident) = model
.idents()
.iter()
.find(|ident| ident.range == callee.text_range())
&& let Some(binding) = model.resolve_local(ident)
{
let scope = model.scope(model.binding(binding).scope).kind;
if scope != crate::semantic::ScopeKind::File || !is_package_r_source {
return true;
}
}
let args: Vec<Arg> = call.args().collect();
let Some(argument) = args
.iter()
.position(|candidate| candidate.syntax() == arg.syntax())
else {
return true;
};
let names: Vec<Option<SmolStr>> = args.iter().map(Arg::name).collect();
let Some(formal) = summary.matched_formal(&names, argument) else {
return true;
};
if !summary.eager.contains(&formal) {
return true;
}
}
let Some(parent) = node.parent() else {
return false;
};
node = parent;
}
}
impl UndefinedSymbol {
fn run_standalone(&self, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let implicit = implicit_attached_packages(ctx.path);
let wildcards = ctx.project.map(|p| p.wildcard_import_packages());
let augmented: Vec<LoadedPackage>;
let loaded: &[LoadedPackage] =
if implicit.is_empty() && wildcards.is_none_or(|packages| packages.is_empty()) {
ctx.model.loaded_packages()
} else {
augmented = ctx
.model
.loaded_packages()
.iter()
.cloned()
.chain(
implicit
.iter()
.copied()
.chain(wildcards.into_iter().flatten().map(String::as_str))
.map(|name| LoadedPackage {
name: SmolStr::new(name),
range: TextRange::default(),
}),
)
.collect();
&augmented
};
if loaded.iter().any(|p| {
!ctx.symbols.package_indexed(&p.name)
|| ctx
.symbols
.attached_packages(&p.name)
.iter()
.any(|m| !ctx.symbols.package_indexed(m))
}) {
return;
}
if ctx.project.is_some_and(|p| p.resolution_incomplete) {
return;
}
let exports = OnceLock::new();
let mut suggestions = Suggestions::default();
for ident in ctx.model.idents() {
if ident.data_masked {
continue;
}
if ident.name == ".packageName" && ctx.is_package_r_source() {
continue;
}
if ctx.model.resolve_local(ident).is_some() {
continue;
}
if ctx.project.is_some_and(|p| p.resolves(&ident.name)) {
continue;
}
if !matches!(
ctx.symbols.origin(&ident.name, loaded),
PackageOrigin::Unknown
) {
continue;
}
let exports = exports.get_or_init(|| candidate_names(ctx.symbols.export_names(loaded)));
sink.push(undefined(
&ident.name,
ident.range,
suggestions.find(ctx, ident, exports.as_ref()),
));
}
}
}
fn undefined(name: &str, range: rowan::TextRange, suggestion: Option<SmolStr>) -> Diagnostic {
let mut message = ViolationData::new(
"undefined-symbol",
format!("no in-scope binding or attached package exports `{name}`"),
);
if let Some(name) = suggestion {
message = message.with_suggestion(format!("did you mean `{name}`?"));
}
Diagnostic {
rule: "undefined-symbol",
severity: Default::default(),
path: Default::default(),
range,
message,
fix: None,
}
}
const MAX_COMPARISONS: usize = 65_536;
const MAX_QUERIES: usize = 256;
struct Suggestions {
remaining: usize,
cache: HashMap<(ScopeId, SmolStr), Option<SmolStr>>,
}
impl Default for Suggestions {
fn default() -> Self {
Self {
remaining: MAX_COMPARISONS,
cache: HashMap::new(),
}
}
}
impl Suggestions {
fn find(
&mut self,
ctx: &RuleContext<'_>,
ident: &IdentRef,
exports: Option<&BTreeSet<SmolStr>>,
) -> Option<SmolStr> {
let key = (ident.scope, ident.name.clone());
if let Some(cached) = self.cache.get(&key) {
return cached.clone();
}
if self.cache.len() == MAX_QUERIES {
return None;
}
let result = self.search(ctx, ident, exports);
self.cache.insert(key, result.clone());
result
}
fn search(
&mut self,
ctx: &RuleContext<'_>,
ident: &IdentRef,
exports: Option<&BTreeSet<SmolStr>>,
) -> Option<SmolStr> {
let exports = exports?;
let query = bounded_chars(unbacktick(&ident.name))?;
if query.len() < 4 {
return None;
}
let mut found: Option<SmolStr> = None;
let mut consider = |name: &str| -> Option<()> {
self.remaining = self.remaining.checked_sub(1)?;
let name = unbacktick(name);
let Some(chars) = bounded_chars(name) else {
return Some(());
};
if one_edit(&query, &chars) {
if found.as_ref().is_some_and(|previous| previous != name) {
return None;
}
found = Some(SmolStr::new(name));
}
Some(())
};
let mut scope = Some(ident.scope);
while let Some(id) = scope {
consider("")?;
let current = ctx.model.scope(id);
for binding in ¤t.bindings {
consider(&ctx.model.binding(*binding).name)?;
}
scope = current.parent;
}
if let Some(project) = ctx.project {
for name in project.visible_layer().iter() {
consider(name)?;
}
}
for name in exports {
consider(name)?;
}
found
}
}