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