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::Unary { operand, .. } => Self::collect_expr(operand, sites),
596            HirExpr::Index { array, index, .. } => {
597                Self::collect_expr(array, sites);
598                Self::collect_expr(index, sites);
599            }
600            HirExpr::Format { args, .. } => {
601                for arg in args {
602                    Self::collect_expr(arg, sites);
603                }
604            }
605        }
606    }
607
608    fn collect_stmt(stmt: &HirStmt, sites: &mut Vec<(SymbolKind, String, Span)>) {
609        match stmt {
610            HirStmt::Expr { expr, .. } => Self::collect_expr(expr, sites),
611            HirStmt::Assign { target, value, .. } => {
612                Self::collect_expr(target, sites);
613                Self::collect_expr(value, sites);
614            }
615            HirStmt::If {
616                branches, r#else, ..
617            } => {
618                for branch in branches {
619                    Self::collect_expr(&branch.condition, sites);
620                    for stmt in &branch.body {
621                        Self::collect_stmt(stmt, sites);
622                    }
623                }
624                if let Some(r#else) = r#else {
625                    for stmt in r#else {
626                        Self::collect_stmt(stmt, sites);
627                    }
628                }
629            }
630            HirStmt::For {
631                variable,
632                iterable,
633                body,
634                ..
635            } => {
636                Self::collect_expr(variable, sites);
637                Self::collect_expr(iterable, sites);
638                for stmt in body {
639                    Self::collect_stmt(stmt, sites);
640                }
641            }
642            HirStmt::While {
643                condition, body, ..
644            } => {
645                Self::collect_expr(condition, sites);
646                for stmt in body {
647                    Self::collect_stmt(stmt, sites);
648                }
649            }
650            HirStmt::DoWhile {
651                condition, body, ..
652            } => {
653                Self::collect_expr(condition, sites);
654                for stmt in body {
655                    Self::collect_stmt(stmt, sites);
656                }
657            }
658            HirStmt::Switch { value, arms, .. } => {
659                Self::collect_expr(value, sites);
660                for arm in arms {
661                    match arm {
662                        hir::SwitchArm::Case { value, body, .. } => {
663                            Self::collect_expr(value, sites);
664                            for stmt in body {
665                                Self::collect_stmt(stmt, sites);
666                            }
667                        }
668                        hir::SwitchArm::Default { body, .. } => {
669                            for stmt in body {
670                                Self::collect_stmt(stmt, sites);
671                            }
672                        }
673                    }
674                }
675            }
676            HirStmt::Break { .. } => {}
677            HirStmt::CallSubroutine { name, span } => {
678                if let Some(span) = span {
679                    sites.push((
680                        SymbolKind::Subroutine,
681                        name.clone(),
682                        to_frontend_span(*span),
683                    ));
684                    sites.push((SymbolKind::Def, name.clone(), to_frontend_span(*span)));
685                }
686            }
687            HirStmt::Pass { .. } => {}
688        }
689    }
690}
691
692/// A custom `enum` declaration (CST-retained; enums fold to constants in the
693/// HIR).
694#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
695pub struct EnumDecl {
696    pub name: String,
697    pub members: Vec<EnumMember>,
698}
699
700/// One custom-enum member with its declaration site.
701#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
702pub struct EnumMember {
703    pub name: String,
704    pub span: SourceLocation,
705}
706
707/// The kind of a program-scope symbol.
708#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
709#[serde(rename_all = "camelCase")]
710pub enum SymbolKind {
711    Global,
712    Player,
713    Subroutine,
714    Def,
715    Constant,
716    Macro,
717}
718
719/// A program-scope symbol with its declaration site and resolved reference
720/// sites.
721#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
722pub struct Symbol {
723    pub name: String,
724    pub kind: SymbolKind,
725    pub declaration: SourceLocation,
726    pub references: Vec<SourceLocation>,
727}
728
729/// Whether the interval `outer` contains the interval `inner` (half-open
730/// end positions, so a 1:1 zero-width span is contained by itself).
731fn span_contains(outer: Span, inner: Span) -> bool {
732    position_leq(outer.start, inner.start) && position_leq(inner.end, outer.end)
733}
734
735fn position_leq(a: Position, b: Position) -> bool {
736    a.line < b.line || (a.line == b.line && a.col <= b.col)
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742
743    fn check_source(source: &str) -> CheckOutcome {
744        check(source, "main.opy", Path::new(""))
745    }
746
747    #[test]
748    fn clean_project_has_no_diagnostics_and_a_model() {
749        let outcome = check_source(
750            "globalvar total = 0\nrule \"r\":\n    @Event global\n    total += 1\n    debug(total)\n",
751        );
752        assert!(
753            outcome.is_clean(),
754            "unexpected diagnostics: {:?}",
755            outcome.diagnostics
756        );
757        let model = outcome.model.expect("a clean project resolves");
758        assert_eq!(outcome.files.len(), 1);
759        assert_eq!(model.declarations().len(), 1);
760        assert_eq!(model.rules().len(), 1);
761    }
762
763    #[test]
764    fn symbols_index_declarations_and_references() {
765        let outcome = check_source(
766            "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",
767        );
768        let model = outcome.model.expect("clean project");
769        let names: Vec<(&str, SymbolKind)> = model
770            .symbols()
771            .iter()
772            .map(|symbol| (symbol.name.as_str(), symbol.kind))
773            .collect();
774        assert_eq!(
775            names,
776            vec![
777                ("total", SymbolKind::Global),
778                ("P", SymbolKind::Player),
779                ("reset", SymbolKind::Subroutine),
780                ("double", SymbolKind::Macro),
781            ]
782        );
783        assert_eq!(model.symbol("total").expect("symbol").references.len(), 2);
784        assert_eq!(model.symbol("P").expect("symbol").references.len(), 1);
785        let reset = model.symbol("reset").expect("symbol");
786        assert_eq!(reset.references.len(), 1);
787        assert_eq!(reset.references[0].path, "main.opy");
788        assert_eq!(model.symbol("double").expect("symbol").references.len(), 1);
789    }
790
791    #[test]
792    fn symbol_lookup_by_name_and_span() {
793        let outcome =
794            check_source("globalvar total\nrule \"r\":\n    @Event global\n    total = 1\n");
795        let model = outcome.model.expect("clean project");
796        let total = model.symbol("total").expect("symbol by name");
797        assert_eq!(total.kind, SymbolKind::Global);
798        // The declaration site answers span lookup…
799        let at_decl = model
800            .symbol_at(total.declaration.to_span())
801            .expect("symbol at declaration span");
802        assert_eq!(at_decl.name, "total");
803        // …and so does a reference site.
804        let at_ref = model
805            .symbol_at(total.references[0].to_span())
806            .expect("symbol at reference span");
807        assert_eq!(at_ref.name, "total");
808        assert!(
809            model
810                .symbol_at(Span::new(99, Position::new(1, 1), Position::new(1, 1)))
811                .is_none()
812        );
813    }
814
815    #[test]
816    fn provenance_resolves_through_the_file_registry() {
817        let outcome =
818            check_source("globalvar total\nrule \"r\":\n    @Event global\n    total = 1\n");
819        let model = outcome.model.expect("clean project");
820        let total = model.symbol("total").expect("symbol");
821        let provenance = model
822            .provenance(total.references[0].to_span())
823            .expect("provenance");
824        assert_eq!(provenance.file_id, 0);
825        assert_eq!(provenance.path, "main.opy");
826        assert_eq!(provenance.start.line, 4);
827        assert_eq!(model.file(0), Some("main.opy"));
828        assert_eq!(model.file(1), None);
829    }
830
831    #[test]
832    fn custom_enums_are_queried_from_the_model() {
833        let outcome = check_source(
834            "globalvar x\nenum Direction:\n    NORTH\n    SOUTH\nrule \"r\":\n    @Event global\n    x = Direction.SOUTH\n",
835        );
836        let model = outcome.model.expect("clean project");
837        assert_eq!(model.enums().len(), 1);
838        let direction = &model.enums()[0];
839        assert_eq!(direction.name, "Direction");
840        let members: Vec<&str> = direction
841            .members
842            .iter()
843            .map(|member| member.name.as_str())
844            .collect();
845        assert_eq!(members, vec!["NORTH", "SOUTH"]);
846        assert!(direction.members[0].span.path.ends_with("main.opy"));
847    }
848
849    #[test]
850    fn check_reports_every_parse_error() {
851        // The parser recovers at statement boundaries; check collects all
852        // parse diagnostics (compile reads only the first). The two rules
853        // missing their colon and the stray directive line yield three
854        // parse-error diagnostics.
855        let outcome = check_source("rule \"a\"\n    @Event global\nrule \"b\"\n");
856        assert!(!outcome.is_clean());
857        assert!(outcome.model.is_none());
858        assert_eq!(outcome.diagnostics.len(), 3);
859        assert!(
860            outcome
861                .diagnostics
862                .iter()
863                .all(|diagnostic| diagnostic.code == "parse-error")
864        );
865    }
866
867    #[test]
868    fn diagnostics_carry_severity_code_and_span() {
869        let outcome = check_source("rule \"r\":\n    @Event global\n    frobnicate()\n");
870        let diagnostic = &outcome.diagnostics[0];
871        assert_eq!(diagnostic.severity, DiagnosticSeverity::Error);
872        assert_eq!(diagnostic.code, "unknown-action");
873        let span = diagnostic.span.as_ref().expect("source-located");
874        assert_eq!(span.path, "main.opy");
875        assert_eq!(span.start.line, 3);
876    }
877}