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