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