use std::collections::HashSet;
use harn_lexer::Span;
use harn_parser::{BindingPattern, DiagnosticCode as Code, Node, SNode};
use crate::diagnostic::{LintDiagnostic, LintSeverity};
const RULE_NAME: &str = "mutable-capture-across-parallel";
pub(crate) fn check_mutable_capture_across_parallel(
program: &[SNode],
diagnostics: &mut Vec<LintDiagnostic>,
) {
for node in program {
visit(node, diagnostics);
}
}
fn opens_scope(node: &Node) -> bool {
matches!(
node,
Node::Closure { .. }
| Node::FnDecl { .. }
| Node::ToolDecl { .. }
| Node::Parallel { .. }
| Node::SpawnExpr { .. }
)
}
fn visit(node: &SNode, diagnostics: &mut Vec<LintDiagnostic>) {
match &node.node {
Node::Parallel { variable, body, .. } => {
let mut bound = HashSet::new();
if let Some(var) = variable {
bound.insert(var.clone());
}
analyze_body(body, bound, diagnostics);
}
Node::SpawnExpr { body } => {
analyze_body(body, HashSet::new(), diagnostics);
}
_ => {}
}
for child in harn_parser::visit::immediate_children(node) {
visit(child, diagnostics);
}
}
fn analyze_body(body: &[SNode], mut bound: HashSet<String>, diagnostics: &mut Vec<LintDiagnostic>) {
for stmt in body {
collect_bound(stmt, &mut bound);
}
for stmt in body {
flag_captured_assignments(stmt, &bound, diagnostics);
}
}
fn collect_bound(node: &SNode, bound: &mut HashSet<String>) {
match &node.node {
Node::LetBinding { pattern, .. } | Node::ConstBinding { pattern, .. } => {
pattern_idents(pattern, bound);
}
Node::ForIn { pattern, .. } => {
pattern_idents(pattern, bound);
}
other if opens_scope(other) => return,
_ => {}
}
for child in harn_parser::visit::immediate_children(node) {
collect_bound(child, bound);
}
}
fn flag_captured_assignments(
node: &SNode,
bound: &HashSet<String>,
diagnostics: &mut Vec<LintDiagnostic>,
) {
if opens_scope(&node.node) {
return;
}
if let Node::Assignment { target, .. } = &node.node {
if let Some((name, span)) = target_root(target)
.filter(|(name, _)| !bound.contains(name) && !harn_parser::is_discard_name(name))
{
diagnostics.push(diagnostic(&name, span));
}
}
for child in harn_parser::visit::immediate_children(node) {
flag_captured_assignments(child, bound, diagnostics);
}
}
fn target_root(node: &SNode) -> Option<(String, Span)> {
match &node.node {
Node::Identifier(name) => Some((name.clone(), node.span)),
Node::PropertyAccess { object, .. }
| Node::OptionalPropertyAccess { object, .. }
| Node::SubscriptAccess { object, .. }
| Node::OptionalSubscriptAccess { object, .. }
| Node::SliceAccess { object, .. } => target_root(object),
_ => None,
}
}
fn pattern_idents(pattern: &BindingPattern, bound: &mut HashSet<String>) {
match pattern {
BindingPattern::Identifier(name) => {
bound.insert(name.clone());
}
BindingPattern::Pair(first, second) => {
bound.insert(first.clone());
bound.insert(second.clone());
}
BindingPattern::Dict(fields) => {
for field in fields {
bound.insert(field.alias.clone().unwrap_or_else(|| field.key.clone()));
}
}
BindingPattern::List(elements) => {
for element in elements {
bound.insert(element.name.clone());
}
}
}
}
fn diagnostic(name: &str, span: Span) -> LintDiagnostic {
LintDiagnostic {
code: Code::LintMutableCaptureAcrossParallel,
rule: RULE_NAME.into(),
message: format!(
"`{name}` is captured from an enclosing scope and reassigned inside a `parallel`/`spawn` body; every concurrent branch shares one cell, so the writes race and can be lost"
),
span,
severity: LintSeverity::Warning,
suggestion: Some(format!(
"have each branch return its contribution and combine the results after the fan-out (e.g. `const parts = parallel each ... {{ ... }}`) instead of mutating the shared `{name}`"
)),
fix: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use harn_lexer::Lexer;
use harn_parser::Parser;
fn flagged_names(source: &str) -> Vec<String> {
let tokens = Lexer::new(source).tokenize().expect("lex");
let program = Parser::new(tokens).parse().expect("parse");
let mut diags = Vec::new();
check_mutable_capture_across_parallel(&program, &mut diags);
for diag in &diags {
assert_eq!(diag.code, Code::LintMutableCaptureAcrossParallel);
assert_eq!(diag.severity, LintSeverity::Warning);
}
diags
.iter()
.map(|d| {
d.message
.split('`')
.nth(1)
.expect("message names the variable")
.to_string()
})
.collect()
}
#[test]
fn warns_on_scalar_reassign_in_parallel_each() {
let names = flagged_names(
r"
pipeline default() {
let total = 0
parallel each [1, 2, 3] { x ->
total = total + x
}
log(total)
}
",
);
assert_eq!(names, vec!["total"]);
}
#[test]
fn warns_on_container_and_field_writes() {
let names = flagged_names(
r"
pipeline default() {
let slots = [0, 0]
let acc = {n: 0}
parallel 2 { i ->
slots[i] = i
acc.n = acc.n + 1
}
log(len(slots))
}
",
);
assert_eq!(names, vec!["slots", "acc"]);
}
#[test]
fn warns_on_spawn_body() {
let names = flagged_names(
r"
pipeline default() {
let flag = 0
scope {
spawn {
flag = 7
}
}
log(flag)
}
",
);
assert_eq!(names, vec!["flag"]);
}
#[test]
fn no_warning_on_body_local_accumulator() {
let names = flagged_names(
r"
pipeline default() {
const parts = parallel each [1, 2, 3] { x ->
let local = 0
local = local + x
return local
}
log(len(parts))
}
",
);
assert!(names.is_empty(), "unexpected warnings: {names:?}");
}
#[test]
fn no_warning_on_readonly_capture() {
let names = flagged_names(
r"
pipeline default() {
let base = 10
const parts = parallel each [1, 2] { x ->
return x + base
}
log(len(parts))
}
",
);
assert!(names.is_empty(), "unexpected warnings: {names:?}");
}
#[test]
fn no_warning_outside_concurrency() {
let names = flagged_names(
r"
pipeline default() {
let n = 0
const inc = { -> n = n + 1 }
inc()
log(n)
}
",
);
assert!(names.is_empty(), "unexpected warnings: {names:?}");
}
}