From 74f4facf755948be2c1041ae8446cf42300d2cad Mon Sep 17 00:00:00 2001 From: Arcticae Date: Tue, 19 May 2026 11:10:58 +0200 Subject: [PATCH] Emit structured compiler diagnostics in JSON output --- scarb/src/compiler/helpers.rs | 17 +- scarb/src/compiler/mod.rs | 1 + .../compiler/structured_diagnostics/core.rs | 246 ++++++++++++++++++ .../compiler/structured_diagnostics/mod.rs | 37 +++ .../structured_diagnostics/scarb_ui.rs | 26 ++ scarb/src/ops/compile.rs | 30 ++- scarb/tests/build.rs | 2 +- utils/scarb-ui/src/lib.rs | 10 + 8 files changed, 350 insertions(+), 19 deletions(-) create mode 100644 scarb/src/compiler/structured_diagnostics/core.rs create mode 100644 scarb/src/compiler/structured_diagnostics/mod.rs create mode 100644 scarb/src/compiler/structured_diagnostics/scarb_ui.rs diff --git a/scarb/src/compiler/helpers.rs b/scarb/src/compiler/helpers.rs index 20ca8551e..c2c042b26 100644 --- a/scarb/src/compiler/helpers.rs +++ b/scarb/src/compiler/helpers.rs @@ -48,6 +48,16 @@ pub fn all_crate_inputs(db: &dyn Database) -> Vec { .collect_vec() } +pub fn non_main_crate_inputs<'db>( + db: &'db dyn Database, + main_crate_ids: &[CrateId<'db>], +) -> Vec { + db.crates() + .iter() + .filter(|crate_id| !main_crate_ids.contains(crate_id)) + .map(|crate_id| crate_id.long(db).clone().into_crate_input(db)) + .collect_vec() +} pub fn build_compiler_config<'c, 'db>( db: &'db dyn Database, unit: &CairoCompilationUnit, @@ -58,12 +68,7 @@ pub fn build_compiler_config<'c, 'db>( where 'db: 'c, { - let ignore_warnings_crates = db - .crates() - .iter() - .filter(|crate_id| !main_crate_ids.contains(crate_id)) - .map(|c| c.long(db).clone().into_crate_input(db)) - .collect_vec(); + let ignore_warnings_crates = non_main_crate_inputs(db, main_crate_ids); // If a crate is cached, we do not need to check it for error diagnostics, // as the cache can only be produced if the crate is error-free. // So if there were any diagnostics here to show, it would mean that the cache is outdated - thus diff --git a/scarb/src/compiler/mod.rs b/scarb/src/compiler/mod.rs index b5a3998b2..4c42ff775 100644 --- a/scarb/src/compiler/mod.rs +++ b/scarb/src/compiler/mod.rs @@ -16,6 +16,7 @@ pub mod incremental; pub mod plugin; mod profile; mod repository; +pub mod structured_diagnostics; mod syntax; pub trait Compiler: Sync { diff --git a/scarb/src/compiler/structured_diagnostics/core.rs b/scarb/src/compiler/structured_diagnostics/core.rs new file mode 100644 index 000000000..06f439f7f --- /dev/null +++ b/scarb/src/compiler/structured_diagnostics/core.rs @@ -0,0 +1,246 @@ +use cairo_lang_defs::db::DefsGroup; +use cairo_lang_defs::ids::ModuleId; +use cairo_lang_diagnostics::{ + DiagnosticEntry, Diagnostics, PluginFileDiagnosticNotes, Severity, UserLocationWithPluginNotes, +}; +use cairo_lang_filesystem::db::FilesGroup; +use cairo_lang_filesystem::ids::{CrateInput, SpanInFile}; +use cairo_lang_lowering::db::LoweringGroup; +use cairo_lang_parser::db::ParserGroup; +use cairo_lang_semantic::db::SemanticGroup; +use cairo_lang_utils::Intern; +use cairo_lang_utils::unordered_hash_set::UnorderedHashSet; +use itertools::Itertools; +use salsa::Database; +use serde::Serialize; + +#[derive(Serialize)] +pub struct StructuredDiagnosticMessage { + r#type: &'static str, + severity: StructuredDiagnosticSeverity, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + code: Option, + file: String, + span: StructuredDiagnosticSpan, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + related: Vec, +} + +#[derive(Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum StructuredDiagnosticSeverity { + Error, + Warning, +} + +struct StructuredDiagnosticLocation { + file: String, + span: StructuredDiagnosticSpan, +} + +#[derive(Serialize)] +struct StructuredDiagnosticSpan { + start: usize, + end: usize, +} + +#[derive(Serialize)] +struct StructuredDiagnosticRelated { + message: String, + file: String, + span: StructuredDiagnosticSpan, +} + +pub trait StructuredDiagnosticsSink { + fn emit(&mut self, message: StructuredDiagnosticMessage); +} + +pub struct StructuredDiagnosticsReporter { + ignore_warnings_crate_ids: Vec, + crates: Vec, +} + +impl StructuredDiagnosticsReporter { + pub fn new(ignore_warnings_crate_ids: Vec, crates: Vec) -> Self { + Self { + ignore_warnings_crate_ids, + crates, + } + } + + pub fn check(&mut self, db: &dyn Database, sink: &mut impl StructuredDiagnosticsSink) -> bool { + let mut found_diagnostics = false; + + for crate_input in self.crates.clone() { + let crate_id = crate_input.clone().into_crate_long_id(db).intern(db); + let Ok(module_file) = db.module_main_file(ModuleId::CrateRoot(crate_id)) else { + found_diagnostics = true; + sink.emit(StructuredDiagnosticMessage::error( + "Failed to get main module file".to_string(), + "".to_string(), + )); + continue; + }; + + if db.file_content(module_file).is_none() { + let file = module_file.full_path(db); + sink.emit(StructuredDiagnosticMessage::error( + format!("{file} not found"), + file, + )); + found_diagnostics = true; + } + + let skip_warnings = self.ignore_warnings_crate_ids.contains(&crate_input); + let modules = db.crate_modules(crate_id); + let mut processed_file_ids = UnorderedHashSet::<_>::default(); + for module_id in modules.iter() { + let default = Default::default(); + let diagnostic_notes = module_id + .module_data(db) + .map(|data| data.diagnostics_notes(db)) + .unwrap_or(&default); + + if let Ok(module_files) = db.module_files(*module_id) { + for file_id in module_files.iter().copied() { + if processed_file_ids.insert(file_id) { + found_diagnostics |= self.check_diag_group( + db.as_dyn_database(), + db.file_syntax_diagnostics(file_id).clone(), + skip_warnings, + diagnostic_notes, + sink, + ); + } + } + } + + if let Ok(group) = db.module_semantic_diagnostics(*module_id) { + found_diagnostics |= self.check_diag_group( + db.as_dyn_database(), + group, + skip_warnings, + diagnostic_notes, + sink, + ); + } + + if let Ok(group) = db.module_lowering_diagnostics(*module_id) { + found_diagnostics |= self.check_diag_group( + db.as_dyn_database(), + group, + skip_warnings, + diagnostic_notes, + sink, + ); + } + } + } + + found_diagnostics + } + + fn check_diag_group<'db, TEntry: DiagnosticEntry<'db> + salsa::Update>( + &mut self, + db: &'db dyn Database, + group: Diagnostics<'db, TEntry>, + skip_warnings: bool, + file_notes: &PluginFileDiagnosticNotes<'db>, + sink: &mut impl StructuredDiagnosticsSink, + ) -> bool { + let mut found = false; + for entry in group.get_diagnostics_without_duplicates(db) { + if skip_warnings && entry.severity() == Severity::Warning { + continue; + } + + if let Some(message) = build_structured_diagnostic_message(db, &entry, file_notes) { + sink.emit(message); + found |= group.check_error_free().is_err(); + } + } + found + } +} + +impl StructuredDiagnosticMessage { + fn error(message: String, file: String) -> Self { + Self { + r#type: "diagnostic", + severity: StructuredDiagnosticSeverity::Error, + message, + code: None, + file, + span: StructuredDiagnosticSpan { start: 0, end: 0 }, + related: vec![], + } + } + + pub fn severity(&self) -> StructuredDiagnosticSeverity { + self.severity + } +} + +impl StructuredDiagnosticLocation { + fn from_user_location(db: &dyn Database, location: SpanInFile<'_>) -> Self { + Self { + file: location.file_id.full_path(db), + span: StructuredDiagnosticSpan { + start: location.span.start.as_u32() as usize, + end: location.span.end.as_u32() as usize, + }, + } + } + + fn into_related(self, message: String) -> StructuredDiagnosticRelated { + StructuredDiagnosticRelated { + message, + file: self.file, + span: self.span, + } + } +} + +fn build_structured_diagnostic_message<'db, TEntry: DiagnosticEntry<'db>>( + db: &'db dyn Database, + entry: &TEntry, + file_notes: &PluginFileDiagnosticNotes<'db>, +) -> Option { + let diag_location = entry.location(db); + let (user_location, parent_file_notes) = + diag_location.user_location_with_plugin_notes(db, file_notes); + let primary = StructuredDiagnosticLocation::from_user_location(db, user_location); + + let mut related = entry + .notes(db) + .iter() + .chain(parent_file_notes.iter()) + .filter_map(|note| { + note.location.map(|location| { + StructuredDiagnosticLocation::from_user_location(db, location.user_location(db)) + .into_related(note.text.clone()) + }) + }) + .collect_vec(); + + if diag_location != user_location { + related.push( + StructuredDiagnosticLocation::from_user_location(db, diag_location) + .into_related("diagnostic originates in generated code".to_string()), + ); + } + + Some(StructuredDiagnosticMessage { + r#type: "diagnostic", + severity: match entry.severity() { + Severity::Error => StructuredDiagnosticSeverity::Error, + Severity::Warning => StructuredDiagnosticSeverity::Warning, + }, + message: entry.format(db), + code: entry.error_code().map(|code| code.to_string()), + file: primary.file, + span: primary.span, + related, + }) +} diff --git a/scarb/src/compiler/structured_diagnostics/mod.rs b/scarb/src/compiler/structured_diagnostics/mod.rs new file mode 100644 index 000000000..d2b7e87b2 --- /dev/null +++ b/scarb/src/compiler/structured_diagnostics/mod.rs @@ -0,0 +1,37 @@ +mod core; +mod scarb_ui; + +use crate::core::Workspace; +use cairo_lang_compiler::diagnostics::DiagnosticsError; +use cairo_lang_filesystem::db::FilesGroup; +use cairo_lang_filesystem::ids::CrateId; +use itertools::Itertools; +use salsa::Database; + +use self::core::StructuredDiagnosticsReporter; +use self::scarb_ui::ScarbUiStructuredDiagnosticsSink; + +pub fn ensure_structured_json_diagnostics<'db>( + db: &'db dyn Database, + main_crate_ids: &[CrateId<'db>], + ws: &Workspace<'_>, +) -> std::result::Result<(), DiagnosticsError> { + let ignore_warnings_crates = db + .crates() + .iter() + .filter(|crate_id| !main_crate_ids.contains(crate_id)) + .map(|c| c.long(db).clone().into_crate_input(db)) + .collect_vec(); + let crates_to_check = db + .crates() + .iter() + .map(|c| c.long(db).clone().into_crate_input(db)) + .collect_vec(); + let mut sink = ScarbUiStructuredDiagnosticsSink::new(ws.config().ui().clone()); + let mut reporter = StructuredDiagnosticsReporter::new(ignore_warnings_crates, crates_to_check); + if reporter.check(db, &mut sink) { + Err(DiagnosticsError) + } else { + Ok(()) + } +} diff --git a/scarb/src/compiler/structured_diagnostics/scarb_ui.rs b/scarb/src/compiler/structured_diagnostics/scarb_ui.rs new file mode 100644 index 000000000..6c5155ca4 --- /dev/null +++ b/scarb/src/compiler/structured_diagnostics/scarb_ui.rs @@ -0,0 +1,26 @@ +use scarb_ui::components::MachineMessage; + +use super::core::{ + StructuredDiagnosticMessage, StructuredDiagnosticSeverity, StructuredDiagnosticsSink, +}; + +pub struct ScarbUiStructuredDiagnosticsSink { + ui: scarb_ui::Ui, +} + +impl ScarbUiStructuredDiagnosticsSink { + pub fn new(ui: scarb_ui::Ui) -> Self { + Self { ui } + } +} + +impl StructuredDiagnosticsSink for ScarbUiStructuredDiagnosticsSink { + fn emit(&mut self, message: StructuredDiagnosticMessage) { + let severity = message.severity(); + match severity { + StructuredDiagnosticSeverity::Error => self.ui.record_error(), + StructuredDiagnosticSeverity::Warning => self.ui.record_warning(), + } + self.ui.print(MachineMessage(message)); + } +} diff --git a/scarb/src/ops/compile.rs b/scarb/src/ops/compile.rs index cd28ba2e8..7ddccba99 100644 --- a/scarb/src/ops/compile.rs +++ b/scarb/src/ops/compile.rs @@ -10,6 +10,7 @@ use crate::compiler::incremental::{ CachedWarnings, IncrementalContext, load_incremental_artifacts, save_incremental_artifacts, }; use crate::compiler::plugin::proc_macro; +use crate::compiler::structured_diagnostics::ensure_structured_json_diagnostics; use crate::compiler::{CairoCompilationUnit, CompilationUnit, CompilationUnitAttributes}; use crate::core::{ FeatureName, PackageId, PackageName, TargetKind, Utf8PathWorkspaceExt, Workspace, @@ -27,6 +28,7 @@ use itertools::Itertools; use salsa::Database; use scarb_fs_utils as fsx; use scarb_ui::HumanDuration; +use scarb_ui::OutputFormat; use scarb_ui::args::FeaturesSpec; use scarb_ui::components::Status; use smol_str::{SmolStr, ToSmolStr}; @@ -400,18 +402,22 @@ fn check_unit(unit: CompilationUnit, ws: &Workspace<'_>) -> Result<()> { build_scarb_root_database(&unit, ws, Default::default())?; let main_crate_ids = collect_main_crate_ids(&unit, &db); check_starknet_dependency(&unit, ws, &db, &package_name); - let mut compiler_config = build_compiler_config( - &db, - &unit, - &main_crate_ids, - &IncrementalContext::Disabled, - ws, - ); - let result = ensure_diagnostics(&db, &mut compiler_config.diagnostics_reporter) - .map_err(|err| err.into()); - - let _ = main_crate_ids; - drop(compiler_config); + let result = if ws.config().ui().output_format() == OutputFormat::Json { + ensure_structured_json_diagnostics(&db, &main_crate_ids, ws) + .map_err(|err| err.into()) + } else { + let mut compiler_config = build_compiler_config( + &db, + &unit, + &main_crate_ids, + &IncrementalContext::Disabled, + ws, + ); + let result = ensure_diagnostics(&db, &mut compiler_config.diagnostics_reporter) + .map_err(|err| err.into()); + drop(compiler_config); + result + }; let span = trace_span!("drop_db"); { let _guard = span.enter(); diff --git a/scarb/tests/build.rs b/scarb/tests/build.rs index a310196af..15bfabb27 100644 --- a/scarb/tests/build.rs +++ b/scarb/tests/build.rs @@ -114,7 +114,7 @@ fn compile_with_syntax_error_json() { .code(1) .stdout_eq(indoc! {r#" {"status":"checking","message":"hello v0.1.0 ([..]Scarb.toml)"} - {"type":"error","message":"Skipped tokens. Expected: Const/Enum/ExternFunction/ExternType/Function/Impl/InlineMacro/Module/Struct/Trait/TypeAlias/Use or an attribute./n --> [..]/lib.cairo:1:14/nnot_a_keyword/n ^/n","code":"E1000"} + {"type":"diagnostic","severity":"error","message":"Skipped tokens. Expected: Const/Enum/ExternFunction/ExternType/Function/Impl/InlineMacro/Module/Struct/Trait/TypeAlias/Use or an attribute.","code":"E1000","file":"[..]/lib.cairo","span":{"start":13,"end":13}} {"type":"error","message":"could not check `hello` due to [..] previous error"} "#}); } diff --git a/utils/scarb-ui/src/lib.rs b/utils/scarb-ui/src/lib.rs index 3f4f01021..e16589a3c 100644 --- a/utils/scarb-ui/src/lib.rs +++ b/utils/scarb-ui/src/lib.rs @@ -173,6 +173,16 @@ impl Ui { self.print(TypedMessage::styled("error", "red", message.as_ref())) } + /// Record an error without printing a message. + pub fn record_error(&self) { + self.counter.error(); + } + + /// Record a warning without printing a message. + pub fn record_warning(&self) { + self.counter.warning(); + } + /// Print a warning to the user. pub fn warn_with_code(&self, code: impl AsRef, message: impl AsRef) { self.counter.warning();