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