use harn_lexer::{FixEdit, Span};
use harn_parser::{visit, BindingPattern, DiagnosticCode as Code, Node, SNode};
use crate::diagnostic::{LintDiagnostic, LintSeverity};
const NIL_RULE_NAME: &str = "nil-coalesce-noop";
const SELF_RULE_NAME: &str = "nil-coalesce-self-fallback";
pub(crate) fn check_nil_coalesce_noop(
_source: &str,
program: &[SNode],
diagnostics: &mut Vec<LintDiagnostic>,
) {
let native_assert_is_visible = !shadows_native_assert(program);
visit::walk_program(program, &mut |node| {
if let Node::FunctionCall { name, args, .. } = &node.node {
if native_assert_is_visible && name == "assert" {
if let Some((left, right)) = args.first().and_then(assert_false_fallback) {
diagnostics.push(make_assert_false_diagnostic(left, right));
}
}
return;
}
let Node::BinaryOp { op, left, right } = &node.node else {
return;
};
if op != "??" {
return;
}
if matches!(right.node, Node::NilLiteral) {
diagnostics.push(make_nil_diagnostic(left, right));
return;
}
if repeated_identifier(left, right) {
diagnostics.push(make_self_diagnostic(left, right));
}
});
}
fn shadows_native_assert(program: &[SNode]) -> bool {
let mut shadowed = false;
visit::walk_program(program, &mut |node| {
shadowed |= match &node.node {
Node::FnDecl { name, params, .. }
| Node::Pipeline { name, params, .. }
| Node::ToolDecl { name, params, .. } => {
name == "assert" || params.iter().any(|param| param.name == "assert")
}
Node::Closure { params, .. } => params.iter().any(|param| param.name == "assert"),
Node::LetBinding { pattern, .. }
| Node::ConstBinding { pattern, .. }
| Node::ForIn { pattern, .. } => binding_pattern_binds_assert(pattern),
Node::OverrideDecl { name, params, .. } => {
name == "assert" || params.iter().any(|param| param == "assert")
}
Node::TryCatch { error_var, .. } => error_var.as_deref() == Some("assert"),
Node::SkillDecl { name, .. }
| Node::StructDecl { name, .. }
| Node::EnumDecl { name, .. } => name == "assert",
Node::EvalPackDecl { binding_name, .. } => binding_name == "assert",
Node::SelectiveImport { names, .. } => names.iter().any(|name| name == "assert"),
Node::NamespaceImport { alias, .. } => alias == "assert",
Node::ImportDecl { .. } => true,
Node::Identifier(name) => name == "assert",
_ => false,
};
});
shadowed
}
fn binding_pattern_binds_assert(pattern: &BindingPattern) -> bool {
match pattern {
BindingPattern::Identifier(name) => name == "assert",
BindingPattern::Dict(fields) => fields
.iter()
.any(|field| field.alias.as_deref().unwrap_or(&field.key) == "assert"),
BindingPattern::List(elements) => elements.iter().any(|element| element.name == "assert"),
BindingPattern::Pair(left, right) => left == "assert" || right == "assert",
}
}
fn assert_false_fallback(argument: &SNode) -> Option<(&SNode, &SNode)> {
let Node::BinaryOp { op, left, right } = &argument.node else {
return None;
};
(op == "??" && matches!(right.node, Node::BoolLiteral(false)))
.then_some((left.as_ref(), right.as_ref()))
}
fn repeated_identifier(left: &SNode, right: &SNode) -> bool {
matches!(
(&left.node, &right.node),
(Node::Identifier(left), Node::Identifier(right)) if left == right
)
}
fn coalesce_fallback_span(left: &SNode, right: &SNode) -> Span {
Span {
start: left.span.end,
end: right.span.end,
line: left.span.end_line,
column: left
.span
.column
.saturating_add(left.span.end.saturating_sub(left.span.start)),
end_line: right.span.end_line,
}
}
fn make_nil_diagnostic(left: &SNode, right: &SNode) -> LintDiagnostic {
let fix_span = coalesce_fallback_span(left, right);
LintDiagnostic {
code: Code::LintNilCoalesceNoop,
rule: NIL_RULE_NAME.into(),
message: "`?? nil` is a no-op; the left expression already evaluates to nil when absent"
.to_string(),
span: fix_span,
severity: LintSeverity::Error,
suggestion: Some("drop the `?? nil` fallback".to_string()),
fix: Some(vec![FixEdit {
span: fix_span,
replacement: String::new(),
}]),
}
}
fn make_assert_false_diagnostic(left: &SNode, right: &SNode) -> LintDiagnostic {
let fix_span = coalesce_fallback_span(left, right);
LintDiagnostic {
code: Code::LintNilCoalesceNoop,
rule: NIL_RULE_NAME.into(),
message: "`?? false` is redundant in a positive assertion; `assert` already rejects every falsy value"
.to_string(),
span: fix_span,
severity: LintSeverity::Error,
suggestion: Some("let `assert` apply native truthiness directly".to_string()),
fix: Some(vec![FixEdit {
span: fix_span,
replacement: String::new(),
}]),
}
}
fn make_self_diagnostic(left: &SNode, right: &SNode) -> LintDiagnostic {
let fix_span = coalesce_fallback_span(left, right);
LintDiagnostic {
code: Code::LintNilCoalesceSelfFallback,
rule: SELF_RULE_NAME.into(),
message: "`x ?? x` is a no-op; the fallback is identical to the left identifier"
.to_string(),
span: fix_span,
severity: LintSeverity::Error,
suggestion: Some("drop the identity fallback".to_string()),
fix: Some(vec![FixEdit {
span: fix_span,
replacement: String::new(),
}]),
}
}
#[cfg(test)]
mod tests {
use super::*;
use harn_lexer::Lexer;
use harn_parser::Parser;
fn lint(source: &str) -> Vec<LintDiagnostic> {
let tokens = Lexer::new(source).tokenize().expect("lex");
let program = Parser::new(tokens).parse().expect("parse");
let mut diags = Vec::new();
check_nil_coalesce_noop(source, &program, &mut diags);
diags
}
#[test]
fn errors_on_nil_fallback() {
let diags = lint(
r"
pipeline default(task) {
const value = task?.flag ?? nil
log(value)
}
",
);
assert_eq!(diags.len(), 1, "diags: {diags:?}");
assert_eq!(diags[0].rule, NIL_RULE_NAME);
assert_eq!(diags[0].severity, LintSeverity::Error);
assert_eq!(diags[0].code, Code::LintNilCoalesceNoop);
}
#[test]
fn errors_on_self_fallback() {
let diags = lint(
r"
pipeline default(task) {
const value = task ?? task
log(value)
}
",
);
assert_eq!(diags.len(), 1, "diags: {diags:?}");
assert_eq!(diags[0].rule, SELF_RULE_NAME);
assert_eq!(diags[0].severity, LintSeverity::Error);
assert_eq!(diags[0].code, Code::LintNilCoalesceSelfFallback);
}
#[test]
fn ignores_non_nil_fallback() {
let diags = lint(
r#"
pipeline default(task) {
const value = task?.flag ?? "off"
log(value)
}
"#,
);
assert!(diags.is_empty(), "diags: {diags:?}");
}
#[test]
fn ignores_repeated_calls() {
let diags = lint(
r"
pipeline default(task) {
const value = load() ?? load()
log(value)
}
",
);
assert!(diags.is_empty(), "calls may have effects: {diags:?}");
}
}