1use std::collections::{HashMap, HashSet};
9
10use crate::ast::*;
11use crate::diagnostic::Diagnostic;
12use crate::lexer::SourceMap;
13use crate::Span;
14
15pub fn analyze(module: &Module, source: &str) -> Vec<Diagnostic> {
17 let mut ctx = Ctx::new(module);
18
19 ctx.check_related_surface_references();
21 ctx.check_discriminator_variants();
22 ctx.check_surface_binding_usage();
23 ctx.check_status_state_machine();
24 ctx.check_external_entity_source_hints();
25
26 ctx.check_type_references();
28 ctx.check_unreachable_triggers();
29 ctx.check_unused_fields();
30 ctx.check_unused_entities();
31 ctx.check_unused_definitions();
32 ctx.check_deferred_location_hints();
33 ctx.check_rule_invalid_triggers();
34 ctx.check_rule_undefined_bindings();
35 ctx.check_duplicate_let_bindings();
36 ctx.check_config_undefined_references();
37 apply_suppressions(ctx.diagnostics, source)
41}
42
43fn apply_suppressions(diagnostics: Vec<Diagnostic>, source: &str) -> Vec<Diagnostic> {
48 if diagnostics.is_empty() {
49 return diagnostics;
50 }
51 let sm = SourceMap::new(source);
52 let directives = collect_suppression_directives(source, &sm);
53 if directives.is_empty() {
54 return diagnostics;
55 }
56 diagnostics
57 .into_iter()
58 .filter(|d| {
59 let (line, _) = sm.line_col(d.span.start);
60 let line = line as i64;
61 let active = directives
62 .get(&(line as u32))
63 .or_else(|| directives.get(&((line - 1).max(0) as u32)));
64 match (active, d.code) {
65 (Some(codes), Some(code)) => !(codes.contains("all") || codes.contains(&code)),
66 (Some(codes), None) => !codes.contains("all"),
67 _ => true,
68 }
69 })
70 .collect()
71}
72
73fn collect_suppression_directives<'a>(source: &'a str, sm: &SourceMap) -> HashMap<u32, HashSet<&'a str>> {
74 let mut directives = HashMap::new();
75 let pattern = regex_lite::Regex::new(r"(?m)^[^\S\n]*--\s*allium-ignore\s+([A-Za-z0-9._,\- \t]+)$").unwrap();
76 for m in pattern.find_iter(source) {
77 let text = m.as_str();
78 let (line, _) = sm.line_col(m.start());
79 if let Some(idx) = text.find("allium-ignore") {
81 let offset = m.start() + idx + "allium-ignore".len();
82 let source_after = &source[offset..m.end()];
83 let codes: HashSet<&'a str> = source_after
84 .split(',')
85 .map(|c| c.trim())
86 .filter(|c| !c.is_empty())
87 .collect();
88 directives.insert(line, codes);
89 }
90 }
91 directives
92}
93
94struct Ctx<'a> {
99 module: &'a Module,
100 diagnostics: Vec<Diagnostic>,
101}
102
103impl<'a> Ctx<'a> {
104 fn new(module: &'a Module) -> Self {
105 Self {
106 module,
107 diagnostics: Vec::new(),
108 }
109 }
110
111 fn blocks(&self, kind: BlockKind) -> impl Iterator<Item = &'a BlockDecl> {
112 self.module.declarations.iter().filter_map(move |d| match d {
113 Decl::Block(b) if b.kind == kind => Some(b),
114 _ => None,
115 })
116 }
117
118 fn variants(&self) -> impl Iterator<Item = &'a VariantDecl> {
119 self.module
120 .declarations
121 .iter()
122 .filter_map(|d| match d {
123 Decl::Variant(v) => Some(v),
124 _ => None,
125 })
126 }
127
128 fn has_use_imports(&self) -> bool {
129 self.module
130 .declarations
131 .iter()
132 .any(|d| matches!(d, Decl::Use(_)))
133 }
134
135 fn push(&mut self, d: Diagnostic) {
136 self.diagnostics.push(d);
137 }
138
139 fn declared_type_names(&self) -> HashSet<&'a str> {
142 let mut names = HashSet::new();
143 for d in &self.module.declarations {
144 match d {
145 Decl::Block(b) => {
146 if matches!(
147 b.kind,
148 BlockKind::Entity
149 | BlockKind::ExternalEntity
150 | BlockKind::Value
151 | BlockKind::Enum
152 | BlockKind::Actor
153 ) {
154 if let Some(n) = &b.name {
155 names.insert(n.name.as_str());
156 }
157 }
158 }
159 Decl::Variant(v) => {
160 names.insert(v.name.name.as_str());
161 }
162 _ => {}
163 }
164 }
165 for t in &[
167 "String", "Integer", "Decimal", "Boolean", "Timestamp", "Duration",
168 "List", "Set", "Map", "Any", "Void",
169 ] {
170 names.insert(t);
171 }
172 for d in &self.module.declarations {
174 if let Decl::Use(u) = d {
175 if let Some(alias) = &u.alias {
176 names.insert(alias.name.as_str());
177 }
178 }
179 }
180 names
181 }
182
183 fn collect_all_accessed_field_names(&self) -> HashSet<&'a str> {
185 let mut names = HashSet::new();
186 for d in &self.module.declarations {
187 match d {
188 Decl::Block(b) => {
189 for item in &b.items {
190 collect_accessed_fields_from_item(&item.kind, &mut names);
191 }
192 }
193 Decl::Invariant(inv) => {
194 collect_accessed_fields_from_expr(&inv.body, &mut names);
195 }
196 _ => {}
197 }
198 }
199 names
200 }
201}
202
203impl Ctx<'_> {
208 fn check_related_surface_references(&mut self) {
209 let surface_names: HashSet<&str> = self
210 .blocks(BlockKind::Surface)
211 .filter_map(|b| b.name.as_ref().map(|n| n.name.as_str()))
212 .collect();
213
214 for surface in self.blocks(BlockKind::Surface) {
215 let surface_name = match &surface.name {
216 Some(n) => &n.name,
217 None => continue,
218 };
219
220 for item in &surface.items {
221 let BlockItemKind::Clause { keyword, value } = &item.kind else {
222 continue;
223 };
224 if keyword != "related" {
225 continue;
226 }
227
228 let refs = extract_related_surface_names(value);
229 for ident in refs {
230 if !surface_names.contains(ident.name.as_str()) {
231 self.push(
232 Diagnostic::error(
233 ident.span,
234 format!(
235 "Surface '{surface_name}' references unknown related surface '{}'.",
236 ident.name
237 ),
238 )
239 .with_code("allium.surface.relatedUndefined"),
240 );
241 }
242 }
243 }
244 }
245 }
246}
247
248fn extract_related_surface_names(expr: &Expr) -> Vec<&Ident> {
249 match expr {
250 Expr::Ident(id) => vec![id],
251 Expr::Call { function, .. } => extract_leading_ident(function).into_iter().collect(),
252 Expr::WhenGuard { action, .. } => extract_related_surface_names(action),
253 Expr::Block { items, .. } => items
254 .iter()
255 .flat_map(extract_related_surface_names)
256 .collect(),
257 _ => vec![],
258 }
259}
260
261fn extract_leading_ident(expr: &Expr) -> Option<&Ident> {
262 match expr {
263 Expr::Ident(id) => Some(id),
264 Expr::MemberAccess { object, .. } => extract_leading_ident(object),
265 _ => None,
266 }
267}
268
269impl Ctx<'_> {
274 fn check_discriminator_variants(&mut self) {
275 let mut variants_by_base: HashMap<&str, HashSet<&str>> = HashMap::new();
276 for v in self.variants() {
277 let base_name = expr_as_ident(&v.base).or_else(|| {
278 if let Expr::JoinLookup { entity, .. } = &v.base {
280 expr_as_ident(entity)
281 } else {
282 None
283 }
284 });
285 if let Some(base_name) = base_name {
286 variants_by_base
287 .entry(base_name)
288 .or_default()
289 .insert(&v.name.name);
290 }
291 }
292
293 for entity in self.blocks(BlockKind::Entity) {
294 let entity_name = match &entity.name {
295 Some(n) => &n.name,
296 None => continue,
297 };
298
299 for item in &entity.items {
300 let BlockItemKind::Assignment { name: field_name, value } = &item.kind else {
301 continue;
302 };
303
304 let mut pipe_idents = Vec::new();
305 collect_pipe_idents(value, &mut pipe_idents);
306 if pipe_idents.len() < 2 {
307 continue;
308 }
309
310 let has_capitalised = pipe_idents.iter().any(|id| starts_uppercase(&id.name));
311 if !has_capitalised {
312 continue;
313 }
314
315 let all_capitalised = pipe_idents.iter().all(|id| starts_uppercase(&id.name));
316 if !all_capitalised {
317 self.push(
318 Diagnostic::error(
319 value.span(),
320 format!(
321 "Entity '{entity_name}' discriminator '{}' must use only capitalised variant names.",
322 field_name.name
323 ),
324 )
325 .with_code("allium.sum.invalidDiscriminator"),
326 );
327 continue;
328 }
329
330 let declared = variants_by_base
331 .get(entity_name.as_str())
332 .cloned()
333 .unwrap_or_default();
334
335 let missing: Vec<&&Ident> = pipe_idents
336 .iter()
337 .filter(|id| !declared.contains(id.name.as_str()))
338 .collect();
339
340 if missing.len() == pipe_idents.len() && declared.is_empty() {
341 self.push(
342 Diagnostic::error(
343 value.span(),
344 format!(
345 "Entity '{entity_name}' field '{}' uses capitalised pipe values with no variant declarations. \
346 In v3, capitalised values are variant references requiring 'variant X : {entity_name}' \
347 declarations. Use lowercase values for a plain enum.",
348 field_name.name
349 ),
350 )
351 .with_code("allium.sum.v1InlineEnum"),
352 );
353 } else {
354 for id in missing {
355 self.push(
356 Diagnostic::error(
357 id.span,
358 format!(
359 "Entity '{entity_name}' discriminator references '{}' without matching \
360 'variant {} : {entity_name}'.",
361 id.name, id.name
362 ),
363 )
364 .with_code("allium.sum.discriminatorUnknownVariant"),
365 );
366 }
367 }
368 }
369 }
370 }
371}
372
373fn starts_uppercase(s: &str) -> bool {
374 s.chars().next().is_some_and(|c| c.is_ascii_uppercase())
375}
376
377fn collect_pipe_idents<'a>(expr: &'a Expr, out: &mut Vec<&'a Ident>) {
378 match expr {
379 Expr::Ident(id) => out.push(id),
380 Expr::Pipe { left, right, .. } => {
381 collect_pipe_idents(left, out);
382 collect_pipe_idents(right, out);
383 }
384 _ => {}
385 }
386}
387
388fn expr_as_ident(expr: &Expr) -> Option<&str> {
389 match expr {
390 Expr::Ident(id) => Some(&id.name),
391 _ => None,
392 }
393}
394
395impl Ctx<'_> {
400 fn check_surface_binding_usage(&mut self) {
401 for surface in self.blocks(BlockKind::Surface) {
402 let surface_name = match &surface.name {
403 Some(n) => &n.name,
404 None => continue,
405 };
406
407 let has_provides = surface
409 .items
410 .iter()
411 .any(|i| matches!(&i.kind, BlockItemKind::Clause { keyword, .. } if keyword == "provides"));
412
413 let mut bindings: Vec<(&str, Span, bool)> = Vec::new(); for item in &surface.items {
415 let BlockItemKind::Clause { keyword, value } = &item.kind else {
416 continue;
417 };
418 if keyword != "facing" && keyword != "context" {
419 continue;
420 }
421 if let Expr::Binding { name, .. } = value {
422 bindings.push((&name.name, name.span, keyword == "facing"));
423 }
424 }
425
426 for (name, span, is_facing) in &bindings {
427 if *name == "_" {
428 continue;
429 }
430 if *is_facing && !has_provides {
432 continue;
433 }
434 let used = surface.items.iter().any(|item| {
435 let BlockItemKind::Clause { keyword, value } = &item.kind else {
436 return item_contains_ident(&item.kind, name);
437 };
438 if keyword == "facing" || keyword == "context" {
439 if let Expr::Binding {
440 name: binding_name, ..
441 } = value
442 {
443 if binding_name.name == *name {
444 return false;
445 }
446 }
447 }
448 expr_contains_ident(value, name)
449 });
450
451 if !used {
452 self.push(
453 Diagnostic::warning(
454 *span,
455 format!(
456 "Surface '{surface_name}' binding '{name}' is not used in the surface body.",
457 ),
458 )
459 .with_code("allium.surface.unusedBinding"),
460 );
461 }
462 }
463 }
464 }
465}
466
467impl Ctx<'_> {
472 fn check_status_state_machine(&mut self) {
473 let mut status_by_entity: HashMap<&str, (Vec<&Ident>, HashSet<&str>)> = HashMap::new();
474 for entity in self.blocks(BlockKind::Entity) {
475 let entity_name = match &entity.name {
476 Some(n) => n.name.as_str(),
477 None => continue,
478 };
479 for item in &entity.items {
480 let BlockItemKind::Assignment { name, value } = &item.kind else {
481 continue;
482 };
483 if name.name != "status" {
484 continue;
485 }
486 let mut idents = Vec::new();
487 collect_pipe_idents(value, &mut idents);
488 if idents.len() < 2 {
489 continue;
490 }
491 if idents.iter().any(|id| starts_uppercase(&id.name)) {
492 continue;
493 }
494 let set: HashSet<&str> = idents.iter().map(|id| id.name.as_str()).collect();
495 status_by_entity.insert(entity_name, (idents, set));
496 }
497 }
498
499 if status_by_entity.is_empty() {
500 return;
501 }
502
503 let mut assigned_by_entity: HashMap<&str, HashSet<&str>> = HashMap::new();
504 let mut transitions_by_entity: HashMap<&str, HashMap<&str, HashSet<&str>>> =
505 HashMap::new();
506
507 for rule in self.blocks(BlockKind::Rule) {
508 let binding_types = collect_rule_binding_types(rule, &status_by_entity);
509 let mut requires_by_binding: HashMap<&str, HashSet<&str>> = HashMap::new();
510
511 for item in &rule.items {
512 let BlockItemKind::Clause { keyword, value } = &item.kind else {
513 continue;
514 };
515 if keyword != "requires" {
516 continue;
517 }
518 visit_status_comparisons(
519 value,
520 &binding_types,
521 &status_by_entity,
522 &mut |binding, status| {
523 requires_by_binding
524 .entry(binding)
525 .or_default()
526 .insert(status);
527 },
528 );
529 }
530
531 for item in &rule.items {
532 let BlockItemKind::Clause { keyword, value } = &item.kind else {
533 continue;
534 };
535 if keyword != "ensures" {
536 continue;
537 }
538 visit_status_assignments(
539 value,
540 &binding_types,
541 &status_by_entity,
542 &mut |binding, target, entity| {
543 assigned_by_entity
544 .entry(entity)
545 .or_default()
546 .insert(target);
547
548 if let Some(sources) = requires_by_binding.get(binding) {
549 let entity_transitions =
550 transitions_by_entity.entry(entity).or_default();
551 for source in sources {
552 entity_transitions
553 .entry(source)
554 .or_default()
555 .insert(target);
556 }
557 }
558 },
559 );
560 }
561 }
562
563 for (entity_name, (idents, values)) in &status_by_entity {
564 let assigned = assigned_by_entity.get(entity_name);
565 let transitions = transitions_by_entity.get(entity_name);
566
567 if let Some(assigned) = assigned {
568 if assigned.iter().any(|v| !values.contains(v)) {
569 continue;
570 }
571 }
572
573 let assigned_set = assigned.cloned().unwrap_or_default();
574 let transition_map = transitions.cloned().unwrap_or_default();
575
576 for id in idents {
577 if !assigned_set.contains(id.name.as_str()) {
578 self.push(
579 Diagnostic::warning(
580 id.span,
581 format!(
582 "Status '{}' in entity '{entity_name}' is never assigned by any rule ensures clause.",
583 id.name
584 ),
585 )
586 .with_code("allium.status.unreachableValue"),
587 );
588 }
589
590 if is_likely_terminal(&id.name) {
591 continue;
592 }
593 let exits = transition_map.get(id.name.as_str());
594 if exits.is_some_and(|e| !e.is_empty()) {
595 continue;
596 }
597 self.push(
598 Diagnostic::warning(
599 id.span,
600 format!(
601 "Status '{}' in entity '{entity_name}' has no observed transition to a different status.",
602 id.name
603 ),
604 )
605 .with_code("allium.status.noExit"),
606 );
607 }
608 }
609 }
610}
611
612fn collect_rule_binding_types<'a>(
613 rule: &'a BlockDecl,
614 status_by_entity: &HashMap<&str, (Vec<&Ident>, HashSet<&str>)>,
615) -> HashMap<&'a str, &'a str> {
616 let mut types = HashMap::new();
617 for item in &rule.items {
618 let BlockItemKind::Clause { keyword, value } = &item.kind else {
619 continue;
620 };
621 if keyword != "when" {
622 continue;
623 }
624 collect_binding_types_from_expr(value, status_by_entity, &mut types);
625 }
626 types
627}
628
629fn collect_binding_types_from_expr<'a>(
630 expr: &'a Expr,
631 status_by_entity: &HashMap<&str, (Vec<&Ident>, HashSet<&str>)>,
632 out: &mut HashMap<&'a str, &'a str>,
633) {
634 match expr {
635 Expr::Binding { name, value, .. } => {
636 if let Some(entity_name) = extract_entity_from_trigger(value) {
637 if status_by_entity.contains_key(entity_name) {
638 out.insert(&name.name, entity_name);
639 }
640 }
641 }
642 Expr::Call { function, args, .. } => {
643 if let Expr::Ident(fn_name) = function.as_ref() {
644 for arg in args {
645 if let CallArg::Positional(Expr::Ident(binding)) = arg {
646 if status_by_entity.contains_key(fn_name.name.as_str()) {
647 out.insert(&binding.name, &fn_name.name);
648 }
649 }
650 }
651 }
652 }
653 Expr::LogicalOp { left, right, .. } => {
654 collect_binding_types_from_expr(left, status_by_entity, out);
655 collect_binding_types_from_expr(right, status_by_entity, out);
656 }
657 _ => {}
658 }
659}
660
661fn extract_entity_from_trigger(expr: &Expr) -> Option<&str> {
662 match expr {
663 Expr::Becomes { subject, .. } | Expr::TransitionsTo { subject, .. } => {
664 extract_entity_from_member(subject)
665 }
666 Expr::MemberAccess { object, .. } => expr_as_ident(object),
667 _ => None,
668 }
669}
670
671fn extract_entity_from_member(expr: &Expr) -> Option<&str> {
672 match expr {
673 Expr::MemberAccess { object, .. } => expr_as_ident(object),
674 _ => None,
675 }
676}
677
678fn visit_status_assignments<'a>(
679 expr: &'a Expr,
680 binding_types: &HashMap<&'a str, &'a str>,
681 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
682 cb: &mut impl FnMut(&'a str, &'a str, &'a str),
683) {
684 match expr {
685 Expr::Comparison {
686 left,
687 op: ComparisonOp::Eq,
688 right,
689 ..
690 } => {
691 if let (Some((binding, "status")), Some(target)) =
692 (expr_as_member_access(left), expr_as_ident(right))
693 {
694 let entity = binding_types.get(binding).copied().or_else(|| {
695 status_by_entity
696 .keys()
697 .find(|name| name.eq_ignore_ascii_case(binding))
698 .copied()
699 });
700 if let Some(entity) = entity {
701 cb(binding, target, entity);
702 }
703 }
704 }
705 Expr::Block { items, .. } => {
706 for item in items {
707 visit_status_assignments(item, binding_types, status_by_entity, cb);
708 }
709 }
710 Expr::Conditional {
711 branches,
712 else_body,
713 ..
714 } => {
715 for branch in branches {
716 visit_status_assignments(&branch.body, binding_types, status_by_entity, cb);
717 }
718 if let Some(body) = else_body {
719 visit_status_assignments(body, binding_types, status_by_entity, cb);
720 }
721 }
722 _ => {}
723 }
724}
725
726fn visit_status_comparisons<'a>(
727 expr: &'a Expr,
728 binding_types: &HashMap<&'a str, &'a str>,
729 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
730 cb: &mut impl FnMut(&'a str, &'a str),
731) {
732 match expr {
733 Expr::Comparison {
734 left,
735 op: ComparisonOp::Eq,
736 right,
737 ..
738 } => {
739 if let (Some((binding, "status")), Some(target)) =
740 (expr_as_member_access(left), expr_as_ident(right))
741 {
742 let known = binding_types.contains_key(binding)
743 || status_by_entity
744 .keys()
745 .any(|name| name.eq_ignore_ascii_case(binding));
746 if known {
747 cb(binding, target);
748 }
749 }
750 }
751 Expr::LogicalOp { left, right, .. } => {
752 visit_status_comparisons(left, binding_types, status_by_entity, cb);
753 visit_status_comparisons(right, binding_types, status_by_entity, cb);
754 }
755 Expr::Block { items, .. } => {
756 for item in items {
757 visit_status_comparisons(item, binding_types, status_by_entity, cb);
758 }
759 }
760 _ => {}
761 }
762}
763
764fn expr_as_member_access(expr: &Expr) -> Option<(&str, &str)> {
765 match expr {
766 Expr::MemberAccess { object, field, .. } => {
767 expr_as_ident(object).map(|obj| (obj, field.name.as_str()))
768 }
769 _ => None,
770 }
771}
772
773fn is_likely_terminal(status: &str) -> bool {
774 matches!(
775 status,
776 "completed"
777 | "cancelled"
778 | "canceled"
779 | "expired"
780 | "closed"
781 | "deleted"
782 | "archived"
783 | "failed"
784 | "rejected"
785 | "done"
786 )
787}
788
789impl Ctx<'_> {
794 fn check_external_entity_source_hints(&mut self) {
795 if self.has_use_imports() {
796 return;
797 }
798
799 let rule_blocks: Vec<&BlockDecl> = self.blocks(BlockKind::Rule).collect();
800
801 for entity in self.blocks(BlockKind::ExternalEntity) {
802 let name = match &entity.name {
803 Some(n) => n,
804 None => continue,
805 };
806
807 let referenced_in_rules = rule_blocks
808 .iter()
809 .any(|rule| rule.items.iter().any(|i| item_contains_ident(&i.kind, &name.name)));
810
811 let msg = format!(
812 "External entity '{}' has no obvious governing specification import in this module.",
813 name.name
814 );
815 if referenced_in_rules {
816 self.push(Diagnostic::info(name.span, msg).with_code("allium.externalEntity.missingSourceHint"));
817 } else {
818 self.push(Diagnostic::warning(name.span, msg).with_code("allium.externalEntity.missingSourceHint"));
819 }
820 }
821 }
822}
823
824impl Ctx<'_> {
829 fn check_type_references(&mut self) {
830 let known = self.declared_type_names();
831
832 for d in &self.module.declarations {
833 let block = match d {
834 Decl::Block(b)
835 if matches!(
836 b.kind,
837 BlockKind::Entity
838 | BlockKind::ExternalEntity
839 | BlockKind::Value
840 ) =>
841 {
842 b
843 }
844 Decl::Variant(v) => {
845 for item in &v.items {
847 self.check_type_ref_in_item(item, &known);
848 }
849 continue;
850 }
851 _ => continue,
852 };
853
854 for item in &block.items {
855 self.check_type_ref_in_item(item, &known);
856 }
857 }
858
859 for rule in self.blocks(BlockKind::Rule) {
861 for item in &rule.items {
862 let BlockItemKind::Clause { keyword, value } = &item.kind else {
863 continue;
864 };
865 if keyword == "when" || keyword == "ensures" || keyword == "requires" {
866 self.check_type_refs_in_rule_expr(value, &known);
867 }
868 }
869 }
870 }
871
872 fn check_type_ref_in_item(&mut self, item: &BlockItem, known: &HashSet<&str>) {
873 match &item.kind {
874 BlockItemKind::Assignment { value, .. }
875 | BlockItemKind::FieldWithWhen { value, .. } => {
876 self.check_type_refs_in_value(value, known);
877 }
878 _ => {}
879 }
880 }
881
882 fn check_type_refs_in_value(&mut self, expr: &Expr, known: &HashSet<&str>) {
883 match expr {
884 Expr::Ident(id) if starts_uppercase(&id.name) => {
885 if !known.contains(id.name.as_str()) {
886 self.push(
887 Diagnostic::error(
888 id.span,
889 format!(
890 "Type reference '{}' is not declared locally or imported.",
891 id.name
892 ),
893 )
894 .with_code("allium.type.undefinedReference"),
895 );
896 }
897 }
898 Expr::GenericType { name, args, .. } => {
899 self.check_type_refs_in_value(name, known);
900 for arg in args {
901 self.check_type_refs_in_value(arg, known);
902 }
903 }
904 Expr::Pipe { left, right, .. } => {
905 self.check_type_refs_in_value(left, known);
906 self.check_type_refs_in_value(right, known);
907 }
908 Expr::TypeOptional { inner, .. } => {
909 self.check_type_refs_in_value(inner, known);
910 }
911 _ => {}
912 }
913 }
914
915 fn check_type_refs_in_rule_expr(&mut self, expr: &Expr, known: &HashSet<&str>) {
916 match expr {
917 Expr::Binding { value, .. } => {
919 self.check_type_refs_in_rule_expr(value, known);
920 }
921 Expr::Becomes { subject, .. } | Expr::TransitionsTo { subject, .. } => {
922 if let Expr::MemberAccess { object, .. } = subject.as_ref() {
923 if let Expr::Ident(id) = object.as_ref() {
924 if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
925 self.push(
926 Diagnostic::error(
927 id.span,
928 format!(
929 "Type reference '{}' is not declared locally or imported.",
930 id.name
931 ),
932 )
933 .with_code("allium.rule.undefinedTypeReference"),
934 );
935 }
936 }
937 }
938 }
939 Expr::Call { function, .. } => {
941 if let Expr::MemberAccess { object, .. } = function.as_ref() {
942 if let Expr::Ident(id) = object.as_ref() {
943 if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
944 self.push(
945 Diagnostic::error(
946 id.span,
947 format!(
948 "Type reference '{}' is not declared locally or imported.",
949 id.name
950 ),
951 )
952 .with_code("allium.rule.undefinedTypeReference"),
953 );
954 }
955 }
956 }
957 }
958 Expr::MemberAccess { object, .. } => {
960 if let Expr::Ident(id) = object.as_ref() {
961 if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
962 self.push(
963 Diagnostic::error(
964 id.span,
965 format!(
966 "Type reference '{}' is not declared locally or imported.",
967 id.name
968 ),
969 )
970 .with_code("allium.rule.undefinedTypeReference"),
971 );
972 }
973 }
974 }
975 Expr::Block { items, .. } => {
976 for item in items {
977 self.check_type_refs_in_rule_expr(item, known);
978 }
979 }
980 Expr::LogicalOp { left, right, .. } => {
981 self.check_type_refs_in_rule_expr(left, known);
982 self.check_type_refs_in_rule_expr(right, known);
983 }
984 _ => {}
985 }
986 }
987}
988
989impl Ctx<'_> {
994 fn check_unreachable_triggers(&mut self) {
995 let mut provided: HashSet<&str> = HashSet::new();
997 for surface in self.blocks(BlockKind::Surface) {
998 for item in &surface.items {
999 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1000 continue;
1001 };
1002 if keyword != "provides" {
1003 continue;
1004 }
1005 collect_call_names(value, &mut provided);
1006 }
1007 }
1008
1009 let mut emitted: HashSet<&str> = HashSet::new();
1013 for rule in self.blocks(BlockKind::Rule) {
1014 for item in &rule.items {
1015 collect_emitted_trigger_from_item(&item.kind, &mut emitted);
1016 }
1017 }
1018
1019 for rule in self.blocks(BlockKind::Rule) {
1020 let rule_name = match &rule.name {
1021 Some(n) => &n.name,
1022 None => continue,
1023 };
1024 for item in &rule.items {
1025 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1026 continue;
1027 };
1028 if keyword != "when" {
1029 continue;
1030 }
1031 let trigger_names = extract_trigger_names(value);
1032 for (name, span) in trigger_names {
1033 if !provided.contains(name) && !emitted.contains(name) {
1034 self.push(
1035 Diagnostic::info(
1036 span,
1037 format!(
1038 "Rule '{rule_name}' listens for trigger '{name}' but no local surface provides or rule emits it.",
1039 ),
1040 )
1041 .with_code("allium.rule.unreachableTrigger"),
1042 );
1043 }
1044 }
1045 }
1046 }
1047 }
1048}
1049
1050fn collect_emitted_trigger_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
1053 match kind {
1054 BlockItemKind::Clause { keyword, value } if keyword == "ensures" => {
1055 collect_leading_ensures_call(value, out);
1056 }
1057 BlockItemKind::ForBlock { items, .. } => {
1058 for item in items {
1059 collect_emitted_trigger_from_item(&item.kind, out);
1060 }
1061 }
1062 BlockItemKind::IfBlock { branches, else_items, .. } => {
1063 for b in branches {
1064 for item in &b.items {
1065 collect_emitted_trigger_from_item(&item.kind, out);
1066 }
1067 }
1068 if let Some(items) = else_items {
1069 for item in items {
1070 collect_emitted_trigger_from_item(&item.kind, out);
1071 }
1072 }
1073 }
1074 _ => {}
1075 }
1076}
1077
1078fn collect_leading_ensures_call<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
1082 match expr {
1083 Expr::Call { function, .. } => {
1084 if let Expr::Ident(id) = function.as_ref() {
1085 if starts_uppercase(&id.name) {
1086 out.insert(&id.name);
1087 }
1088 }
1089 }
1090 Expr::Block { items, .. } => {
1091 if let Some(first) = items.first() {
1092 collect_leading_ensures_call(first, out);
1093 }
1094 }
1095 _ => {}
1096 }
1097}
1098
1099fn collect_call_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
1100 match expr {
1101 Expr::Call { function, .. } => {
1102 if let Expr::Ident(id) = function.as_ref() {
1103 if starts_uppercase(&id.name) {
1104 out.insert(&id.name);
1105 }
1106 }
1107 }
1108 Expr::Block { items, .. } => {
1109 for item in items {
1110 collect_call_names(item, out);
1111 }
1112 }
1113 Expr::WhenGuard { action, .. } => {
1114 collect_call_names(action, out);
1115 }
1116 Expr::Conditional { branches, else_body, .. } => {
1117 for b in branches {
1118 collect_call_names(&b.body, out);
1119 }
1120 if let Some(body) = else_body {
1121 collect_call_names(body, out);
1122 }
1123 }
1124 _ => {}
1125 }
1126}
1127
1128fn extract_trigger_names(expr: &Expr) -> Vec<(&str, Span)> {
1129 match expr {
1130 Expr::Call { function, .. } => {
1131 if let Expr::Ident(id) = function.as_ref() {
1132 if starts_uppercase(&id.name) {
1133 return vec![(&id.name, id.span)];
1134 }
1135 }
1136 vec![]
1137 }
1138 Expr::Binding { .. } => {
1139 vec![]
1141 }
1142 Expr::LogicalOp { left, right, .. } => {
1143 let mut out = extract_trigger_names(left);
1144 out.extend(extract_trigger_names(right));
1145 out
1146 }
1147 _ => vec![],
1148 }
1149}
1150
1151impl Ctx<'_> {
1156 fn check_unused_fields(&mut self) {
1157 let accessed = self.collect_all_accessed_field_names();
1158
1159 for d in &self.module.declarations {
1160 let block = match d {
1161 Decl::Block(b)
1162 if matches!(
1163 b.kind,
1164 BlockKind::Entity | BlockKind::ExternalEntity
1165 ) =>
1166 {
1167 b
1168 }
1169 Decl::Variant(v) => {
1170 let entity_name = &v.name.name;
1171 for item in &v.items {
1172 if let BlockItemKind::Assignment { name, .. }
1173 | BlockItemKind::FieldWithWhen { name, .. } = &item.kind
1174 {
1175 if !accessed.contains(name.name.as_str()) {
1176 self.push(
1177 Diagnostic::info(
1178 name.span,
1179 format!(
1180 "Field '{entity_name}.{}' is declared but not referenced elsewhere.",
1181 name.name
1182 ),
1183 )
1184 .with_code("allium.field.unused"),
1185 );
1186 }
1187 }
1188 }
1189 continue;
1190 }
1191 _ => continue,
1192 };
1193
1194 let entity_name = match &block.name {
1195 Some(n) => &n.name,
1196 None => continue,
1197 };
1198
1199 for item in &block.items {
1200 if let BlockItemKind::Assignment { name, .. }
1201 | BlockItemKind::FieldWithWhen { name, .. } = &item.kind
1202 {
1203 if !accessed.contains(name.name.as_str()) {
1204 self.push(
1205 Diagnostic::info(
1206 name.span,
1207 format!(
1208 "Field '{entity_name}.{}' is declared but not referenced elsewhere.",
1209 name.name
1210 ),
1211 )
1212 .with_code("allium.field.unused"),
1213 );
1214 }
1215 }
1216 }
1217 }
1218 }
1219}
1220
1221fn collect_accessed_fields_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
1222 match kind {
1223 BlockItemKind::Clause { value, .. }
1224 | BlockItemKind::Assignment { value, .. }
1225 | BlockItemKind::ParamAssignment { value, .. }
1226 | BlockItemKind::Let { value, .. }
1227 | BlockItemKind::PathAssignment { value, .. }
1228 | BlockItemKind::InvariantBlock { body: value, .. }
1229 | BlockItemKind::FieldWithWhen { value, .. } => {
1230 collect_accessed_fields_from_expr(value, out);
1231 }
1232 BlockItemKind::ForBlock {
1233 collection,
1234 filter,
1235 items,
1236 ..
1237 } => {
1238 collect_accessed_fields_from_expr(collection, out);
1239 if let Some(f) = filter {
1240 collect_accessed_fields_from_expr(f, out);
1241 }
1242 for item in items {
1243 collect_accessed_fields_from_item(&item.kind, out);
1244 }
1245 }
1246 BlockItemKind::IfBlock {
1247 branches,
1248 else_items,
1249 } => {
1250 for b in branches {
1251 collect_accessed_fields_from_expr(&b.condition, out);
1252 for item in &b.items {
1253 collect_accessed_fields_from_item(&item.kind, out);
1254 }
1255 }
1256 if let Some(items) = else_items {
1257 for item in items {
1258 collect_accessed_fields_from_item(&item.kind, out);
1259 }
1260 }
1261 }
1262 _ => {}
1263 }
1264}
1265
1266fn collect_accessed_fields_from_expr<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
1267 match expr {
1268 Expr::MemberAccess { object, field, .. } | Expr::OptionalAccess { object, field, .. } => {
1269 out.insert(&field.name);
1270 collect_accessed_fields_from_expr(object, out);
1271 }
1272 Expr::Call { function, args, .. } => {
1273 collect_accessed_fields_from_expr(function, out);
1274 for a in args {
1275 match a {
1276 CallArg::Positional(e) => collect_accessed_fields_from_expr(e, out),
1277 CallArg::Named(n) => collect_accessed_fields_from_expr(&n.value, out),
1278 }
1279 }
1280 }
1281 Expr::BinaryOp { left, right, .. }
1282 | Expr::Comparison { left, right, .. }
1283 | Expr::LogicalOp { left, right, .. }
1284 | Expr::Pipe { left, right, .. }
1285 | Expr::NullCoalesce { left, right, .. } => {
1286 collect_accessed_fields_from_expr(left, out);
1287 collect_accessed_fields_from_expr(right, out);
1288 }
1289 Expr::Not { operand, .. }
1290 | Expr::Exists { operand, .. }
1291 | Expr::NotExists { operand, .. }
1292 | Expr::TypeOptional { inner: operand, .. } => {
1293 collect_accessed_fields_from_expr(operand, out);
1294 }
1295 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
1296 collect_accessed_fields_from_expr(element, out);
1297 collect_accessed_fields_from_expr(collection, out);
1298 }
1299 Expr::Where { source, condition, .. }
1300 | Expr::With {
1301 source,
1302 predicate: condition,
1303 ..
1304 } => {
1305 collect_accessed_fields_from_expr(source, out);
1306 collect_accessed_fields_from_expr(condition, out);
1307 }
1308 Expr::WhenGuard { action, condition, .. } => {
1309 collect_accessed_fields_from_expr(action, out);
1310 collect_accessed_fields_from_expr(condition, out);
1311 }
1312 Expr::Block { items, .. } => {
1313 for item in items {
1314 collect_accessed_fields_from_expr(item, out);
1315 }
1316 }
1317 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
1318 collect_accessed_fields_from_expr(value, out);
1319 }
1320 Expr::Conditional { branches, else_body, .. } => {
1321 for b in branches {
1322 collect_accessed_fields_from_expr(&b.condition, out);
1323 collect_accessed_fields_from_expr(&b.body, out);
1324 }
1325 if let Some(body) = else_body {
1326 collect_accessed_fields_from_expr(body, out);
1327 }
1328 }
1329 Expr::For { collection, filter, body, .. } => {
1330 collect_accessed_fields_from_expr(collection, out);
1331 if let Some(f) = filter {
1332 collect_accessed_fields_from_expr(f, out);
1333 }
1334 collect_accessed_fields_from_expr(body, out);
1335 }
1336 Expr::Lambda { body, .. } => {
1337 collect_accessed_fields_from_expr(body, out);
1338 }
1339 Expr::JoinLookup { entity, fields, .. } => {
1340 collect_accessed_fields_from_expr(entity, out);
1341 for f in fields {
1342 out.insert(&f.field.name);
1343 if let Some(v) = &f.value {
1344 collect_accessed_fields_from_expr(v, out);
1345 }
1346 }
1347 }
1348 Expr::TransitionsTo { subject, new_state, .. }
1349 | Expr::Becomes { subject, new_state, .. } => {
1350 collect_accessed_fields_from_expr(subject, out);
1351 collect_accessed_fields_from_expr(new_state, out);
1352 }
1353 Expr::SetLiteral { elements, .. } => {
1354 for e in elements {
1355 collect_accessed_fields_from_expr(e, out);
1356 }
1357 }
1358 Expr::ObjectLiteral { fields, .. } => {
1359 for f in fields {
1360 collect_accessed_fields_from_expr(&f.value, out);
1361 }
1362 }
1363 Expr::GenericType { name, args, .. } => {
1364 collect_accessed_fields_from_expr(name, out);
1365 for a in args {
1366 collect_accessed_fields_from_expr(a, out);
1367 }
1368 }
1369 Expr::ProjectionMap { source, .. } => {
1370 collect_accessed_fields_from_expr(source, out);
1371 }
1372 _ => {}
1373 }
1374}
1375
1376impl Ctx<'_> {
1381 fn check_unused_entities(&mut self) {
1382 let mut all_idents = self.collect_all_referenced_idents();
1383 for v in self.variants() {
1385 let base = expr_as_ident(&v.base).or_else(|| {
1386 if let Expr::JoinLookup { entity, .. } = &v.base {
1387 expr_as_ident(entity)
1388 } else {
1389 None
1390 }
1391 });
1392 if let Some(name) = base {
1393 all_idents.insert(name);
1394 }
1395 }
1396 let mut findings = Vec::new();
1397
1398 for d in &self.module.declarations {
1399 let block = match d {
1400 Decl::Block(b)
1401 if matches!(
1402 b.kind,
1403 BlockKind::Entity | BlockKind::ExternalEntity
1404 ) =>
1405 {
1406 b
1407 }
1408 _ => continue,
1409 };
1410 let name = match &block.name {
1411 Some(n) => n,
1412 None => continue,
1413 };
1414 if !all_idents.contains(name.name.as_str()) {
1415 findings.push(
1416 Diagnostic::warning(
1417 name.span,
1418 format!(
1419 "Entity '{}' is declared but not referenced elsewhere in this specification.",
1420 name.name
1421 ),
1422 )
1423 .with_code("allium.entity.unused"),
1424 );
1425 }
1426 }
1427 self.diagnostics.extend(findings);
1428 }
1429
1430 fn check_unused_definitions(&mut self) {
1431 let all_idents = self.collect_all_referenced_idents();
1432 let mut findings = Vec::new();
1433
1434 for d in &self.module.declarations {
1435 match d {
1436 Decl::Block(b) if b.kind == BlockKind::Value || b.kind == BlockKind::Enum => {
1437 let name = match &b.name {
1438 Some(n) => n,
1439 None => continue,
1440 };
1441 if !all_idents.contains(name.name.as_str()) {
1442 findings.push(
1443 Diagnostic::warning(
1444 name.span,
1445 format!(
1446 "Value '{}' is declared but not referenced elsewhere.",
1447 name.name
1448 ),
1449 )
1450 .with_code("allium.definition.unused"),
1451 );
1452 }
1453 }
1454 _ => {}
1455 }
1456 }
1457 self.diagnostics.extend(findings);
1458 }
1459
1460 fn collect_all_referenced_idents(&self) -> HashSet<&str> {
1463 let mut names = HashSet::new();
1464 for d in &self.module.declarations {
1465 match d {
1466 Decl::Block(b) => {
1467 for item in &b.items {
1468 collect_uppercase_idents_from_item(&item.kind, &mut names);
1469 }
1470 }
1471 Decl::Variant(v) => {
1472 if let Some(name) = expr_as_ident(&v.base) {
1474 names.insert(name);
1475 }
1476 for item in &v.items {
1477 collect_uppercase_idents_from_item(&item.kind, &mut names);
1478 }
1479 }
1480 Decl::Invariant(inv) => {
1481 collect_uppercase_idents_from_expr(&inv.body, &mut names);
1482 }
1483 Decl::Default(def) => {
1484 if let Some(tn) = &def.type_name {
1485 names.insert(tn.name.as_str());
1486 }
1487 collect_uppercase_idents_from_expr(&def.value, &mut names);
1488 }
1489 _ => {}
1490 }
1491 }
1492 names
1493 }
1494}
1495
1496fn collect_uppercase_idents_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
1497 match kind {
1498 BlockItemKind::Clause { value, .. }
1499 | BlockItemKind::Assignment { value, .. }
1500 | BlockItemKind::ParamAssignment { value, .. }
1501 | BlockItemKind::Let { value, .. }
1502 | BlockItemKind::PathAssignment { value, .. }
1503 | BlockItemKind::InvariantBlock { body: value, .. }
1504 | BlockItemKind::FieldWithWhen { value, .. } => {
1505 collect_uppercase_idents_from_expr(value, out);
1506 }
1507 BlockItemKind::ForBlock {
1508 collection,
1509 filter,
1510 items,
1511 ..
1512 } => {
1513 collect_uppercase_idents_from_expr(collection, out);
1514 if let Some(f) = filter {
1515 collect_uppercase_idents_from_expr(f, out);
1516 }
1517 for item in items {
1518 collect_uppercase_idents_from_item(&item.kind, out);
1519 }
1520 }
1521 BlockItemKind::IfBlock {
1522 branches,
1523 else_items,
1524 } => {
1525 for b in branches {
1526 collect_uppercase_idents_from_expr(&b.condition, out);
1527 for item in &b.items {
1528 collect_uppercase_idents_from_item(&item.kind, out);
1529 }
1530 }
1531 if let Some(items) = else_items {
1532 for item in items {
1533 collect_uppercase_idents_from_item(&item.kind, out);
1534 }
1535 }
1536 }
1537 BlockItemKind::ContractsClause { entries } => {
1538 for e in entries {
1539 out.insert(e.name.name.as_str());
1540 }
1541 }
1542 _ => {}
1543 }
1544}
1545
1546fn collect_uppercase_idents_from_expr<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
1547 match expr {
1548 Expr::Ident(id) if starts_uppercase(&id.name) => {
1549 out.insert(&id.name);
1550 }
1551 Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
1552 collect_uppercase_idents_from_expr(object, out);
1553 }
1554 Expr::Call { function, args, .. } => {
1555 collect_uppercase_idents_from_expr(function, out);
1556 for a in args {
1557 match a {
1558 CallArg::Positional(e) => collect_uppercase_idents_from_expr(e, out),
1559 CallArg::Named(n) => collect_uppercase_idents_from_expr(&n.value, out),
1560 }
1561 }
1562 }
1563 Expr::JoinLookup { entity, fields, .. } => {
1564 collect_uppercase_idents_from_expr(entity, out);
1565 for f in fields {
1566 if let Some(v) = &f.value {
1567 collect_uppercase_idents_from_expr(v, out);
1568 }
1569 }
1570 }
1571 Expr::BinaryOp { left, right, .. }
1572 | Expr::Comparison { left, right, .. }
1573 | Expr::LogicalOp { left, right, .. }
1574 | Expr::Pipe { left, right, .. }
1575 | Expr::NullCoalesce { left, right, .. } => {
1576 collect_uppercase_idents_from_expr(left, out);
1577 collect_uppercase_idents_from_expr(right, out);
1578 }
1579 Expr::Not { operand, .. }
1580 | Expr::Exists { operand, .. }
1581 | Expr::NotExists { operand, .. }
1582 | Expr::TypeOptional { inner: operand, .. } => {
1583 collect_uppercase_idents_from_expr(operand, out);
1584 }
1585 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
1586 collect_uppercase_idents_from_expr(element, out);
1587 collect_uppercase_idents_from_expr(collection, out);
1588 }
1589 Expr::Where { source, condition, .. }
1590 | Expr::With {
1591 source,
1592 predicate: condition,
1593 ..
1594 } => {
1595 collect_uppercase_idents_from_expr(source, out);
1596 collect_uppercase_idents_from_expr(condition, out);
1597 }
1598 Expr::WhenGuard { action, condition, .. } => {
1599 collect_uppercase_idents_from_expr(action, out);
1600 collect_uppercase_idents_from_expr(condition, out);
1601 }
1602 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
1603 collect_uppercase_idents_from_expr(value, out);
1604 }
1605 Expr::Block { items, .. } => {
1606 for item in items {
1607 collect_uppercase_idents_from_expr(item, out);
1608 }
1609 }
1610 Expr::Conditional { branches, else_body, .. } => {
1611 for b in branches {
1612 collect_uppercase_idents_from_expr(&b.condition, out);
1613 collect_uppercase_idents_from_expr(&b.body, out);
1614 }
1615 if let Some(body) = else_body {
1616 collect_uppercase_idents_from_expr(body, out);
1617 }
1618 }
1619 Expr::For { collection, filter, body, .. } => {
1620 collect_uppercase_idents_from_expr(collection, out);
1621 if let Some(f) = filter {
1622 collect_uppercase_idents_from_expr(f, out);
1623 }
1624 collect_uppercase_idents_from_expr(body, out);
1625 }
1626 Expr::Lambda { body, .. } => {
1627 collect_uppercase_idents_from_expr(body, out);
1628 }
1629 Expr::TransitionsTo { subject, new_state, .. }
1630 | Expr::Becomes { subject, new_state, .. } => {
1631 collect_uppercase_idents_from_expr(subject, out);
1632 collect_uppercase_idents_from_expr(new_state, out);
1633 }
1634 Expr::GenericType { name, args, .. } => {
1635 collect_uppercase_idents_from_expr(name, out);
1636 for a in args {
1637 collect_uppercase_idents_from_expr(a, out);
1638 }
1639 }
1640 Expr::SetLiteral { elements, .. } => {
1641 for e in elements {
1642 collect_uppercase_idents_from_expr(e, out);
1643 }
1644 }
1645 Expr::ObjectLiteral { fields, .. } => {
1646 for f in fields {
1647 collect_uppercase_idents_from_expr(&f.value, out);
1648 }
1649 }
1650 Expr::ProjectionMap { source, .. } => {
1651 collect_uppercase_idents_from_expr(source, out);
1652 }
1653 Expr::QualifiedName(q) => {
1654 out.insert(&q.name);
1655 }
1656 _ => {}
1657 }
1658}
1659
1660impl Ctx<'_> {
1665 fn check_deferred_location_hints(&mut self) {
1666 for d in &self.module.declarations {
1667 let Decl::Deferred(def) = d else {
1668 continue;
1669 };
1670 self.push(
1674 Diagnostic::warning(
1675 def.span,
1676 format!(
1677 "Deferred specification '{}' should include a location hint.",
1678 expr_to_dotpath(&def.path),
1679 ),
1680 )
1681 .with_code("allium.deferred.missingLocationHint"),
1682 );
1683 }
1684 }
1685}
1686
1687fn expr_to_dotpath(expr: &Expr) -> String {
1688 match expr {
1689 Expr::Ident(id) => id.name.clone(),
1690 Expr::MemberAccess { object, field, .. } => {
1691 format!("{}.{}", expr_to_dotpath(object), field.name)
1692 }
1693 _ => "?".to_string(),
1694 }
1695}
1696
1697impl Ctx<'_> {
1702 fn check_rule_invalid_triggers(&mut self) {
1703 for rule in self.blocks(BlockKind::Rule) {
1704 let rule_name = match &rule.name {
1705 Some(n) => &n.name,
1706 None => continue,
1707 };
1708
1709 for item in &rule.items {
1710 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1711 continue;
1712 };
1713 if keyword != "when" {
1714 continue;
1715 }
1716 if !is_valid_trigger(value) {
1717 self.push(
1718 Diagnostic::error(
1719 item.span,
1720 format!(
1721 "Rule '{rule_name}' uses an unsupported trigger form in 'when:'.",
1722 ),
1723 )
1724 .with_code("allium.rule.invalidTrigger"),
1725 );
1726 }
1727 }
1728 }
1729 }
1730}
1731
1732fn is_valid_trigger(expr: &Expr) -> bool {
1733 match expr {
1734 Expr::Call { function, .. } => {
1736 matches!(function.as_ref(), Expr::Ident(_) | Expr::MemberAccess { .. })
1737 }
1738 Expr::Binding { value, .. } => {
1740 matches!(
1741 value.as_ref(),
1742 Expr::Becomes { .. }
1743 | Expr::TransitionsTo { .. }
1744 | Expr::MemberAccess { .. }
1745 | Expr::Comparison { .. }
1746 )
1747 }
1748 Expr::LogicalOp {
1750 op: LogicalOp::Or,
1751 left,
1752 right,
1753 ..
1754 } => is_valid_trigger(left) && is_valid_trigger(right),
1755 Expr::Comparison { left, .. } => {
1757 matches!(left.as_ref(), Expr::MemberAccess { .. })
1758 }
1759 _ => false,
1760 }
1761}
1762
1763impl Ctx<'_> {
1768 fn check_rule_undefined_bindings(&mut self) {
1769 let mut given_bindings: HashSet<&str> = HashSet::new();
1771 for given in self.blocks(BlockKind::Given) {
1772 for item in &given.items {
1773 if let BlockItemKind::Assignment { name, .. } = &item.kind {
1774 given_bindings.insert(&name.name);
1775 }
1776 }
1777 }
1778
1779 let mut default_names: HashSet<&str> = HashSet::new();
1781 for d in &self.module.declarations {
1782 if let Decl::Default(def) = d {
1783 default_names.insert(&def.name.name);
1784 }
1785 }
1786
1787 for rule in self.blocks(BlockKind::Rule) {
1788 let rule_name = match &rule.name {
1789 Some(n) => &n.name,
1790 None => continue,
1791 };
1792
1793 let mut bound: HashSet<&str> = HashSet::new();
1794 bound.extend(&given_bindings);
1795 bound.extend(&default_names);
1796
1797 for item in &rule.items {
1799 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1800 continue;
1801 };
1802 if keyword != "when" {
1803 continue;
1804 }
1805 collect_bound_names(value, &mut bound);
1806 }
1807
1808 for item in &rule.items {
1810 if let BlockItemKind::Let { name, .. } = &item.kind {
1811 bound.insert(&name.name);
1812 }
1813 }
1814
1815 for item in &rule.items {
1817 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1818 continue;
1819 };
1820 if keyword != "requires" && keyword != "ensures" {
1821 continue;
1822 }
1823 check_unbound_roots(value, &bound, rule_name, &mut self.diagnostics);
1824 }
1825
1826 for item in &rule.items {
1828 match &item.kind {
1829 BlockItemKind::ForBlock {
1830 binding,
1831 items,
1832 ..
1833 } => {
1834 let mut inner_bound = bound.clone();
1835 match binding {
1836 ForBinding::Single(id) => { inner_bound.insert(&id.name); }
1837 ForBinding::Destructured(ids, _) => {
1838 for id in ids {
1839 inner_bound.insert(&id.name);
1840 }
1841 }
1842 }
1843 for sub_item in items {
1844 if let BlockItemKind::Clause { keyword, value } = &sub_item.kind {
1845 if keyword == "ensures" || keyword == "requires" {
1846 check_unbound_roots(value, &inner_bound, rule_name, &mut self.diagnostics);
1847 }
1848 }
1849 }
1850 }
1851 _ => {}
1852 }
1853 }
1854
1855 for item in &rule.items {
1859 let BlockItemKind::Clause { keyword, value } = &item.kind else { continue };
1860 if keyword != "when" { continue }
1861 let Expr::Binding { name: binding_name, value: trigger_value, .. } = value else { continue };
1862 if !matches!(trigger_value.as_ref(), Expr::Ident(id) if starts_uppercase(&id.name)) {
1863 continue;
1864 }
1865 let mut found = false;
1867 for check_item in &rule.items {
1868 let BlockItemKind::Clause { keyword: kw, value: v } = &check_item.kind else { continue };
1869 if kw != "requires" && kw != "ensures" { continue }
1870 if expr_contains_ident(v, &binding_name.name) {
1871 self.push(
1872 Diagnostic::error(
1873 check_item.span,
1874 format!(
1875 "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
1876 binding_name.name
1877 ),
1878 )
1879 .with_code("allium.rule.undefinedBinding"),
1880 );
1881 found = true;
1882 break;
1883 }
1884 }
1885 if found { break; }
1886 }
1887 }
1888 }
1889}
1890
1891fn collect_bound_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
1892 match expr {
1893 Expr::Binding { name, .. } => {
1894 out.insert(&name.name);
1895 }
1896 Expr::Call { args, .. } => {
1897 for arg in args {
1898 if let CallArg::Positional(Expr::Ident(id)) = arg {
1899 out.insert(&id.name);
1900 }
1901 }
1902 }
1903 Expr::LogicalOp { left, right, .. } => {
1904 collect_bound_names(left, out);
1905 collect_bound_names(right, out);
1906 }
1907 _ => {}
1908 }
1909}
1910
1911fn check_unbound_roots(
1912 expr: &Expr,
1913 bound: &HashSet<&str>,
1914 rule_name: &str,
1915 diagnostics: &mut Vec<Diagnostic>,
1916) {
1917 match expr {
1918 Expr::MemberAccess { object, .. } => {
1919 if let Expr::Ident(id) = object.as_ref() {
1920 if !starts_uppercase(&id.name)
1921 && !bound.contains(id.name.as_str())
1922 && !is_builtin_name(&id.name)
1923 {
1924 diagnostics.push(
1925 Diagnostic::error(
1926 id.span,
1927 format!(
1928 "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
1929 id.name
1930 ),
1931 )
1932 .with_code("allium.rule.undefinedBinding"),
1933 );
1934 }
1935 }
1936 }
1937 Expr::Comparison { left, right, .. } => {
1938 check_unbound_roots(left, bound, rule_name, diagnostics);
1939 check_unbound_roots(right, bound, rule_name, diagnostics);
1940 }
1941 Expr::LogicalOp { left, right, .. } => {
1942 check_unbound_roots(left, bound, rule_name, diagnostics);
1943 check_unbound_roots(right, bound, rule_name, diagnostics);
1944 }
1945 Expr::Block { items, .. } => {
1946 let mut block_bound = bound.clone();
1947 for item in items {
1948 if let Expr::LetExpr { name, value, .. } = item {
1949 check_unbound_roots(value, &block_bound, rule_name, diagnostics);
1950 block_bound.insert(name.name.as_str());
1951 } else {
1952 check_unbound_roots(item, &block_bound, rule_name, diagnostics);
1953 }
1954 }
1955 }
1956 Expr::For { binding, collection, body, .. } => {
1957 check_unbound_roots(collection, bound, rule_name, diagnostics);
1958 let mut inner = bound.clone();
1960 match binding {
1961 ForBinding::Single(id) => { inner.insert(id.name.as_str()); }
1962 ForBinding::Destructured(ids, _) => {
1963 for id in ids {
1964 inner.insert(id.name.as_str());
1965 }
1966 }
1967 }
1968 check_unbound_roots(body, &inner, rule_name, diagnostics);
1969 }
1970 Expr::BinaryOp { left, right, .. } => {
1971 check_unbound_roots(left, bound, rule_name, diagnostics);
1972 check_unbound_roots(right, bound, rule_name, diagnostics);
1973 }
1974 Expr::Call { function, args, .. } => {
1975 if !matches!(function.as_ref(), Expr::MemberAccess { .. }) {
1977 check_unbound_roots(function, bound, rule_name, diagnostics);
1978 }
1979 let mut call_bound = bound.clone();
1981 for a in args {
1982 if let CallArg::Positional(Expr::Lambda { param, .. }) = a {
1983 if let Expr::Ident(id) = param.as_ref() {
1984 call_bound.insert(id.name.as_str());
1985 }
1986 }
1987 }
1988 for a in args {
1989 match a {
1990 CallArg::Positional(Expr::Lambda { body, .. }) => {
1991 check_unbound_roots(body, &call_bound, rule_name, diagnostics);
1992 }
1993 CallArg::Positional(e) => {
1994 check_unbound_roots(e, &call_bound, rule_name, diagnostics);
1995 }
1996 CallArg::Named(n) => check_unbound_roots(&n.value, &call_bound, rule_name, diagnostics),
1997 }
1998 }
1999 }
2000 Expr::Not { operand, .. }
2001 | Expr::Exists { operand, .. }
2002 | Expr::NotExists { operand, .. } => {
2003 check_unbound_roots(operand, bound, rule_name, diagnostics);
2004 }
2005 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
2006 check_unbound_roots(element, bound, rule_name, diagnostics);
2007 check_unbound_roots(collection, bound, rule_name, diagnostics);
2008 }
2009 Expr::Conditional { branches, else_body, .. } => {
2010 for b in branches {
2011 check_unbound_roots(&b.condition, bound, rule_name, diagnostics);
2012 check_unbound_roots(&b.body, bound, rule_name, diagnostics);
2013 }
2014 if let Some(body) = else_body {
2015 check_unbound_roots(body, bound, rule_name, diagnostics);
2016 }
2017 }
2018 _ => {}
2019 }
2020}
2021
2022fn is_builtin_name(name: &str) -> bool {
2023 matches!(name, "config" | "now" | "this" | "within" | "true" | "false" | "null")
2024}
2025
2026impl Ctx<'_> {
2031 fn check_duplicate_let_bindings(&mut self) {
2032 for rule in self.blocks(BlockKind::Rule) {
2033 let mut seen: HashMap<&str, Span> = HashMap::new();
2034 self.check_duplicate_lets_in_items(&rule.items, &mut seen);
2035 }
2036 }
2037
2038 fn check_duplicate_lets_in_items<'b>(
2039 &mut self,
2040 items: &'b [BlockItem],
2041 seen: &mut HashMap<&'b str, Span>,
2042 ) {
2043 for item in items {
2044 match &item.kind {
2045 BlockItemKind::Let { name, .. } => {
2046 if seen.contains_key(name.name.as_str()) {
2047 self.push(
2048 Diagnostic::error(
2049 name.span,
2050 format!("Duplicate let binding '{}' in this rule.", name.name),
2051 )
2052 .with_code("allium.let.duplicateBinding"),
2053 );
2054 } else {
2055 seen.insert(&name.name, name.span);
2056 }
2057 }
2058 BlockItemKind::ForBlock { items, .. } => {
2059 self.check_duplicate_lets_in_items(items, seen);
2060 }
2061 BlockItemKind::IfBlock {
2062 branches,
2063 else_items,
2064 } => {
2065 for b in branches {
2066 self.check_duplicate_lets_in_items(&b.items, seen);
2067 }
2068 if let Some(items) = else_items {
2069 self.check_duplicate_lets_in_items(items, seen);
2070 }
2071 }
2072 BlockItemKind::Clause { value, .. } => {
2073 self.check_duplicate_lets_in_expr(value, seen);
2074 }
2075 _ => {}
2076 }
2077 }
2078 }
2079
2080 fn check_duplicate_lets_in_expr<'b>(
2081 &mut self,
2082 expr: &'b Expr,
2083 seen: &mut HashMap<&'b str, Span>,
2084 ) {
2085 match expr {
2086 Expr::LetExpr { name, value, .. } => {
2087 if seen.contains_key(name.name.as_str()) {
2088 self.push(
2089 Diagnostic::error(
2090 name.span,
2091 format!("Duplicate let binding '{}' in this rule.", name.name),
2092 )
2093 .with_code("allium.let.duplicateBinding"),
2094 );
2095 } else {
2096 seen.insert(&name.name, name.span);
2097 }
2098 self.check_duplicate_lets_in_expr(value, seen);
2099 }
2100 Expr::Block { items, .. } => {
2101 for item in items {
2102 self.check_duplicate_lets_in_expr(item, seen);
2103 }
2104 }
2105 Expr::For { body, .. } => {
2106 self.check_duplicate_lets_in_expr(body, seen);
2107 }
2108 Expr::Conditional { branches, else_body, .. } => {
2109 for b in branches {
2110 self.check_duplicate_lets_in_expr(&b.body, seen);
2111 }
2112 if let Some(body) = else_body {
2113 self.check_duplicate_lets_in_expr(body, seen);
2114 }
2115 }
2116 _ => {}
2117 }
2118 }
2119}
2120
2121impl Ctx<'_> {
2126 fn check_config_undefined_references(&mut self) {
2127 let mut config_params: HashSet<&str> = HashSet::new();
2128 for config in self.blocks(BlockKind::Config) {
2129 for item in &config.items {
2130 if let BlockItemKind::Assignment { name, .. } = &item.kind {
2131 config_params.insert(&name.name);
2132 }
2133 }
2134 }
2135
2136 for d in &self.module.declarations {
2138 match d {
2139 Decl::Block(b) => {
2140 if b.kind == BlockKind::Config {
2141 continue;
2142 }
2143 for item in &b.items {
2144 self.check_config_refs_in_item(&item.kind, &config_params);
2145 }
2146 }
2147 Decl::Invariant(inv) => {
2148 self.check_config_refs_in_expr(&inv.body, &config_params);
2149 }
2150 _ => {}
2151 }
2152 }
2153 }
2154
2155 fn check_config_refs_in_item(&mut self, kind: &BlockItemKind, params: &HashSet<&str>) {
2156 match kind {
2157 BlockItemKind::Clause { value, .. }
2158 | BlockItemKind::Assignment { value, .. }
2159 | BlockItemKind::ParamAssignment { value, .. }
2160 | BlockItemKind::Let { value, .. }
2161 | BlockItemKind::FieldWithWhen { value, .. } => {
2162 self.check_config_refs_in_expr(value, params);
2163 }
2164 BlockItemKind::ForBlock { collection, filter, items, .. } => {
2165 self.check_config_refs_in_expr(collection, params);
2166 if let Some(f) = filter {
2167 self.check_config_refs_in_expr(f, params);
2168 }
2169 for item in items {
2170 self.check_config_refs_in_item(&item.kind, params);
2171 }
2172 }
2173 BlockItemKind::IfBlock { branches, else_items } => {
2174 for b in branches {
2175 self.check_config_refs_in_expr(&b.condition, params);
2176 for item in &b.items {
2177 self.check_config_refs_in_item(&item.kind, params);
2178 }
2179 }
2180 if let Some(items) = else_items {
2181 for item in items {
2182 self.check_config_refs_in_item(&item.kind, params);
2183 }
2184 }
2185 }
2186 _ => {}
2187 }
2188 }
2189
2190 fn check_config_refs_in_expr(&mut self, expr: &Expr, params: &HashSet<&str>) {
2191 match expr {
2192 Expr::MemberAccess { object, field, .. } => {
2193 if let Expr::Ident(id) = object.as_ref() {
2194 if id.name == "config" && !params.contains(field.name.as_str()) {
2195 self.push(
2196 Diagnostic::warning(
2197 field.span,
2198 format!(
2199 "Config reference 'config.{}' is not declared in any config block.",
2200 field.name
2201 ),
2202 )
2203 .with_code("allium.config.undefinedReference"),
2204 );
2205 return;
2206 }
2207 }
2208 self.check_config_refs_in_expr(object, params);
2209 }
2210 Expr::Call { function, args, .. } => {
2211 self.check_config_refs_in_expr(function, params);
2212 for a in args {
2213 match a {
2214 CallArg::Positional(e) => self.check_config_refs_in_expr(e, params),
2215 CallArg::Named(n) => self.check_config_refs_in_expr(&n.value, params),
2216 }
2217 }
2218 }
2219 Expr::BinaryOp { left, right, .. }
2220 | Expr::Comparison { left, right, .. }
2221 | Expr::LogicalOp { left, right, .. }
2222 | Expr::Pipe { left, right, .. }
2223 | Expr::NullCoalesce { left, right, .. } => {
2224 self.check_config_refs_in_expr(left, params);
2225 self.check_config_refs_in_expr(right, params);
2226 }
2227 Expr::Not { operand, .. }
2228 | Expr::Exists { operand, .. }
2229 | Expr::NotExists { operand, .. } => {
2230 self.check_config_refs_in_expr(operand, params);
2231 }
2232 Expr::Block { items, .. } => {
2233 for item in items {
2234 self.check_config_refs_in_expr(item, params);
2235 }
2236 }
2237 Expr::Conditional { branches, else_body, .. } => {
2238 for b in branches {
2239 self.check_config_refs_in_expr(&b.condition, params);
2240 self.check_config_refs_in_expr(&b.body, params);
2241 }
2242 if let Some(body) = else_body {
2243 self.check_config_refs_in_expr(body, params);
2244 }
2245 }
2246 Expr::For { collection, filter, body, .. } => {
2247 self.check_config_refs_in_expr(collection, params);
2248 if let Some(f) = filter {
2249 self.check_config_refs_in_expr(f, params);
2250 }
2251 self.check_config_refs_in_expr(body, params);
2252 }
2253 Expr::LetExpr { value, .. } => {
2254 self.check_config_refs_in_expr(value, params);
2255 }
2256 Expr::Lambda { body, .. } => {
2257 self.check_config_refs_in_expr(body, params);
2258 }
2259 _ => {}
2260 }
2261 }
2262}
2263
2264impl Ctx<'_> {
2269 fn check_surface_unused_paths(&mut self) {
2270 let mut rule_paths: HashSet<String> = HashSet::new();
2272 for rule in self.blocks(BlockKind::Rule) {
2273 for item in &rule.items {
2274 collect_dotpaths_from_item(&item.kind, &mut rule_paths);
2275 }
2276 }
2277
2278 for surface in self.blocks(BlockKind::Surface) {
2279 let surface_name = match &surface.name {
2280 Some(n) => &n.name,
2281 None => continue,
2282 };
2283
2284 let mut binding_names: HashSet<&str> = HashSet::new();
2286 for item in &surface.items {
2287 let BlockItemKind::Clause { keyword, value } = &item.kind else {
2288 continue;
2289 };
2290 if keyword == "facing" || keyword == "context" {
2291 if let Expr::Binding { name, .. } = value {
2292 binding_names.insert(&name.name);
2293 }
2294 }
2295 }
2296
2297 for item in &surface.items {
2298 let BlockItemKind::Clause { keyword, value } = &item.kind else {
2299 continue;
2300 };
2301 if keyword != "exposes" {
2302 continue;
2303 }
2304 check_unused_surface_paths(
2305 value,
2306 &binding_names,
2307 &rule_paths,
2308 surface_name,
2309 &mut self.diagnostics,
2310 );
2311 }
2312 }
2313 }
2314}
2315
2316fn check_unused_surface_paths(
2317 expr: &Expr,
2318 binding_names: &HashSet<&str>,
2319 rule_paths: &HashSet<String>,
2320 surface_name: &str,
2321 diagnostics: &mut Vec<Diagnostic>,
2322) {
2323 match expr {
2324 Expr::MemberAccess { object, field, .. } => {
2325 if let Expr::Ident(root) = object.as_ref() {
2327 if binding_names.contains(root.name.as_str()) {
2328 let path = format!("{}.{}", root.name, field.name);
2329 let field_used = rule_paths.iter().any(|p| p.ends_with(&format!(".{}", field.name)));
2331 if !field_used {
2332 diagnostics.push(
2333 Diagnostic::info(
2334 expr.span(),
2335 format!(
2336 "Surface '{surface_name}' path '{path}' is not observed in rule field references.",
2337 ),
2338 )
2339 .with_code("allium.surface.unusedPath"),
2340 );
2341 }
2342 }
2343 }
2344 }
2345 Expr::Block { items, .. } => {
2346 for item in items {
2347 check_unused_surface_paths(item, binding_names, rule_paths, surface_name, diagnostics);
2348 }
2349 }
2350 _ => {}
2351 }
2352}
2353
2354fn collect_dotpaths_from_item(kind: &BlockItemKind, out: &mut HashSet<String>) {
2355 match kind {
2356 BlockItemKind::Clause { value, .. }
2357 | BlockItemKind::Assignment { value, .. }
2358 | BlockItemKind::Let { value, .. }
2359 | BlockItemKind::FieldWithWhen { value, .. } => {
2360 collect_dotpaths_from_expr(value, out);
2361 }
2362 BlockItemKind::ForBlock {
2363 collection,
2364 filter,
2365 items,
2366 ..
2367 } => {
2368 collect_dotpaths_from_expr(collection, out);
2369 if let Some(f) = filter {
2370 collect_dotpaths_from_expr(f, out);
2371 }
2372 for item in items {
2373 collect_dotpaths_from_item(&item.kind, out);
2374 }
2375 }
2376 BlockItemKind::IfBlock {
2377 branches,
2378 else_items,
2379 } => {
2380 for b in branches {
2381 collect_dotpaths_from_expr(&b.condition, out);
2382 for item in &b.items {
2383 collect_dotpaths_from_item(&item.kind, out);
2384 }
2385 }
2386 if let Some(items) = else_items {
2387 for item in items {
2388 collect_dotpaths_from_item(&item.kind, out);
2389 }
2390 }
2391 }
2392 _ => {}
2393 }
2394}
2395
2396fn collect_dotpaths_from_expr(expr: &Expr, out: &mut HashSet<String>) {
2397 match expr {
2398 Expr::MemberAccess { object, field, .. } => {
2399 out.insert(format!("{}.{}", expr_root_name(object).unwrap_or("?"), field.name));
2400 collect_dotpaths_from_expr(object, out);
2401 }
2402 Expr::Comparison { left, right, .. } => {
2403 collect_dotpaths_from_expr(left, out);
2404 collect_dotpaths_from_expr(right, out);
2405 }
2406 Expr::LogicalOp { left, right, .. } => {
2407 collect_dotpaths_from_expr(left, out);
2408 collect_dotpaths_from_expr(right, out);
2409 }
2410 Expr::Block { items, .. } => {
2411 for item in items {
2412 collect_dotpaths_from_expr(item, out);
2413 }
2414 }
2415 Expr::Call { function, args, .. } => {
2416 collect_dotpaths_from_expr(function, out);
2417 for a in args {
2418 match a {
2419 CallArg::Positional(e) => collect_dotpaths_from_expr(e, out),
2420 CallArg::Named(n) => {
2421 out.insert(format!("_.{}", n.name.name));
2423 collect_dotpaths_from_expr(&n.value, out);
2424 }
2425 }
2426 }
2427 }
2428 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
2429 collect_dotpaths_from_expr(element, out);
2430 collect_dotpaths_from_expr(collection, out);
2431 }
2432 Expr::Conditional { branches, else_body, .. } => {
2433 for b in branches {
2434 collect_dotpaths_from_expr(&b.condition, out);
2435 collect_dotpaths_from_expr(&b.body, out);
2436 }
2437 if let Some(body) = else_body {
2438 collect_dotpaths_from_expr(body, out);
2439 }
2440 }
2441 _ => {}
2442 }
2443}
2444
2445fn expr_root_name(expr: &Expr) -> Option<&str> {
2446 match expr {
2447 Expr::Ident(id) => Some(&id.name),
2448 Expr::MemberAccess { object, .. } => expr_root_name(object),
2449 _ => None,
2450 }
2451}
2452
2453fn item_contains_ident(kind: &BlockItemKind, name: &str) -> bool {
2458 match kind {
2459 BlockItemKind::Clause { value, .. } => expr_contains_ident(value, name),
2460 BlockItemKind::Assignment { value, .. } => expr_contains_ident(value, name),
2461 BlockItemKind::ParamAssignment { value, .. } => expr_contains_ident(value, name),
2462 BlockItemKind::Let { value, .. } => expr_contains_ident(value, name),
2463 BlockItemKind::ForBlock {
2464 collection,
2465 filter,
2466 items,
2467 ..
2468 } => {
2469 expr_contains_ident(collection, name)
2470 || filter.as_ref().is_some_and(|f| expr_contains_ident(f, name))
2471 || items.iter().any(|i| item_contains_ident(&i.kind, name))
2472 }
2473 BlockItemKind::IfBlock {
2474 branches,
2475 else_items,
2476 } => {
2477 branches.iter().any(|b| {
2478 expr_contains_ident(&b.condition, name)
2479 || b.items.iter().any(|i| item_contains_ident(&i.kind, name))
2480 }) || else_items
2481 .as_ref()
2482 .is_some_and(|items| items.iter().any(|i| item_contains_ident(&i.kind, name)))
2483 }
2484 BlockItemKind::PathAssignment { path, value } => {
2485 expr_contains_ident(path, name) || expr_contains_ident(value, name)
2486 }
2487 BlockItemKind::InvariantBlock { body, .. } => expr_contains_ident(body, name),
2488 BlockItemKind::FieldWithWhen { value, .. } => expr_contains_ident(value, name),
2489 BlockItemKind::ContractsClause { .. }
2490 | BlockItemKind::EnumVariant { .. }
2491 | BlockItemKind::OpenQuestion { .. }
2492 | BlockItemKind::Annotation(_)
2493 | BlockItemKind::TransitionsBlock(_) => false,
2494 }
2495}
2496
2497fn expr_contains_ident(expr: &Expr, name: &str) -> bool {
2498 match expr {
2499 Expr::Ident(id) => id.name == name,
2500 Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
2501 expr_contains_ident(object, name)
2502 }
2503 Expr::Call { function, args, .. } => {
2504 expr_contains_ident(function, name)
2505 || args.iter().any(|a| match a {
2506 CallArg::Positional(e) => expr_contains_ident(e, name),
2507 CallArg::Named(n) => expr_contains_ident(&n.value, name),
2508 })
2509 }
2510 Expr::JoinLookup { entity, fields, .. } => {
2511 expr_contains_ident(entity, name)
2512 || fields
2513 .iter()
2514 .any(|f| f.value.as_ref().is_some_and(|v| expr_contains_ident(v, name)))
2515 }
2516 Expr::BinaryOp { left, right, .. }
2517 | Expr::Comparison { left, right, .. }
2518 | Expr::LogicalOp { left, right, .. }
2519 | Expr::Pipe { left, right, .. }
2520 | Expr::NullCoalesce { left, right, .. } => {
2521 expr_contains_ident(left, name) || expr_contains_ident(right, name)
2522 }
2523 Expr::Not { operand, .. }
2524 | Expr::Exists { operand, .. }
2525 | Expr::NotExists { operand, .. }
2526 | Expr::TypeOptional { inner: operand, .. } => expr_contains_ident(operand, name),
2527 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
2528 expr_contains_ident(element, name) || expr_contains_ident(collection, name)
2529 }
2530 Expr::Where {
2531 source, condition, ..
2532 }
2533 | Expr::With {
2534 source,
2535 predicate: condition,
2536 ..
2537 } => expr_contains_ident(source, name) || expr_contains_ident(condition, name),
2538 Expr::WhenGuard {
2539 action, condition, ..
2540 } => expr_contains_ident(action, name) || expr_contains_ident(condition, name),
2541 Expr::Lambda { param, body, .. } => {
2542 expr_contains_ident(param, name) || expr_contains_ident(body, name)
2543 }
2544 Expr::Binding { name: n, value, .. } => {
2545 n.name == name || expr_contains_ident(value, name)
2546 }
2547 Expr::SetLiteral { elements, .. } => {
2548 elements.iter().any(|e| expr_contains_ident(e, name))
2549 }
2550 Expr::ObjectLiteral { fields, .. } => {
2551 fields.iter().any(|f| expr_contains_ident(&f.value, name))
2552 }
2553 Expr::GenericType { name: n, args, .. } => {
2554 expr_contains_ident(n, name) || args.iter().any(|a| expr_contains_ident(a, name))
2555 }
2556 Expr::Conditional {
2557 branches,
2558 else_body,
2559 ..
2560 } => {
2561 branches.iter().any(|b| {
2562 expr_contains_ident(&b.condition, name) || expr_contains_ident(&b.body, name)
2563 }) || else_body
2564 .as_ref()
2565 .is_some_and(|e| expr_contains_ident(e, name))
2566 }
2567 Expr::For {
2568 collection,
2569 filter,
2570 body,
2571 ..
2572 } => {
2573 expr_contains_ident(collection, name)
2574 || filter
2575 .as_ref()
2576 .is_some_and(|f| expr_contains_ident(f, name))
2577 || expr_contains_ident(body, name)
2578 }
2579 Expr::TransitionsTo {
2580 subject, new_state, ..
2581 }
2582 | Expr::Becomes {
2583 subject, new_state, ..
2584 } => expr_contains_ident(subject, name) || expr_contains_ident(new_state, name),
2585 Expr::ProjectionMap { source, .. } => expr_contains_ident(source, name),
2586 Expr::LetExpr { value, .. } => expr_contains_ident(value, name),
2587 Expr::Block { items, .. } => items.iter().any(|e| expr_contains_ident(e, name)),
2588 Expr::QualifiedName(_)
2589 | Expr::StringLiteral(_)
2590 | Expr::BacktickLiteral { .. }
2591 | Expr::NumberLiteral { .. }
2592 | Expr::BoolLiteral { .. }
2593 | Expr::Null { .. }
2594 | Expr::Now { .. }
2595 | Expr::This { .. }
2596 | Expr::Within { .. }
2597 | Expr::DurationLiteral { .. } => false,
2598 }
2599}
2600
2601#[cfg(test)]
2606mod tests {
2607 use super::*;
2608 use crate::diagnostic::Severity;
2609 use crate::parser::parse;
2610
2611 fn analyze_src(src: &str) -> Vec<Diagnostic> {
2612 let input = if src.starts_with("-- allium:") {
2613 src.to_string()
2614 } else {
2615 format!("-- allium: 3\n{src}")
2616 };
2617 let result = parse(&input);
2618 analyze(&result.module, &input)
2619 }
2620
2621 fn has_code(diagnostics: &[Diagnostic], code: &str) -> bool {
2622 diagnostics.iter().any(|d| d.code == Some(code))
2623 }
2624
2625 fn count_code(diagnostics: &[Diagnostic], code: &str) -> usize {
2626 diagnostics.iter().filter(|d| d.code == Some(code)).count()
2627 }
2628
2629 #[test]
2632 fn suppression_on_previous_line() {
2633 let ds = analyze_src("entity A {\n -- allium-ignore allium.field.unused\n x: String\n}\n");
2634 assert!(!has_code(&ds, "allium.field.unused"));
2635 }
2636
2637 #[test]
2638 fn suppression_all() {
2639 let ds = analyze_src("entity A {\n -- allium-ignore all\n x: String\n}\n");
2640 assert!(!has_code(&ds, "allium.field.unused"));
2641 }
2642
2643 #[test]
2646 fn related_clause_with_binding_and_guard() {
2647 let ds = analyze_src(
2648 "surface QuoteVersions {\n facing user: User\n}\n\n\
2649 surface Dashboard {\n facing user: User\n related:\n QuoteVersions(quote) when quote.version_count > 1\n}\n",
2650 );
2651 assert!(!has_code(&ds, "allium.surface.relatedUndefined"));
2652 }
2653
2654 #[test]
2655 fn related_clause_reports_unknown_surface() {
2656 let ds = analyze_src(
2657 "surface Dashboard {\n facing user: User\n related:\n MissingSurface\n}\n",
2658 );
2659 assert!(has_code(&ds, "allium.surface.relatedUndefined"));
2660 }
2661
2662 #[test]
2665 fn v1_capitalised_inline_enum() {
2666 let ds = analyze_src("entity Quote {\n status: Quoted | OrderSubmitted | Filled\n}\n");
2667 assert!(has_code(&ds, "allium.sum.v1InlineEnum"));
2668 }
2669
2670 #[test]
2673 fn discard_binding_no_warning() {
2674 let ds = analyze_src(
2675 "surface QuoteFeed {\n facing _: Service\n exposes:\n System.status\n}\n",
2676 );
2677 assert!(!has_code(&ds, "allium.surface.unusedBinding"));
2678 }
2679
2680 #[test]
2683 fn variable_status_assignment_suppresses_unreachable() {
2684 let ds = analyze_src(
2685 "entity Quote {\n status: pending | quoted | filled\n}\n\n\
2686 rule ApplyStatusUpdate {\n when: update: Quote.status becomes pending\n \
2687 ensures: update.status = new_status\n}\n",
2688 );
2689 assert!(!has_code(&ds, "allium.status.unreachableValue"));
2690 assert!(!has_code(&ds, "allium.status.noExit"));
2691 }
2692
2693 #[test]
2696 fn external_entity_referenced_in_rules_info() {
2697 let ds = analyze_src(
2698 "external entity Client {\n id: String\n}\n\n\
2699 rule IngestQuote {\n when: RawQuoteReceived(data)\n ensures:\n Client.lookup(data.client_id)\n}\n",
2700 );
2701 let hint = ds.iter().find(|d| d.code == Some("allium.externalEntity.missingSourceHint"));
2702 assert!(hint.is_some());
2703 assert_eq!(hint.unwrap().severity, Severity::Info);
2704 }
2705
2706 #[test]
2709 fn undefined_type_reference() {
2710 let ds = analyze_src("entity Foo {\n bar: MissingType\n}\n");
2711 assert!(has_code(&ds, "allium.type.undefinedReference"));
2712 }
2713
2714 #[test]
2715 fn known_type_reference_ok() {
2716 let ds = analyze_src("entity Foo {\n bar: String\n}\n");
2717 assert!(!has_code(&ds, "allium.type.undefinedReference"));
2718 }
2719
2720 #[test]
2723 fn unreachable_trigger_reported() {
2724 let ds = analyze_src(
2725 "rule A {\n when: ExternalEvent(x)\n ensures: Done()\n}\n",
2726 );
2727 assert!(has_code(&ds, "allium.rule.unreachableTrigger"));
2728 }
2729
2730 #[test]
2733 fn unused_field_reported() {
2734 let ds = analyze_src("entity A {\n x: String\n y: String\n}\n\nrule R {\n when: Ping(a)\n ensures: a.x = \"hi\"\n}\n");
2735 assert!(has_code(&ds, "allium.field.unused"));
2736 let unused: Vec<_> = ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
2738 assert!(unused.iter().any(|d| d.message.contains("A.y")));
2739 assert!(!unused.iter().any(|d| d.message.contains("A.x")));
2740 }
2741
2742 #[test]
2745 fn unused_entity_reported() {
2746 let ds = analyze_src("entity Orphan {\n x: String\n}\n");
2747 assert!(has_code(&ds, "allium.entity.unused"));
2748 }
2749
2750 #[test]
2753 fn deferred_missing_location_hint() {
2754 let ds = analyze_src("deferred Foo.bar\n");
2755 assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
2756 }
2757
2758 #[test]
2761 fn valid_trigger_ok() {
2762 let ds = analyze_src("rule A {\n when: Ping(x)\n ensures: Done()\n}\n");
2763 assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
2764 }
2765
2766 #[test]
2769 fn duplicate_let_binding() {
2770 let ds = analyze_src(
2771 "rule A {\n when: Ping(x)\n let a = 1\n let a = 2\n ensures: Done()\n}\n",
2772 );
2773 assert!(has_code(&ds, "allium.let.duplicateBinding"));
2774 }
2775
2776 #[test]
2779 fn config_undefined_reference() {
2780 let ds = analyze_src(
2781 "config {\n max_retries: 3\n}\n\nrule A {\n when: Ping(x)\n requires: config.missing_param > 0\n ensures: Done()\n}\n",
2782 );
2783 assert!(has_code(&ds, "allium.config.undefinedReference"));
2784 }
2785
2786 #[test]
2787 fn config_valid_reference_ok() {
2788 let ds = analyze_src(
2789 "config {\n max_retries: 3\n}\n\nrule A {\n when: Ping(x)\n requires: config.max_retries > 0\n ensures: Done()\n}\n",
2790 );
2791 assert!(!has_code(&ds, "allium.config.undefinedReference"));
2792 }
2793}