Skip to main content

opy_rs/
tooling.rs

1//! Workshop-independent tooling APIs: check a project and query the resolved
2//! semantic model.
3//!
4//! This module is the public tooling surface for Wright and other consumers
5//! that want to parse, check, inspect, and reason about OPY projects before
6//! any Workshop backend is connected (issue #7):
7//!
8//! * [`check`] / [`check_with_overlay`] run the full frontend pipeline
9//!   (preprocess → parse → resolve) on a main file plus its includes and
10//!   return every structured diagnostic together with the file registry,
11//!   without requiring lowering to any Workshop backend. Resolution stops at
12//!   the Opy HIR semantic model ([`hir::Program`]); Workshop emission,
13//!   decompilation, and catalog behavior are deliberately out of scope here.
14//! * [`SemanticModel`] wraps the resolved program and answers semantic
15//!   queries: declarations, rule listing, symbol/reference lookup by name or
16//!   span, custom-enum declarations, macro defines, and source provenance
17//!   (span → file id, path, line/col).
18//!
19//! Diagnostics contract: every [`Diagnostic`] carries a stable machine code,
20//! a severity, a human message, and — when known — a resolved source location
21//! (`path:line:col` through the file registry). Codes are the same ones the
22//! compile pipeline emits (`lex-error`, `parse-error`, `unknown-identifier`,
23//! `unknown-action`, `include-not-found`, …); see
24//! `docs/opy/tooling-api.md` for the full table.
25//!
26//! Parse diagnostics are collected in full (the parser recovers at statement
27//! boundaries); semantic-resolution diagnostics follow the compile contract
28//! and report the first error, so `check` never disagrees with `compile`
29//! about whether a project is clean.
30
31use std::path::Path;
32
33use serde::Serialize;
34
35use crate::cst;
36use crate::diag::{OpyError, Position, Span};
37use crate::hir;
38use crate::hir::types::{
39    Declaration, Define, Expr as HirExpr, RuleEntry, SourceFile, Stmt as HirStmt,
40};
41use crate::preprocess::{FileRecord, PreprocessOutcome, PreprocessWarning};
42
43/// The outcome of [`check`]: structured diagnostics plus the resolved model.
44///
45/// `model` is present exactly when `diagnostics` contains no errors;
46/// `files` is the frontend file registry (main file id 0, then one entry per
47/// include) and is retained even on failure so diagnostics map to real
48/// sources.
49#[derive(Debug, Clone)]
50pub struct CheckOutcome {
51    pub diagnostics: Vec<Diagnostic>,
52    pub model: Option<SemanticModel>,
53    pub files: Vec<FileRecord>,
54    /// The declared `#!postCompileHook` script, when the source declared one
55    /// and the project checked clean.
56    ///
57    /// This is the declaration record, not an execution result: the frontend
58    /// recognizes, parses, validates, and records the directive, but never
59    /// executes the hook. Execution against the final Workshop text is
60    /// lowering-dependent (workshop-rs emission, issue #8); the frontend
61    /// never fabricates a Workshop payload.
62    pub post_compile_hook: Option<crate::preprocess::PostCompileHook>,
63}
64
65impl CheckOutcome {
66    /// Whether the project checked clean.
67    pub fn is_clean(&self) -> bool {
68        self.diagnostics
69            .iter()
70            .all(|diagnostic| diagnostic.severity != DiagnosticSeverity::Error)
71    }
72}
73
74/// Check one `.opy` project: preprocess (includes/defines) → parse (CST) →
75/// resolve (Opy HIR). `main_path` is the display path recorded in the file
76/// registry; `root` is the include base. No Workshop backend is required.
77pub fn check(source: &str, main_path: &str, root: &Path) -> CheckOutcome {
78    check_with_overlay(source, main_path, root, &std::collections::BTreeMap::new())
79}
80
81/// [`check`] with open-document overlays (unsaved editor buffers participate
82/// in include resolution, see [`crate::preprocess::preprocess_with_overlay`]).
83pub fn check_with_overlay(
84    source: &str,
85    main_path: &str,
86    root: &Path,
87    overlay: &std::collections::BTreeMap<String, String>,
88) -> CheckOutcome {
89    let PreprocessOutcome {
90        result,
91        files,
92        warnings,
93    } = crate::preprocess::preprocess_with_overlay_outcome(source, main_path, root, overlay);
94    let preprocessed = match result {
95        Ok((preprocessed, _)) => preprocessed,
96        Err(error) => {
97            let mut diagnostics = warnings
98                .iter()
99                .map(|warning| Diagnostic::from_warning(warning, &files))
100                .collect::<Vec<_>>();
101            diagnostics.push(Diagnostic::from_error(error, &files));
102            return CheckOutcome {
103                diagnostics,
104                model: None,
105                files,
106                post_compile_hook: None,
107            };
108        }
109    };
110    let parsed = crate::parser::parse_with_options(
111        &preprocessed.tokens,
112        preprocessed.preprocessing.allow_macro_redeclaration,
113    );
114    let Some(mut program) = parsed.program else {
115        // The parser recovers at statement boundaries; every collected error
116        // is reported (the compile pipeline reads only the first).
117        let mut diagnostics = preprocessed
118            .warnings
119            .iter()
120            .map(|warning| Diagnostic::from_warning(warning, &files))
121            .collect::<Vec<_>>();
122        diagnostics.extend(
123            parsed
124                .errors
125                .iter()
126                .map(|error| Diagnostic::from_error(error.clone(), &files)),
127        );
128        return CheckOutcome {
129            diagnostics,
130            model: None,
131            files,
132            post_compile_hook: None,
133        };
134    };
135    // Parse the extracted settings block into the CST; expression values are
136    // resolved after ordinary CST-to-HIR lowering so they use the shared OPY
137    // semantic path (#86, #188).
138    if let Some(block) = &preprocessed.settings {
139        match crate::settings::parse_block(block) {
140            Ok(parsed_settings) => program.settings = Some(parsed_settings),
141            Err(error) => {
142                let mut diagnostics = preprocessed
143                    .warnings
144                    .iter()
145                    .map(|warning| Diagnostic::from_warning(warning, &files))
146                    .collect::<Vec<_>>();
147                diagnostics.push(Diagnostic::from_error(error, &files));
148                return CheckOutcome {
149                    diagnostics,
150                    model: None,
151                    files,
152                    post_compile_hook: None,
153                };
154            }
155        }
156    }
157    let defines = preprocessed
158        .defines
159        .iter()
160        .map(|define| Define {
161            name: define.name.clone(),
162            is_function: define.is_function,
163            is_member: define.is_member,
164            span: define.span.map(Into::into),
165        })
166        .collect();
167    let hir_files = files
168        .iter()
169        .map(|file| hir::types::SourceFile {
170            id: file.id,
171            path: file.path.clone(),
172        })
173        .collect();
174    match crate::lower::lower_with_preprocessing(
175        &program,
176        hir_files,
177        defines,
178        &preprocessed.preprocessing,
179    ) {
180        Ok(mut hir) => {
181            hir.preprocessing = preprocessed.preprocessing;
182            if let Err(error) = crate::settings::resolve_hir_settings(&mut hir, &program) {
183                let mut diagnostics = preprocessed
184                    .warnings
185                    .iter()
186                    .map(|warning| Diagnostic::from_warning(warning, &files))
187                    .collect::<Vec<_>>();
188                diagnostics.push(Diagnostic::from_error(error, &files));
189                return CheckOutcome {
190                    diagnostics,
191                    model: None,
192                    files,
193                    post_compile_hook: None,
194                };
195            }
196            CheckOutcome {
197                diagnostics: preprocessed
198                    .warnings
199                    .iter()
200                    .map(|warning| Diagnostic::from_warning(warning, &files))
201                    .collect(),
202                model: Some(SemanticModel::build(hir, &program)),
203                files,
204                // The directive was parsed, validated, and recorded by
205                // preprocessing; the frontend never executes the hook (real hook
206                // execution receives the final Workshop text and is
207                // lowering-dependent, issue #8).
208                post_compile_hook: preprocessed.post_compile_hook,
209            }
210        }
211        Err(error) => {
212            let mut diagnostics = preprocessed
213                .warnings
214                .iter()
215                .map(|warning| Diagnostic::from_warning(warning, &files))
216                .collect::<Vec<_>>();
217            diagnostics.push(Diagnostic::from_error(error, &files));
218            CheckOutcome {
219                diagnostics,
220                model: None,
221                files,
222                post_compile_hook: None,
223            }
224        }
225    }
226}
227
228/// A structured, source-attributed diagnostic.
229///
230/// `code` is the stable machine contract (see the module docs and
231/// `docs/opy/tooling-api.md`); `message` is human wording and not part of the
232/// contract; `span` resolves through the file registry when known.
233#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
234pub struct Diagnostic {
235    pub severity: DiagnosticSeverity,
236    pub code: String,
237    pub message: String,
238    pub span: Option<SourceLocation>,
239}
240
241impl Diagnostic {
242    fn from_warning(warning: &PreprocessWarning, files: &[FileRecord]) -> Diagnostic {
243        Diagnostic {
244            severity: DiagnosticSeverity::Warning,
245            code: warning.code.clone(),
246            message: warning.message.clone(),
247            span: resolve_record_span(warning.span, files),
248        }
249    }
250
251    fn from_error(error: OpyError, files: &[FileRecord]) -> Diagnostic {
252        Diagnostic {
253            severity: DiagnosticSeverity::Error,
254            code: error.code,
255            message: error.message,
256            span: error.span.and_then(|span| resolve_record_span(span, files)),
257        }
258    }
259}
260
261/// The severity of a diagnostic in the frontend contract.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
263#[serde(rename_all = "camelCase")]
264pub enum DiagnosticSeverity {
265    Error,
266    Warning,
267}
268
269impl DiagnosticSeverity {
270    pub fn as_str(&self) -> &'static str {
271        match self {
272            DiagnosticSeverity::Error => "error",
273            DiagnosticSeverity::Warning => "warning",
274        }
275    }
276}
277
278/// A resolved source location: a span's file id and path (through the file
279/// registry) plus its 1-based line/column interval.
280#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
281pub struct SourceLocation {
282    pub file_id: u32,
283    pub path: String,
284    pub start: Position,
285    pub end: Position,
286}
287
288impl SourceLocation {
289    /// Recover the frontend span (file id + positions) of this location.
290    pub fn to_span(&self) -> Span {
291        Span::new(self.file_id, self.start, self.end)
292    }
293}
294
295fn resolve_span(span: Span, files: &[SourceFile]) -> Option<SourceLocation> {
296    let path = files.iter().find(|file| file.id == span.file)?.path.clone();
297    Some(SourceLocation {
298        file_id: span.file,
299        path,
300        start: span.start,
301        end: span.end,
302    })
303}
304
305/// Resolve a span through the preprocess file registry (used for
306/// diagnostics, where the model may not exist).
307fn resolve_record_span(span: Span, files: &[FileRecord]) -> Option<SourceLocation> {
308    let path = files.iter().find(|file| file.id == span.file)?.path.clone();
309    Some(SourceLocation {
310        file_id: span.file,
311        path,
312        start: span.start,
313        end: span.end,
314    })
315}
316
317/// Map an Opy HIR span (the protocol type) back to the frontend span type;
318/// positions are the same 1-based source coordinates carried through
319/// lowering.
320fn to_frontend_span(span: hir::types::Span) -> Span {
321    Span::new(
322        span.file,
323        Position::new(span.start.line, span.start.col),
324        Position::new(span.end.line, span.end.col),
325    )
326}
327
328/// The resolved program model: the Opy HIR semantic program plus the
329/// queryable symbol index and custom-enum declarations.
330///
331/// Custom enums are not retained in the Opy HIR (they fold to numeric
332/// constants at use sites, reference behavior), so they are carried here from
333/// the CST to keep declarations queryable.
334#[derive(Debug, Clone, Serialize)]
335pub struct SemanticModel {
336    pub hir: hir::Program,
337    pub enums: Vec<EnumDecl>,
338    pub symbols: Vec<Symbol>,
339}
340
341impl SemanticModel {
342    /// Build the queryable model from a resolved HIR program and its parsed
343    /// CST (required for custom-enum declarations).
344    pub fn build(hir: hir::Program, cst: &cst::Program) -> SemanticModel {
345        let enums = cst
346            .declarations
347            .iter()
348            .filter_map(|decl| match decl {
349                cst::Decl::Enum { name, members, .. } => Some(EnumDecl {
350                    name: name.clone(),
351                    members: members
352                        .iter()
353                        .map(|(member, span)| EnumMember {
354                            name: member.clone(),
355                            span: resolve_span(*span, &hir.files)
356                                .expect("every token span resolves through the file registry"),
357                        })
358                        .collect(),
359                }),
360                _ => None,
361            })
362            .collect();
363        let mut model = SemanticModel {
364            hir,
365            enums,
366            symbols: Vec::new(),
367        };
368        model.index_symbols();
369        model
370    }
371
372    /// The HIR declarations (globals, players, subroutines, constants,
373    /// macros). Custom enums are queried through [`SemanticModel::enums`].
374    pub fn declarations(&self) -> &[Declaration] {
375        &self.hir.declarations
376    }
377
378    /// The rule listing: rules and subroutine definitions.
379    pub fn rules(&self) -> &[RuleEntry] {
380        &self.hir.rules
381    }
382
383    /// The recorded preprocessing defines (macro-expansion provenance).
384    pub fn defines(&self) -> &[Define] {
385        &self.hir.defines
386    }
387
388    /// The custom-enum declarations of the project.
389    pub fn enums(&self) -> &[EnumDecl] {
390        &self.enums
391    }
392
393    /// Every indexed program-scope symbol with its declaration site and
394    /// reference sites.
395    pub fn symbols(&self) -> &[Symbol] {
396        &self.symbols
397    }
398
399    /// The first symbol bound under `name` (a `subroutine` declaration and a
400    /// `def` definition of the same name index as separate symbols).
401    pub fn symbol(&self, name: &str) -> Option<&Symbol> {
402        self.symbols.iter().find(|symbol| symbol.name == name)
403    }
404
405    /// The symbol whose declaration site contains `span`, or — failing that —
406    /// the symbol owning a reference site containing `span`.
407    pub fn symbol_at(&self, span: Span) -> Option<&Symbol> {
408        self.symbols.iter().find(|symbol| {
409            span_contains(symbol.declaration.to_span(), span)
410                || symbol
411                    .references
412                    .iter()
413                    .any(|reference| span_contains(reference.to_span(), span))
414        })
415    }
416
417    /// Resolve a span to its file id, path, and line/column through the file
418    /// registry.
419    pub fn provenance(&self, span: Span) -> Option<SourceLocation> {
420        resolve_span(span, &self.hir.files)
421    }
422
423    /// The registry path of a file id.
424    pub fn file(&self, id: u32) -> Option<&str> {
425        self.hir
426            .files
427            .iter()
428            .find(|file| file.id == id)
429            .map(|file| file.path.as_str())
430    }
431
432    /// Index every program-scope binding, then attach resolved reference
433    /// sites by name/kind.
434    fn index_symbols(&mut self) {
435        for decl in &self.hir.declarations {
436            let (kind, name, span) = match decl {
437                Declaration::GlobalVariable {
438                    name,
439                    name_span,
440                    span,
441                    ..
442                } => (SymbolKind::Global, name, name_span.or(*span)),
443                Declaration::PlayerVariable {
444                    name,
445                    name_span,
446                    span,
447                    ..
448                } => (SymbolKind::Player, name, name_span.or(*span)),
449                Declaration::Subroutine {
450                    name,
451                    name_span,
452                    span,
453                    ..
454                } => (SymbolKind::Subroutine, name, name_span.or(*span)),
455                Declaration::Constant { name, span, .. } => (SymbolKind::Constant, name, *span),
456                Declaration::Macro { name, span, .. } => (SymbolKind::Macro, name, *span),
457            };
458            let Some(span) = span.map(to_frontend_span) else {
459                // Foreign payloads may omit spans; such declarations are not
460                // addressable and stay out of the index.
461                continue;
462            };
463            let Some(declaration) = resolve_span(span, &self.hir.files) else {
464                continue;
465            };
466            self.symbols.push(Symbol {
467                name: name.clone(),
468                kind,
469                declaration,
470                references: Vec::new(),
471            });
472        }
473        for entry in &self.hir.rules {
474            let RuleEntry::SubroutineDef {
475                name,
476                source_name,
477                name_span,
478                span,
479                ..
480            } = entry
481            else {
482                continue;
483            };
484            let Some(span) = name_span.or(*span).map(to_frontend_span) else {
485                continue;
486            };
487            let Some(declaration) = resolve_span(span, &self.hir.files) else {
488                continue;
489            };
490            self.symbols.push(Symbol {
491                name: if source_name.is_empty() {
492                    name.clone()
493                } else {
494                    source_name.clone()
495                },
496                kind: SymbolKind::Def,
497                declaration,
498                references: Vec::new(),
499            });
500        }
501
502        let mut sites: Vec<(SymbolKind, String, Span)> = Vec::new();
503        for decl in &self.hir.declarations {
504            match decl {
505                Declaration::GlobalVariable {
506                    initializer: Some(initializer),
507                    ..
508                }
509                | Declaration::PlayerVariable {
510                    initializer: Some(initializer),
511                    ..
512                } => Self::collect_expr(initializer, &mut sites),
513                Declaration::Constant { value, .. } => Self::collect_expr(value, &mut sites),
514                Declaration::Macro { body, .. } => {
515                    for stmt in body {
516                        Self::collect_stmt(stmt, &mut sites);
517                    }
518                }
519                _ => {}
520            }
521        }
522        for entry in &self.hir.rules {
523            match entry {
524                RuleEntry::Rule(rule) => {
525                    for arg in &rule.event.args {
526                        Self::collect_expr(arg, &mut sites);
527                    }
528                    for condition in &rule.conditions {
529                        Self::collect_expr(condition, &mut sites);
530                    }
531                    for stmt in &rule.actions {
532                        Self::collect_stmt(stmt, &mut sites);
533                    }
534                }
535                RuleEntry::SubroutineDef { body, .. } => {
536                    for stmt in body {
537                        Self::collect_stmt(stmt, &mut sites);
538                    }
539                }
540            }
541        }
542        for (kind, name, span) in sites {
543            self.attach_reference(kind, &name, span);
544        }
545    }
546
547    /// Record a reference site for the first symbol of `kind` named `name`.
548    /// A call site is offered to both the `subroutine` and the `def` binding
549    /// kinds so both bindings of a defined subroutine collect their uses.
550    fn attach_reference(&mut self, kind: SymbolKind, name: &str, span: Span) {
551        let Some(location) = resolve_span(span, &self.hir.files) else {
552            return;
553        };
554        if let Some(index) = self
555            .symbols
556            .iter()
557            .position(|symbol| symbol.kind == kind && symbol.name == name)
558        {
559            self.symbols[index].references.push(location);
560        }
561    }
562
563    fn collect_expr(expr: &HirExpr, sites: &mut Vec<(SymbolKind, String, Span)>) {
564        match expr {
565            HirExpr::Number { .. }
566            | HirExpr::String { .. }
567            | HirExpr::Bool { .. }
568            | HirExpr::Null { .. }
569            | HirExpr::Enum { .. }
570            | HirExpr::EventPlayer { .. }
571            | HirExpr::HostPlayer { .. }
572            | HirExpr::MacroParam { .. }
573            | HirExpr::StringModifier { .. }
574            | HirExpr::Local { .. } => {}
575            HirExpr::Type { args, .. } => {
576                for arg in args {
577                    Self::collect_expr(arg, sites);
578                }
579            }
580            HirExpr::GlobalVar { name, span } | HirExpr::Constant { name, span } => {
581                let kind = if matches!(expr, HirExpr::GlobalVar { .. }) {
582                    SymbolKind::Global
583                } else {
584                    SymbolKind::Constant
585                };
586                if let Some(span) = span {
587                    sites.push((kind, name.clone(), to_frontend_span(*span)));
588                }
589            }
590            HirExpr::PlayerVar {
591                name,
592                member_span,
593                span,
594                ..
595            } => {
596                if let Some(span) = member_span.as_ref().or(span.as_ref()) {
597                    sites.push((SymbolKind::Player, name.clone(), to_frontend_span(*span)));
598                }
599            }
600            HirExpr::Member { receiver, .. } => Self::collect_expr(receiver, sites),
601            HirExpr::Array { elements, .. } => {
602                for element in elements {
603                    Self::collect_expr(element, sites);
604                }
605            }
606            HirExpr::Dict { entries, .. } => {
607                for entry in entries {
608                    Self::collect_expr(&entry.key, sites);
609                    Self::collect_expr(&entry.value, sites);
610                }
611            }
612            HirExpr::Comprehension {
613                element,
614                iterable,
615                condition,
616                ..
617            } => {
618                Self::collect_expr(iterable, sites);
619                Self::collect_expr(element, sites);
620                if let Some(condition) = condition {
621                    Self::collect_expr(condition, sites);
622                }
623            }
624            HirExpr::Lambda { body, .. } => Self::collect_expr(body, sites),
625            HirExpr::Vector { x, y, z, .. } => {
626                Self::collect_expr(x, sites);
627                Self::collect_expr(y, sites);
628                Self::collect_expr(z, sites);
629            }
630            HirExpr::Call {
631                name, span, args, ..
632            } => {
633                // A call may name a declared subroutine (with arguments) or
634                // nothing user-declared (a builtin); unresolved names never
635                // reach the model. Offer both subroutine binding kinds.
636                if let Some(span) = span {
637                    sites.push((
638                        SymbolKind::Subroutine,
639                        name.clone(),
640                        to_frontend_span(*span),
641                    ));
642                    sites.push((SymbolKind::Def, name.clone(), to_frontend_span(*span)));
643                }
644                for arg in args {
645                    Self::collect_expr(arg, sites);
646                }
647            }
648            HirExpr::MacroCall { name, span, args } => {
649                if let Some(span) = span {
650                    sites.push((SymbolKind::Macro, name.clone(), to_frontend_span(*span)));
651                }
652                for arg in args {
653                    Self::collect_expr(arg, sites);
654                }
655            }
656            HirExpr::ReceiverCall { receiver, args, .. } => {
657                // The receiver may be a call (e.g. getPlayersInRadius(...).x)
658                // whose name binds a symbol; the call span of the outer node
659                // is attributed to the member name, not the receiver.
660                Self::collect_expr(receiver, sites);
661                for arg in args {
662                    Self::collect_expr(arg, sites);
663                }
664            }
665            HirExpr::Binary { left, right, .. } => {
666                Self::collect_expr(left, sites);
667                Self::collect_expr(right, sites);
668            }
669            HirExpr::Conditional {
670                then_value,
671                condition,
672                else_value,
673                ..
674            } => {
675                Self::collect_expr(then_value, sites);
676                Self::collect_expr(condition, sites);
677                Self::collect_expr(else_value, sites);
678            }
679            HirExpr::Unary { operand, .. } => Self::collect_expr(operand, sites),
680            HirExpr::Index { array, index, .. } => {
681                Self::collect_expr(array, sites);
682                Self::collect_expr(index, sites);
683            }
684            HirExpr::Format { args, .. } => {
685                for arg in args {
686                    Self::collect_expr(arg, sites);
687                }
688            }
689        }
690    }
691
692    fn collect_stmt(stmt: &HirStmt, sites: &mut Vec<(SymbolKind, String, Span)>) {
693        match stmt {
694            HirStmt::Expr { expr, .. } => Self::collect_expr(expr, sites),
695            HirStmt::Assign { target, value, .. } => {
696                Self::collect_expr(target, sites);
697                Self::collect_expr(value, sites);
698            }
699            HirStmt::Delete { target, .. } => Self::collect_expr(target, sites),
700            HirStmt::If {
701                branches, r#else, ..
702            } => {
703                for branch in branches {
704                    Self::collect_expr(&branch.condition, sites);
705                    for stmt in &branch.body {
706                        Self::collect_stmt(stmt, sites);
707                    }
708                }
709                if let Some(r#else) = r#else {
710                    for stmt in r#else {
711                        Self::collect_stmt(stmt, sites);
712                    }
713                }
714            }
715            HirStmt::For {
716                variable,
717                iterable,
718                body,
719                ..
720            } => {
721                Self::collect_expr(variable, sites);
722                Self::collect_expr(iterable, sites);
723                for stmt in body {
724                    Self::collect_stmt(stmt, sites);
725                }
726            }
727            HirStmt::While {
728                condition, body, ..
729            } => {
730                Self::collect_expr(condition, sites);
731                for stmt in body {
732                    Self::collect_stmt(stmt, sites);
733                }
734            }
735            HirStmt::DoWhile {
736                condition, body, ..
737            } => {
738                Self::collect_expr(condition, sites);
739                for stmt in body {
740                    Self::collect_stmt(stmt, sites);
741                }
742            }
743            HirStmt::Switch { value, arms, .. } => {
744                Self::collect_expr(value, sites);
745                for arm in arms {
746                    match arm {
747                        hir::SwitchArm::Case { value, body, .. } => {
748                            Self::collect_expr(value, sites);
749                            for stmt in body {
750                                Self::collect_stmt(stmt, sites);
751                            }
752                        }
753                        hir::SwitchArm::Default { body, .. } => {
754                            for stmt in body {
755                                Self::collect_stmt(stmt, sites);
756                            }
757                        }
758                    }
759                }
760            }
761            HirStmt::Break { .. } => {}
762            HirStmt::Return { .. } => {}
763            HirStmt::Continue { .. } | HirStmt::Label { .. } => {}
764            HirStmt::Goto { offset, .. } => {
765                if let Some(offset) = offset {
766                    Self::collect_expr(offset, sites);
767                }
768            }
769            HirStmt::CallSubroutine { name, span } => {
770                if let Some(span) = span {
771                    sites.push((
772                        SymbolKind::Subroutine,
773                        name.clone(),
774                        to_frontend_span(*span),
775                    ));
776                    sites.push((SymbolKind::Def, name.clone(), to_frontend_span(*span)));
777                }
778            }
779            HirStmt::Pass { .. } => {}
780        }
781    }
782}
783
784/// A custom `enum` declaration (CST-retained; enums fold to constants in the
785/// HIR).
786#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
787pub struct EnumDecl {
788    pub name: String,
789    pub members: Vec<EnumMember>,
790}
791
792/// One custom-enum member with its declaration site.
793#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
794pub struct EnumMember {
795    pub name: String,
796    pub span: SourceLocation,
797}
798
799/// The kind of a program-scope symbol.
800#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
801#[serde(rename_all = "camelCase")]
802pub enum SymbolKind {
803    Global,
804    Player,
805    Subroutine,
806    Def,
807    Constant,
808    Macro,
809}
810
811/// A program-scope symbol with its declaration site and resolved reference
812/// sites.
813#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
814pub struct Symbol {
815    pub name: String,
816    pub kind: SymbolKind,
817    pub declaration: SourceLocation,
818    pub references: Vec<SourceLocation>,
819}
820
821/// Whether the interval `outer` contains the interval `inner` (half-open
822/// end positions, so a 1:1 zero-width span is contained by itself).
823fn span_contains(outer: Span, inner: Span) -> bool {
824    position_leq(outer.start, inner.start) && position_leq(inner.end, outer.end)
825}
826
827fn position_leq(a: Position, b: Position) -> bool {
828    a.line < b.line || (a.line == b.line && a.col <= b.col)
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834
835    fn check_source(source: &str) -> CheckOutcome {
836        check(source, "main.opy", Path::new(""))
837    }
838
839    #[test]
840    fn clean_project_has_no_diagnostics_and_a_model() {
841        let outcome = check_source(
842            "globalvar total = 0\nrule \"r\":\n    @Event global\n    total += 1\n    debug(total)\n",
843        );
844        assert!(
845            outcome.is_clean(),
846            "unexpected diagnostics: {:?}",
847            outcome.diagnostics
848        );
849        let model = outcome.model.expect("a clean project resolves");
850        assert_eq!(outcome.files.len(), 1);
851        assert_eq!(model.declarations().len(), 1);
852        assert_eq!(model.rules().len(), 1);
853    }
854
855    #[test]
856    fn symbols_index_declarations_and_references() {
857        let outcome = check_source(
858            "globalvar total\nplayervar P\nsubroutine reset\nmacro double(x):\n    x + x\nrule \"r\":\n    @Event eachPlayer\n    total = 1\n    eventPlayer.P = total\n    reset()\n    double(2)\n",
859        );
860        let model = outcome.model.expect("clean project");
861        let names: Vec<(&str, SymbolKind)> = model
862            .symbols()
863            .iter()
864            .map(|symbol| (symbol.name.as_str(), symbol.kind))
865            .collect();
866        assert_eq!(
867            names,
868            vec![
869                ("total", SymbolKind::Global),
870                ("P", SymbolKind::Player),
871                ("reset", SymbolKind::Subroutine),
872                ("double", SymbolKind::Macro),
873            ]
874        );
875        assert_eq!(model.symbol("total").expect("symbol").references.len(), 2);
876        assert_eq!(model.symbol("P").expect("symbol").references.len(), 1);
877        let reset = model.symbol("reset").expect("symbol");
878        assert_eq!(reset.references.len(), 1);
879        assert_eq!(reset.references[0].path, "main.opy");
880        assert_eq!(model.symbol("double").expect("symbol").references.len(), 1);
881    }
882
883    #[test]
884    fn symbol_lookup_by_name_and_span() {
885        let outcome =
886            check_source("globalvar total\nrule \"r\":\n    @Event global\n    total = 1\n");
887        let model = outcome.model.expect("clean project");
888        let total = model.symbol("total").expect("symbol by name");
889        assert_eq!(total.kind, SymbolKind::Global);
890        // The declaration site answers span lookup…
891        let at_decl = model
892            .symbol_at(total.declaration.to_span())
893            .expect("symbol at declaration span");
894        assert_eq!(at_decl.name, "total");
895        // …and so does a reference site.
896        let at_ref = model
897            .symbol_at(total.references[0].to_span())
898            .expect("symbol at reference span");
899        assert_eq!(at_ref.name, "total");
900        assert!(
901            model
902                .symbol_at(Span::new(99, Position::new(1, 1), Position::new(1, 1)))
903                .is_none()
904        );
905    }
906
907    #[test]
908    fn provenance_resolves_through_the_file_registry() {
909        let outcome =
910            check_source("globalvar total\nrule \"r\":\n    @Event global\n    total = 1\n");
911        let model = outcome.model.expect("clean project");
912        let total = model.symbol("total").expect("symbol");
913        let provenance = model
914            .provenance(total.references[0].to_span())
915            .expect("provenance");
916        assert_eq!(provenance.file_id, 0);
917        assert_eq!(provenance.path, "main.opy");
918        assert_eq!(provenance.start.line, 4);
919        assert_eq!(model.file(0), Some("main.opy"));
920        assert_eq!(model.file(1), None);
921    }
922
923    #[test]
924    fn custom_enums_are_queried_from_the_model() {
925        let outcome = check_source(
926            "globalvar x\nenum Direction:\n    NORTH\n    SOUTH\nrule \"r\":\n    @Event global\n    x = Direction.SOUTH\n",
927        );
928        let model = outcome.model.expect("clean project");
929        assert_eq!(model.enums().len(), 1);
930        let direction = &model.enums()[0];
931        assert_eq!(direction.name, "Direction");
932        let members: Vec<&str> = direction
933            .members
934            .iter()
935            .map(|member| member.name.as_str())
936            .collect();
937        assert_eq!(members, vec!["NORTH", "SOUTH"]);
938        assert!(direction.members[0].span.path.ends_with("main.opy"));
939    }
940
941    #[test]
942    fn check_reports_every_parse_error() {
943        // The parser recovers at statement boundaries; check collects all
944        // parse diagnostics (compile reads only the first). The two rules
945        // missing their colon and the stray directive line yield three
946        // parse-error diagnostics.
947        let outcome = check_source("rule \"a\"\n    @Event global\nrule \"b\"\n");
948        assert!(!outcome.is_clean());
949        assert!(outcome.model.is_none());
950        assert_eq!(outcome.diagnostics.len(), 3);
951        assert!(
952            outcome
953                .diagnostics
954                .iter()
955                .all(|diagnostic| diagnostic.code == "parse-error")
956        );
957    }
958
959    #[test]
960    fn diagnostics_carry_severity_code_and_span() {
961        let outcome = check_source("rule \"r\":\n    @Event global\n    frobnicate()\n");
962        let diagnostic = &outcome.diagnostics[0];
963        assert_eq!(diagnostic.severity, DiagnosticSeverity::Error);
964        assert_eq!(diagnostic.code, "unknown-action");
965        let span = diagnostic.span.as_ref().expect("source-located");
966        assert_eq!(span.path, "main.opy");
967        assert_eq!(span.start.line, 3);
968    }
969}