use harn_parser::{DiagnosticCode as Code, Node, SNode};
use super::Linter;
use crate::diagnostic::{LintDiagnostic, LintSeverity};
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum BlockKind {
Statement,
Value,
}
const PURE_COLLECTION_METHODS: &[&str] = &[
"char_at",
"chars",
"chunk",
"compact",
"each_cons",
"each_slice",
"enumerate",
"flatten",
"lines",
"lower",
"lowercase",
"map_keys",
"map_values",
"pad_left",
"pad_right",
"partition",
"pop",
"push",
"rekey",
"repeat",
"reverse",
"skip",
"slice",
"sliding_window",
"sort",
"sort_by",
"split",
"substring",
"tally",
"to_dict",
"to_list",
"to_set",
"trim",
"trim_end",
"trim_start",
"unique",
"upper",
"uppercase",
"window",
"zip",
];
fn is_pure_collection_method(method: &str) -> bool {
PURE_COLLECTION_METHODS.binary_search(&method).is_ok()
}
impl Linter<'_> {
pub(super) fn collect_impl_method_names(&mut self, nodes: &[SNode]) {
for node in nodes {
let inner = match &node.node {
Node::AttributedDecl { inner, .. } => &inner.node,
other => other,
};
let Node::ImplBlock { methods, .. } = inner else {
continue;
};
for method in methods {
if let Node::FnDecl { name, .. } = &method.node {
self.impl_method_names.insert(name.clone());
}
}
}
}
pub(super) fn check_discarded_pure_result(&mut self, node: &SNode) {
let (object, method, args) = match &node.node {
Node::MethodCall {
object,
method,
args,
}
| Node::OptionalMethodCall {
object,
method,
args,
} => (object, method, args),
_ => return,
};
if !is_pure_collection_method(method)
|| args.iter().any(|a| matches!(&a.node, Node::Closure { .. }))
|| self.impl_method_names.contains(method.as_str())
{
return;
}
let receiver = Self::method_receiver_root(object);
if receiver == Some("harness") {
return;
}
let (message, suggestion) = match receiver {
Some(name) => (
format!(
"the result of `{method}` is discarded, so this statement leaves `{name}` unchanged"
),
format!(
"`{method}` returns a new value instead of modifying `{name}` — assign it back, \
as in `{name} = {name}.{method}(...)`, which needs `{name}` declared `let`. \
To call `{method}` purely for its errors, discard the result explicitly with \
`const _ = {name}.{method}(...)`"
),
),
None => (
format!("the result of `{method}` is discarded, so this statement has no effect"),
format!(
"`{method}` returns a new value rather than modifying its receiver — bind the \
result, or discard it explicitly with `const _ = ...{method}(...)` if the call \
is made only for its errors"
),
),
};
self.diagnostics.push(LintDiagnostic {
code: Code::LintDiscardedPureResult,
rule: "discarded-pure-result".into(),
message,
span: node.span,
severity: LintSeverity::Error,
suggestion: Some(suggestion),
fix: None,
});
}
fn method_receiver_root(object: &SNode) -> Option<&str> {
match &object.node {
Node::Identifier(name) => Some(name.as_str()),
Node::PropertyAccess { object, .. }
| Node::OptionalPropertyAccess { object, .. }
| Node::SubscriptAccess { object, .. }
| Node::MethodCall { object, .. }
| Node::OptionalMethodCall { object, .. } => Self::method_receiver_root(object),
_ => None,
}
}
pub(super) fn check_discarded_approval_result(&mut self, node: &SNode) {
let name = match &node.node {
Node::FunctionCall { name, .. } if Self::is_approval_record_builtin(name) => {
name.as_str()
}
Node::HitlExpr {
kind: harn_parser::HitlKind::RequestApproval,
..
} => "request_approval",
_ => return,
};
self.diagnostics.push(LintDiagnostic {
code: Code::LintUnhandledApprovalResult,
rule: "unhandled-approval-result".into(),
message: format!("approval result from `{name}` is discarded"),
span: node.span,
severity: LintSeverity::Warning,
suggestion: Some(
"bind the result, inspect its signed approver receipts, or explicitly assign it to `_`"
.to_string(),
),
fix: None,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use harn_lexer::Lexer;
use harn_parser::Parser;
fn flagged(source: &str) -> Vec<String> {
let tokens = Lexer::new(source).tokenize().expect("lex");
let program = Parser::new(tokens).parse().expect("parse");
crate::lint(&program)
.into_iter()
.filter(|d| d.code == Code::LintDiscardedPureResult)
.map(|d| {
assert_eq!(d.severity, LintSeverity::Error, "LNT-066 is error-grade");
d.message
.split('`')
.nth(1)
.expect("method name in backticks")
.to_string()
})
.collect()
}
#[test]
fn flags_discarded_push_in_statement_position() {
assert_eq!(
flagged("fn main(h: Harness) {\n const l = []\n l.push(1)\n log(l)\n}"),
vec!["push"]
);
}
#[test]
fn flags_discarded_push_as_the_tail_of_a_fn_body() {
assert_eq!(
flagged("fn build(l: list<int>) {\n l.push(1)\n}"),
vec!["push"]
);
}
#[test]
fn flags_discarded_push_in_a_loop_body() {
assert_eq!(
flagged(
"fn main(h: Harness) {\n let out = []\n for i in [1] {\n out.push(i)\n }\n}"
),
vec!["push"]
);
}
#[test]
fn allows_the_result_being_assigned_back() {
assert!(flagged("fn main(h: Harness) {\n let l = []\n l = l.push(1)\n}").is_empty());
}
#[test]
fn allows_an_explicit_discard() {
assert!(flagged("fn main(h: Harness) {\n const _ = [1].sort()\n}").is_empty());
}
#[test]
fn allows_a_call_taking_a_closure() {
assert!(flagged(
"fn main(h: Harness) {\n const l = [1]\n l.sort_by({ a, b -> a - b })\n log(l)\n}"
)
.is_empty());
}
#[test]
fn allows_a_same_named_user_impl_method() {
assert!(flagged(
"struct Basket { items: list<int> }\nimpl Basket {\n fn push(self, i: int) -> Basket { return self }\n}\nfn main(h: Harness) {\n const b = Basket { items: [] }\n b.push(1)\n}"
)
.is_empty());
}
#[test]
fn allows_a_harness_rooted_receiver() {
assert!(flagged("fn main(h: Harness) {\n harness.stdio.split(\"a\")\n}").is_empty());
}
#[test]
fn allows_the_tail_of_every_value_producing_block() {
for source in [
"fn main(h: Harness) {\n const f = { -> [1].sort() }\n log(f())\n}",
"fn main(h: Harness) {\n const m = match 1 {\n 1 -> { [1].sort() }\n _ -> { [] }\n }\n log(m)\n}",
"fn main(h: Harness) {\n const i = if true { [1].sort() } else { [] }\n log(i)\n}",
"fn main(h: Harness) {\n const t = try { [1].sort() } catch (e) { [] }\n log(t)\n}",
] {
assert!(
flagged(source).is_empty(),
"value-block tail must not be flagged: {source}"
);
}
}
#[test]
fn flags_a_non_tail_statement_inside_a_value_block() {
assert_eq!(
flagged("fn main(h: Harness) {\n const f = { -> \n [1].sort()\n 42\n }\n log(f())\n}"),
vec!["sort"]
);
}
#[test]
fn pure_collection_methods_are_sorted_and_unique() {
let mut sorted = PURE_COLLECTION_METHODS.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(
sorted.as_slice(),
PURE_COLLECTION_METHODS,
"PURE_COLLECTION_METHODS must stay sorted and duplicate-free for binary_search"
);
}
#[test]
fn ambiguous_host_verbs_are_not_listed() {
for name in ["get", "add", "remove", "delete", "merge", "count", "find"] {
assert!(
!is_pure_collection_method(name),
"`{name}` is too ambiguous to flag as a discarded pure result"
);
}
}
}