use std::path::{Path, PathBuf};
use crate::Span;
#[derive(Debug, Clone)]
pub struct PreprocessDiagnostic {
pub span: Span,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SkippedInclude {
pub name_span: Span,
pub is_lib: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SkippedDefine {
pub name_span: Span,
}
#[derive(Debug)]
pub struct BranchContext {
pub source_tokens: Vec<erl_pp::SourceToken>,
pub tree: erl_parse::SyntaxTree,
pub preprocess_diagnostics: Vec<PreprocessDiagnostic>,
pub skipped_includes: Vec<SkippedInclude>,
pub skipped_defines: Vec<SkippedDefine>,
}
#[derive(Debug)]
pub struct Context {
pub path: PathBuf,
pub text: String,
pub original_tokens: Vec<erl_tokenize::Token>,
pub branches: Vec<BranchContext>,
}
impl Context {
pub fn analyze<P: AsRef<Path>>(path: P, text: String) -> Result<Self, erl_tokenize::Error> {
let path = path.as_ref().to_path_buf();
let original_tokens = erl_tokenize::scan_tokens(&text)?;
let source = erl_pp::Source::new(
path.to_string_lossy().as_ref(),
text.clone(),
original_tokens.clone(),
);
let branches = preprocess(source)
.into_iter()
.map(
|(source_tokens, preprocess_diagnostics, skipped_includes, skipped_defines)| {
let tokens: Vec<_> = source_tokens.iter().map(|t| *t.token()).collect();
let tree = erl_parse::parse(tokens, erl_parse::ParseMode::Module);
BranchContext {
source_tokens,
tree,
preprocess_diagnostics,
skipped_includes,
skipped_defines,
}
},
)
.collect();
Ok(Self {
path,
text,
original_tokens,
branches,
})
}
}
impl BranchContext {
pub fn span_of_range(&self, range: erl_parse::TokenRange) -> Option<Span> {
if range.is_empty() {
let i = range.start().get();
if i == 0 {
return Some(Span::new(0, 0));
}
return self.span_of_token(i - 1).map(|s| Span::new(s.end, s.end));
}
let mut start = None;
let mut end = None;
for i in range {
let Some(s) = self.span_of_token(i.get()) else {
continue;
};
start = Some(start.map_or(s.start, |t: usize| t.min(s.start)));
end = Some(end.map_or(s.end, |t: usize| t.max(s.end)));
}
Some(Span::new(start?, end?))
}
fn span_of_token(&self, index: usize) -> Option<Span> {
let token = self.source_tokens.get(index)?;
span_in_original_file(token.origin(), token.source_span())
}
pub fn enclosing_function_name(&self, node: erl_parse::NodeId) -> Option<String> {
let view = self.tree.view(node)?;
let decl = view
.ancestors()
.find(|a| a.kind() == erl_parse::SyntaxKind::FunctionDecl)?;
let clause = decl
.children()
.find(|c| c.kind() == erl_parse::SyntaxKind::FunctionClause)?;
let name = clause_name(self, clause)?;
let arity = clause_arity(clause)?;
Some(format!("{name}/{arity}"))
}
}
pub(crate) fn clause_name(branch: &BranchContext, node: erl_parse::NodeView<'_>) -> Option<String> {
node.indexed_tokens().find_map(|(i, _)| {
let token = branch.source_tokens.get(i.get())?;
if !token.token().kind().is_lexical() {
return None;
}
match token.value() {
erl_tokenize::TokenValue::Atom(name) => Some(name.into_owned()),
_ => None,
}
})
}
pub(crate) fn clause_arity(node: erl_parse::NodeView<'_>) -> Option<u64> {
let args = node
.children()
.find(|c| c.kind() == erl_parse::SyntaxKind::ArgumentList)?;
Some(args.children().count() as u64)
}
struct Fork {
pp: erl_pp::Preprocessor,
depth: usize,
stop_depth: Option<usize>,
source_tokens: Vec<erl_pp::SourceToken>,
diagnostics: Vec<PreprocessDiagnostic>,
skipped_includes: Vec<SkippedInclude>,
skipped_defines: Vec<SkippedDefine>,
}
type PreprocessBranch = (
Vec<erl_pp::SourceToken>,
Vec<PreprocessDiagnostic>,
Vec<SkippedInclude>,
Vec<SkippedDefine>,
);
fn preprocess(source: erl_pp::Source) -> Vec<PreprocessBranch> {
let mut pending = vec![Fork {
pp: erl_pp::Preprocessor::new([source]),
depth: 0,
stop_depth: None,
source_tokens: Vec::new(),
diagnostics: Vec::new(),
skipped_includes: Vec::new(),
skipped_defines: Vec::new(),
}];
let mut done = Vec::new();
while let Some(mut fork) = pending.pop() {
loop {
let event = fork.pp.step().expect("preprocessor protocol");
match event {
erl_pp::Event::Token(token) => fork.source_tokens.push(token),
erl_pp::Event::AwaitingInclude(include) => {
if let Some(skipped) = skipped_include_from_directive(&include) {
fork.skipped_includes.push(skipped);
}
fork.pp
.resume_include(empty_source("<skipped-include>"))
.expect("preprocessor protocol");
}
erl_pp::Event::AwaitingConditional(conditional) => {
let opens_conditional = matches!(
conditional,
erl_pp::Conditional::Ifdef(_)
| erl_pp::Conditional::Ifndef(_)
| erl_pp::Conditional::If(_)
);
let (base_depth, next_depth) = if opens_conditional {
(fork.depth, fork.depth + 1)
} else {
(fork.depth.saturating_sub(1), fork.depth)
};
let mut main = fork.pp.clone();
let mut side = fork.pp.clone();
main.resume_conditional(erl_pp::Branch::Then)
.expect("preprocessor protocol");
side.resume_conditional(erl_pp::Branch::Else)
.expect("preprocessor protocol");
fork.pp = main;
fork.depth = next_depth;
pending.push(Fork {
pp: side,
depth: next_depth,
stop_depth: Some(base_depth),
source_tokens: Vec::new(),
diagnostics: Vec::new(),
skipped_includes: Vec::new(),
skipped_defines: Vec::new(),
});
}
erl_pp::Event::AwaitingMacroExpansion(_) => {
fork.pp
.resume_macro_expansion(dummy_macro_source())
.expect("preprocessor protocol");
}
erl_pp::Event::Diagnostic(_) => {
}
erl_pp::Event::PreprocessError(error) => {
fork.diagnostics.push(PreprocessDiagnostic {
span: source_span_to_span(error.span()),
message: preprocess_error_message(&error),
});
}
erl_pp::Event::MacroDefined(definition) => {
if let Some(skipped) = skipped_define_from_definition(&definition) {
fork.skipped_defines.push(skipped);
}
}
erl_pp::Event::MacroUndefined(_) => {}
erl_pp::Event::BranchBoundary(boundary) => {
if matches!(boundary, erl_pp::BranchBoundary::Endif { .. }) {
fork.depth = fork.depth.saturating_sub(1);
if fork.stop_depth.is_some_and(|stop| fork.depth <= stop) {
break;
}
}
}
erl_pp::Event::Complete => break,
}
}
done.push((
fork.source_tokens,
fork.diagnostics,
fork.skipped_includes,
fork.skipped_defines,
));
}
done
}
fn skipped_include_from_directive(include: &erl_pp::IncludeDirective) -> Option<SkippedInclude> {
let directive = span_in_original_file(&include.parent_origin, include.directive_span)?;
let is_lib = matches!(include.kind, erl_pp::IncludeKind::IncludeLib);
let name = if is_lib { "include_lib" } else { "include" };
name_span_after_hyphen(directive, name).map(|name_span| SkippedInclude { name_span, is_lib })
}
fn skipped_define_from_definition(definition: &erl_pp::MacroDefinition) -> Option<SkippedDefine> {
let directive = span_in_original_file(&definition.origin, definition.directive_span)?;
name_span_after_hyphen(directive, "define").map(|name_span| SkippedDefine { name_span })
}
fn name_span_after_hyphen(directive: Span, name: &str) -> Option<Span> {
let name_start = directive.start.checked_add(1)?;
let name_end = name_start.checked_add(name.len())?;
if name_end > directive.end {
return None;
}
Some(Span::new(name_start, name_end))
}
const DUMMY_MACRO_ATOM: &str = "elint_dummy";
fn empty_source(name: &str) -> erl_pp::Source {
erl_pp::Source::new(name, "", Vec::new())
}
fn dummy_macro_source() -> erl_pp::Source {
erl_pp::Source::new(
"<elint-dummy-macro>",
DUMMY_MACRO_ATOM,
erl_tokenize::scan_tokens(DUMMY_MACRO_ATOM).expect("dummy atom always tokenizes"),
)
}
fn source_span_to_span(span: erl_pp::SourceSpan) -> Span {
Span::new(span.start.offset(), span.end.offset())
}
fn preprocess_error_message(error: &erl_pp::PreprocessError) -> String {
match error {
erl_pp::PreprocessError::ParseUnexpectedToken { expected, .. } => {
format!("unexpected token in directive; expected {expected}")
}
erl_pp::PreprocessError::ParseUnexpectedEof { expected, .. } => {
format!("unexpected end of file in directive; expected {expected}")
}
erl_pp::PreprocessError::DuplicateParameter { name, .. } => {
format!("duplicate macro parameter: {}", name.as_str())
}
erl_pp::PreprocessError::ArityMismatch { name, .. } => {
format!("macro arity mismatch: {}", name.as_str())
}
erl_pp::PreprocessError::UnclosedArgument { .. } => "unclosed macro argument".into(),
erl_pp::PreprocessError::LeadingEmptyArgument { .. } => {
"leading empty macro argument".into()
}
erl_pp::PreprocessError::TrailingEmptyArgument { .. } => {
"trailing empty macro argument".into()
}
erl_pp::PreprocessError::InvalidStringificationTarget { .. } => {
"invalid stringification target".into()
}
erl_pp::PreprocessError::CircularExpansion { name, .. } => {
format!("circular macro expansion: {name}")
}
erl_pp::PreprocessError::StrayElse { .. } => "stray -else".into(),
erl_pp::PreprocessError::StrayEndif { .. } => "stray -endif".into(),
erl_pp::PreprocessError::DoubleElse { .. } => "double -else".into(),
erl_pp::PreprocessError::UnclosedConditional { .. } => "unclosed conditional".into(),
erl_pp::PreprocessError::StrayElif { .. } => "stray -elif".into(),
erl_pp::PreprocessError::ElifAfterElse { .. } => "-elif after -else".into(),
}
}
fn span_in_original_file(origin: &erl_pp::Origin, span: erl_pp::SourceSpan) -> Option<Span> {
match origin {
erl_pp::Origin::Source => Some(source_span_to_span(span)),
erl_pp::Origin::Include {
parent,
include_site,
..
} => span_in_original_file(parent, *include_site),
erl_pp::Origin::MacroBody {
parent, call_site, ..
}
| erl_pp::Origin::MacroArgument {
parent, call_site, ..
}
| erl_pp::Origin::Stringification {
parent, call_site, ..
}
| erl_pp::Origin::SourceInfo {
parent, call_site, ..
}
| erl_pp::Origin::CallerExpansion {
parent, call_site, ..
} => span_in_original_file(parent, *call_site),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn analyze_ok(src: &str) -> Context {
Context::analyze("t.erl", src.to_string()).expect("test source must scan")
}
#[test]
fn analyze_module_yields_mainline_branch() {
let ctx = analyze_ok("-module(foo).\n");
let branch = &ctx.branches[0];
assert!(branch.tree.diagnostics().is_empty());
assert_eq!(branch.tree.tokens().len(), branch.source_tokens.len());
assert!(!branch.tree.tokens().is_empty());
assert_eq!(branch.tree.roots().count(), 1);
}
#[test]
fn unknown_macro_expands_to_dummy_atom_without_diagnostic() {
let ctx = analyze_ok("-module(foo).\nfoo() -> ?BAR.\n");
let branch = &ctx.branches[0];
assert!(branch.preprocess_diagnostics.is_empty());
assert!(branch.tree.diagnostics().is_empty());
assert!(branch.source_tokens.iter().any(|token| matches!(
token.value(),
erl_tokenize::TokenValue::Atom(name) if name == DUMMY_MACRO_ATOM
)));
}
#[test]
fn include_is_skipped_without_leaving_the_file() {
let ctx = analyze_ok("-module(foo).\n-include(\"missing.hrl\").\n");
let branch = &ctx.branches[0];
assert!(branch.tree.diagnostics().is_empty());
assert_eq!(branch.tree.roots().count(), 1);
assert_eq!(branch.skipped_includes.len(), 1);
assert!(!branch.skipped_includes[0].is_lib);
assert_eq!(
branch.skipped_includes[0].name_span.text(&ctx.text),
"include"
);
}
#[test]
fn tokenize_error_is_returned() {
let err = Context::analyze("t.erl", "\"unterminated\n".to_string())
.expect_err("unclosed string must fail");
assert!(err.position.offset() < 20);
}
#[test]
fn explores_every_arm_and_keeps_mainline_first() {
let ctx = analyze_ok(
"\
-module(foo).
-ifdef(A).
foo() -> then_arm.
-else.
foo() -> else_arm.
-endif.
bar() -> ok.
",
);
assert_eq!(ctx.branches.len(), 2);
let mainline = &ctx.branches[0];
let mainline_text: String = mainline
.tree
.tokens()
.iter()
.map(|t| t.text(&ctx.text))
.collect();
assert!(mainline_text.contains("then_arm"));
assert!(mainline_text.contains("bar()"));
let side_text: String = ctx.branches[1]
.tree
.tokens()
.iter()
.map(|t| t.text(&ctx.text))
.collect();
assert!(side_text.contains("else_arm"));
assert!(!side_text.contains("bar()"));
for branch in &ctx.branches {
for (i, _token) in branch.tree.tokens().iter().enumerate() {
let Some(span) = branch
.span_of_range(erl_parse::TokenRange::single(erl_parse::TokenIndex::new(i)))
else {
continue;
};
assert!(span.start < span.end);
}
}
}
#[test]
fn side_branch_stops_at_its_own_endif() {
let ctx = analyze_ok(
"\
-module(foo).
-ifdef(A).
ok.
-else.
foo() -> ok.
-endif.
bar() -> ok.
",
);
let side = &ctx.branches[1];
let side_text: String = side
.tree
.tokens()
.iter()
.map(|t| t.text(&ctx.text))
.collect();
assert!(side_text.contains("foo()"));
assert!(!side_text.contains("bar()"));
}
#[test]
fn consecutive_ifdefs_do_not_form_cartesian_product() {
let ctx = analyze_ok(
"\
-module(foo).
-ifdef(A).
a1.
-else.
a2.
-endif.
-ifdef(B).
b1.
-else.
b2.
-endif.
",
);
assert_eq!(ctx.branches.len(), 3);
}
#[test]
fn nested_conditionals_explore_inner_arms() {
let ctx = analyze_ok(
"\
-module(foo).
-ifdef(A).
-ifdef(B).
n1.
-else.
n2.
-endif.
-else.
-ifdef(C).
n3.
-else.
n4.
-endif.
-endif.
",
);
assert_eq!(ctx.branches.len(), 4);
let marker = |branch: &BranchContext, m: &str| {
branch.tree.tokens().iter().any(|t| t.text(&ctx.text) == m)
};
assert!(marker(&ctx.branches[0], "n1"));
let mut seen = std::collections::HashSet::new();
for branch in &ctx.branches {
let present: Vec<_> = ["n1", "n2", "n3", "n4"]
.into_iter()
.filter(|m| marker(branch, m))
.collect();
assert_eq!(
present.len(),
1,
"branch must contain exactly one marker: {present:?}"
);
seen.insert(present[0]);
}
assert_eq!(seen.len(), 4);
}
#[test]
fn if_elif_chain_explores_every_arm() {
let ctx = analyze_ok(
"\
-module(foo).
-if(true). c1. -elif(false). c2. -elif(true). c3. -else. c4. -endif.
",
);
assert_eq!(ctx.branches.len(), 4);
let mut seen = std::collections::HashSet::new();
for branch in &ctx.branches {
let present: Vec<_> = ["c1", "c2", "c3", "c4"]
.into_iter()
.filter(|m| branch.tree.tokens().iter().any(|t| t.text(&ctx.text) == *m))
.collect();
assert_eq!(
present.len(),
1,
"branch must contain exactly one marker: {present:?}"
);
seen.insert(present[0]);
}
assert_eq!(seen.len(), 4);
}
#[test]
fn findings_in_a_side_branch_are_reachable() {
let ctx = analyze_ok(
"\
-module(foo).
-ifdef(A).
ok.
-else.
foo(T) -> element(1, T).
-endif.
",
);
let branch = &ctx.branches[1];
let rule = crate::rules::RULES
.iter()
.find(|rule| rule.name == "element_bif")
.expect("element_bif rule");
let findings = (rule.check)(&ctx, branch);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].span.text(&ctx.text), "element(1, T)");
}
#[test]
fn enclosing_function_name_resolves_function() {
let ctx = analyze_ok("-module(t).\nfoo(A, B) ->\n element(1, A).\n");
let branch = &ctx.branches[0];
let call = branch
.tree
.nodes()
.find(|n| n.kind() == erl_parse::SyntaxKind::CallExpr)
.expect("call expr");
assert_eq!(
branch.enclosing_function_name(call.node_id()).as_deref(),
Some("foo/2")
);
}
#[test]
fn enclosing_function_name_resolves_for_clause_node() {
let ctx = analyze_ok("-module(t).\nfoo() -> ok.\n");
let branch = &ctx.branches[0];
let clause = branch
.tree
.nodes()
.find(|n| n.kind() == erl_parse::SyntaxKind::FunctionClause)
.expect("function clause");
assert_eq!(
branch.enclosing_function_name(clause.node_id()).as_deref(),
Some("foo/0")
);
}
#[test]
fn enclosing_function_name_is_none_outside_function() {
let ctx = analyze_ok("-module(t).\nfoo() -> ok.\n");
let branch = &ctx.branches[0];
let attribute = branch
.tree
.nodes()
.find(|n| n.kind() == erl_parse::SyntaxKind::Attribute)
.expect("attribute");
assert_eq!(branch.enclosing_function_name(attribute.node_id()), None);
}
}