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                        diagnostics.extend(
215                            workshop_rs::settings::check_emission_diagnostics(&settings)
216                                .into_iter()
217                                .map(|diagnostic| {
218                                    Diagnostic::from_settings_diagnostic(
219                                        diagnostic,
220                                        hir.settings.as_ref(),
221                                        &files,
222                                    )
223                                }),
224                        );
225                    }
226                }
227                Err(error) => diagnostics.push(Diagnostic::from_integration_error(error, &files)),
228            }
229            if diagnostics
230                .iter()
231                .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
232            {
233                return CheckOutcome::failure(diagnostics, files, display_root);
234            }
235            CheckOutcome {
236                diagnostics,
237                model: Some(SemanticModel::build(hir, &program)),
238                files,
239                // The directive was parsed, validated, and recorded by
240                // preprocessing; the frontend never executes the hook (real hook
241                // execution receives the final Workshop text and is
242                // lowering-dependent, issue #8).
243                post_compile_hook: preprocessed.post_compile_hook,
244                display_root,
245            }
246        }
247        Err(error) => {
248            let mut diagnostics = visible_warnings(&preprocessed)
249                .map(|warning| Diagnostic::from_warning(warning, &files))
250                .collect::<Vec<_>>();
251            diagnostics.push(Diagnostic::from_error(error, &files));
252            CheckOutcome::failure(diagnostics, files, display_root)
253        }
254    }
255}
256
257/// A structured, source-attributed diagnostic.
258///
259/// `code` is the stable machine contract (see the module docs and
260/// `docs/opy/tooling-api.md`); `message` is human wording and not part of the
261/// contract; `span` resolves through the file registry when known.
262#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
263pub struct Diagnostic {
264    pub severity: DiagnosticSeverity,
265    pub code: String,
266    pub message: String,
267    pub span: Option<SourceLocation>,
268}
269
270impl Diagnostic {
271    fn from_warning(warning: &PreprocessWarning, files: &[FileRecord]) -> Diagnostic {
272        Diagnostic {
273            severity: DiagnosticSeverity::Warning,
274            code: warning.code.clone(),
275            message: warning.message.clone(),
276            span: resolve_record_span(warning.span, files),
277        }
278    }
279
280    fn from_error(error: OpyError, files: &[FileRecord]) -> Diagnostic {
281        Diagnostic {
282            severity: DiagnosticSeverity::Error,
283            code: error.code,
284            message: error.message,
285            span: error.span.and_then(|span| resolve_record_span(span, files)),
286        }
287    }
288
289    /// A canonical Workshop settings error surfaced under the same code the
290    /// compile pipeline reports for emission failures. The message names the
291    /// keys valid at the rejected member's path in a `did you mean` suffix
292    /// (issue #469).
293    fn from_settings_diagnostic(
294        diagnostic: workshop_rs::settings::SettingsDiagnostic,
295        hir_settings: Option<&crate::hir::types::Settings>,
296        files: &[FileRecord],
297    ) -> Diagnostic {
298        let error = diagnostic.error;
299        let span = crate::compiler::workshop_error_span(&error).map(|span| {
300            Span::new(
301                span.file.index() as u32,
302                Position::new(span.start.line, span.start.col),
303                Position::new(span.end.line, span.end.col),
304            )
305        });
306        let candidates = crate::matcher::settings_member_candidates(
307            hir_settings,
308            &error,
309            diagnostic.suggestion.as_deref(),
310        );
311        Diagnostic {
312            severity: DiagnosticSeverity::Error,
313            code: "workshop-emission".to_string(),
314            message: crate::matcher::did_you_mean(error.to_string(), &candidates),
315            span: span.and_then(|span| resolve_record_span(span, files)),
316        }
317    }
318
319    /// A settings key or enum value outside the catalog that compiles as
320    /// written. It is accepted like upstream, but usually a misspelling.
321    fn from_unknown_setting(
322        member: crate::compiler::settings::UnknownSetting,
323        hir_settings: Option<&crate::hir::types::Settings>,
324        files: &[FileRecord],
325    ) -> Diagnostic {
326        let anchor = workshop_rs::WorkshopError::malformed(String::new(), member.span);
327        let candidates = crate::matcher::settings_member_candidates(hir_settings, &anchor, None);
328        let message = match &member.value {
329            None => format!(
330                "unknown settings key '{}' is passed through unchanged",
331                member.name
332            ),
333            Some(value) => format!(
334                "unknown value '{value}' for settings key '{}' is passed through unchanged",
335                member.name
336            ),
337        };
338        let span = member.span.map(|span| {
339            Span::new(
340                span.file.index() as u32,
341                Position::new(span.start.line, span.start.col),
342                Position::new(span.end.line, span.end.col),
343            )
344        });
345        Diagnostic {
346            severity: DiagnosticSeverity::Warning,
347            code: "unknown-setting".to_string(),
348            message: crate::matcher::did_you_mean(message, &candidates),
349            span: span.and_then(|span| resolve_record_span(span, files)),
350        }
351    }
352
353    /// A settings-conversion failure at the Workshop integration boundary.
354    fn from_integration_error(
355        error: crate::compiler::IntegrationError,
356        files: &[FileRecord],
357    ) -> Diagnostic {
358        Diagnostic {
359            severity: DiagnosticSeverity::Error,
360            code: error.diagnostic.code,
361            message: error.diagnostic.message,
362            span: error
363                .diagnostic
364                .span
365                .map(to_frontend_span)
366                .and_then(|span| resolve_record_span(span, files)),
367        }
368    }
369}
370
371/// The severity of a diagnostic in the frontend contract.
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
373#[serde(rename_all = "camelCase")]
374pub enum DiagnosticSeverity {
375    Error,
376    Warning,
377}
378
379impl DiagnosticSeverity {
380    pub fn as_str(&self) -> &'static str {
381        match self {
382            DiagnosticSeverity::Error => "error",
383            DiagnosticSeverity::Warning => "warning",
384        }
385    }
386}
387
388/// A resolved source location: a span's file id and path (through the file
389/// registry) plus its 1-based line/column interval.
390#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
391pub struct SourceLocation {
392    pub file_id: u32,
393    pub path: String,
394    pub start: Position,
395    pub end: Position,
396}
397
398impl SourceLocation {
399    /// Recover the frontend span (file id + positions) of this location.
400    pub fn to_span(&self) -> Span {
401        Span::new(self.file_id, self.start, self.end)
402    }
403}
404
405fn resolve_span(span: Span, files: &[SourceFile]) -> Option<SourceLocation> {
406    let path = files.iter().find(|file| file.id == span.file)?.path.clone();
407    Some(SourceLocation {
408        file_id: span.file,
409        path,
410        start: span.start,
411        end: span.end,
412    })
413}
414
415/// Resolve a span through the preprocess file registry (used for
416/// diagnostics, where the model may not exist).
417fn resolve_record_span(span: Span, files: &[FileRecord]) -> Option<SourceLocation> {
418    let path = files.iter().find(|file| file.id == span.file)?.path.clone();
419    Some(SourceLocation {
420        file_id: span.file,
421        path,
422        start: span.start,
423        end: span.end,
424    })
425}
426
427/// Map an Opy HIR span (the protocol type) back to the frontend span type;
428/// positions are the same 1-based source coordinates carried through
429/// lowering.
430fn to_frontend_span(span: hir::types::Span) -> Span {
431    Span::new(
432        span.file,
433        Position::new(span.start.line, span.start.col),
434        Position::new(span.end.line, span.end.col),
435    )
436}
437
438/// The resolved program model: the Opy HIR semantic program plus the
439/// queryable symbol index and custom-enum declarations.
440///
441/// Custom enums are not retained in the Opy HIR (they fold to numeric
442/// constants at use sites, reference behavior), so they are carried here from
443/// the CST to keep declarations queryable.
444#[derive(Debug, Clone, Serialize)]
445pub struct SemanticModel {
446    pub hir: hir::Program,
447    pub enums: Vec<EnumDecl>,
448    pub symbols: Vec<Symbol>,
449}
450
451impl SemanticModel {
452    /// Build the queryable model from a resolved HIR program and its parsed
453    /// CST (required for custom-enum declarations).
454    pub fn build(hir: hir::Program, cst: &cst::Program) -> SemanticModel {
455        let enums = cst
456            .declarations
457            .iter()
458            .filter_map(|decl| match decl {
459                cst::Decl::Enum { name, members, .. } => Some(EnumDecl {
460                    name: name.clone(),
461                    members: members
462                        .iter()
463                        .map(|(member, span)| EnumMember {
464                            name: member.clone(),
465                            span: resolve_span(*span, &hir.files)
466                                .expect("every token span resolves through the file registry"),
467                        })
468                        .collect(),
469                }),
470                _ => None,
471            })
472            .collect();
473        let mut model = SemanticModel {
474            hir,
475            enums,
476            symbols: Vec::new(),
477        };
478        model.index_symbols();
479        model
480    }
481
482    /// The HIR declarations (globals, players, subroutines, constants,
483    /// macros). Custom enums are queried through [`SemanticModel::enums`].
484    pub fn declarations(&self) -> &[Declaration] {
485        &self.hir.declarations
486    }
487
488    /// The rule listing: rules and subroutine definitions.
489    pub fn rules(&self) -> &[RuleEntry] {
490        &self.hir.rules
491    }
492
493    /// The recorded preprocessing defines (macro-expansion provenance).
494    pub fn defines(&self) -> &[Define] {
495        &self.hir.defines
496    }
497
498    /// The custom-enum declarations of the project.
499    pub fn enums(&self) -> &[EnumDecl] {
500        &self.enums
501    }
502
503    /// Every indexed program-scope symbol with its declaration site and
504    /// reference sites.
505    pub fn symbols(&self) -> &[Symbol] {
506        &self.symbols
507    }
508
509    /// The first symbol bound under `name` (a `subroutine` declaration and a
510    /// `def` definition of the same name index as separate symbols).
511    pub fn symbol(&self, name: &str) -> Option<&Symbol> {
512        self.symbols.iter().find(|symbol| symbol.name == name)
513    }
514
515    /// The symbol whose declaration site contains `span`, or — failing that —
516    /// the symbol owning a reference site containing `span`.
517    pub fn symbol_at(&self, span: Span) -> Option<&Symbol> {
518        self.symbols.iter().find(|symbol| {
519            span_contains(symbol.declaration.to_span(), span)
520                || symbol
521                    .references
522                    .iter()
523                    .any(|reference| span_contains(reference.to_span(), span))
524        })
525    }
526
527    /// Resolve a span to its file id, path, and line/column through the file
528    /// registry.
529    pub fn provenance(&self, span: Span) -> Option<SourceLocation> {
530        resolve_span(span, &self.hir.files)
531    }
532
533    /// The registry path of a file id.
534    pub fn file(&self, id: u32) -> Option<&str> {
535        self.hir
536            .files
537            .iter()
538            .find(|file| file.id == id)
539            .map(|file| file.path.as_str())
540    }
541
542    /// Index every program-scope binding, then attach resolved reference
543    /// sites by name/kind.
544    fn index_symbols(&mut self) {
545        for decl in &self.hir.declarations {
546            let (kind, name, span) = match decl {
547                Declaration::GlobalVariable {
548                    name,
549                    name_span,
550                    span,
551                    ..
552                } => (SymbolKind::Global, name, name_span.or(*span)),
553                Declaration::PlayerVariable {
554                    name,
555                    name_span,
556                    span,
557                    ..
558                } => (SymbolKind::Player, name, name_span.or(*span)),
559                Declaration::Subroutine {
560                    name,
561                    name_span,
562                    span,
563                    ..
564                } => (SymbolKind::Subroutine, name, name_span.or(*span)),
565                Declaration::Constant { name, span, .. } => (SymbolKind::Constant, name, *span),
566                Declaration::Macro { name, span, .. } => (SymbolKind::Macro, name, *span),
567            };
568            let Some(span) = span.map(to_frontend_span) else {
569                // Foreign payloads may omit spans; such declarations are not
570                // addressable and stay out of the index.
571                continue;
572            };
573            let Some(declaration) = resolve_span(span, &self.hir.files) else {
574                continue;
575            };
576            self.symbols.push(Symbol {
577                name: name.clone(),
578                kind,
579                declaration,
580                references: Vec::new(),
581            });
582        }
583        for entry in &self.hir.rules {
584            let RuleEntry::SubroutineDef {
585                name,
586                source_name,
587                name_span,
588                span,
589                ..
590            } = entry
591            else {
592                continue;
593            };
594            let Some(span) = name_span.or(*span).map(to_frontend_span) else {
595                continue;
596            };
597            let Some(declaration) = resolve_span(span, &self.hir.files) else {
598                continue;
599            };
600            self.symbols.push(Symbol {
601                name: if source_name.is_empty() {
602                    name.clone()
603                } else {
604                    source_name.clone()
605                },
606                kind: SymbolKind::Def,
607                declaration,
608                references: Vec::new(),
609            });
610        }
611
612        let mut collector = ReferenceSiteCollector { sites: Vec::new() };
613        hir::visit::walk_program(&mut collector, &self.hir);
614        for (kind, name, span) in collector.sites {
615            self.attach_reference(kind, &name, span);
616        }
617    }
618
619    /// Record a reference site for the first symbol of `kind` named `name`.
620    /// A call site is offered to both `subroutine` and `def` bindings.
621    fn attach_reference(&mut self, kind: SymbolKind, name: &str, span: Span) {
622        let Some(location) = resolve_span(span, &self.hir.files) else {
623            return;
624        };
625        if let Some(index) = self
626            .symbols
627            .iter()
628            .position(|symbol| symbol.kind == kind && symbol.name == name)
629        {
630            self.symbols[index].references.push(location);
631        }
632    }
633}
634
635struct ReferenceSiteCollector {
636    sites: Vec<(SymbolKind, String, Span)>,
637}
638
639impl hir::visit::Visitor for ReferenceSiteCollector {
640    fn visit_expr(&mut self, expression: &HirExpr) {
641        match expression {
642            HirExpr::GlobalVar {
643                name,
644                span: Some(span),
645            } => {
646                self.sites
647                    .push((SymbolKind::Global, name.clone(), to_frontend_span(*span)));
648            }
649            HirExpr::Constant {
650                name,
651                span: Some(span),
652            } => {
653                self.sites
654                    .push((SymbolKind::Constant, name.clone(), to_frontend_span(*span)));
655            }
656            HirExpr::PlayerVar {
657                name,
658                member_span,
659                span,
660                ..
661            } => {
662                if let Some(span) = member_span.as_ref().or(span.as_ref()) {
663                    self.sites
664                        .push((SymbolKind::Player, name.clone(), to_frontend_span(*span)));
665                }
666            }
667            HirExpr::Call {
668                name,
669                span: Some(span),
670                ..
671            } => {
672                let span = to_frontend_span(*span);
673                self.sites
674                    .push((SymbolKind::Subroutine, name.clone(), span));
675                self.sites.push((SymbolKind::Def, name.clone(), span));
676            }
677            HirExpr::MacroCall {
678                name,
679                span: Some(span),
680                ..
681            } => {
682                self.sites
683                    .push((SymbolKind::Macro, name.clone(), to_frontend_span(*span)));
684            }
685            _ => {}
686        }
687        hir::visit::walk_expr(self, expression);
688    }
689
690    fn visit_comprehension(
691        &mut self,
692        element: &HirExpr,
693        iterable: &HirExpr,
694        condition: Option<&HirExpr>,
695    ) {
696        hir::visit::Visitor::visit_expr(self, iterable);
697        hir::visit::Visitor::visit_expr(self, element);
698        if let Some(condition) = condition {
699            hir::visit::Visitor::visit_expr(self, condition);
700        }
701    }
702
703    fn visit_stmt(&mut self, statement: &HirStmt) {
704        if let HirStmt::CallSubroutine {
705            name,
706            span: Some(span),
707        } = statement
708        {
709            let span = to_frontend_span(*span);
710            self.sites
711                .push((SymbolKind::Subroutine, name.clone(), span));
712            self.sites.push((SymbolKind::Def, name.clone(), span));
713        }
714        hir::visit::walk_stmt(self, statement);
715    }
716}
717
718/// A custom `enum` declaration (CST-retained; enums fold to constants in the
719/// HIR).
720#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
721pub struct EnumDecl {
722    pub name: String,
723    pub members: Vec<EnumMember>,
724}
725
726/// One custom-enum member with its declaration site.
727#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
728pub struct EnumMember {
729    pub name: String,
730    pub span: SourceLocation,
731}
732
733/// The kind of a program-scope symbol.
734#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
735#[serde(rename_all = "camelCase")]
736pub enum SymbolKind {
737    Global,
738    Player,
739    Subroutine,
740    Def,
741    Constant,
742    Macro,
743}
744
745/// A program-scope symbol with its declaration site and resolved reference
746/// sites.
747#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
748pub struct Symbol {
749    pub name: String,
750    pub kind: SymbolKind,
751    pub declaration: SourceLocation,
752    pub references: Vec<SourceLocation>,
753}
754
755/// Whether the interval `outer` contains the interval `inner` (half-open
756/// end positions, so a 1:1 zero-width span is contained by itself).
757fn span_contains(outer: Span, inner: Span) -> bool {
758    position_leq(outer.start, inner.start) && position_leq(inner.end, outer.end)
759}
760
761fn position_leq(a: Position, b: Position) -> bool {
762    a.line < b.line || (a.line == b.line && a.col <= b.col)
763}
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768
769    fn check_source(source: &str) -> CheckOutcome {
770        check(source, "main.opy", Path::new(""))
771    }
772
773    #[test]
774    fn clean_project_has_no_diagnostics_and_a_model() {
775        let outcome = check_source(
776            "globalvar total = 0\nrule \"r\":\n    @Event global\n    total += 1\n    debug(total)\n",
777        );
778        assert!(
779            outcome.is_clean(),
780            "unexpected diagnostics: {:?}",
781            outcome.diagnostics
782        );
783        let model = outcome.model.expect("a clean project resolves");
784        assert_eq!(outcome.files.len(), 1);
785        assert_eq!(model.declarations().len(), 1);
786        assert_eq!(model.rules().len(), 1);
787    }
788
789    #[test]
790    fn symbols_index_declarations_and_references() {
791        let outcome = check_source(
792            "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",
793        );
794        let model = outcome.model.expect("clean project");
795        let names: Vec<(&str, SymbolKind)> = model
796            .symbols()
797            .iter()
798            .map(|symbol| (symbol.name.as_str(), symbol.kind))
799            .collect();
800        assert_eq!(
801            names,
802            vec![
803                ("total", SymbolKind::Global),
804                ("P", SymbolKind::Player),
805                ("reset", SymbolKind::Subroutine),
806                ("double", SymbolKind::Macro),
807            ]
808        );
809        assert_eq!(model.symbol("total").expect("symbol").references.len(), 2);
810        assert_eq!(model.symbol("P").expect("symbol").references.len(), 1);
811        let reset = model.symbol("reset").expect("symbol");
812        assert_eq!(reset.references.len(), 1);
813        assert_eq!(reset.references[0].path, "main.opy");
814        assert_eq!(model.symbol("double").expect("symbol").references.len(), 1);
815    }
816
817    #[test]
818    fn symbol_lookup_by_name_and_span() {
819        let outcome =
820            check_source("globalvar total\nrule \"r\":\n    @Event global\n    total = 1\n");
821        let model = outcome.model.expect("clean project");
822        let total = model.symbol("total").expect("symbol by name");
823        assert_eq!(total.kind, SymbolKind::Global);
824        // The declaration site answers span lookup…
825        let at_decl = model
826            .symbol_at(total.declaration.to_span())
827            .expect("symbol at declaration span");
828        assert_eq!(at_decl.name, "total");
829        // …and so does a reference site.
830        let at_ref = model
831            .symbol_at(total.references[0].to_span())
832            .expect("symbol at reference span");
833        assert_eq!(at_ref.name, "total");
834        assert!(
835            model
836                .symbol_at(Span::new(99, Position::new(1, 1), Position::new(1, 1)))
837                .is_none()
838        );
839    }
840
841    #[test]
842    fn provenance_resolves_through_the_file_registry() {
843        let outcome =
844            check_source("globalvar total\nrule \"r\":\n    @Event global\n    total = 1\n");
845        let model = outcome.model.expect("clean project");
846        let total = model.symbol("total").expect("symbol");
847        let provenance = model
848            .provenance(total.references[0].to_span())
849            .expect("provenance");
850        assert_eq!(provenance.file_id, 0);
851        assert_eq!(provenance.path, "main.opy");
852        assert_eq!(provenance.start.line, 4);
853        assert_eq!(model.file(0), Some("main.opy"));
854        assert_eq!(model.file(1), None);
855    }
856
857    #[test]
858    fn custom_enums_are_queried_from_the_model() {
859        let outcome = check_source(
860            "globalvar x\nenum Direction:\n    NORTH\n    SOUTH\nrule \"r\":\n    @Event global\n    x = Direction.SOUTH\n",
861        );
862        let model = outcome.model.expect("clean project");
863        assert_eq!(model.enums().len(), 1);
864        let direction = &model.enums()[0];
865        assert_eq!(direction.name, "Direction");
866        let members: Vec<&str> = direction
867            .members
868            .iter()
869            .map(|member| member.name.as_str())
870            .collect();
871        assert_eq!(members, vec!["NORTH", "SOUTH"]);
872        assert!(direction.members[0].span.path.ends_with("main.opy"));
873    }
874
875    #[test]
876    fn unknown_setting_names_the_keys_valid_at_the_path() {
877        // `notASetting` sits under `gamemodes.ffa`; nothing is near, so the
878        // warning falls back to the bounded list of keys the path accepts
879        // (issue #469).
880        let outcome = check_source(
881            "settings {\n    \"gamemodes\": {\"ffa\": {\"notASetting\": true}}\n}\nrule \"a\":\n    @Event global\n    wait(1)\n",
882        );
883        let diagnostic = outcome
884            .diagnostics
885            .iter()
886            .find(|d| d.code == "unknown-setting")
887            .expect("unknown setting diagnostic");
888        assert_eq!(diagnostic.severity, DiagnosticSeverity::Warning);
889        assert!(
890            diagnostic.message.contains("(did you mean "),
891            "message: {}",
892            diagnostic.message
893        );
894        assert!(
895            diagnostic.message.contains("'disabledMaps'"),
896            "message: {}",
897            diagnostic.message
898        );
899    }
900
901    #[test]
902    fn unknown_setting_preserves_template_paths_and_percent_suffixes() {
903        let outcome = check_source(
904            "settings {\n    \"main\": {\"description\": \"t\"},\n    \"gamemodes\": {},\n    \"heroes\": {\"team1\": {\"general\": {\"damageReceiveed%\": 50}}}\n}\nrule \"a\":\n    @Event global\n    wait(1)\n",
905        );
906        let diagnostic = outcome
907            .diagnostics
908            .iter()
909            .find(|d| d.code == "unknown-setting")
910            .expect("unknown setting diagnostic");
911        assert_eq!(diagnostic.severity, DiagnosticSeverity::Warning);
912        assert!(
913            diagnostic.message.contains("'damageReceived%'"),
914            "message: {}",
915            diagnostic.message
916        );
917    }
918
919    #[test]
920    fn check_reports_every_parse_error() {
921        // The parser recovers at statement boundaries; check collects all
922        // parse diagnostics (compile reads only the first). The two rules
923        // missing their colon and the stray directive line yield three
924        // parse-error diagnostics.
925        let outcome = check_source("rule \"a\"\n    @Event global\nrule \"b\"\n");
926        assert!(!outcome.is_clean());
927        assert!(outcome.model.is_none());
928        assert_eq!(outcome.diagnostics.len(), 3);
929        assert!(
930            outcome
931                .diagnostics
932                .iter()
933                .all(|diagnostic| diagnostic.code == "parse-error")
934        );
935    }
936
937    #[test]
938    fn diagnostics_carry_severity_code_and_span() {
939        let outcome = check_source("rule \"r\":\n    @Event global\n    frobnicate()\n");
940        let diagnostic = &outcome.diagnostics[0];
941        assert_eq!(diagnostic.severity, DiagnosticSeverity::Error);
942        assert_eq!(diagnostic.code, "unknown-action");
943        let span = diagnostic.span.as_ref().expect("source-located");
944        assert_eq!(span.path, "main.opy");
945        assert_eq!(span.start.line, 3);
946    }
947
948    #[test]
949    fn canonical_workshop_ids_are_source_diagnostics() {
950        // #410: canonical Workshop ids that upstream OverPy does not spell
951        // are rejected with a source span, not accepted as callables.
952        for (statement, code) in [
953            ("g = allTankHeroes()", "unknown-value"),
954            ("g = lastCreatedEntity()", "unknown-value"),
955            ("g = evaluateOnce(1)", "unknown-value"),
956            ("destroyAllHudText()", "unknown-action"),
957        ] {
958            let outcome = check_source(&format!(
959                "globalvar g\nrule \"r\":\n    @Event global\n    {statement}\n"
960            ));
961            assert!(!outcome.is_clean(), "{statement}");
962            let diagnostic = outcome
963                .diagnostics
964                .iter()
965                .find(|diagnostic| diagnostic.code == code)
966                .unwrap_or_else(|| panic!("{statement}: expected {code}"));
967            let span = diagnostic.span.as_ref().expect("source-located");
968            assert_eq!(span.start.line, 4, "{statement}");
969        }
970
971        let outcome = check_source(
972            "globalvar g\nrule \"r\":\n    @Event eachPlayer\n    g = eventPlayer.isButtonHeld(Button.PRIMARY_FIRE)\n",
973        );
974        assert!(!outcome.is_clean());
975        let diagnostic = outcome
976            .diagnostics
977            .iter()
978            .find(|diagnostic| diagnostic.code == "unknown-member")
979            .expect("unknown-member diagnostic");
980        assert_eq!(diagnostic.span.as_ref().unwrap().start.line, 4);
981
982        // The upstream spellings stay clean.
983        let outcome = check_source(
984            "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",
985        );
986        assert!(
987            outcome.is_clean(),
988            "unexpected diagnostics: {:?}",
989            outcome.diagnostics
990        );
991    }
992}