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