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