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