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