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