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(&settings)
211 .into_iter()
212 .map(|error| Diagnostic::from_workshop_error(error, &files)),
213 ),
214 Ok(None) => {}
215 Err(error) => diagnostics.push(Diagnostic::from_integration_error(error, &files)),
216 }
217 if diagnostics
218 .iter()
219 .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
220 {
221 return CheckOutcome::failure(diagnostics, files, display_root);
222 }
223 CheckOutcome {
224 diagnostics,
225 model: Some(SemanticModel::build(hir, &program)),
226 files,
227 post_compile_hook: preprocessed.post_compile_hook,
232 display_root,
233 }
234 }
235 Err(error) => {
236 let mut diagnostics = visible_warnings(&preprocessed)
237 .map(|warning| Diagnostic::from_warning(warning, &files))
238 .collect::<Vec<_>>();
239 diagnostics.push(Diagnostic::from_error(error, &files));
240 CheckOutcome::failure(diagnostics, files, display_root)
241 }
242 }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
251pub struct Diagnostic {
252 pub severity: DiagnosticSeverity,
253 pub code: String,
254 pub message: String,
255 pub span: Option<SourceLocation>,
256}
257
258impl Diagnostic {
259 fn from_warning(warning: &PreprocessWarning, files: &[FileRecord]) -> Diagnostic {
260 Diagnostic {
261 severity: DiagnosticSeverity::Warning,
262 code: warning.code.clone(),
263 message: warning.message.clone(),
264 span: resolve_record_span(warning.span, files),
265 }
266 }
267
268 fn from_error(error: OpyError, files: &[FileRecord]) -> Diagnostic {
269 Diagnostic {
270 severity: DiagnosticSeverity::Error,
271 code: error.code,
272 message: error.message,
273 span: error.span.and_then(|span| resolve_record_span(span, files)),
274 }
275 }
276
277 fn from_workshop_error(error: workshop_rs::WorkshopError, files: &[FileRecord]) -> Diagnostic {
280 let span = crate::compiler::workshop_error_span(&error).map(|span| {
281 Span::new(
282 span.file.index() as u32,
283 Position::new(span.start.line, span.start.col),
284 Position::new(span.end.line, span.end.col),
285 )
286 });
287 Diagnostic {
288 severity: DiagnosticSeverity::Error,
289 code: "workshop-emission".to_string(),
290 message: error.to_string(),
291 span: span.and_then(|span| resolve_record_span(span, files)),
292 }
293 }
294
295 fn from_integration_error(
297 error: crate::compiler::IntegrationError,
298 files: &[FileRecord],
299 ) -> Diagnostic {
300 Diagnostic {
301 severity: DiagnosticSeverity::Error,
302 code: error.diagnostic.code,
303 message: error.diagnostic.message,
304 span: error
305 .diagnostic
306 .span
307 .map(to_frontend_span)
308 .and_then(|span| resolve_record_span(span, files)),
309 }
310 }
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
315#[serde(rename_all = "camelCase")]
316pub enum DiagnosticSeverity {
317 Error,
318 Warning,
319}
320
321impl DiagnosticSeverity {
322 pub fn as_str(&self) -> &'static str {
323 match self {
324 DiagnosticSeverity::Error => "error",
325 DiagnosticSeverity::Warning => "warning",
326 }
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
333pub struct SourceLocation {
334 pub file_id: u32,
335 pub path: String,
336 pub start: Position,
337 pub end: Position,
338}
339
340impl SourceLocation {
341 pub fn to_span(&self) -> Span {
343 Span::new(self.file_id, self.start, self.end)
344 }
345}
346
347fn resolve_span(span: Span, files: &[SourceFile]) -> Option<SourceLocation> {
348 let path = files.iter().find(|file| file.id == span.file)?.path.clone();
349 Some(SourceLocation {
350 file_id: span.file,
351 path,
352 start: span.start,
353 end: span.end,
354 })
355}
356
357fn resolve_record_span(span: Span, files: &[FileRecord]) -> Option<SourceLocation> {
360 let path = files.iter().find(|file| file.id == span.file)?.path.clone();
361 Some(SourceLocation {
362 file_id: span.file,
363 path,
364 start: span.start,
365 end: span.end,
366 })
367}
368
369fn to_frontend_span(span: hir::types::Span) -> Span {
373 Span::new(
374 span.file,
375 Position::new(span.start.line, span.start.col),
376 Position::new(span.end.line, span.end.col),
377 )
378}
379
380#[derive(Debug, Clone, Serialize)]
387pub struct SemanticModel {
388 pub hir: hir::Program,
389 pub enums: Vec<EnumDecl>,
390 pub symbols: Vec<Symbol>,
391}
392
393impl SemanticModel {
394 pub fn build(hir: hir::Program, cst: &cst::Program) -> SemanticModel {
397 let enums = cst
398 .declarations
399 .iter()
400 .filter_map(|decl| match decl {
401 cst::Decl::Enum { name, members, .. } => Some(EnumDecl {
402 name: name.clone(),
403 members: members
404 .iter()
405 .map(|(member, span)| EnumMember {
406 name: member.clone(),
407 span: resolve_span(*span, &hir.files)
408 .expect("every token span resolves through the file registry"),
409 })
410 .collect(),
411 }),
412 _ => None,
413 })
414 .collect();
415 let mut model = SemanticModel {
416 hir,
417 enums,
418 symbols: Vec::new(),
419 };
420 model.index_symbols();
421 model
422 }
423
424 pub fn declarations(&self) -> &[Declaration] {
427 &self.hir.declarations
428 }
429
430 pub fn rules(&self) -> &[RuleEntry] {
432 &self.hir.rules
433 }
434
435 pub fn defines(&self) -> &[Define] {
437 &self.hir.defines
438 }
439
440 pub fn enums(&self) -> &[EnumDecl] {
442 &self.enums
443 }
444
445 pub fn symbols(&self) -> &[Symbol] {
448 &self.symbols
449 }
450
451 pub fn symbol(&self, name: &str) -> Option<&Symbol> {
454 self.symbols.iter().find(|symbol| symbol.name == name)
455 }
456
457 pub fn symbol_at(&self, span: Span) -> Option<&Symbol> {
460 self.symbols.iter().find(|symbol| {
461 span_contains(symbol.declaration.to_span(), span)
462 || symbol
463 .references
464 .iter()
465 .any(|reference| span_contains(reference.to_span(), span))
466 })
467 }
468
469 pub fn provenance(&self, span: Span) -> Option<SourceLocation> {
472 resolve_span(span, &self.hir.files)
473 }
474
475 pub fn file(&self, id: u32) -> Option<&str> {
477 self.hir
478 .files
479 .iter()
480 .find(|file| file.id == id)
481 .map(|file| file.path.as_str())
482 }
483
484 fn index_symbols(&mut self) {
487 for decl in &self.hir.declarations {
488 let (kind, name, span) = match decl {
489 Declaration::GlobalVariable {
490 name,
491 name_span,
492 span,
493 ..
494 } => (SymbolKind::Global, name, name_span.or(*span)),
495 Declaration::PlayerVariable {
496 name,
497 name_span,
498 span,
499 ..
500 } => (SymbolKind::Player, name, name_span.or(*span)),
501 Declaration::Subroutine {
502 name,
503 name_span,
504 span,
505 ..
506 } => (SymbolKind::Subroutine, name, name_span.or(*span)),
507 Declaration::Constant { name, span, .. } => (SymbolKind::Constant, name, *span),
508 Declaration::Macro { name, span, .. } => (SymbolKind::Macro, name, *span),
509 };
510 let Some(span) = span.map(to_frontend_span) else {
511 continue;
514 };
515 let Some(declaration) = resolve_span(span, &self.hir.files) else {
516 continue;
517 };
518 self.symbols.push(Symbol {
519 name: name.clone(),
520 kind,
521 declaration,
522 references: Vec::new(),
523 });
524 }
525 for entry in &self.hir.rules {
526 let RuleEntry::SubroutineDef {
527 name,
528 source_name,
529 name_span,
530 span,
531 ..
532 } = entry
533 else {
534 continue;
535 };
536 let Some(span) = name_span.or(*span).map(to_frontend_span) else {
537 continue;
538 };
539 let Some(declaration) = resolve_span(span, &self.hir.files) else {
540 continue;
541 };
542 self.symbols.push(Symbol {
543 name: if source_name.is_empty() {
544 name.clone()
545 } else {
546 source_name.clone()
547 },
548 kind: SymbolKind::Def,
549 declaration,
550 references: Vec::new(),
551 });
552 }
553
554 let mut collector = ReferenceSiteCollector { sites: Vec::new() };
555 hir::visit::walk_program(&mut collector, &self.hir);
556 for (kind, name, span) in collector.sites {
557 self.attach_reference(kind, &name, span);
558 }
559 }
560
561 fn attach_reference(&mut self, kind: SymbolKind, name: &str, span: Span) {
564 let Some(location) = resolve_span(span, &self.hir.files) else {
565 return;
566 };
567 if let Some(index) = self
568 .symbols
569 .iter()
570 .position(|symbol| symbol.kind == kind && symbol.name == name)
571 {
572 self.symbols[index].references.push(location);
573 }
574 }
575}
576
577struct ReferenceSiteCollector {
578 sites: Vec<(SymbolKind, String, Span)>,
579}
580
581impl hir::visit::Visitor for ReferenceSiteCollector {
582 fn visit_expr(&mut self, expression: &HirExpr) {
583 match expression {
584 HirExpr::GlobalVar {
585 name,
586 span: Some(span),
587 } => {
588 self.sites
589 .push((SymbolKind::Global, name.clone(), to_frontend_span(*span)));
590 }
591 HirExpr::Constant {
592 name,
593 span: Some(span),
594 } => {
595 self.sites
596 .push((SymbolKind::Constant, name.clone(), to_frontend_span(*span)));
597 }
598 HirExpr::PlayerVar {
599 name,
600 member_span,
601 span,
602 ..
603 } => {
604 if let Some(span) = member_span.as_ref().or(span.as_ref()) {
605 self.sites
606 .push((SymbolKind::Player, name.clone(), to_frontend_span(*span)));
607 }
608 }
609 HirExpr::Call {
610 name,
611 span: Some(span),
612 ..
613 } => {
614 let span = to_frontend_span(*span);
615 self.sites
616 .push((SymbolKind::Subroutine, name.clone(), span));
617 self.sites.push((SymbolKind::Def, name.clone(), span));
618 }
619 HirExpr::MacroCall {
620 name,
621 span: Some(span),
622 ..
623 } => {
624 self.sites
625 .push((SymbolKind::Macro, name.clone(), to_frontend_span(*span)));
626 }
627 _ => {}
628 }
629 hir::visit::walk_expr(self, expression);
630 }
631
632 fn visit_comprehension(
633 &mut self,
634 element: &HirExpr,
635 iterable: &HirExpr,
636 condition: Option<&HirExpr>,
637 ) {
638 hir::visit::Visitor::visit_expr(self, iterable);
639 hir::visit::Visitor::visit_expr(self, element);
640 if let Some(condition) = condition {
641 hir::visit::Visitor::visit_expr(self, condition);
642 }
643 }
644
645 fn visit_stmt(&mut self, statement: &HirStmt) {
646 if let HirStmt::CallSubroutine {
647 name,
648 span: Some(span),
649 } = statement
650 {
651 let span = to_frontend_span(*span);
652 self.sites
653 .push((SymbolKind::Subroutine, name.clone(), span));
654 self.sites.push((SymbolKind::Def, name.clone(), span));
655 }
656 hir::visit::walk_stmt(self, statement);
657 }
658}
659
660#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
663pub struct EnumDecl {
664 pub name: String,
665 pub members: Vec<EnumMember>,
666}
667
668#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
670pub struct EnumMember {
671 pub name: String,
672 pub span: SourceLocation,
673}
674
675#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
677#[serde(rename_all = "camelCase")]
678pub enum SymbolKind {
679 Global,
680 Player,
681 Subroutine,
682 Def,
683 Constant,
684 Macro,
685}
686
687#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
690pub struct Symbol {
691 pub name: String,
692 pub kind: SymbolKind,
693 pub declaration: SourceLocation,
694 pub references: Vec<SourceLocation>,
695}
696
697fn span_contains(outer: Span, inner: Span) -> bool {
700 position_leq(outer.start, inner.start) && position_leq(inner.end, outer.end)
701}
702
703fn position_leq(a: Position, b: Position) -> bool {
704 a.line < b.line || (a.line == b.line && a.col <= b.col)
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710
711 fn check_source(source: &str) -> CheckOutcome {
712 check(source, "main.opy", Path::new(""))
713 }
714
715 #[test]
716 fn clean_project_has_no_diagnostics_and_a_model() {
717 let outcome = check_source(
718 "globalvar total = 0\nrule \"r\":\n @Event global\n total += 1\n debug(total)\n",
719 );
720 assert!(
721 outcome.is_clean(),
722 "unexpected diagnostics: {:?}",
723 outcome.diagnostics
724 );
725 let model = outcome.model.expect("a clean project resolves");
726 assert_eq!(outcome.files.len(), 1);
727 assert_eq!(model.declarations().len(), 1);
728 assert_eq!(model.rules().len(), 1);
729 }
730
731 #[test]
732 fn symbols_index_declarations_and_references() {
733 let outcome = check_source(
734 "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",
735 );
736 let model = outcome.model.expect("clean project");
737 let names: Vec<(&str, SymbolKind)> = model
738 .symbols()
739 .iter()
740 .map(|symbol| (symbol.name.as_str(), symbol.kind))
741 .collect();
742 assert_eq!(
743 names,
744 vec![
745 ("total", SymbolKind::Global),
746 ("P", SymbolKind::Player),
747 ("reset", SymbolKind::Subroutine),
748 ("double", SymbolKind::Macro),
749 ]
750 );
751 assert_eq!(model.symbol("total").expect("symbol").references.len(), 2);
752 assert_eq!(model.symbol("P").expect("symbol").references.len(), 1);
753 let reset = model.symbol("reset").expect("symbol");
754 assert_eq!(reset.references.len(), 1);
755 assert_eq!(reset.references[0].path, "main.opy");
756 assert_eq!(model.symbol("double").expect("symbol").references.len(), 1);
757 }
758
759 #[test]
760 fn symbol_lookup_by_name_and_span() {
761 let outcome =
762 check_source("globalvar total\nrule \"r\":\n @Event global\n total = 1\n");
763 let model = outcome.model.expect("clean project");
764 let total = model.symbol("total").expect("symbol by name");
765 assert_eq!(total.kind, SymbolKind::Global);
766 let at_decl = model
768 .symbol_at(total.declaration.to_span())
769 .expect("symbol at declaration span");
770 assert_eq!(at_decl.name, "total");
771 let at_ref = model
773 .symbol_at(total.references[0].to_span())
774 .expect("symbol at reference span");
775 assert_eq!(at_ref.name, "total");
776 assert!(
777 model
778 .symbol_at(Span::new(99, Position::new(1, 1), Position::new(1, 1)))
779 .is_none()
780 );
781 }
782
783 #[test]
784 fn provenance_resolves_through_the_file_registry() {
785 let outcome =
786 check_source("globalvar total\nrule \"r\":\n @Event global\n total = 1\n");
787 let model = outcome.model.expect("clean project");
788 let total = model.symbol("total").expect("symbol");
789 let provenance = model
790 .provenance(total.references[0].to_span())
791 .expect("provenance");
792 assert_eq!(provenance.file_id, 0);
793 assert_eq!(provenance.path, "main.opy");
794 assert_eq!(provenance.start.line, 4);
795 assert_eq!(model.file(0), Some("main.opy"));
796 assert_eq!(model.file(1), None);
797 }
798
799 #[test]
800 fn custom_enums_are_queried_from_the_model() {
801 let outcome = check_source(
802 "globalvar x\nenum Direction:\n NORTH\n SOUTH\nrule \"r\":\n @Event global\n x = Direction.SOUTH\n",
803 );
804 let model = outcome.model.expect("clean project");
805 assert_eq!(model.enums().len(), 1);
806 let direction = &model.enums()[0];
807 assert_eq!(direction.name, "Direction");
808 let members: Vec<&str> = direction
809 .members
810 .iter()
811 .map(|member| member.name.as_str())
812 .collect();
813 assert_eq!(members, vec!["NORTH", "SOUTH"]);
814 assert!(direction.members[0].span.path.ends_with("main.opy"));
815 }
816
817 #[test]
818 fn check_reports_every_parse_error() {
819 let outcome = check_source("rule \"a\"\n @Event global\nrule \"b\"\n");
824 assert!(!outcome.is_clean());
825 assert!(outcome.model.is_none());
826 assert_eq!(outcome.diagnostics.len(), 3);
827 assert!(
828 outcome
829 .diagnostics
830 .iter()
831 .all(|diagnostic| diagnostic.code == "parse-error")
832 );
833 }
834
835 #[test]
836 fn diagnostics_carry_severity_code_and_span() {
837 let outcome = check_source("rule \"r\":\n @Event global\n frobnicate()\n");
838 let diagnostic = &outcome.diagnostics[0];
839 assert_eq!(diagnostic.severity, DiagnosticSeverity::Error);
840 assert_eq!(diagnostic.code, "unknown-action");
841 let span = diagnostic.span.as_ref().expect("source-located");
842 assert_eq!(span.path, "main.opy");
843 assert_eq!(span.start.line, 3);
844 }
845
846 #[test]
847 fn canonical_workshop_ids_are_source_diagnostics() {
848 for (statement, code) in [
851 ("g = allTankHeroes()", "unknown-value"),
852 ("g = lastCreatedEntity()", "unknown-value"),
853 ("g = evaluateOnce(1)", "unknown-value"),
854 ("destroyAllHudText()", "unknown-action"),
855 ] {
856 let outcome = check_source(&format!(
857 "globalvar g\nrule \"r\":\n @Event global\n {statement}\n"
858 ));
859 assert!(!outcome.is_clean(), "{statement}");
860 let diagnostic = outcome
861 .diagnostics
862 .iter()
863 .find(|diagnostic| diagnostic.code == code)
864 .unwrap_or_else(|| panic!("{statement}: expected {code}"));
865 let span = diagnostic.span.as_ref().expect("source-located");
866 assert_eq!(span.start.line, 4, "{statement}");
867 }
868
869 let outcome = check_source(
870 "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = eventPlayer.isButtonHeld(Button.PRIMARY_FIRE)\n",
871 );
872 assert!(!outcome.is_clean());
873 let diagnostic = outcome
874 .diagnostics
875 .iter()
876 .find(|diagnostic| diagnostic.code == "unknown-member")
877 .expect("unknown-member diagnostic");
878 assert_eq!(diagnostic.span.as_ref().unwrap().start.line, 4);
879
880 let outcome = check_source(
882 "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",
883 );
884 assert!(
885 outcome.is_clean(),
886 "unexpected diagnostics: {:?}",
887 outcome.diagnostics
888 );
889 }
890}