use schemars::JsonSchema;
use serde::Serialize;
use crate::diagnostic::{DetailKind, DiagnosticKind, DiagnosticMessage};
use quarto_source_map::SourceContext;
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct JsonDiagnosticDetail {
pub kind: String,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_column: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_column: Option<u32>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct JsonDiagnostic {
#[serde(rename = "$schema")]
#[schemars(rename = "$schema")]
pub schema: &'static str,
pub kind: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub problem: Option<String>,
pub hints: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_column: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_column: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_file: Option<String>,
pub details: Vec<JsonDiagnosticDetail>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rendered: Option<String>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct JsonPass1Failure {
#[serde(rename = "$schema")]
#[schemars(rename = "$schema")]
pub schema: &'static str,
pub source_file: String,
pub error: String,
pub diagnostics: Vec<JsonDiagnostic>,
}
impl JsonDiagnostic {
pub const SCHEMA_URL: &'static str = "https://quarto.org/schemas/v1/json-diagnostic.json";
}
impl JsonPass1Failure {
pub const SCHEMA_URL: &'static str = "https://quarto.org/schemas/v1/json-pass1-failure.json";
pub fn new(source_file: String, error: String, diagnostics: Vec<JsonDiagnostic>) -> Self {
Self {
schema: Self::SCHEMA_URL,
source_file,
error,
diagnostics,
}
}
}
pub fn diagnostic_to_json(diag: &DiagnosticMessage, ctx: &SourceContext) -> JsonDiagnostic {
let (start_line, start_column, end_line, end_column) = if let Some(loc) = &diag.location {
let start = loc.map_offset(0, ctx);
let end = loc
.map_offset(loc.length(), ctx)
.or_else(|| {
if loc.length() > 0 {
loc.map_offset(loc.length() - 1, ctx)
} else {
None
}
})
.or_else(|| start.clone());
match (start, end) {
(Some(s), Some(e)) => (
Some((s.location.row + 1) as u32), Some((s.location.column + 1) as u32), Some((e.location.row + 1) as u32),
Some((e.location.column + 1) as u32),
),
(Some(s), None) => (
Some((s.location.row + 1) as u32),
Some((s.location.column + 1) as u32),
None,
None,
),
_ => (None, None, None, None),
}
} else {
(None, None, None, None)
};
let details: Vec<JsonDiagnosticDetail> = diag
.details
.iter()
.map(|detail| {
let (d_start_line, d_start_col, d_end_line, d_end_col) =
if let Some(loc) = &detail.location {
let start = loc.map_offset(0, ctx);
let end = loc.map_offset(loc.length(), ctx).or_else(|| start.clone());
match (start, end) {
(Some(s), Some(e)) => (
Some((s.location.row + 1) as u32),
Some((s.location.column + 1) as u32),
Some((e.location.row + 1) as u32),
Some((e.location.column + 1) as u32),
),
(Some(s), None) => (
Some((s.location.row + 1) as u32),
Some((s.location.column + 1) as u32),
None,
None,
),
_ => (None, None, None, None),
}
} else {
(None, None, None, None)
};
let kind_str = match detail.kind {
DetailKind::Error => "error",
DetailKind::Info => "info",
DetailKind::Note | DetailKind::Faded => "note",
};
JsonDiagnosticDetail {
kind: kind_str.to_string(),
content: detail.content.as_str().to_string(),
start_line: d_start_line,
start_column: d_start_col,
end_line: d_end_line,
end_column: d_end_col,
}
})
.collect();
let kind_str = match diag.kind {
DiagnosticKind::Error => "error",
DiagnosticKind::Warning => "warning",
DiagnosticKind::Info => "info",
DiagnosticKind::Note => "note",
};
let hints: Vec<String> = diag.hints.iter().map(|h| h.as_str().to_string()).collect();
let rendered = if diag.location.is_some() {
Some(diag.to_text(Some(ctx)))
} else {
None
};
JsonDiagnostic {
schema: JsonDiagnostic::SCHEMA_URL,
kind: kind_str.to_string(),
title: diag.title.clone(),
code: diag.code.clone(),
problem: diag.problem.as_ref().map(|p| p.as_str().to_string()),
hints,
start_line,
start_column,
end_line,
end_column,
source_file: None,
details,
rendered,
}
}
pub fn with_source_file(mut diag: JsonDiagnostic, source_file: String) -> JsonDiagnostic {
diag.source_file = Some(source_file);
diag
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DiagnosticMessage;
#[test]
fn warning_with_no_location_serializes_without_position_fields() {
let diag = DiagnosticMessage::warning("Test warning").with_code("Q-1-1");
let ctx = SourceContext::new();
let json = diagnostic_to_json(&diag, &ctx);
assert_eq!(json.kind, "warning");
assert_eq!(json.title, "Test warning");
assert_eq!(json.code.as_deref(), Some("Q-1-1"));
assert!(json.start_line.is_none());
assert!(json.start_column.is_none());
}
#[test]
fn error_kind_serializes_as_lowercase() {
let diag = DiagnosticMessage::error("Boom");
let ctx = SourceContext::new();
assert_eq!(diagnostic_to_json(&diag, &ctx).kind, "error");
}
#[test]
fn info_and_note_kinds_serialize() {
let ctx = SourceContext::new();
assert_eq!(
diagnostic_to_json(&DiagnosticMessage::info("i"), &ctx).kind,
"info"
);
assert_eq!(
diagnostic_to_json(&DiagnosticMessage::new(DiagnosticKind::Note, "n"), &ctx).kind,
"note"
);
}
#[test]
fn with_source_file_tags_the_diagnostic() {
let json = diagnostic_to_json(
&DiagnosticMessage::warning("Bad sibling"),
&SourceContext::new(),
);
let tagged = with_source_file(json, "other.qmd".to_string());
assert_eq!(tagged.source_file.as_deref(), Some("other.qmd"));
}
#[test]
fn diagnostic_carries_schema_url() {
let json = diagnostic_to_json(
&DiagnosticMessage::warning("with schema"),
&SourceContext::new(),
);
assert_eq!(json.schema, JsonDiagnostic::SCHEMA_URL);
let tagged = with_source_file(json, "a.qmd".to_string());
assert_eq!(tagged.schema, JsonDiagnostic::SCHEMA_URL);
}
#[test]
fn diagnostic_serializes_schema_field_as_dollar_schema() {
let json = diagnostic_to_json(
&DiagnosticMessage::warning("wire form"),
&SourceContext::new(),
);
let s = serde_json::to_value(&json).unwrap();
assert_eq!(
s.get("$schema").and_then(|v| v.as_str()),
Some(JsonDiagnostic::SCHEMA_URL)
);
assert!(
s.get("schema").is_none(),
"the serde rename should suppress the un-renamed `schema` key"
);
}
#[test]
fn pass1_failure_carries_schema_url() {
let f = JsonPass1Failure::new("other.qmd".to_string(), "boom".to_string(), vec![]);
assert_eq!(f.schema, JsonPass1Failure::SCHEMA_URL);
let s = serde_json::to_value(&f).unwrap();
assert_eq!(
s.get("$schema").and_then(|v| v.as_str()),
Some(JsonPass1Failure::SCHEMA_URL)
);
}
fn synth_located_diag() -> (DiagnosticMessage, SourceContext) {
use quarto_source_map::{
SourceInfo,
types::{Location, Range},
};
let mut ctx = SourceContext::new();
let file_id = ctx.add_file(
"fixture.qmd".to_string(),
Some("# Title\n\nA paragraph that has _unclosed emphasis.\n".to_string()),
);
let info = SourceInfo::from_range(
file_id,
Range {
start: Location {
offset: 28,
row: 2,
column: 19,
},
end: Location {
offset: 29,
row: 2,
column: 20,
},
},
);
let mut diag =
DiagnosticMessage::warning("Unclosed Underscore Emphasis").with_code("Q-2-5");
diag.location = Some(info);
(diag, ctx)
}
#[test]
fn rendered_is_some_when_location_present() {
let (diag, ctx) = synth_located_diag();
let json = diagnostic_to_json(&diag, &ctx);
let rendered = json
.rendered
.as_deref()
.expect("rendered should be populated when the diagnostic has a location");
assert!(
rendered.contains('\u{256D}'),
"rendered text should contain ariadne's box-drawing chars; got: {rendered:?}",
);
assert!(rendered.contains("Unclosed Underscore Emphasis"));
assert!(rendered.contains("Q-2-5"));
}
#[test]
fn rendered_is_none_when_location_absent() {
let diag = DiagnosticMessage::warning("Floating warning").with_code("Q-9-9");
let ctx = SourceContext::new();
let json = diagnostic_to_json(&diag, &ctx);
assert!(
json.rendered.is_none(),
"rendered should be None for a diagnostic without a location; got: {:?}",
json.rendered,
);
}
#[test]
fn rendered_skipped_in_json_when_none() {
let diag = DiagnosticMessage::warning("Floating warning");
let ctx = SourceContext::new();
let json = diagnostic_to_json(&diag, &ctx);
let serialized = serde_json::to_string(&json).unwrap();
assert!(
!serialized.contains("\"rendered\""),
"JSON should omit `rendered` when None; got: {serialized}",
);
}
}