use crate::detector::{Detector, Finding, Severity};
use crate::ir::{DamlModule, Expr, Statement};
use std::collections::HashSet;
pub struct HeadOfListQuery;
const QUERY_FUNCS: [&str; 4] = ["query", "queryFilter", "queryContractId", "queryInterface"];
struct HeadScanContext<'a> {
file: &'a std::path::Path,
context: &'a str,
}
impl Detector for HeadOfListQuery {
fn name(&self) -> &str {
"head-of-list-query"
}
fn severity(&self) -> Severity {
Severity::Medium
}
fn description(&self) -> &str {
"Head-of-list pattern on query result (non-deterministic order)"
}
fn detect(&self, module: &DamlModule) -> Vec<Finding> {
let mut findings = Vec::new();
let mut bodies: Vec<(&[Statement], String)> = Vec::new();
for template in &module.templates {
for choice in &template.choices {
bodies.push((&choice.body, format!("choice '{}'", choice.name)));
}
}
for func in &module.functions {
bodies.push((&func.body, format!("function '{}'", func.name)));
}
for (statements, context) in bodies {
let mut query_binders: HashSet<String> = HashSet::new();
self.scan_statements(
statements,
&mut query_binders,
&HeadScanContext {
file: &module.file,
context: &context,
},
&mut findings,
);
}
findings
}
}
impl HeadOfListQuery {
fn scan_statements(
&self,
statements: &[Statement],
query_binders: &mut HashSet<String>,
scan_context: &HeadScanContext<'_>,
findings: &mut Vec<Finding>,
) {
for statement in statements {
for expr in crate::ir::statement_exprs(statement) {
self.scan_expr(expr, query_binders, scan_context, findings);
}
match statement {
Statement::TryCatch {
try_body,
catch_body,
..
} => {
let mut try_binders = query_binders.clone();
self.scan_statements(try_body, &mut try_binders, scan_context, findings);
let mut catch_binders = query_binders.clone();
self.scan_statements(catch_body, &mut catch_binders, scan_context, findings);
}
Statement::Branch {
scrutinee: Some(scrutinee),
arms,
span,
} => {
self.scan_branch_patterns(
scrutinee,
arms,
*span,
query_binders,
scan_context,
findings,
);
for arm in arms {
let mut arm_binders = query_binders.clone();
self.scan_statements(&arm.body, &mut arm_binders, scan_context, findings);
}
}
Statement::Branch { arms, .. } => {
for arm in arms {
let mut arm_binders = query_binders.clone();
self.scan_statements(&arm.body, &mut arm_binders, scan_context, findings);
}
}
_ => {}
}
self.update_query_binding(statement, query_binders, scan_context, findings);
}
}
fn scan_expr(
&self,
expr: &Expr,
query_binders: &HashSet<String>,
scan_context: &HeadScanContext<'_>,
findings: &mut Vec<Finding>,
) {
let on_query = |e: &Expr| matches!(e.ref_string(), Some(r) if query_binders.contains(&r));
match expr {
Expr::App { func, args, span }
if args.len() == 1 && head_or_last(func).is_some() && on_query(&args[0]) =>
{
let f = head_or_last(func).unwrap();
findings.push(self.finding(
scan_context.file,
span.line,
format!(
"`{}` on query result in {}. Query results have \
non-deterministic order.",
f, scan_context.context
),
format!("{} {}", f, args[0].render_text()),
));
}
Expr::BinOp { op, lhs, rhs, span } if op == "$" && on_query(rhs) => {
if let Some(f) = head_or_last(lhs) {
findings.push(self.finding(
scan_context.file,
span.line,
format!(
"`{} $` on query result in {}. Query results have \
non-deterministic order.",
f, scan_context.context
),
format!("{} $ {}", f, rhs.render_text()),
));
}
}
Expr::BinOp { op, lhs, span, .. } if op == "!!" && on_query(lhs) => {
findings.push(self.finding(
scan_context.file,
span.line,
format!(
"Index `!!` into query result in {}. Query results have \
non-deterministic order.",
scan_context.context
),
format!("{} !!", lhs.render_text()),
));
}
Expr::DoBlock { statements, .. } => {
let mut nested_binders = query_binders.clone();
self.scan_statements(statements, &mut nested_binders, scan_context, findings);
}
_ => {}
}
for child_expr in crate::ir::child_exprs(expr) {
self.scan_expr(child_expr, query_binders, scan_context, findings);
}
}
fn update_query_binding(
&self,
statement: &Statement,
query_binders: &mut HashSet<String>,
scan_context: &HeadScanContext<'_>,
findings: &mut Vec<Finding>,
) {
let Some((name, value)) = binder_and_value(statement) else {
return;
};
if !is_plain_identifier(name) {
if is_query_app(value) {
self.flag_destructure_bind(
name,
stmt_line(statement),
scan_context.file,
scan_context.context,
findings,
);
}
return;
}
if is_query_app(value) {
query_binders.insert(name.to_string());
} else if let Some(src) = value.ref_string() {
if query_binders.contains(&src) {
query_binders.insert(name.to_string());
} else {
query_binders.remove(name);
}
} else {
if let Some(f) = fmap_head_of_query(value) {
findings.push(self.finding(
scan_context.file,
stmt_line(statement),
format!(
"`{}` over query result in {}. Query results \
have non-deterministic order.",
f, scan_context.context
),
format!("{} <$> query", f),
));
}
query_binders.remove(name);
}
}
fn finding(
&self,
file: &std::path::Path,
line: usize,
message: String,
evidence: String,
) -> Finding {
Finding {
detector: self.name().to_string(),
severity: self.severity(),
file: file.to_path_buf(),
line,
column: 1,
message,
evidence,
}
}
fn scan_branch_patterns(
&self,
scrutinee: &Expr,
arms: &[crate::ir::BranchArm],
span: crate::ir::SrcPos,
query_binders: &HashSet<String>,
scan_context: &HeadScanContext<'_>,
findings: &mut Vec<Finding>,
) {
if !matches!(scrutinee.ref_string(), Some(r) if query_binders.contains(&r)) {
return;
}
for arm in arms {
let Some(pattern) = arm.pattern.as_deref() else {
continue;
};
if is_cons_head_binder(pattern) {
findings.push(self.finding(
scan_context.file,
span.line,
format!(
"Head-of-list pattern '{}' on query result in {}. \
Query results have non-deterministic order.",
pattern, scan_context.context
),
pattern.to_string(),
));
} else if is_singleton_list_pattern(pattern) {
findings.push(self.finding(
scan_context.file,
span.line,
format!(
"Single-element list pattern '{}' on query result in {}. \
Crashes on 0 or 2+ results.",
pattern, scan_context.context
),
pattern.to_string(),
));
}
}
}
fn flag_destructure_bind(
&self,
binder: &str,
line: usize,
file: &std::path::Path,
context: &str,
findings: &mut Vec<Finding>,
) {
if is_cons_head_binder(binder) {
findings.push(self.finding(
file,
line,
format!(
"Head-of-list bind '{} <- query' in {}. \
Query results have non-deterministic order.",
binder, context
),
binder.to_string(),
));
} else if is_singleton_list_pattern(binder) {
findings.push(self.finding(
file,
line,
format!(
"Single-element list bind '{} <- query' in {}. \
Crashes on 0 or 2+ results.",
binder, context
),
binder.to_string(),
));
}
}
}
fn binder_and_value(s: &Statement) -> Option<(&str, &Expr)> {
match s {
Statement::Other {
binder: Some(name),
expr,
..
} => Some((name, expr)),
Statement::Let { name, value, .. } => Some((name, value)),
_ => None,
}
}
const fn stmt_line(s: &Statement) -> usize {
match s {
Statement::Other { span, .. } | Statement::Let { span, .. } => span.line,
_ => 0,
}
}
fn is_plain_identifier(name: &str) -> bool {
let t = name.trim();
!t.is_empty()
&& t.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'\'')
}
fn is_cons_head_binder(binder: &str) -> bool {
let trimmed = binder.trim();
let inner = trimmed
.strip_prefix('(')
.and_then(|s| s.strip_suffix(')'))
.map_or_else(|| trimmed, str::trim);
let rest = match inner.strip_prefix("::") {
Some(r) => r.trim(),
None => return false,
};
matches!(rest.rsplit_once(char::is_whitespace), Some((_, tail)) if tail.trim() == "_")
}
fn is_query_app(e: &Expr) -> bool {
matches!(e.app_head(), Expr::Var { name, qualifier: None, .. } if QUERY_FUNCS.contains(&name.as_str()))
}
fn head_or_last(func: &Expr) -> Option<&str> {
match func {
Expr::Var { name, .. } if name == "head" || name == "last" => Some(name),
_ => None,
}
}
fn fmap_head_of_query(e: &Expr) -> Option<&str> {
match e {
Expr::BinOp { op, lhs, rhs, .. } if op == "<$>" => {
head_or_last(lhs).filter(|_| is_query_app(rhs))
}
Expr::App { func, args, .. }
if args.len() == 2
&& matches!(func.as_ref(), Expr::Var { name, .. } if name == "fmap") =>
{
head_or_last(&args[0]).filter(|_| is_query_app(&args[1]))
}
_ => None,
}
}
fn is_singleton_list_pattern(pattern: &str) -> bool {
let inner = match pattern
.trim()
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
{
Some(i) => i.trim(),
None => return false,
};
!inner.is_empty() && !has_top_level_comma(inner)
}
fn has_top_level_comma(s: &str) -> bool {
let mut depth = 0i32;
for b in s.bytes() {
match b {
b'(' | b'[' => depth += 1,
b')' | b']' => depth -= 1,
b',' if depth == 0 => return true,
_ => {}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse_daml;
use std::path::Path;
#[test]
fn test_head_of_list_cons_pattern() {
let source = r#"module Test where
getFeaturedAppRight owner = do
results <- queryFilter @FeaturedAppRight (\r -> r.provider == owner)
case results of
(rightCid, _) :: _ -> do
pure (Some rightCid)
[] -> pure None
"#;
let module = parse_daml(source, Path::new("AmuletRegistry.daml"));
let findings = HeadOfListQuery.detect(&module);
assert!(!findings.is_empty());
assert!(findings[0].message.contains("::"));
}
#[test]
fn test_head_of_list_singleton_pattern() {
let source = r#"module Test where
getTransferFactory owner = do
results <- query @TransferFactory owner
case results of
[(rulesCid, _)] -> pure rulesCid
"#;
let module = parse_daml(source, Path::new("SimpleRegistry.daml"));
let findings = HeadOfListQuery.detect(&module);
assert!(!findings.is_empty());
}
#[test]
fn test_safe_query_usage_passes() {
let source = r#"module Test where
getAllFactories owner = do
results <- query @TransferFactory owner
mapA (\(cid, _) -> fetch cid) results
"#;
let module = parse_daml(source, Path::new("Safe.daml"));
let findings = HeadOfListQuery.detect(&module);
assert!(findings.is_empty());
}
#[test]
fn test_cons_pattern_reported_once() {
let source = r#"module Test where
getOne owner = do
results <- query @Foo owner
case results of
x :: _ -> pure (Some x)
[] -> pure None
"#;
let module = parse_daml(source, Path::new("Once.daml"));
let findings = HeadOfListQuery.detect(&module);
assert_eq!(findings.len(), 1, "exactly one finding: {:?}", findings);
}
#[test]
fn test_head_on_non_query_list_is_ignored() {
let source = r#"module Test where
firstOf xs = pure (head xs)
"#;
let module = parse_daml(source, Path::new("Plain.daml"));
let findings = HeadOfListQuery.detect(&module);
assert!(
findings.is_empty(),
"head on an arbitrary list is not a query bug: {:?}",
findings
);
}
#[test]
fn test_head_and_index_on_query_binding_flag() {
let head_src = r#"module Test where
pick owner = do
results <- query @Foo owner
pure (head results)
"#;
let m = parse_daml(head_src, Path::new("Head.daml"));
assert!(
HeadOfListQuery
.detect(&m)
.iter()
.any(|f| f.message.contains("head")),
"head on a query result must flag"
);
let idx_src = r#"module Test where
pick owner = do
results <- query @Foo owner
pure (results !! 0)
"#;
let m = parse_daml(idx_src, Path::new("Index.daml"));
assert!(
!HeadOfListQuery.detect(&m).is_empty(),
"`results !! 0` on a query result must flag"
);
}
#[test]
fn test_head_dollar_on_query_binding() {
let flagged = r#"module Test where
pick owner = do
results <- query @Foo owner
pure (head $ results)
"#;
let m = parse_daml(flagged, Path::new("Dollar.daml"));
assert!(
!HeadOfListQuery.detect(&m).is_empty(),
"head $ results on a query result must flag"
);
let safe = r#"module Test where
pick owner = do
results <- query @Foo owner
pure (head $ sortOn snd results)
"#;
let m = parse_daml(safe, Path::new("DollarSorted.daml"));
assert!(
HeadOfListQuery.detect(&m).is_empty(),
"head $ sortOn ... results is deterministic: {:?}",
HeadOfListQuery.detect(&m)
);
}
#[test]
fn test_qualified_head_on_query_binding() {
let source = r#"module Test where
pick owner = do
results <- query @Foo owner
pure (DA.List.head results)
"#;
let m = parse_daml(source, Path::new("Qual.daml"));
assert!(
!HeadOfListQuery.detect(&m).is_empty(),
"DA.List.head on a query result must flag"
);
}
#[test]
fn test_head_of_sorted_query_result_is_not_flagged() {
let source = r#"module Test where
pick owner = do
results <- query @Foo owner
let sorted = sortOn snd results
case sorted of
x :: _ -> pure (Some x)
[] -> pure None
"#;
let module = parse_daml(source, Path::new("Sorted.daml"));
let findings = HeadOfListQuery.detect(&module);
assert!(
findings.is_empty(),
"head of a sorted (deterministic) list is safe: {:?}",
findings
);
}
#[test]
fn test_recursive_cons_binding_tail_is_not_flagged() {
let source = r#"module Test where
go owner = do
results <- query @Foo owner
case results of
x :: rest -> process x rest
[] -> pure ()
"#;
let module = parse_daml(source, Path::new("Rec.daml"));
let findings = HeadOfListQuery.detect(&module);
assert!(
findings.is_empty(),
"`x :: rest` binds the tail (proper recursion), not head-of-list: {:?}",
findings
);
}
#[test]
fn test_singleton_bind_from_query_is_flagged() {
let source = r#"module Test where
pick owner = do
[theOne] <- query @Foo owner
pure theOne
"#;
let module = parse_daml(source, Path::new("BindOne.daml"));
let findings = HeadOfListQuery.detect(&module);
assert_eq!(
findings.len(),
1,
"`[theOne] <- query` is a single-element destructure: {:?}",
findings
);
assert!(findings[0].message.contains("Single-element"));
}
#[test]
fn test_cons_head_bind_from_query_is_flagged() {
let source = r#"module Test where
pick owner = do
(x :: _) <- query @Foo owner
pure x
"#;
let module = parse_daml(source, Path::new("BindCons.daml"));
let findings = HeadOfListQuery.detect(&module);
assert_eq!(
findings.len(),
1,
"`(x :: _) <- query` discards the tail (head-of-list): {:?}",
findings
);
}
#[test]
fn test_safe_binds_from_query_are_not_flagged() {
for src in [
"module Test where\n\nf owner = do\n [a, b] <- query @Foo owner\n pure (a, b)\n",
"module Test where\n\nf owner = do\n (x :: rest) <- query @Foo owner\n process x rest\n",
"module Test where\n\nf owner = do\n results <- query @Foo owner\n mapA fetch results\n",
] {
let module = parse_daml(src, Path::new("SafeBind.daml"));
let findings = HeadOfListQuery.detect(&module);
assert!(
findings.is_empty(),
"safe bind must not flag for {src:?}: {:?}",
findings
);
}
}
#[test]
fn test_nested_case_on_local_list_is_not_flagged() {
let source = r#"module Test where
pick owner = do
results <- query @Foo owner
case results of
_ -> do
let names = ["a", "b"]
case names of
first :: _ -> pure (Some first)
[] -> pure None
"#;
let module = parse_daml(source, Path::new("Nested.daml"));
let findings = HeadOfListQuery.detect(&module);
assert!(
findings.is_empty(),
"head of a fixed local list inside a query-case branch is safe: {:?}",
findings
);
}
#[test]
fn test_nested_case_on_query_result_flags_once() {
let source = r#"module Test where
pick owner = do
results <- query @Foo owner
case owner of
_ -> do
case results of
first :: _ -> pure (Some first)
[] -> pure None
"#;
let module = parse_daml(source, Path::new("NestedQ.daml"));
let findings = HeadOfListQuery.detect(&module);
assert_eq!(
findings.len(),
1,
"nested case on the query result is one finding: {:?}",
findings
);
}
#[test]
fn test_rebind_to_sorted_list_is_not_flagged() {
let monadic = r#"module Test where
pick owner = do
results <- query @Foo owner
results <- pure (sortOn snd results)
pure (head results)
"#;
let module = parse_daml(monadic, Path::new("Rebind.daml"));
let findings = HeadOfListQuery.detect(&module);
assert!(
findings.is_empty(),
"`head` of a re-bound sorted list is deterministic: {:?}",
findings
);
let let_rebind = r#"module Test where
pick owner = do
raw <- query @Foo owner
let raw = sortOn snd raw
pure (head raw)
"#;
let module = parse_daml(let_rebind, Path::new("RebindLet.daml"));
let findings = HeadOfListQuery.detect(&module);
assert!(
findings.is_empty(),
"`let raw = sortOn snd raw` clears the query tracking: {:?}",
findings
);
}
#[test]
fn test_head_before_sorted_rebind_is_still_flagged() {
let source = r#"module Test where
pick owner = do
results <- query @Foo owner
first <- pure (head results)
results <- pure (sortOn snd results)
pure first
"#;
let module = parse_daml(source, Path::new("HeadBeforeRebind.daml"));
let findings = HeadOfListQuery.detect(&module);
assert!(
findings.iter().any(|f| f.message.contains("head")),
"`head results` before a safe rebind is still unsafe: {:?}",
findings
);
}
#[test]
fn test_requery_rebind_still_flags() {
let source = r#"module Test where
pick owner = do
results <- query @Foo owner
results <- query @Bar owner
pure (head results)
"#;
let module = parse_daml(source, Path::new("Requery.daml"));
let findings = HeadOfListQuery.detect(&module);
assert!(
!findings.is_empty(),
"a re-query is still a raw query result: {:?}",
findings
);
}
#[test]
fn test_alias_of_query_result_is_flagged() {
let source = r#"module Test where
pick owner = do
results <- query @Foo owner
let alias = results
pure (head alias)
"#;
let module = parse_daml(source, Path::new("Alias.daml"));
let findings = HeadOfListQuery.detect(&module);
assert!(
!findings.is_empty(),
"`head` of a direct alias of the query result must flag: {:?}",
findings
);
let chained = r#"module Test where
pick owner = do
results <- query @Foo owner
let a = results
let b = a
pure (head b)
"#;
let module = parse_daml(chained, Path::new("AliasChain.daml"));
let findings = HeadOfListQuery.detect(&module);
assert!(
!findings.is_empty(),
"an alias chain to the query result must flag: {:?}",
findings
);
}
#[test]
fn test_derived_binding_is_not_an_alias() {
let source = r#"module Test where
pick owner = do
results <- query @Foo owner
let sorted = sortOn snd results
pure (head sorted)
"#;
let module = parse_daml(source, Path::new("Derived.daml"));
let findings = HeadOfListQuery.detect(&module);
assert!(
findings.is_empty(),
"a sorted derivation is not a pure alias: {:?}",
findings
);
}
#[test]
fn test_fmap_head_over_query_is_flagged() {
for src in [
"module Test where\n\npick owner = do\n x <- head <$> query @Foo owner\n pure x\n",
"module Test where\n\npick owner = do\n x <- fmap head (query @Foo owner)\n pure x\n",
"module Test where\n\npick owner = do\n x <- last <$> query @Foo owner\n pure x\n",
] {
let module = parse_daml(src, Path::new("Fmap.daml"));
let findings = HeadOfListQuery.detect(&module);
assert_eq!(
findings.len(),
1,
"fmap head over a query must flag for {src:?}: {:?}",
findings
);
}
}
#[test]
fn test_fmap_non_head_over_query_is_not_flagged() {
let source = r#"module Test where
pick owner = do
xs <- sortOn snd <$> query @Foo owner
pure xs
"#;
let module = parse_daml(source, Path::new("FmapSafe.daml"));
let findings = HeadOfListQuery.detect(&module);
assert!(
findings.is_empty(),
"a non-head fmap over a query keeps the whole list: {:?}",
findings
);
}
}