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(&hir) {
217 Ok((settings, unknown)) => {
218 diagnostics.extend(unknown.into_iter().map(|member| {
219 Diagnostic::from_unknown_setting(member, hir.settings.as_ref(), &files)
220 }));
221 if let Some(settings) = settings {
222 diagnostics.extend(
228 workshop_rs::settings::check_emission_diagnostics(&settings)
229 .into_iter()
230 .filter(|diagnostic| {
231 diagnostic.severity
232 == workshop_rs::settings::DiagnosticSeverity::Error
233 })
234 .map(|diagnostic| {
235 Diagnostic::from_settings_diagnostic(
236 diagnostic,
237 hir.settings.as_ref(),
238 &files,
239 )
240 }),
241 );
242 }
243 }
244 Err(error) => diagnostics.push(Diagnostic::from_integration_error(error, &files)),
245 }
246 if diagnostics
247 .iter()
248 .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
249 {
250 return CheckOutcome::failure(diagnostics, files, display_root);
251 }
252 CheckOutcome {
253 diagnostics,
254 model: Some(SemanticModel::build(hir, &program)),
255 files,
256 post_compile_hook: preprocessed.post_compile_hook,
261 display_root,
262 }
263 }
264 Err(error) => {
265 let mut diagnostics = visible_warnings(&preprocessed, &parse_warnings)
266 .map(|warning| Diagnostic::from_warning(warning, &files))
267 .collect::<Vec<_>>();
268 diagnostics.push(Diagnostic::from_error(error, &files));
269 CheckOutcome::failure(diagnostics, files, display_root)
270 }
271 }
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
280pub struct Diagnostic {
281 pub severity: DiagnosticSeverity,
282 pub code: String,
283 pub message: String,
284 pub span: Option<SourceLocation>,
285}
286
287impl Diagnostic {
288 fn from_warning(warning: &PreprocessWarning, files: &[FileRecord]) -> Diagnostic {
289 Diagnostic {
290 severity: DiagnosticSeverity::Warning,
291 code: warning.code.clone(),
292 message: warning.message.clone(),
293 span: resolve_record_span(warning.span, files),
294 }
295 }
296
297 fn from_error(error: OpyError, files: &[FileRecord]) -> Diagnostic {
298 Diagnostic {
299 severity: DiagnosticSeverity::Error,
300 code: error.code,
301 message: error.message,
302 span: error.span.and_then(|span| resolve_record_span(span, files)),
303 }
304 }
305
306 fn from_settings_diagnostic(
311 diagnostic: workshop_rs::settings::SettingsDiagnostic,
312 hir_settings: Option<&crate::hir::types::Settings>,
313 files: &[FileRecord],
314 ) -> Diagnostic {
315 let error = diagnostic.error;
316 let span = crate::compiler::workshop_error_span(&error).map(|span| {
317 Span::new(
318 span.file.index() as u32,
319 Position::new(span.start.line, span.start.col),
320 Position::new(span.end.line, span.end.col),
321 )
322 });
323 let candidates = crate::matcher::settings_member_candidates(
324 hir_settings,
325 &error,
326 diagnostic.suggestion.as_deref(),
327 );
328 Diagnostic {
329 severity: DiagnosticSeverity::Error,
330 code: "workshop-emission".to_string(),
331 message: crate::matcher::did_you_mean(error.to_string(), &candidates),
332 span: span.and_then(|span| resolve_record_span(span, files)),
333 }
334 }
335
336 fn from_unknown_setting(
339 member: crate::compiler::settings::UnknownSetting,
340 hir_settings: Option<&crate::hir::types::Settings>,
341 files: &[FileRecord],
342 ) -> Diagnostic {
343 let anchor = workshop_rs::WorkshopError::malformed(String::new(), member.span);
344 let candidates = crate::matcher::settings_member_candidates(hir_settings, &anchor, None);
345 let message = match &member.value {
346 None => format!(
347 "unknown settings key '{}' is passed through unchanged",
348 member.name
349 ),
350 Some(value) => format!(
351 "unknown value '{value}' for settings key '{}' is passed through unchanged",
352 member.name
353 ),
354 };
355 let span = member.span.map(|span| {
356 Span::new(
357 span.file.index() as u32,
358 Position::new(span.start.line, span.start.col),
359 Position::new(span.end.line, span.end.col),
360 )
361 });
362 Diagnostic {
363 severity: DiagnosticSeverity::Warning,
364 code: "unknown-setting".to_string(),
365 message: crate::matcher::did_you_mean(message, &candidates),
366 span: span.and_then(|span| resolve_record_span(span, files)),
367 }
368 }
369
370 fn from_integration_error(
372 error: crate::compiler::IntegrationError,
373 files: &[FileRecord],
374 ) -> Diagnostic {
375 Diagnostic {
376 severity: DiagnosticSeverity::Error,
377 code: error.diagnostic.code,
378 message: error.diagnostic.message,
379 span: error
380 .diagnostic
381 .span
382 .map(to_frontend_span)
383 .and_then(|span| resolve_record_span(span, files)),
384 }
385 }
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
390#[serde(rename_all = "camelCase")]
391pub enum DiagnosticSeverity {
392 Error,
393 Warning,
394}
395
396impl DiagnosticSeverity {
397 pub fn as_str(&self) -> &'static str {
398 match self {
399 DiagnosticSeverity::Error => "error",
400 DiagnosticSeverity::Warning => "warning",
401 }
402 }
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
408pub struct SourceLocation {
409 pub file_id: u32,
410 pub path: String,
411 pub start: Position,
412 pub end: Position,
413}
414
415impl SourceLocation {
416 pub fn to_span(&self) -> Span {
418 Span::new(self.file_id, self.start, self.end)
419 }
420}
421
422fn resolve_span(span: Span, files: &[SourceFile]) -> Option<SourceLocation> {
423 let path = files.iter().find(|file| file.id == span.file)?.path.clone();
424 Some(SourceLocation {
425 file_id: span.file,
426 path,
427 start: span.start,
428 end: span.end,
429 })
430}
431
432fn resolve_record_span(span: Span, files: &[FileRecord]) -> Option<SourceLocation> {
435 let path = files.iter().find(|file| file.id == span.file)?.path.clone();
436 Some(SourceLocation {
437 file_id: span.file,
438 path,
439 start: span.start,
440 end: span.end,
441 })
442}
443
444fn to_frontend_span(span: hir::types::Span) -> Span {
448 Span::new(
449 span.file,
450 Position::new(span.start.line, span.start.col),
451 Position::new(span.end.line, span.end.col),
452 )
453}
454
455#[derive(Debug, Clone, Serialize)]
462pub struct SemanticModel {
463 pub hir: hir::Program,
464 pub enums: Vec<EnumDecl>,
465 pub symbols: Vec<Symbol>,
466}
467
468impl SemanticModel {
469 pub fn build(hir: hir::Program, cst: &cst::Program) -> SemanticModel {
472 let enums = cst
473 .declarations
474 .iter()
475 .filter_map(|decl| match decl {
476 cst::Decl::Enum { name, members, .. } => Some(EnumDecl {
477 name: name.clone(),
478 members: members
479 .iter()
480 .map(|(member, span)| EnumMember {
481 name: member.clone(),
482 span: resolve_span(*span, &hir.files)
483 .expect("every token span resolves through the file registry"),
484 })
485 .collect(),
486 }),
487 _ => None,
488 })
489 .collect();
490 let mut model = SemanticModel {
491 hir,
492 enums,
493 symbols: Vec::new(),
494 };
495 model.index_symbols();
496 model
497 }
498
499 pub fn declarations(&self) -> &[Declaration] {
502 &self.hir.declarations
503 }
504
505 pub fn rules(&self) -> &[RuleEntry] {
507 &self.hir.rules
508 }
509
510 pub fn defines(&self) -> &[Define] {
512 &self.hir.defines
513 }
514
515 pub fn enums(&self) -> &[EnumDecl] {
517 &self.enums
518 }
519
520 pub fn symbols(&self) -> &[Symbol] {
523 &self.symbols
524 }
525
526 pub fn symbol(&self, name: &str) -> Option<&Symbol> {
529 self.symbols.iter().find(|symbol| symbol.name == name)
530 }
531
532 pub fn symbol_at(&self, span: Span) -> Option<&Symbol> {
535 self.symbols.iter().find(|symbol| {
536 span_contains(symbol.declaration.to_span(), span)
537 || symbol
538 .references
539 .iter()
540 .any(|reference| span_contains(reference.to_span(), span))
541 })
542 }
543
544 pub fn provenance(&self, span: Span) -> Option<SourceLocation> {
547 resolve_span(span, &self.hir.files)
548 }
549
550 pub fn file(&self, id: u32) -> Option<&str> {
552 self.hir
553 .files
554 .iter()
555 .find(|file| file.id == id)
556 .map(|file| file.path.as_str())
557 }
558
559 fn index_symbols(&mut self) {
562 for decl in &self.hir.declarations {
563 let (kind, name, span) = match decl {
564 Declaration::GlobalVariable {
565 name,
566 name_span,
567 span,
568 ..
569 } => (SymbolKind::Global, name, name_span.or(*span)),
570 Declaration::PlayerVariable {
571 name,
572 name_span,
573 span,
574 ..
575 } => (SymbolKind::Player, name, name_span.or(*span)),
576 Declaration::Subroutine {
577 name,
578 name_span,
579 span,
580 ..
581 } => (SymbolKind::Subroutine, name, name_span.or(*span)),
582 Declaration::Constant { name, span, .. } => (SymbolKind::Constant, name, *span),
583 Declaration::Macro { name, span, .. } => (SymbolKind::Macro, name, *span),
584 };
585 let Some(span) = span.map(to_frontend_span) else {
586 continue;
589 };
590 let Some(declaration) = resolve_span(span, &self.hir.files) else {
591 continue;
592 };
593 self.symbols.push(Symbol {
594 name: name.clone(),
595 kind,
596 declaration,
597 references: Vec::new(),
598 });
599 }
600 for entry in &self.hir.rules {
601 let RuleEntry::SubroutineDef {
602 name,
603 source_name,
604 name_span,
605 span,
606 ..
607 } = entry
608 else {
609 continue;
610 };
611 let Some(span) = name_span.or(*span).map(to_frontend_span) else {
612 continue;
613 };
614 let Some(declaration) = resolve_span(span, &self.hir.files) else {
615 continue;
616 };
617 self.symbols.push(Symbol {
618 name: if source_name.is_empty() {
619 name.clone()
620 } else {
621 source_name.clone()
622 },
623 kind: SymbolKind::Def,
624 declaration,
625 references: Vec::new(),
626 });
627 }
628
629 let mut collector = ReferenceSiteCollector { sites: Vec::new() };
630 hir::visit::walk_program(&mut collector, &self.hir);
631 for (kind, name, span) in collector.sites {
632 self.attach_reference(kind, &name, span);
633 }
634 }
635
636 fn attach_reference(&mut self, kind: SymbolKind, name: &str, span: Span) {
639 let Some(location) = resolve_span(span, &self.hir.files) else {
640 return;
641 };
642 if let Some(index) = self
643 .symbols
644 .iter()
645 .position(|symbol| symbol.kind == kind && symbol.name == name)
646 {
647 self.symbols[index].references.push(location);
648 }
649 }
650}
651
652struct ReferenceSiteCollector {
653 sites: Vec<(SymbolKind, String, Span)>,
654}
655
656impl hir::visit::Visitor for ReferenceSiteCollector {
657 fn visit_expr(&mut self, expression: &HirExpr) {
658 match expression {
659 HirExpr::GlobalVar {
660 name,
661 span: Some(span),
662 } => {
663 self.sites
664 .push((SymbolKind::Global, name.clone(), to_frontend_span(*span)));
665 }
666 HirExpr::Constant {
667 name,
668 span: Some(span),
669 } => {
670 self.sites
671 .push((SymbolKind::Constant, name.clone(), to_frontend_span(*span)));
672 }
673 HirExpr::PlayerVar {
674 name,
675 member_span,
676 span,
677 ..
678 } => {
679 if let Some(span) = member_span.as_ref().or(span.as_ref()) {
680 self.sites
681 .push((SymbolKind::Player, name.clone(), to_frontend_span(*span)));
682 }
683 }
684 HirExpr::Call {
685 name,
686 span: Some(span),
687 ..
688 } => {
689 let span = to_frontend_span(*span);
690 self.sites
691 .push((SymbolKind::Subroutine, name.clone(), span));
692 self.sites.push((SymbolKind::Def, name.clone(), span));
693 }
694 HirExpr::MacroCall {
695 name,
696 span: Some(span),
697 ..
698 } => {
699 self.sites
700 .push((SymbolKind::Macro, name.clone(), to_frontend_span(*span)));
701 }
702 _ => {}
703 }
704 hir::visit::walk_expr(self, expression);
705 }
706
707 fn visit_comprehension(
708 &mut self,
709 element: &HirExpr,
710 iterable: &HirExpr,
711 condition: Option<&HirExpr>,
712 ) {
713 hir::visit::Visitor::visit_expr(self, iterable);
714 hir::visit::Visitor::visit_expr(self, element);
715 if let Some(condition) = condition {
716 hir::visit::Visitor::visit_expr(self, condition);
717 }
718 }
719
720 fn visit_stmt(&mut self, statement: &HirStmt) {
721 if let HirStmt::CallSubroutine {
722 name,
723 span: Some(span),
724 } = statement
725 {
726 let span = to_frontend_span(*span);
727 self.sites
728 .push((SymbolKind::Subroutine, name.clone(), span));
729 self.sites.push((SymbolKind::Def, name.clone(), span));
730 }
731 hir::visit::walk_stmt(self, statement);
732 }
733}
734
735#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
738pub struct EnumDecl {
739 pub name: String,
740 pub members: Vec<EnumMember>,
741}
742
743#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
745pub struct EnumMember {
746 pub name: String,
747 pub span: SourceLocation,
748}
749
750#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
752#[serde(rename_all = "camelCase")]
753pub enum SymbolKind {
754 Global,
755 Player,
756 Subroutine,
757 Def,
758 Constant,
759 Macro,
760}
761
762#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
765pub struct Symbol {
766 pub name: String,
767 pub kind: SymbolKind,
768 pub declaration: SourceLocation,
769 pub references: Vec<SourceLocation>,
770}
771
772fn span_contains(outer: Span, inner: Span) -> bool {
775 position_leq(outer.start, inner.start) && position_leq(inner.end, outer.end)
776}
777
778fn position_leq(a: Position, b: Position) -> bool {
779 a.line < b.line || (a.line == b.line && a.col <= b.col)
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785
786 fn check_source(source: &str) -> CheckOutcome {
787 check(source, "main.opy", Path::new(""))
788 }
789
790 #[test]
791 fn clean_project_has_no_diagnostics_and_a_model() {
792 let outcome = check_source(
793 "globalvar total = 0\nrule \"r\":\n @Event global\n total += 1\n debug(total)\n",
794 );
795 assert!(
796 outcome.is_clean(),
797 "unexpected diagnostics: {:?}",
798 outcome.diagnostics
799 );
800 let model = outcome.model.expect("a clean project resolves");
801 assert_eq!(outcome.files.len(), 1);
802 assert_eq!(model.declarations().len(), 1);
803 assert_eq!(model.rules().len(), 1);
804 }
805
806 #[test]
807 fn symbols_index_declarations_and_references() {
808 let outcome = check_source(
809 "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",
810 );
811 let model = outcome.model.expect("clean project");
812 let names: Vec<(&str, SymbolKind)> = model
813 .symbols()
814 .iter()
815 .map(|symbol| (symbol.name.as_str(), symbol.kind))
816 .collect();
817 assert_eq!(
818 names,
819 vec![
820 ("total", SymbolKind::Global),
821 ("P", SymbolKind::Player),
822 ("reset", SymbolKind::Subroutine),
823 ("double", SymbolKind::Macro),
824 ]
825 );
826 assert_eq!(model.symbol("total").expect("symbol").references.len(), 2);
827 assert_eq!(model.symbol("P").expect("symbol").references.len(), 1);
828 let reset = model.symbol("reset").expect("symbol");
829 assert_eq!(reset.references.len(), 1);
830 assert_eq!(reset.references[0].path, "main.opy");
831 assert_eq!(model.symbol("double").expect("symbol").references.len(), 1);
832 }
833
834 #[test]
835 fn symbol_lookup_by_name_and_span() {
836 let outcome =
837 check_source("globalvar total\nrule \"r\":\n @Event global\n total = 1\n");
838 let model = outcome.model.expect("clean project");
839 let total = model.symbol("total").expect("symbol by name");
840 assert_eq!(total.kind, SymbolKind::Global);
841 let at_decl = model
843 .symbol_at(total.declaration.to_span())
844 .expect("symbol at declaration span");
845 assert_eq!(at_decl.name, "total");
846 let at_ref = model
848 .symbol_at(total.references[0].to_span())
849 .expect("symbol at reference span");
850 assert_eq!(at_ref.name, "total");
851 assert!(
852 model
853 .symbol_at(Span::new(99, Position::new(1, 1), Position::new(1, 1)))
854 .is_none()
855 );
856 }
857
858 #[test]
859 fn provenance_resolves_through_the_file_registry() {
860 let outcome =
861 check_source("globalvar total\nrule \"r\":\n @Event global\n total = 1\n");
862 let model = outcome.model.expect("clean project");
863 let total = model.symbol("total").expect("symbol");
864 let provenance = model
865 .provenance(total.references[0].to_span())
866 .expect("provenance");
867 assert_eq!(provenance.file_id, 0);
868 assert_eq!(provenance.path, "main.opy");
869 assert_eq!(provenance.start.line, 4);
870 assert_eq!(model.file(0), Some("main.opy"));
871 assert_eq!(model.file(1), None);
872 }
873
874 #[test]
875 fn custom_enums_are_queried_from_the_model() {
876 let outcome = check_source(
877 "globalvar x\nenum Direction:\n NORTH\n SOUTH\nrule \"r\":\n @Event global\n x = Direction.SOUTH\n",
878 );
879 let model = outcome.model.expect("clean project");
880 assert_eq!(model.enums().len(), 1);
881 let direction = &model.enums()[0];
882 assert_eq!(direction.name, "Direction");
883 let members: Vec<&str> = direction
884 .members
885 .iter()
886 .map(|member| member.name.as_str())
887 .collect();
888 assert_eq!(members, vec!["NORTH", "SOUTH"]);
889 assert!(direction.members[0].span.path.ends_with("main.opy"));
890 }
891
892 #[test]
893 fn unknown_setting_names_the_keys_valid_at_the_path() {
894 let outcome = check_source(
898 "settings {\n \"gamemodes\": {\"ffa\": {\"notASetting\": true}}\n}\nrule \"a\":\n @Event global\n wait(1)\n",
899 );
900 let diagnostic = outcome
901 .diagnostics
902 .iter()
903 .find(|d| d.code == "unknown-setting")
904 .expect("unknown setting diagnostic");
905 assert_eq!(diagnostic.severity, DiagnosticSeverity::Warning);
906 assert!(
907 diagnostic.message.contains("(did you mean "),
908 "message: {}",
909 diagnostic.message
910 );
911 assert!(
912 diagnostic.message.contains("'disabledMaps'"),
913 "message: {}",
914 diagnostic.message
915 );
916 }
917
918 #[test]
919 fn unknown_setting_preserves_template_paths_and_percent_suffixes() {
920 let outcome = check_source(
921 "settings {\n \"main\": {\"description\": \"t\"},\n \"gamemodes\": {},\n \"heroes\": {\"team1\": {\"general\": {\"damageReceiveed%\": 50}}}\n}\nrule \"a\":\n @Event global\n wait(1)\n",
922 );
923 let diagnostic = outcome
924 .diagnostics
925 .iter()
926 .find(|d| d.code == "unknown-setting")
927 .expect("unknown setting diagnostic");
928 assert_eq!(diagnostic.severity, DiagnosticSeverity::Warning);
929 assert!(
930 diagnostic.message.contains("'damageReceived%'"),
931 "message: {}",
932 diagnostic.message
933 );
934 }
935
936 #[test]
937 fn check_reports_every_parse_error() {
938 let outcome = check_source("rule \"a\"\n @Event global\nrule \"b\"\n");
943 assert!(!outcome.is_clean());
944 assert!(outcome.model.is_none());
945 assert_eq!(outcome.diagnostics.len(), 3);
946 assert!(
947 outcome
948 .diagnostics
949 .iter()
950 .all(|diagnostic| diagnostic.code == "parse-error")
951 );
952 }
953
954 #[test]
955 fn diagnostics_carry_severity_code_and_span() {
956 let outcome = check_source("rule \"r\":\n @Event global\n frobnicate()\n");
957 let diagnostic = &outcome.diagnostics[0];
958 assert_eq!(diagnostic.severity, DiagnosticSeverity::Error);
959 assert_eq!(diagnostic.code, "unknown-action");
960 let span = diagnostic.span.as_ref().expect("source-located");
961 assert_eq!(span.path, "main.opy");
962 assert_eq!(span.start.line, 3);
963 }
964
965 #[test]
966 fn canonical_workshop_ids_are_source_diagnostics() {
967 for (statement, code) in [
970 ("g = allTankHeroes()", "unknown-value"),
971 ("g = lastCreatedEntity()", "unknown-value"),
972 ("g = evaluateOnce(1)", "unknown-value"),
973 ("destroyAllHudText()", "unknown-action"),
974 ] {
975 let outcome = check_source(&format!(
976 "globalvar g\nrule \"r\":\n @Event global\n {statement}\n"
977 ));
978 assert!(!outcome.is_clean(), "{statement}");
979 let diagnostic = outcome
980 .diagnostics
981 .iter()
982 .find(|diagnostic| diagnostic.code == code)
983 .unwrap_or_else(|| panic!("{statement}: expected {code}"));
984 let span = diagnostic.span.as_ref().expect("source-located");
985 assert_eq!(span.start.line, 4, "{statement}");
986 }
987
988 let outcome = check_source(
989 "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = eventPlayer.isButtonHeld(Button.PRIMARY_FIRE)\n",
990 );
991 assert!(!outcome.is_clean());
992 let diagnostic = outcome
993 .diagnostics
994 .iter()
995 .find(|diagnostic| diagnostic.code == "unknown-member")
996 .expect("unknown-member diagnostic");
997 assert_eq!(diagnostic.span.as_ref().unwrap().start.line, 4);
998
999 let outcome = check_source(
1001 "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",
1002 );
1003 assert!(
1004 outcome.is_clean(),
1005 "unexpected diagnostics: {:?}",
1006 outcome.diagnostics
1007 );
1008 }
1009}