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