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<'a>(
47 preprocessed: &'a Preprocessed,
48 parse_warnings: &'a [PreprocessWarning],
49) -> impl Iterator<Item = &'a PreprocessWarning> {
50 preprocessed
51 .warnings
52 .iter()
53 .chain(parse_warnings)
54 .filter(|warning| {
55 !preprocessed
56 .preprocessing
57 .suppressed_warnings
58 .iter()
59 .any(|code| code == &warning.code)
60 })
61}
62
63#[derive(Debug, Clone)]
70pub struct CheckOutcome {
71 pub diagnostics: Vec<Diagnostic>,
72 pub model: Option<SemanticModel>,
73 pub files: Vec<FileRecord>,
74 pub post_compile_hook: Option<crate::preprocess::PostCompileHook>,
83 pub display_root: std::path::PathBuf,
87}
88
89impl CheckOutcome {
90 fn failure(
91 diagnostics: Vec<Diagnostic>,
92 files: Vec<FileRecord>,
93 display_root: std::path::PathBuf,
94 ) -> Self {
95 Self {
96 diagnostics,
97 model: None,
98 files,
99 post_compile_hook: None,
100 display_root,
101 }
102 }
103
104 pub fn is_clean(&self) -> bool {
106 self.diagnostics
107 .iter()
108 .all(|diagnostic| diagnostic.severity != DiagnosticSeverity::Error)
109 }
110}
111
112pub fn check(source: &str, main_path: &str, root: &Path) -> CheckOutcome {
118 check_with_overlay(source, main_path, root, &std::collections::BTreeMap::new())
119}
120
121pub fn check_with_overlay(
124 source: &str,
125 main_path: &str,
126 root: &Path,
127 overlay: &std::collections::BTreeMap<String, String>,
128) -> CheckOutcome {
129 let PreprocessOutcome {
130 result,
131 files,
132 display_root,
133 warnings,
134 } = crate::preprocess::preprocess_with_overlay_outcome(source, main_path, root, overlay);
135 let preprocessed = match result {
136 Ok((preprocessed, _)) => preprocessed,
137 Err(error) => {
138 let mut diagnostics = warnings
139 .iter()
140 .map(|warning| Diagnostic::from_warning(warning, &files))
141 .collect::<Vec<_>>();
142 diagnostics.push(Diagnostic::from_error(error, &files));
143 return CheckOutcome::failure(diagnostics, files, display_root);
144 }
145 };
146 let parsed = crate::parser::parse_with_options(
147 &preprocessed.tokens,
148 preprocessed.preprocessing.allow_macro_redeclaration,
149 );
150 let parse_warnings = parsed.warnings;
151 let Some(mut program) = parsed.program else {
152 let mut diagnostics = visible_warnings(&preprocessed, &parse_warnings)
155 .map(|warning| Diagnostic::from_warning(warning, &files))
156 .collect::<Vec<_>>();
157 diagnostics.extend(
158 parsed
159 .errors
160 .iter()
161 .map(|error| Diagnostic::from_error(error.clone(), &files)),
162 );
163 return CheckOutcome::failure(diagnostics, files, display_root);
164 };
165 if let Some(block) = &preprocessed.settings {
169 match crate::settings::parse_block(block) {
170 Ok(parsed_settings) => program.settings = Some(parsed_settings),
171 Err(error) => {
172 let mut diagnostics = visible_warnings(&preprocessed, &parse_warnings)
173 .map(|warning| Diagnostic::from_warning(warning, &files))
174 .collect::<Vec<_>>();
175 diagnostics.push(Diagnostic::from_error(error, &files));
176 return CheckOutcome::failure(diagnostics, files, display_root);
177 }
178 }
179 }
180 let defines = preprocessed
181 .defines
182 .iter()
183 .map(|define| Define {
184 name: define.name.clone(),
185 is_function: define.is_function,
186 is_member: define.is_member,
187 span: define.span.map(Into::into),
188 })
189 .collect();
190 let hir_files = files
191 .iter()
192 .map(|file| hir::types::SourceFile {
193 id: file.id,
194 path: file.path.clone(),
195 })
196 .collect();
197 match crate::lower::lower_with_preprocessing(
198 &program,
199 hir_files,
200 defines,
201 &preprocessed.preprocessing,
202 ) {
203 Ok(mut hir) => {
204 let mut diagnostics = visible_warnings(&preprocessed, &parse_warnings)
205 .map(|warning| Diagnostic::from_warning(warning, &files))
206 .collect::<Vec<_>>();
207 hir.preprocessing = preprocessed.preprocessing;
208 if let Err(error) = crate::settings::resolve_hir_settings(&mut hir, &program) {
209 diagnostics.push(Diagnostic::from_error(error, &files));
210 return CheckOutcome::failure(diagnostics, files, display_root);
211 }
212 match crate::compiler::settings::workshop_settings(
219 &hir,
220 &workshop_rs::catalog::Locale::new("en-US"),
221 ) {
222 Ok((settings, unknown)) => {
223 diagnostics.extend(unknown.into_iter().map(|member| {
224 Diagnostic::from_unknown_setting(member, hir.settings.as_ref(), &files)
225 }));
226 if let Some(settings) = settings {
227 diagnostics.extend(
233 workshop_rs::settings::check_emission_diagnostics(&settings)
234 .into_iter()
235 .filter(|diagnostic| {
236 diagnostic.severity
237 == workshop_rs::settings::DiagnosticSeverity::Error
238 })
239 .map(|diagnostic| {
240 Diagnostic::from_settings_diagnostic(
241 diagnostic,
242 hir.settings.as_ref(),
243 &files,
244 )
245 }),
246 );
247 }
248 }
249 Err(error) => diagnostics.push(Diagnostic::from_integration_error(error, &files)),
250 }
251 if diagnostics
252 .iter()
253 .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
254 {
255 return CheckOutcome::failure(diagnostics, files, display_root);
256 }
257 CheckOutcome {
258 diagnostics,
259 model: Some(SemanticModel::build(hir, &program)),
260 files,
261 post_compile_hook: preprocessed.post_compile_hook,
266 display_root,
267 }
268 }
269 Err(error) => {
270 let mut diagnostics = visible_warnings(&preprocessed, &parse_warnings)
271 .map(|warning| Diagnostic::from_warning(warning, &files))
272 .collect::<Vec<_>>();
273 diagnostics.push(Diagnostic::from_error(error, &files));
274 CheckOutcome::failure(diagnostics, files, display_root)
275 }
276 }
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
285pub struct Diagnostic {
286 pub severity: DiagnosticSeverity,
287 pub code: String,
288 pub message: String,
289 pub span: Option<SourceLocation>,
290}
291
292impl Diagnostic {
293 fn from_warning(warning: &PreprocessWarning, files: &[FileRecord]) -> Diagnostic {
294 Diagnostic {
295 severity: DiagnosticSeverity::Warning,
296 code: warning.code.clone(),
297 message: warning.message.clone(),
298 span: resolve_record_span(warning.span, files),
299 }
300 }
301
302 fn from_error(error: OpyError, files: &[FileRecord]) -> Diagnostic {
303 Diagnostic {
304 severity: DiagnosticSeverity::Error,
305 code: error.code,
306 message: error.message,
307 span: error.span.and_then(|span| resolve_record_span(span, files)),
308 }
309 }
310
311 fn from_settings_diagnostic(
316 diagnostic: workshop_rs::settings::SettingsDiagnostic,
317 hir_settings: Option<&crate::hir::types::Settings>,
318 files: &[FileRecord],
319 ) -> Diagnostic {
320 let error = diagnostic.error;
321 let span = crate::compiler::workshop_error_span(&error).map(|span| {
322 Span::new(
323 span.file.index() as u32,
324 Position::new(span.start.line, span.start.col),
325 Position::new(span.end.line, span.end.col),
326 )
327 });
328 let candidates = crate::matcher::settings_member_candidates(
329 hir_settings,
330 &error,
331 diagnostic.suggestion.as_deref(),
332 );
333 Diagnostic {
334 severity: DiagnosticSeverity::Error,
335 code: "workshop-emission".to_string(),
336 message: crate::matcher::did_you_mean(error.to_string(), &candidates),
337 span: span.and_then(|span| resolve_record_span(span, files)),
338 }
339 }
340
341 fn from_unknown_setting(
344 member: crate::compiler::settings::UnknownSetting,
345 hir_settings: Option<&crate::hir::types::Settings>,
346 files: &[FileRecord],
347 ) -> Diagnostic {
348 let anchor = workshop_rs::WorkshopError::malformed(String::new(), member.span);
349 let candidates = crate::matcher::settings_member_candidates(hir_settings, &anchor, None);
350 let message = match &member.value {
351 None => format!(
352 "unknown settings key '{}' is passed through unchanged",
353 member.name
354 ),
355 Some(value) => format!(
356 "unknown value '{value}' for settings key '{}' is passed through unchanged",
357 member.name
358 ),
359 };
360 let span = member.span.map(|span| {
361 Span::new(
362 span.file.index() as u32,
363 Position::new(span.start.line, span.start.col),
364 Position::new(span.end.line, span.end.col),
365 )
366 });
367 Diagnostic {
368 severity: DiagnosticSeverity::Warning,
369 code: "unknown-setting".to_string(),
370 message: crate::matcher::did_you_mean(message, &candidates),
371 span: span.and_then(|span| resolve_record_span(span, files)),
372 }
373 }
374
375 fn from_integration_error(
377 error: crate::compiler::IntegrationError,
378 files: &[FileRecord],
379 ) -> Diagnostic {
380 Diagnostic {
381 severity: DiagnosticSeverity::Error,
382 code: error.diagnostic.code,
383 message: error.diagnostic.message,
384 span: error
385 .diagnostic
386 .span
387 .map(to_frontend_span)
388 .and_then(|span| resolve_record_span(span, files)),
389 }
390 }
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
395#[serde(rename_all = "camelCase")]
396pub enum DiagnosticSeverity {
397 Error,
398 Warning,
399}
400
401impl DiagnosticSeverity {
402 pub fn as_str(&self) -> &'static str {
403 match self {
404 DiagnosticSeverity::Error => "error",
405 DiagnosticSeverity::Warning => "warning",
406 }
407 }
408}
409
410#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
413pub struct SourceLocation {
414 pub file_id: u32,
415 pub path: String,
416 pub start: Position,
417 pub end: Position,
418}
419
420impl SourceLocation {
421 pub fn to_span(&self) -> Span {
423 Span::new(self.file_id, self.start, self.end)
424 }
425}
426
427fn resolve_span(span: Span, files: &[SourceFile]) -> Option<SourceLocation> {
428 let path = files.iter().find(|file| file.id == span.file)?.path.clone();
429 Some(SourceLocation {
430 file_id: span.file,
431 path,
432 start: span.start,
433 end: span.end,
434 })
435}
436
437fn resolve_record_span(span: Span, files: &[FileRecord]) -> Option<SourceLocation> {
440 let path = files.iter().find(|file| file.id == span.file)?.path.clone();
441 Some(SourceLocation {
442 file_id: span.file,
443 path,
444 start: span.start,
445 end: span.end,
446 })
447}
448
449fn to_frontend_span(span: hir::types::Span) -> Span {
453 Span::new(
454 span.file,
455 Position::new(span.start.line, span.start.col),
456 Position::new(span.end.line, span.end.col),
457 )
458}
459
460#[derive(Debug, Clone, Serialize)]
467pub struct SemanticModel {
468 pub hir: hir::Program,
469 pub enums: Vec<EnumDecl>,
470 pub symbols: Vec<Symbol>,
471}
472
473impl SemanticModel {
474 pub fn build(hir: hir::Program, cst: &cst::Program) -> SemanticModel {
477 let enums = cst
478 .declarations
479 .iter()
480 .filter_map(|decl| match decl {
481 cst::Decl::Enum { name, members, .. } => Some(EnumDecl {
482 name: name.clone(),
483 members: members
484 .iter()
485 .map(|(member, span)| EnumMember {
486 name: member.clone(),
487 span: resolve_span(*span, &hir.files)
488 .expect("every token span resolves through the file registry"),
489 })
490 .collect(),
491 }),
492 _ => None,
493 })
494 .collect();
495 let mut model = SemanticModel {
496 hir,
497 enums,
498 symbols: Vec::new(),
499 };
500 model.index_symbols();
501 model
502 }
503
504 pub fn declarations(&self) -> &[Declaration] {
507 &self.hir.declarations
508 }
509
510 pub fn rules(&self) -> &[RuleEntry] {
512 &self.hir.rules
513 }
514
515 pub fn defines(&self) -> &[Define] {
517 &self.hir.defines
518 }
519
520 pub fn enums(&self) -> &[EnumDecl] {
522 &self.enums
523 }
524
525 pub fn symbols(&self) -> &[Symbol] {
528 &self.symbols
529 }
530
531 pub fn symbol(&self, name: &str) -> Option<&Symbol> {
534 self.symbols.iter().find(|symbol| symbol.name == name)
535 }
536
537 pub fn symbol_at(&self, span: Span) -> Option<&Symbol> {
540 self.symbols.iter().find(|symbol| {
541 span_contains(symbol.declaration.to_span(), span)
542 || symbol
543 .references
544 .iter()
545 .any(|reference| span_contains(reference.to_span(), span))
546 })
547 }
548
549 pub fn provenance(&self, span: Span) -> Option<SourceLocation> {
552 resolve_span(span, &self.hir.files)
553 }
554
555 pub fn file(&self, id: u32) -> Option<&str> {
557 self.hir
558 .files
559 .iter()
560 .find(|file| file.id == id)
561 .map(|file| file.path.as_str())
562 }
563
564 fn index_symbols(&mut self) {
567 for decl in &self.hir.declarations {
568 let (kind, name, span) = match decl {
569 Declaration::GlobalVariable {
570 name,
571 name_span,
572 span,
573 ..
574 } => (SymbolKind::Global, name, name_span.or(*span)),
575 Declaration::PlayerVariable {
576 name,
577 name_span,
578 span,
579 ..
580 } => (SymbolKind::Player, name, name_span.or(*span)),
581 Declaration::Subroutine {
582 name,
583 name_span,
584 span,
585 ..
586 } => (SymbolKind::Subroutine, name, name_span.or(*span)),
587 Declaration::Constant { name, span, .. } => (SymbolKind::Constant, name, *span),
588 Declaration::Macro { name, span, .. } => (SymbolKind::Macro, name, *span),
589 };
590 let Some(span) = span.map(to_frontend_span) else {
591 continue;
594 };
595 let Some(declaration) = resolve_span(span, &self.hir.files) else {
596 continue;
597 };
598 self.symbols.push(Symbol {
599 name: name.clone(),
600 kind,
601 declaration,
602 references: Vec::new(),
603 });
604 }
605 for entry in &self.hir.rules {
606 let RuleEntry::SubroutineDef {
607 name,
608 source_name,
609 name_span,
610 span,
611 ..
612 } = entry
613 else {
614 continue;
615 };
616 let Some(span) = name_span.or(*span).map(to_frontend_span) else {
617 continue;
618 };
619 let Some(declaration) = resolve_span(span, &self.hir.files) else {
620 continue;
621 };
622 self.symbols.push(Symbol {
623 name: if source_name.is_empty() {
624 name.clone()
625 } else {
626 source_name.clone()
627 },
628 kind: SymbolKind::Def,
629 declaration,
630 references: Vec::new(),
631 });
632 }
633
634 let mut collector = ReferenceSiteCollector { sites: Vec::new() };
635 hir::visit::walk_program(&mut collector, &self.hir);
636 for (kind, name, span) in collector.sites {
637 self.attach_reference(kind, &name, span);
638 }
639 }
640
641 fn attach_reference(&mut self, kind: SymbolKind, name: &str, span: Span) {
644 let Some(location) = resolve_span(span, &self.hir.files) else {
645 return;
646 };
647 if let Some(index) = self
648 .symbols
649 .iter()
650 .position(|symbol| symbol.kind == kind && symbol.name == name)
651 {
652 self.symbols[index].references.push(location);
653 }
654 }
655}
656
657struct ReferenceSiteCollector {
658 sites: Vec<(SymbolKind, String, Span)>,
659}
660
661impl hir::visit::Visitor for ReferenceSiteCollector {
662 fn visit_expr(&mut self, expression: &HirExpr) {
663 match expression {
664 HirExpr::GlobalVar {
665 name,
666 span: Some(span),
667 } => {
668 self.sites
669 .push((SymbolKind::Global, name.clone(), to_frontend_span(*span)));
670 }
671 HirExpr::Constant {
672 name,
673 span: Some(span),
674 } => {
675 self.sites
676 .push((SymbolKind::Constant, name.clone(), to_frontend_span(*span)));
677 }
678 HirExpr::PlayerVar {
679 name,
680 member_span,
681 span,
682 ..
683 } => {
684 if let Some(span) = member_span.as_ref().or(span.as_ref()) {
685 self.sites
686 .push((SymbolKind::Player, name.clone(), to_frontend_span(*span)));
687 }
688 }
689 HirExpr::Call {
690 name,
691 span: Some(span),
692 ..
693 } => {
694 let span = to_frontend_span(*span);
695 self.sites
696 .push((SymbolKind::Subroutine, name.clone(), span));
697 self.sites.push((SymbolKind::Def, name.clone(), span));
698 }
699 HirExpr::MacroCall {
700 name,
701 span: Some(span),
702 ..
703 } => {
704 self.sites
705 .push((SymbolKind::Macro, name.clone(), to_frontend_span(*span)));
706 }
707 _ => {}
708 }
709 hir::visit::walk_expr(self, expression);
710 }
711
712 fn visit_comprehension(
713 &mut self,
714 element: &HirExpr,
715 iterable: &HirExpr,
716 condition: Option<&HirExpr>,
717 ) {
718 hir::visit::Visitor::visit_expr(self, iterable);
719 hir::visit::Visitor::visit_expr(self, element);
720 if let Some(condition) = condition {
721 hir::visit::Visitor::visit_expr(self, condition);
722 }
723 }
724
725 fn visit_stmt(&mut self, statement: &HirStmt) {
726 if let HirStmt::CallSubroutine {
727 name,
728 span: Some(span),
729 } = statement
730 {
731 let span = to_frontend_span(*span);
732 self.sites
733 .push((SymbolKind::Subroutine, name.clone(), span));
734 self.sites.push((SymbolKind::Def, name.clone(), span));
735 }
736 hir::visit::walk_stmt(self, statement);
737 }
738}
739
740#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
743pub struct EnumDecl {
744 pub name: String,
745 pub members: Vec<EnumMember>,
746}
747
748#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
750pub struct EnumMember {
751 pub name: String,
752 pub span: SourceLocation,
753}
754
755#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
757#[serde(rename_all = "camelCase")]
758pub enum SymbolKind {
759 Global,
760 Player,
761 Subroutine,
762 Def,
763 Constant,
764 Macro,
765}
766
767#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
770pub struct Symbol {
771 pub name: String,
772 pub kind: SymbolKind,
773 pub declaration: SourceLocation,
774 pub references: Vec<SourceLocation>,
775}
776
777fn span_contains(outer: Span, inner: Span) -> bool {
780 position_leq(outer.start, inner.start) && position_leq(inner.end, outer.end)
781}
782
783fn position_leq(a: Position, b: Position) -> bool {
784 a.line < b.line || (a.line == b.line && a.col <= b.col)
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790
791 fn check_source(source: &str) -> CheckOutcome {
792 check(source, "main.opy", Path::new(""))
793 }
794
795 #[test]
796 fn clean_project_has_no_diagnostics_and_a_model() {
797 let outcome = check_source(
798 "globalvar total = 0\nrule \"r\":\n @Event global\n total += 1\n debug(total)\n",
799 );
800 assert!(
801 outcome.is_clean(),
802 "unexpected diagnostics: {:?}",
803 outcome.diagnostics
804 );
805 let model = outcome.model.expect("a clean project resolves");
806 assert_eq!(outcome.files.len(), 1);
807 assert_eq!(model.declarations().len(), 1);
808 assert_eq!(model.rules().len(), 1);
809 }
810
811 #[test]
812 fn symbols_index_declarations_and_references() {
813 let outcome = check_source(
814 "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",
815 );
816 let model = outcome.model.expect("clean project");
817 let names: Vec<(&str, SymbolKind)> = model
818 .symbols()
819 .iter()
820 .map(|symbol| (symbol.name.as_str(), symbol.kind))
821 .collect();
822 assert_eq!(
823 names,
824 vec![
825 ("total", SymbolKind::Global),
826 ("P", SymbolKind::Player),
827 ("reset", SymbolKind::Subroutine),
828 ("double", SymbolKind::Macro),
829 ]
830 );
831 assert_eq!(model.symbol("total").expect("symbol").references.len(), 2);
832 assert_eq!(model.symbol("P").expect("symbol").references.len(), 1);
833 let reset = model.symbol("reset").expect("symbol");
834 assert_eq!(reset.references.len(), 1);
835 assert_eq!(reset.references[0].path, "main.opy");
836 assert_eq!(model.symbol("double").expect("symbol").references.len(), 1);
837 }
838
839 #[test]
840 fn symbol_lookup_by_name_and_span() {
841 let outcome =
842 check_source("globalvar total\nrule \"r\":\n @Event global\n total = 1\n");
843 let model = outcome.model.expect("clean project");
844 let total = model.symbol("total").expect("symbol by name");
845 assert_eq!(total.kind, SymbolKind::Global);
846 let at_decl = model
848 .symbol_at(total.declaration.to_span())
849 .expect("symbol at declaration span");
850 assert_eq!(at_decl.name, "total");
851 let at_ref = model
853 .symbol_at(total.references[0].to_span())
854 .expect("symbol at reference span");
855 assert_eq!(at_ref.name, "total");
856 assert!(
857 model
858 .symbol_at(Span::new(99, Position::new(1, 1), Position::new(1, 1)))
859 .is_none()
860 );
861 }
862
863 #[test]
864 fn provenance_resolves_through_the_file_registry() {
865 let outcome =
866 check_source("globalvar total\nrule \"r\":\n @Event global\n total = 1\n");
867 let model = outcome.model.expect("clean project");
868 let total = model.symbol("total").expect("symbol");
869 let provenance = model
870 .provenance(total.references[0].to_span())
871 .expect("provenance");
872 assert_eq!(provenance.file_id, 0);
873 assert_eq!(provenance.path, "main.opy");
874 assert_eq!(provenance.start.line, 4);
875 assert_eq!(model.file(0), Some("main.opy"));
876 assert_eq!(model.file(1), None);
877 }
878
879 #[test]
880 fn custom_enums_are_queried_from_the_model() {
881 let outcome = check_source(
882 "globalvar x\nenum Direction:\n NORTH\n SOUTH\nrule \"r\":\n @Event global\n x = Direction.SOUTH\n",
883 );
884 let model = outcome.model.expect("clean project");
885 assert_eq!(model.enums().len(), 1);
886 let direction = &model.enums()[0];
887 assert_eq!(direction.name, "Direction");
888 let members: Vec<&str> = direction
889 .members
890 .iter()
891 .map(|member| member.name.as_str())
892 .collect();
893 assert_eq!(members, vec!["NORTH", "SOUTH"]);
894 assert!(direction.members[0].span.path.ends_with("main.opy"));
895 }
896
897 #[test]
898 fn unknown_setting_names_the_keys_valid_at_the_path() {
899 let outcome = check_source(
903 "settings {\n \"gamemodes\": {\"ffa\": {\"notASetting\": true}}\n}\nrule \"a\":\n @Event global\n wait(1)\n",
904 );
905 let diagnostic = outcome
906 .diagnostics
907 .iter()
908 .find(|d| d.code == "unknown-setting")
909 .expect("unknown setting diagnostic");
910 assert_eq!(diagnostic.severity, DiagnosticSeverity::Warning);
911 assert!(
912 diagnostic.message.contains("(did you mean "),
913 "message: {}",
914 diagnostic.message
915 );
916 assert!(
917 diagnostic.message.contains("'disabledMaps'"),
918 "message: {}",
919 diagnostic.message
920 );
921 }
922
923 #[test]
924 fn unknown_setting_preserves_template_paths_and_percent_suffixes() {
925 let outcome = check_source(
926 "settings {\n \"main\": {\"description\": \"t\"},\n \"gamemodes\": {},\n \"heroes\": {\"team1\": {\"general\": {\"damageReceiveed%\": 50}}}\n}\nrule \"a\":\n @Event global\n wait(1)\n",
927 );
928 let diagnostic = outcome
929 .diagnostics
930 .iter()
931 .find(|d| d.code == "unknown-setting")
932 .expect("unknown setting diagnostic");
933 assert_eq!(diagnostic.severity, DiagnosticSeverity::Warning);
934 assert!(
935 diagnostic.message.contains("'damageReceived%'"),
936 "message: {}",
937 diagnostic.message
938 );
939 }
940
941 #[test]
942 fn check_reports_every_parse_error() {
943 let outcome = check_source("rule \"a\"\n @Event global\nrule \"b\"\n");
948 assert!(!outcome.is_clean());
949 assert!(outcome.model.is_none());
950 assert_eq!(outcome.diagnostics.len(), 3);
951 assert!(
952 outcome
953 .diagnostics
954 .iter()
955 .all(|diagnostic| diagnostic.code == "parse-error")
956 );
957 }
958
959 #[test]
960 fn diagnostics_carry_severity_code_and_span() {
961 let outcome = check_source("rule \"r\":\n @Event global\n frobnicate()\n");
962 let diagnostic = &outcome.diagnostics[0];
963 assert_eq!(diagnostic.severity, DiagnosticSeverity::Error);
964 assert_eq!(diagnostic.code, "unknown-action");
965 let span = diagnostic.span.as_ref().expect("source-located");
966 assert_eq!(span.path, "main.opy");
967 assert_eq!(span.start.line, 3);
968 }
969
970 #[test]
971 fn canonical_workshop_ids_are_source_diagnostics() {
972 for (statement, code) in [
975 ("g = allTankHeroes()", "unknown-value"),
976 ("g = lastCreatedEntity()", "unknown-value"),
977 ("g = evaluateOnce(1)", "unknown-value"),
978 ("destroyAllHudText()", "unknown-action"),
979 ] {
980 let outcome = check_source(&format!(
981 "globalvar g\nrule \"r\":\n @Event global\n {statement}\n"
982 ));
983 assert!(!outcome.is_clean(), "{statement}");
984 let diagnostic = outcome
985 .diagnostics
986 .iter()
987 .find(|diagnostic| diagnostic.code == code)
988 .unwrap_or_else(|| panic!("{statement}: expected {code}"));
989 let span = diagnostic.span.as_ref().expect("source-located");
990 assert_eq!(span.start.line, 4, "{statement}");
991 }
992
993 let outcome = check_source(
994 "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = eventPlayer.isButtonHeld(Button.PRIMARY_FIRE)\n",
995 );
996 assert!(!outcome.is_clean());
997 let diagnostic = outcome
998 .diagnostics
999 .iter()
1000 .find(|diagnostic| diagnostic.code == "unknown-member")
1001 .expect("unknown-member diagnostic");
1002 assert_eq!(diagnostic.span.as_ref().unwrap().start.line, 4);
1003
1004 let outcome = check_source(
1006 "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",
1007 );
1008 assert!(
1009 outcome.is_clean(),
1010 "unexpected diagnostics: {:?}",
1011 outcome.diagnostics
1012 );
1013 }
1014}