1use std::path::Path;
35
36use serde::Serialize;
37
38use crate::cst;
39use crate::diag::{OpyError, Position, Span};
40use crate::hir;
41use crate::hir::types::{
42 Declaration, Define, Expr as HirExpr, RuleEntry, SourceFile, Stmt as HirStmt,
43};
44use crate::preprocess::{FileRecord, PreprocessOutcome, PreprocessWarning, Preprocessed};
45
46fn visible_warnings(preprocessed: &Preprocessed) -> impl Iterator<Item = &PreprocessWarning> {
47 preprocessed.warnings.iter().filter(|warning| {
48 !preprocessed
49 .preprocessing
50 .suppressed_warnings
51 .iter()
52 .any(|code| code == &warning.code)
53 })
54}
55
56#[derive(Debug, Clone)]
63pub struct CheckOutcome {
64 pub diagnostics: Vec<Diagnostic>,
65 pub model: Option<SemanticModel>,
66 pub files: Vec<FileRecord>,
67 pub post_compile_hook: Option<crate::preprocess::PostCompileHook>,
76}
77
78impl CheckOutcome {
79 fn failure(diagnostics: Vec<Diagnostic>, files: Vec<FileRecord>) -> Self {
80 Self {
81 diagnostics,
82 model: None,
83 files,
84 post_compile_hook: None,
85 }
86 }
87
88 pub fn is_clean(&self) -> bool {
90 self.diagnostics
91 .iter()
92 .all(|diagnostic| diagnostic.severity != DiagnosticSeverity::Error)
93 }
94}
95
96pub fn check(source: &str, main_path: &str, root: &Path) -> CheckOutcome {
102 check_with_overlay(source, main_path, root, &std::collections::BTreeMap::new())
103}
104
105pub fn check_with_overlay(
108 source: &str,
109 main_path: &str,
110 root: &Path,
111 overlay: &std::collections::BTreeMap<String, String>,
112) -> CheckOutcome {
113 let PreprocessOutcome {
114 result,
115 files,
116 warnings,
117 } = crate::preprocess::preprocess_with_overlay_outcome(source, main_path, root, overlay);
118 let preprocessed = match result {
119 Ok((preprocessed, _)) => preprocessed,
120 Err(error) => {
121 let mut diagnostics = warnings
122 .iter()
123 .map(|warning| Diagnostic::from_warning(warning, &files))
124 .collect::<Vec<_>>();
125 diagnostics.push(Diagnostic::from_error(error, &files));
126 return CheckOutcome::failure(diagnostics, files);
127 }
128 };
129 let parsed = crate::parser::parse_with_options(
130 &preprocessed.tokens,
131 preprocessed.preprocessing.allow_macro_redeclaration,
132 );
133 let Some(mut program) = parsed.program else {
134 let mut diagnostics = visible_warnings(&preprocessed)
137 .map(|warning| Diagnostic::from_warning(warning, &files))
138 .collect::<Vec<_>>();
139 diagnostics.extend(
140 parsed
141 .errors
142 .iter()
143 .map(|error| Diagnostic::from_error(error.clone(), &files)),
144 );
145 return CheckOutcome::failure(diagnostics, files);
146 };
147 if let Some(block) = &preprocessed.settings {
151 match crate::settings::parse_block(block) {
152 Ok(parsed_settings) => program.settings = Some(parsed_settings),
153 Err(error) => {
154 let mut diagnostics = visible_warnings(&preprocessed)
155 .map(|warning| Diagnostic::from_warning(warning, &files))
156 .collect::<Vec<_>>();
157 diagnostics.push(Diagnostic::from_error(error, &files));
158 return CheckOutcome::failure(diagnostics, files);
159 }
160 }
161 }
162 let defines = preprocessed
163 .defines
164 .iter()
165 .map(|define| Define {
166 name: define.name.clone(),
167 is_function: define.is_function,
168 is_member: define.is_member,
169 span: define.span.map(Into::into),
170 })
171 .collect();
172 let hir_files = files
173 .iter()
174 .map(|file| hir::types::SourceFile {
175 id: file.id,
176 path: file.path.clone(),
177 })
178 .collect();
179 match crate::lower::lower_with_preprocessing(
180 &program,
181 hir_files,
182 defines,
183 &preprocessed.preprocessing,
184 ) {
185 Ok(mut hir) => {
186 let mut diagnostics = visible_warnings(&preprocessed)
187 .map(|warning| Diagnostic::from_warning(warning, &files))
188 .collect::<Vec<_>>();
189 hir.preprocessing = preprocessed.preprocessing;
190 if let Err(error) = crate::settings::resolve_hir_settings(&mut hir, &program) {
191 diagnostics.push(Diagnostic::from_error(error, &files));
192 return CheckOutcome::failure(diagnostics, files);
193 }
194 match crate::compiler::settings::workshop_settings(&hir) {
199 Ok(Some(settings)) => diagnostics.extend(
200 workshop_rs::settings::check_emission(&settings)
201 .into_iter()
202 .map(|error| Diagnostic::from_workshop_error(error, &files)),
203 ),
204 Ok(None) => {}
205 Err(error) => diagnostics.push(Diagnostic::from_integration_error(error, &files)),
206 }
207 if diagnostics
208 .iter()
209 .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
210 {
211 return CheckOutcome::failure(diagnostics, files);
212 }
213 CheckOutcome {
214 diagnostics,
215 model: Some(SemanticModel::build(hir, &program)),
216 files,
217 post_compile_hook: preprocessed.post_compile_hook,
222 }
223 }
224 Err(error) => {
225 let mut diagnostics = visible_warnings(&preprocessed)
226 .map(|warning| Diagnostic::from_warning(warning, &files))
227 .collect::<Vec<_>>();
228 diagnostics.push(Diagnostic::from_error(error, &files));
229 CheckOutcome::failure(diagnostics, files)
230 }
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
240pub struct Diagnostic {
241 pub severity: DiagnosticSeverity,
242 pub code: String,
243 pub message: String,
244 pub span: Option<SourceLocation>,
245}
246
247impl Diagnostic {
248 fn from_warning(warning: &PreprocessWarning, files: &[FileRecord]) -> Diagnostic {
249 Diagnostic {
250 severity: DiagnosticSeverity::Warning,
251 code: warning.code.clone(),
252 message: warning.message.clone(),
253 span: resolve_record_span(warning.span, files),
254 }
255 }
256
257 fn from_error(error: OpyError, files: &[FileRecord]) -> Diagnostic {
258 Diagnostic {
259 severity: DiagnosticSeverity::Error,
260 code: error.code,
261 message: error.message,
262 span: error.span.and_then(|span| resolve_record_span(span, files)),
263 }
264 }
265
266 fn from_workshop_error(error: workshop_rs::WorkshopError, files: &[FileRecord]) -> Diagnostic {
269 let span = crate::compiler::workshop_error_span(&error).map(|span| {
270 Span::new(
271 span.file.index() as u32,
272 Position::new(span.start.line, span.start.col),
273 Position::new(span.end.line, span.end.col),
274 )
275 });
276 Diagnostic {
277 severity: DiagnosticSeverity::Error,
278 code: "workshop-emission".to_string(),
279 message: error.to_string(),
280 span: span.and_then(|span| resolve_record_span(span, files)),
281 }
282 }
283
284 fn from_integration_error(
286 error: crate::compiler::IntegrationError,
287 files: &[FileRecord],
288 ) -> Diagnostic {
289 Diagnostic {
290 severity: DiagnosticSeverity::Error,
291 code: error.diagnostic.code,
292 message: error.diagnostic.message,
293 span: error
294 .diagnostic
295 .span
296 .map(to_frontend_span)
297 .and_then(|span| resolve_record_span(span, files)),
298 }
299 }
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
304#[serde(rename_all = "camelCase")]
305pub enum DiagnosticSeverity {
306 Error,
307 Warning,
308}
309
310impl DiagnosticSeverity {
311 pub fn as_str(&self) -> &'static str {
312 match self {
313 DiagnosticSeverity::Error => "error",
314 DiagnosticSeverity::Warning => "warning",
315 }
316 }
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
322pub struct SourceLocation {
323 pub file_id: u32,
324 pub path: String,
325 pub start: Position,
326 pub end: Position,
327}
328
329impl SourceLocation {
330 pub fn to_span(&self) -> Span {
332 Span::new(self.file_id, self.start, self.end)
333 }
334}
335
336fn resolve_span(span: Span, files: &[SourceFile]) -> Option<SourceLocation> {
337 let path = files.iter().find(|file| file.id == span.file)?.path.clone();
338 Some(SourceLocation {
339 file_id: span.file,
340 path,
341 start: span.start,
342 end: span.end,
343 })
344}
345
346fn resolve_record_span(span: Span, files: &[FileRecord]) -> Option<SourceLocation> {
349 let path = files.iter().find(|file| file.id == span.file)?.path.clone();
350 Some(SourceLocation {
351 file_id: span.file,
352 path,
353 start: span.start,
354 end: span.end,
355 })
356}
357
358fn to_frontend_span(span: hir::types::Span) -> Span {
362 Span::new(
363 span.file,
364 Position::new(span.start.line, span.start.col),
365 Position::new(span.end.line, span.end.col),
366 )
367}
368
369#[derive(Debug, Clone, Serialize)]
376pub struct SemanticModel {
377 pub hir: hir::Program,
378 pub enums: Vec<EnumDecl>,
379 pub symbols: Vec<Symbol>,
380}
381
382impl SemanticModel {
383 pub fn build(hir: hir::Program, cst: &cst::Program) -> SemanticModel {
386 let enums = cst
387 .declarations
388 .iter()
389 .filter_map(|decl| match decl {
390 cst::Decl::Enum { name, members, .. } => Some(EnumDecl {
391 name: name.clone(),
392 members: members
393 .iter()
394 .map(|(member, span)| EnumMember {
395 name: member.clone(),
396 span: resolve_span(*span, &hir.files)
397 .expect("every token span resolves through the file registry"),
398 })
399 .collect(),
400 }),
401 _ => None,
402 })
403 .collect();
404 let mut model = SemanticModel {
405 hir,
406 enums,
407 symbols: Vec::new(),
408 };
409 model.index_symbols();
410 model
411 }
412
413 pub fn declarations(&self) -> &[Declaration] {
416 &self.hir.declarations
417 }
418
419 pub fn rules(&self) -> &[RuleEntry] {
421 &self.hir.rules
422 }
423
424 pub fn defines(&self) -> &[Define] {
426 &self.hir.defines
427 }
428
429 pub fn enums(&self) -> &[EnumDecl] {
431 &self.enums
432 }
433
434 pub fn symbols(&self) -> &[Symbol] {
437 &self.symbols
438 }
439
440 pub fn symbol(&self, name: &str) -> Option<&Symbol> {
443 self.symbols.iter().find(|symbol| symbol.name == name)
444 }
445
446 pub fn symbol_at(&self, span: Span) -> Option<&Symbol> {
449 self.symbols.iter().find(|symbol| {
450 span_contains(symbol.declaration.to_span(), span)
451 || symbol
452 .references
453 .iter()
454 .any(|reference| span_contains(reference.to_span(), span))
455 })
456 }
457
458 pub fn provenance(&self, span: Span) -> Option<SourceLocation> {
461 resolve_span(span, &self.hir.files)
462 }
463
464 pub fn file(&self, id: u32) -> Option<&str> {
466 self.hir
467 .files
468 .iter()
469 .find(|file| file.id == id)
470 .map(|file| file.path.as_str())
471 }
472
473 fn index_symbols(&mut self) {
476 for decl in &self.hir.declarations {
477 let (kind, name, span) = match decl {
478 Declaration::GlobalVariable {
479 name,
480 name_span,
481 span,
482 ..
483 } => (SymbolKind::Global, name, name_span.or(*span)),
484 Declaration::PlayerVariable {
485 name,
486 name_span,
487 span,
488 ..
489 } => (SymbolKind::Player, name, name_span.or(*span)),
490 Declaration::Subroutine {
491 name,
492 name_span,
493 span,
494 ..
495 } => (SymbolKind::Subroutine, name, name_span.or(*span)),
496 Declaration::Constant { name, span, .. } => (SymbolKind::Constant, name, *span),
497 Declaration::Macro { name, span, .. } => (SymbolKind::Macro, name, *span),
498 };
499 let Some(span) = span.map(to_frontend_span) else {
500 continue;
503 };
504 let Some(declaration) = resolve_span(span, &self.hir.files) else {
505 continue;
506 };
507 self.symbols.push(Symbol {
508 name: name.clone(),
509 kind,
510 declaration,
511 references: Vec::new(),
512 });
513 }
514 for entry in &self.hir.rules {
515 let RuleEntry::SubroutineDef {
516 name,
517 source_name,
518 name_span,
519 span,
520 ..
521 } = entry
522 else {
523 continue;
524 };
525 let Some(span) = name_span.or(*span).map(to_frontend_span) else {
526 continue;
527 };
528 let Some(declaration) = resolve_span(span, &self.hir.files) else {
529 continue;
530 };
531 self.symbols.push(Symbol {
532 name: if source_name.is_empty() {
533 name.clone()
534 } else {
535 source_name.clone()
536 },
537 kind: SymbolKind::Def,
538 declaration,
539 references: Vec::new(),
540 });
541 }
542
543 let mut collector = ReferenceSiteCollector { sites: Vec::new() };
544 hir::visit::walk_program(&mut collector, &self.hir);
545 for (kind, name, span) in collector.sites {
546 self.attach_reference(kind, &name, span);
547 }
548 }
549
550 fn attach_reference(&mut self, kind: SymbolKind, name: &str, span: Span) {
553 let Some(location) = resolve_span(span, &self.hir.files) else {
554 return;
555 };
556 if let Some(index) = self
557 .symbols
558 .iter()
559 .position(|symbol| symbol.kind == kind && symbol.name == name)
560 {
561 self.symbols[index].references.push(location);
562 }
563 }
564}
565
566struct ReferenceSiteCollector {
567 sites: Vec<(SymbolKind, String, Span)>,
568}
569
570impl hir::visit::Visitor for ReferenceSiteCollector {
571 fn visit_expr(&mut self, expression: &HirExpr) {
572 match expression {
573 HirExpr::GlobalVar {
574 name,
575 span: Some(span),
576 } => {
577 self.sites
578 .push((SymbolKind::Global, name.clone(), to_frontend_span(*span)));
579 }
580 HirExpr::Constant {
581 name,
582 span: Some(span),
583 } => {
584 self.sites
585 .push((SymbolKind::Constant, name.clone(), to_frontend_span(*span)));
586 }
587 HirExpr::PlayerVar {
588 name,
589 member_span,
590 span,
591 ..
592 } => {
593 if let Some(span) = member_span.as_ref().or(span.as_ref()) {
594 self.sites
595 .push((SymbolKind::Player, name.clone(), to_frontend_span(*span)));
596 }
597 }
598 HirExpr::Call {
599 name,
600 span: Some(span),
601 ..
602 } => {
603 let span = to_frontend_span(*span);
604 self.sites
605 .push((SymbolKind::Subroutine, name.clone(), span));
606 self.sites.push((SymbolKind::Def, name.clone(), span));
607 }
608 HirExpr::MacroCall {
609 name,
610 span: Some(span),
611 ..
612 } => {
613 self.sites
614 .push((SymbolKind::Macro, name.clone(), to_frontend_span(*span)));
615 }
616 _ => {}
617 }
618 hir::visit::walk_expr(self, expression);
619 }
620
621 fn visit_comprehension(
622 &mut self,
623 element: &HirExpr,
624 iterable: &HirExpr,
625 condition: Option<&HirExpr>,
626 ) {
627 hir::visit::Visitor::visit_expr(self, iterable);
628 hir::visit::Visitor::visit_expr(self, element);
629 if let Some(condition) = condition {
630 hir::visit::Visitor::visit_expr(self, condition);
631 }
632 }
633
634 fn visit_stmt(&mut self, statement: &HirStmt) {
635 if let HirStmt::CallSubroutine {
636 name,
637 span: Some(span),
638 } = statement
639 {
640 let span = to_frontend_span(*span);
641 self.sites
642 .push((SymbolKind::Subroutine, name.clone(), span));
643 self.sites.push((SymbolKind::Def, name.clone(), span));
644 }
645 hir::visit::walk_stmt(self, statement);
646 }
647}
648
649#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
652pub struct EnumDecl {
653 pub name: String,
654 pub members: Vec<EnumMember>,
655}
656
657#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
659pub struct EnumMember {
660 pub name: String,
661 pub span: SourceLocation,
662}
663
664#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
666#[serde(rename_all = "camelCase")]
667pub enum SymbolKind {
668 Global,
669 Player,
670 Subroutine,
671 Def,
672 Constant,
673 Macro,
674}
675
676#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
679pub struct Symbol {
680 pub name: String,
681 pub kind: SymbolKind,
682 pub declaration: SourceLocation,
683 pub references: Vec<SourceLocation>,
684}
685
686fn span_contains(outer: Span, inner: Span) -> bool {
689 position_leq(outer.start, inner.start) && position_leq(inner.end, outer.end)
690}
691
692fn position_leq(a: Position, b: Position) -> bool {
693 a.line < b.line || (a.line == b.line && a.col <= b.col)
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699
700 fn check_source(source: &str) -> CheckOutcome {
701 check(source, "main.opy", Path::new(""))
702 }
703
704 #[test]
705 fn clean_project_has_no_diagnostics_and_a_model() {
706 let outcome = check_source(
707 "globalvar total = 0\nrule \"r\":\n @Event global\n total += 1\n debug(total)\n",
708 );
709 assert!(
710 outcome.is_clean(),
711 "unexpected diagnostics: {:?}",
712 outcome.diagnostics
713 );
714 let model = outcome.model.expect("a clean project resolves");
715 assert_eq!(outcome.files.len(), 1);
716 assert_eq!(model.declarations().len(), 1);
717 assert_eq!(model.rules().len(), 1);
718 }
719
720 #[test]
721 fn symbols_index_declarations_and_references() {
722 let outcome = check_source(
723 "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",
724 );
725 let model = outcome.model.expect("clean project");
726 let names: Vec<(&str, SymbolKind)> = model
727 .symbols()
728 .iter()
729 .map(|symbol| (symbol.name.as_str(), symbol.kind))
730 .collect();
731 assert_eq!(
732 names,
733 vec![
734 ("total", SymbolKind::Global),
735 ("P", SymbolKind::Player),
736 ("reset", SymbolKind::Subroutine),
737 ("double", SymbolKind::Macro),
738 ]
739 );
740 assert_eq!(model.symbol("total").expect("symbol").references.len(), 2);
741 assert_eq!(model.symbol("P").expect("symbol").references.len(), 1);
742 let reset = model.symbol("reset").expect("symbol");
743 assert_eq!(reset.references.len(), 1);
744 assert_eq!(reset.references[0].path, "main.opy");
745 assert_eq!(model.symbol("double").expect("symbol").references.len(), 1);
746 }
747
748 #[test]
749 fn symbol_lookup_by_name_and_span() {
750 let outcome =
751 check_source("globalvar total\nrule \"r\":\n @Event global\n total = 1\n");
752 let model = outcome.model.expect("clean project");
753 let total = model.symbol("total").expect("symbol by name");
754 assert_eq!(total.kind, SymbolKind::Global);
755 let at_decl = model
757 .symbol_at(total.declaration.to_span())
758 .expect("symbol at declaration span");
759 assert_eq!(at_decl.name, "total");
760 let at_ref = model
762 .symbol_at(total.references[0].to_span())
763 .expect("symbol at reference span");
764 assert_eq!(at_ref.name, "total");
765 assert!(
766 model
767 .symbol_at(Span::new(99, Position::new(1, 1), Position::new(1, 1)))
768 .is_none()
769 );
770 }
771
772 #[test]
773 fn provenance_resolves_through_the_file_registry() {
774 let outcome =
775 check_source("globalvar total\nrule \"r\":\n @Event global\n total = 1\n");
776 let model = outcome.model.expect("clean project");
777 let total = model.symbol("total").expect("symbol");
778 let provenance = model
779 .provenance(total.references[0].to_span())
780 .expect("provenance");
781 assert_eq!(provenance.file_id, 0);
782 assert_eq!(provenance.path, "main.opy");
783 assert_eq!(provenance.start.line, 4);
784 assert_eq!(model.file(0), Some("main.opy"));
785 assert_eq!(model.file(1), None);
786 }
787
788 #[test]
789 fn custom_enums_are_queried_from_the_model() {
790 let outcome = check_source(
791 "globalvar x\nenum Direction:\n NORTH\n SOUTH\nrule \"r\":\n @Event global\n x = Direction.SOUTH\n",
792 );
793 let model = outcome.model.expect("clean project");
794 assert_eq!(model.enums().len(), 1);
795 let direction = &model.enums()[0];
796 assert_eq!(direction.name, "Direction");
797 let members: Vec<&str> = direction
798 .members
799 .iter()
800 .map(|member| member.name.as_str())
801 .collect();
802 assert_eq!(members, vec!["NORTH", "SOUTH"]);
803 assert!(direction.members[0].span.path.ends_with("main.opy"));
804 }
805
806 #[test]
807 fn check_reports_every_parse_error() {
808 let outcome = check_source("rule \"a\"\n @Event global\nrule \"b\"\n");
813 assert!(!outcome.is_clean());
814 assert!(outcome.model.is_none());
815 assert_eq!(outcome.diagnostics.len(), 3);
816 assert!(
817 outcome
818 .diagnostics
819 .iter()
820 .all(|diagnostic| diagnostic.code == "parse-error")
821 );
822 }
823
824 #[test]
825 fn diagnostics_carry_severity_code_and_span() {
826 let outcome = check_source("rule \"r\":\n @Event global\n frobnicate()\n");
827 let diagnostic = &outcome.diagnostics[0];
828 assert_eq!(diagnostic.severity, DiagnosticSeverity::Error);
829 assert_eq!(diagnostic.code, "unknown-action");
830 let span = diagnostic.span.as_ref().expect("source-located");
831 assert_eq!(span.path, "main.opy");
832 assert_eq!(span.start.line, 3);
833 }
834
835 #[test]
836 fn canonical_workshop_ids_are_source_diagnostics() {
837 for (statement, code) in [
840 ("g = allTankHeroes()", "unknown-value"),
841 ("g = lastCreatedEntity()", "unknown-value"),
842 ("g = evaluateOnce(1)", "unknown-value"),
843 ("destroyAllHudText()", "unknown-action"),
844 ] {
845 let outcome = check_source(&format!(
846 "globalvar g\nrule \"r\":\n @Event global\n {statement}\n"
847 ));
848 assert!(!outcome.is_clean(), "{statement}");
849 let diagnostic = outcome
850 .diagnostics
851 .iter()
852 .find(|diagnostic| diagnostic.code == code)
853 .unwrap_or_else(|| panic!("{statement}: expected {code}"));
854 let span = diagnostic.span.as_ref().expect("source-located");
855 assert_eq!(span.start.line, 4, "{statement}");
856 }
857
858 let outcome = check_source(
859 "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = eventPlayer.isButtonHeld(Button.PRIMARY_FIRE)\n",
860 );
861 assert!(!outcome.is_clean());
862 let diagnostic = outcome
863 .diagnostics
864 .iter()
865 .find(|diagnostic| diagnostic.code == "unknown-member")
866 .expect("unknown-member diagnostic");
867 assert_eq!(diagnostic.span.as_ref().unwrap().start.line, 4);
868
869 let outcome = check_source(
871 "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = getTankHeroes()\n g = getLastCreatedEntity()\n g = evalOnce(1)\n g = eventPlayer.isHoldingButton(Button.PRIMARY_FIRE)\n destroyAllHudTexts()\n",
872 );
873 assert!(
874 outcome.is_clean(),
875 "unexpected diagnostics: {:?}",
876 outcome.diagnostics
877 );
878 }
879}