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::HostPlayer { .. }
511            | HirExpr::MacroParam { .. }
512            | HirExpr::StringModifier { .. }
513            | HirExpr::Local { .. } => {}
514            HirExpr::Type { args, .. } => {
515                for arg in args {
516                    Self::collect_expr(arg, sites);
517                }
518            }
519            HirExpr::GlobalVar { name, span } | HirExpr::Constant { name, span } => {
520                let kind = if matches!(expr, HirExpr::GlobalVar { .. }) {
521                    SymbolKind::Global
522                } else {
523                    SymbolKind::Constant
524                };
525                if let Some(span) = span {
526                    sites.push((kind, name.clone(), to_frontend_span(*span)));
527                }
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                    sites.push((SymbolKind::Player, name.clone(), to_frontend_span(*span)));
537                }
538            }
539            HirExpr::Member { receiver, .. } => Self::collect_expr(receiver, sites),
540            HirExpr::Array { elements, .. } => {
541                for element in elements {
542                    Self::collect_expr(element, sites);
543                }
544            }
545            HirExpr::Dict { entries, .. } => {
546                for entry in entries {
547                    Self::collect_expr(&entry.key, sites);
548                    Self::collect_expr(&entry.value, sites);
549                }
550            }
551            HirExpr::Comprehension {
552                element,
553                iterable,
554                condition,
555                ..
556            } => {
557                Self::collect_expr(iterable, sites);
558                Self::collect_expr(element, sites);
559                if let Some(condition) = condition {
560                    Self::collect_expr(condition, sites);
561                }
562            }
563            HirExpr::Lambda { body, .. } => Self::collect_expr(body, sites),
564            HirExpr::Vector { x, y, z, .. } => {
565                Self::collect_expr(x, sites);
566                Self::collect_expr(y, sites);
567                Self::collect_expr(z, sites);
568            }
569            HirExpr::Call { name, span, args } => {
570                // A call may name a declared subroutine (with arguments) or
571                // nothing user-declared (a builtin); unresolved names never
572                // reach the model. Offer both subroutine binding kinds.
573                if let Some(span) = span {
574                    sites.push((
575                        SymbolKind::Subroutine,
576                        name.clone(),
577                        to_frontend_span(*span),
578                    ));
579                    sites.push((SymbolKind::Def, name.clone(), to_frontend_span(*span)));
580                }
581                for arg in args {
582                    Self::collect_expr(arg, sites);
583                }
584            }
585            HirExpr::MacroCall { name, span, args } => {
586                if let Some(span) = span {
587                    sites.push((SymbolKind::Macro, name.clone(), to_frontend_span(*span)));
588                }
589                for arg in args {
590                    Self::collect_expr(arg, sites);
591                }
592            }
593            HirExpr::ReceiverCall { receiver, args, .. } => {
594                // The receiver may be a call (e.g. getPlayersInRadius(...).x)
595                // whose name binds a symbol; the call span of the outer node
596                // is attributed to the member name, not the receiver.
597                Self::collect_expr(receiver, sites);
598                for arg in args {
599                    Self::collect_expr(arg, sites);
600                }
601            }
602            HirExpr::Binary { left, right, .. } => {
603                Self::collect_expr(left, sites);
604                Self::collect_expr(right, sites);
605            }
606            HirExpr::Conditional {
607                then_value,
608                condition,
609                else_value,
610                ..
611            } => {
612                Self::collect_expr(then_value, sites);
613                Self::collect_expr(condition, sites);
614                Self::collect_expr(else_value, sites);
615            }
616            HirExpr::Unary { operand, .. } => Self::collect_expr(operand, sites),
617            HirExpr::Index { array, index, .. } => {
618                Self::collect_expr(array, sites);
619                Self::collect_expr(index, sites);
620            }
621            HirExpr::Format { args, .. } => {
622                for arg in args {
623                    Self::collect_expr(arg, sites);
624                }
625            }
626        }
627    }
628
629    fn collect_stmt(stmt: &HirStmt, sites: &mut Vec<(SymbolKind, String, Span)>) {
630        match stmt {
631            HirStmt::Expr { expr, .. } => Self::collect_expr(expr, sites),
632            HirStmt::Assign { target, value, .. } => {
633                Self::collect_expr(target, sites);
634                Self::collect_expr(value, sites);
635            }
636            HirStmt::If {
637                branches, r#else, ..
638            } => {
639                for branch in branches {
640                    Self::collect_expr(&branch.condition, sites);
641                    for stmt in &branch.body {
642                        Self::collect_stmt(stmt, sites);
643                    }
644                }
645                if let Some(r#else) = r#else {
646                    for stmt in r#else {
647                        Self::collect_stmt(stmt, sites);
648                    }
649                }
650            }
651            HirStmt::For {
652                variable,
653                iterable,
654                body,
655                ..
656            } => {
657                Self::collect_expr(variable, sites);
658                Self::collect_expr(iterable, sites);
659                for stmt in body {
660                    Self::collect_stmt(stmt, sites);
661                }
662            }
663            HirStmt::While {
664                condition, body, ..
665            } => {
666                Self::collect_expr(condition, sites);
667                for stmt in body {
668                    Self::collect_stmt(stmt, sites);
669                }
670            }
671            HirStmt::DoWhile {
672                condition, body, ..
673            } => {
674                Self::collect_expr(condition, sites);
675                for stmt in body {
676                    Self::collect_stmt(stmt, sites);
677                }
678            }
679            HirStmt::Switch { value, arms, .. } => {
680                Self::collect_expr(value, sites);
681                for arm in arms {
682                    match arm {
683                        hir::SwitchArm::Case { value, body, .. } => {
684                            Self::collect_expr(value, sites);
685                            for stmt in body {
686                                Self::collect_stmt(stmt, sites);
687                            }
688                        }
689                        hir::SwitchArm::Default { body, .. } => {
690                            for stmt in body {
691                                Self::collect_stmt(stmt, sites);
692                            }
693                        }
694                    }
695                }
696            }
697            HirStmt::Break { .. } => {}
698            HirStmt::CallSubroutine { name, span } => {
699                if let Some(span) = span {
700                    sites.push((
701                        SymbolKind::Subroutine,
702                        name.clone(),
703                        to_frontend_span(*span),
704                    ));
705                    sites.push((SymbolKind::Def, name.clone(), to_frontend_span(*span)));
706                }
707            }
708            HirStmt::Pass { .. } => {}
709        }
710    }
711}
712
713/// A custom `enum` declaration (CST-retained; enums fold to constants in the
714/// HIR).
715#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
716pub struct EnumDecl {
717    pub name: String,
718    pub members: Vec<EnumMember>,
719}
720
721/// One custom-enum member with its declaration site.
722#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
723pub struct EnumMember {
724    pub name: String,
725    pub span: SourceLocation,
726}
727
728/// The kind of a program-scope symbol.
729#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
730#[serde(rename_all = "camelCase")]
731pub enum SymbolKind {
732    Global,
733    Player,
734    Subroutine,
735    Def,
736    Constant,
737    Macro,
738}
739
740/// A program-scope symbol with its declaration site and resolved reference
741/// sites.
742#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
743pub struct Symbol {
744    pub name: String,
745    pub kind: SymbolKind,
746    pub declaration: SourceLocation,
747    pub references: Vec<SourceLocation>,
748}
749
750/// Whether the interval `outer` contains the interval `inner` (half-open
751/// end positions, so a 1:1 zero-width span is contained by itself).
752fn span_contains(outer: Span, inner: Span) -> bool {
753    position_leq(outer.start, inner.start) && position_leq(inner.end, outer.end)
754}
755
756fn position_leq(a: Position, b: Position) -> bool {
757    a.line < b.line || (a.line == b.line && a.col <= b.col)
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763
764    fn check_source(source: &str) -> CheckOutcome {
765        check(source, "main.opy", Path::new(""))
766    }
767
768    #[test]
769    fn clean_project_has_no_diagnostics_and_a_model() {
770        let outcome = check_source(
771            "globalvar total = 0\nrule \"r\":\n    @Event global\n    total += 1\n    debug(total)\n",
772        );
773        assert!(
774            outcome.is_clean(),
775            "unexpected diagnostics: {:?}",
776            outcome.diagnostics
777        );
778        let model = outcome.model.expect("a clean project resolves");
779        assert_eq!(outcome.files.len(), 1);
780        assert_eq!(model.declarations().len(), 1);
781        assert_eq!(model.rules().len(), 1);
782    }
783
784    #[test]
785    fn symbols_index_declarations_and_references() {
786        let outcome = check_source(
787            "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",
788        );
789        let model = outcome.model.expect("clean project");
790        let names: Vec<(&str, SymbolKind)> = model
791            .symbols()
792            .iter()
793            .map(|symbol| (symbol.name.as_str(), symbol.kind))
794            .collect();
795        assert_eq!(
796            names,
797            vec![
798                ("total", SymbolKind::Global),
799                ("P", SymbolKind::Player),
800                ("reset", SymbolKind::Subroutine),
801                ("double", SymbolKind::Macro),
802            ]
803        );
804        assert_eq!(model.symbol("total").expect("symbol").references.len(), 2);
805        assert_eq!(model.symbol("P").expect("symbol").references.len(), 1);
806        let reset = model.symbol("reset").expect("symbol");
807        assert_eq!(reset.references.len(), 1);
808        assert_eq!(reset.references[0].path, "main.opy");
809        assert_eq!(model.symbol("double").expect("symbol").references.len(), 1);
810    }
811
812    #[test]
813    fn symbol_lookup_by_name_and_span() {
814        let outcome =
815            check_source("globalvar total\nrule \"r\":\n    @Event global\n    total = 1\n");
816        let model = outcome.model.expect("clean project");
817        let total = model.symbol("total").expect("symbol by name");
818        assert_eq!(total.kind, SymbolKind::Global);
819        // The declaration site answers span lookup…
820        let at_decl = model
821            .symbol_at(total.declaration.to_span())
822            .expect("symbol at declaration span");
823        assert_eq!(at_decl.name, "total");
824        // …and so does a reference site.
825        let at_ref = model
826            .symbol_at(total.references[0].to_span())
827            .expect("symbol at reference span");
828        assert_eq!(at_ref.name, "total");
829        assert!(
830            model
831                .symbol_at(Span::new(99, Position::new(1, 1), Position::new(1, 1)))
832                .is_none()
833        );
834    }
835
836    #[test]
837    fn provenance_resolves_through_the_file_registry() {
838        let outcome =
839            check_source("globalvar total\nrule \"r\":\n    @Event global\n    total = 1\n");
840        let model = outcome.model.expect("clean project");
841        let total = model.symbol("total").expect("symbol");
842        let provenance = model
843            .provenance(total.references[0].to_span())
844            .expect("provenance");
845        assert_eq!(provenance.file_id, 0);
846        assert_eq!(provenance.path, "main.opy");
847        assert_eq!(provenance.start.line, 4);
848        assert_eq!(model.file(0), Some("main.opy"));
849        assert_eq!(model.file(1), None);
850    }
851
852    #[test]
853    fn custom_enums_are_queried_from_the_model() {
854        let outcome = check_source(
855            "globalvar x\nenum Direction:\n    NORTH\n    SOUTH\nrule \"r\":\n    @Event global\n    x = Direction.SOUTH\n",
856        );
857        let model = outcome.model.expect("clean project");
858        assert_eq!(model.enums().len(), 1);
859        let direction = &model.enums()[0];
860        assert_eq!(direction.name, "Direction");
861        let members: Vec<&str> = direction
862            .members
863            .iter()
864            .map(|member| member.name.as_str())
865            .collect();
866        assert_eq!(members, vec!["NORTH", "SOUTH"]);
867        assert!(direction.members[0].span.path.ends_with("main.opy"));
868    }
869
870    #[test]
871    fn check_reports_every_parse_error() {
872        // The parser recovers at statement boundaries; check collects all
873        // parse diagnostics (compile reads only the first). The two rules
874        // missing their colon and the stray directive line yield three
875        // parse-error diagnostics.
876        let outcome = check_source("rule \"a\"\n    @Event global\nrule \"b\"\n");
877        assert!(!outcome.is_clean());
878        assert!(outcome.model.is_none());
879        assert_eq!(outcome.diagnostics.len(), 3);
880        assert!(
881            outcome
882                .diagnostics
883                .iter()
884                .all(|diagnostic| diagnostic.code == "parse-error")
885        );
886    }
887
888    #[test]
889    fn diagnostics_carry_severity_code_and_span() {
890        let outcome = check_source("rule \"r\":\n    @Event global\n    frobnicate()\n");
891        let diagnostic = &outcome.diagnostics[0];
892        assert_eq!(diagnostic.severity, DiagnosticSeverity::Error);
893        assert_eq!(diagnostic.code, "unknown-action");
894        let span = diagnostic.span.as_ref().expect("source-located");
895        assert_eq!(span.path, "main.opy");
896        assert_eq!(span.start.line, 3);
897    }
898}