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