use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DiagnosticKind {
Error,
Warning,
Info,
Note,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DetailKind {
Error,
Info,
Note,
Faded,
}
#[derive(Debug, Clone)]
pub struct TextRenderOptions {
pub enable_hyperlinks: bool,
}
impl Default for TextRenderOptions {
fn default() -> Self {
Self {
enable_hyperlinks: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SourceRenderer {
#[cfg(feature = "ariadne")]
Ariadne,
#[cfg(feature = "annotate-snippets")]
AnnotateSnippets,
}
impl SourceRenderer {
pub fn default_for_features() -> Option<Self> {
#[cfg(feature = "ariadne")]
{
Some(Self::Ariadne)
}
#[cfg(all(not(feature = "ariadne"), feature = "annotate-snippets"))]
{
Some(Self::AnnotateSnippets)
}
#[cfg(all(not(feature = "ariadne"), not(feature = "annotate-snippets")))]
{
None
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MessageContent {
Plain(String),
Markdown(String),
}
impl MessageContent {
pub fn as_str(&self) -> &str {
match self {
MessageContent::Plain(s) => s,
MessageContent::Markdown(s) => s,
}
}
pub fn to_json(&self) -> serde_json::Value {
use serde_json::json;
match self {
MessageContent::Plain(s) => json!({
"type": "plain",
"content": s
}),
MessageContent::Markdown(s) => json!({
"type": "markdown",
"content": s
}),
}
}
}
impl From<String> for MessageContent {
fn from(s: String) -> Self {
MessageContent::Markdown(s)
}
}
impl From<&str> for MessageContent {
fn from(s: &str) -> Self {
MessageContent::Markdown(s.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DetailItem {
pub kind: DetailKind,
pub content: MessageContent,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<quarto_source_map::SourceInfo>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DiagnosticMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
pub title: String,
pub kind: DiagnosticKind,
pub problem: Option<MessageContent>,
pub details: Vec<DetailItem>,
pub hints: Vec<MessageContent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<quarto_source_map::SourceInfo>,
}
impl DiagnosticMessage {
pub fn builder() -> crate::builder::DiagnosticMessageBuilder {
crate::builder::DiagnosticMessageBuilder::error("")
}
pub fn new(kind: DiagnosticKind, title: impl Into<String>) -> Self {
Self {
code: None,
title: title.into(),
kind,
problem: None,
details: Vec::new(),
hints: Vec::new(),
location: None,
}
}
pub fn error(title: impl Into<String>) -> Self {
Self::new(DiagnosticKind::Error, title)
}
pub fn warning(title: impl Into<String>) -> Self {
Self::new(DiagnosticKind::Warning, title)
}
pub fn info(title: impl Into<String>) -> Self {
Self::new(DiagnosticKind::Info, title)
}
pub fn with_code(mut self, code: impl Into<String>) -> Self {
self.code = Some(code.into());
self
}
pub fn docs_url(&self) -> Option<&str> {
self.code
.as_ref()
.and_then(|code| crate::catalog::get_docs_url(code))
}
pub fn to_text(&self, ctx: Option<&quarto_source_map::SourceContext>) -> String {
self.to_text_with_options(ctx, &TextRenderOptions::default())
}
pub fn to_text_with_options(
&self,
ctx: Option<&quarto_source_map::SourceContext>,
options: &TextRenderOptions,
) -> String {
self.to_text_with_renderer(ctx, options, None)
}
pub fn to_text_with_renderer(
&self,
ctx: Option<&quarto_source_map::SourceContext>,
options: &TextRenderOptions,
renderer: Option<SourceRenderer>,
) -> String {
use std::fmt::Write;
let mut result = String::new();
let has_any_location =
self.location.is_some() || self.details.iter().any(|d| d.location.is_some());
let has_source_render = if let (true, Some(ctx_val)) = (has_any_location, ctx) {
let location = self
.location
.as_ref()
.or_else(|| self.details.iter().find_map(|d| d.location.as_ref()));
if let Some(loc) = location {
if let Some(snippet_output) =
self.render_source_context(loc, ctx_val, options.enable_hyperlinks, renderer)
{
result.push_str(&snippet_output);
true
} else {
false
}
} else {
false
}
} else {
false
};
if !has_source_render {
let kind_str = match self.kind {
DiagnosticKind::Error => "Error",
DiagnosticKind::Warning => "Warning",
DiagnosticKind::Info => "Info",
DiagnosticKind::Note => "Note",
};
if let Some(code) = &self.code {
writeln!(result, "{} [{}]: {}", kind_str, code, self.title).unwrap();
} else {
writeln!(result, "{}: {}", kind_str, self.title).unwrap();
}
if let Some(loc) = &self.location {
if let Some(ctx) = ctx {
if let Some(mapped) = loc.map_offset(loc.start_offset(), ctx)
&& let Some(file) = ctx.get_file(mapped.file_id)
{
writeln!(
result,
" at {}:{}:{}",
file.path,
mapped.location.row + 1,
mapped.location.column + 1
)
.unwrap();
}
} else {
writeln!(result, " at offset {}", loc.start_offset()).unwrap();
}
}
if let Some(problem) = &self.problem {
writeln!(result, "{}", problem.as_str()).unwrap();
}
for detail in &self.details {
let bullet = match detail.kind {
DetailKind::Error => "✖",
DetailKind::Info => "ℹ",
DetailKind::Note | DetailKind::Faded => "•",
};
writeln!(result, "{} {}", bullet, detail.content.as_str()).unwrap();
}
for hint in &self.hints {
writeln!(result, "ℹ {}", hint.as_str()).unwrap();
}
} else {
for detail in &self.details {
if detail.location.is_none() {
let bullet = match detail.kind {
DetailKind::Error => "✖",
DetailKind::Info => "ℹ",
DetailKind::Note | DetailKind::Faded => "•",
};
writeln!(result, "{} {}", bullet, detail.content.as_str()).unwrap();
}
}
for hint in &self.hints {
writeln!(result, "ℹ {}", hint.as_str()).unwrap();
}
}
result
}
pub fn to_json(&self) -> serde_json::Value {
use serde_json::json;
let kind_str = match self.kind {
DiagnosticKind::Error => "error",
DiagnosticKind::Warning => "warning",
DiagnosticKind::Info => "info",
DiagnosticKind::Note => "note",
};
let mut obj = json!({
"kind": kind_str,
"title": self.title,
});
if let Some(code) = &self.code {
obj["code"] = json!(code);
}
if let Some(problem) = &self.problem {
obj["problem"] = problem.to_json();
}
if !self.details.is_empty() {
let details: Vec<_> = self
.details
.iter()
.map(|d| {
let detail_kind = match d.kind {
DetailKind::Error => "error",
DetailKind::Info => "info",
DetailKind::Note => "note",
DetailKind::Faded => "faded",
};
let mut detail_obj = json!({
"kind": detail_kind,
"content": d.content.to_json()
});
if let Some(location) = &d.location {
detail_obj["location"] = json!(location);
}
detail_obj
})
.collect();
obj["details"] = json!(details);
}
if !self.hints.is_empty() {
let hints: Vec<_> = self.hints.iter().map(|h| h.to_json()).collect();
obj["hints"] = json!(hints);
}
if let Some(location) = &self.location {
obj["location"] = json!(location); }
obj
}
#[cfg_attr(
not(any(feature = "ariadne", feature = "annotate-snippets")),
allow(unused_variables)
)]
fn render_source_context(
&self,
main_location: &quarto_source_map::SourceInfo,
ctx: &quarto_source_map::SourceContext,
enable_hyperlinks: bool,
renderer: Option<SourceRenderer>,
) -> Option<String> {
let renderer = renderer.or_else(SourceRenderer::default_for_features)?;
match renderer {
#[cfg(feature = "ariadne")]
SourceRenderer::Ariadne => {
self.render_ariadne_source_context(main_location, ctx, enable_hyperlinks)
}
#[cfg(feature = "annotate-snippets")]
SourceRenderer::AnnotateSnippets => {
self.render_annotate_snippets_source_context(main_location, ctx, enable_hyperlinks)
}
}
}
#[cfg(all(feature = "ariadne", not(target_family = "wasm")))]
fn wrap_path_with_hyperlink(
path: &str,
has_disk_file: bool,
line: Option<usize>,
column: Option<usize>,
enable_hyperlinks: bool,
) -> String {
if !enable_hyperlinks {
return path.to_string();
}
if !has_disk_file {
return path.to_string();
}
let abs_path = match std::fs::canonicalize(path) {
Ok(p) => p,
Err(_) => return path.to_string(), };
let mut file_url = match url::Url::from_file_path(&abs_path) {
Ok(url) => url.as_str().to_string(),
Err(_) => return path.to_string(), };
if let Some(line_num) = line {
if let Some(col_num) = column {
file_url.push_str(&format!("#{}:{}", line_num, col_num));
} else {
file_url.push_str(&format!("#{}", line_num));
}
}
format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", file_url, path)
}
#[cfg(all(feature = "ariadne", target_family = "wasm"))]
fn wrap_path_with_hyperlink(
path: &str,
_has_disk_file: bool,
_line: Option<usize>,
_column: Option<usize>,
_enable_hyperlinks: bool,
) -> String {
path.to_string()
}
#[cfg(feature = "ariadne")]
fn render_ariadne_source_context(
&self,
main_location: &quarto_source_map::SourceInfo,
ctx: &quarto_source_map::SourceContext,
enable_hyperlinks: bool,
) -> Option<String> {
use ariadne::{Color, Config, IndexType, Label, Report, ReportKind, Source};
const ARIADNE_UNIMPORTANT_COLOR: Color = Color::Fixed(249);
let file_id = main_location.root_file_id()?;
let file = ctx.get_file(file_id)?;
let content = match &file.content {
Some(c) => c.clone(),
None => match std::fs::read_to_string(&file.path) {
Ok(s) => s,
Err(_) => return None,
},
};
let start_mapped = main_location.map_offset(0, ctx)?;
let end_mapped = main_location
.map_offset(main_location.length(), ctx)
.or_else(|| {
if main_location.length() > 0 {
main_location.map_offset(main_location.length() - 1, ctx)
} else {
None
}
})
.unwrap_or_else(|| start_mapped.clone());
let is_disk_file = std::path::Path::new(&file.path).exists();
let line = Some(start_mapped.location.row + 1);
let column = Some(start_mapped.location.column + 1);
let display_path = Self::wrap_path_with_hyperlink(
&file.path,
is_disk_file,
line,
column,
enable_hyperlinks,
);
let (report_kind, main_color) = match self.kind {
DiagnosticKind::Error => (ReportKind::Error, Color::Red),
DiagnosticKind::Warning => (ReportKind::Warning, Color::Yellow),
DiagnosticKind::Info => (ReportKind::Advice, Color::Cyan),
DiagnosticKind::Note => (ReportKind::Advice, Color::Blue),
};
let mut report = Report::build(
report_kind,
(
display_path.clone(),
start_mapped.location.offset..start_mapped.location.offset,
),
)
.with_config(Config::default().with_index_type(IndexType::Byte));
if let Some(code) = &self.code {
report = report.with_message(format!("[{}] {}", code, self.title));
} else {
report = report.with_message(&self.title);
}
let main_span = start_mapped.location.offset..end_mapped.location.offset;
let main_message = if let Some(problem) = &self.problem {
problem.as_str()
} else {
&self.title
};
report = report.with_label(
Label::new((display_path.clone(), main_span.clone()))
.with_message(main_message)
.with_color(main_color)
.with_order(main_span.end as i32),
);
for detail in &self.details {
if let Some(detail_loc) = &detail.location {
let detail_file_id = match detail_loc.root_file_id() {
Some(fid) => fid,
None => continue, };
if detail_file_id == file_id {
if let (Some(detail_start), Some(detail_end)) = (
detail_loc.map_offset(0, ctx),
detail_loc.map_offset(detail_loc.length(), ctx),
) {
let detail_span = detail_start.location.offset..detail_end.location.offset;
let detail_color = match detail.kind {
DetailKind::Error => Color::Red,
DetailKind::Info => Color::Cyan,
DetailKind::Note => Color::Blue,
DetailKind::Faded => ARIADNE_UNIMPORTANT_COLOR,
};
let mut label = Label::new((display_path.clone(), detail_span.clone()))
.with_color(detail_color)
.with_order(detail_span.end as i32);
if !detail.content.as_str().is_empty() {
label = label.with_message(detail.content.as_str());
}
report = report.with_label(label);
}
}
}
}
let report = report.finish();
let mut output = Vec::new();
report
.write(
(display_path.clone(), Source::from(content.as_str())),
&mut output,
)
.ok()?;
let output_str = String::from_utf8(output).ok()?;
if is_disk_file && enable_hyperlinks {
Some(Self::extend_hyperlink_to_include_line_column(
&output_str,
&file.path,
))
} else {
Some(output_str)
}
}
#[cfg(feature = "annotate-snippets")]
fn render_annotate_snippets_source_context(
&self,
main_location: &quarto_source_map::SourceInfo,
ctx: &quarto_source_map::SourceContext,
_enable_hyperlinks: bool,
) -> Option<String> {
use annotate_snippets::{AnnotationKind, Level, Renderer, Snippet};
let file_id = main_location.root_file_id()?;
let file = ctx.get_file(file_id)?;
let content = match &file.content {
Some(c) => c.clone(),
None => std::fs::read_to_string(&file.path).ok()?,
};
let content_len = content.len();
let clamp = |start: usize, end: usize| -> std::ops::Range<usize> {
let s = start.min(content_len);
let e = end.min(content_len).max(s);
s..e
};
let start_mapped = main_location.map_offset(0, ctx)?;
let end_mapped = main_location
.map_offset(main_location.length(), ctx)
.or_else(|| {
if main_location.length() > 0 {
main_location.map_offset(main_location.length() - 1, ctx)
} else {
None
}
})
.unwrap_or_else(|| start_mapped.clone());
let main_span = clamp(start_mapped.location.offset, end_mapped.location.offset);
let level = match self.kind {
DiagnosticKind::Error => Level::ERROR,
DiagnosticKind::Warning => Level::WARNING,
DiagnosticKind::Info => Level::INFO,
DiagnosticKind::Note => Level::NOTE,
};
let main_message = match &self.problem {
Some(problem) => problem.as_str(),
None => self.title.as_str(),
};
let mut snippet = Snippet::source(content.as_str())
.path(file.path.as_str())
.line_start(1)
.annotation(AnnotationKind::Primary.span(main_span).label(main_message));
for detail in &self.details {
if detail.content.as_str().is_empty() {
continue;
}
let Some(detail_loc) = &detail.location else {
continue;
};
if detail_loc.root_file_id() != Some(file_id) {
continue;
}
if let (Some(detail_start), Some(detail_end)) = (
detail_loc.map_offset(0, ctx),
detail_loc.map_offset(detail_loc.length(), ctx),
) {
let detail_span = clamp(detail_start.location.offset, detail_end.location.offset);
snippet = snippet.annotation(
AnnotationKind::Context
.span(detail_span)
.label(detail.content.as_str()),
);
}
}
let mut title = level.primary_title(self.title.as_str());
if let Some(code) = &self.code {
title = title.id(code.as_str());
}
let group = title.element(snippet);
let mut rendered = Renderer::styled().render(&[group]);
if !rendered.ends_with('\n') {
rendered.push('\n');
}
Some(rendered)
}
#[cfg(feature = "ariadne")]
fn extend_hyperlink_to_include_line_column(output: &str, original_path: &str) -> String {
let end_marker = "\x1b]8;;\x1b\\";
let search_pattern = format!("{}{}", original_path, end_marker);
let mut result = output.to_string();
while let Some(pos) = result.find(&search_pattern) {
let after_marker = pos + search_pattern.len();
if let Some(rest) = result.get(after_marker..) {
if let Some(colon_end) = Self::find_line_column_end(rest) {
let before = &result[..pos + original_path.len()];
let line_col = &rest[..colon_end];
let after = &rest[colon_end..];
result = format!("{}{}{}{}", before, line_col, end_marker, after);
continue;
}
}
break;
}
result
}
#[cfg(feature = "ariadne")]
fn find_line_column_end(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
if bytes.is_empty() || bytes[0] != b':' {
return None;
}
let mut pos = 1;
while pos < bytes.len() && bytes[pos].is_ascii_digit() {
pos += 1;
}
if pos == 1 || pos >= bytes.len() || bytes[pos] != b':' {
return None; }
pos += 1; let col_start = pos;
while pos < bytes.len() && bytes[pos].is_ascii_digit() {
pos += 1;
}
if pos == col_start {
return None; }
Some(pos)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_diagnostic_kind() {
assert_eq!(DiagnosticKind::Error, DiagnosticKind::Error);
assert_ne!(DiagnosticKind::Error, DiagnosticKind::Warning);
}
#[test]
fn test_message_content_from_str() {
let content: MessageContent = "test".into();
assert_eq!(content.as_str(), "test");
}
#[test]
fn test_diagnostic_message_new() {
let msg = DiagnosticMessage::new(DiagnosticKind::Error, "Test error");
assert_eq!(msg.title, "Test error");
assert_eq!(msg.kind, DiagnosticKind::Error);
assert!(msg.code.is_none());
assert!(msg.problem.is_none());
assert!(msg.details.is_empty());
assert!(msg.hints.is_empty());
}
#[test]
fn test_diagnostic_message_constructors() {
let error = DiagnosticMessage::error("Error");
assert_eq!(error.kind, DiagnosticKind::Error);
assert!(error.code.is_none());
let warning = DiagnosticMessage::warning("Warning");
assert_eq!(warning.kind, DiagnosticKind::Warning);
let info = DiagnosticMessage::info("Info");
assert_eq!(info.kind, DiagnosticKind::Info);
}
#[test]
fn test_with_code() {
let msg = DiagnosticMessage::error("Test error").with_code("Q-1-1");
assert_eq!(msg.code, Some("Q-1-1".to_string()));
}
#[test]
fn test_docs_url_without_code() {
let msg = DiagnosticMessage::error("Test error");
assert!(msg.docs_url().is_none());
}
#[test]
fn test_docs_url_invalid_code() {
let msg = DiagnosticMessage::error("Test error").with_code("Q-999-999"); assert!(msg.docs_url().is_none());
}
#[test]
fn test_to_text_simple_error() {
let msg = DiagnosticMessage::error("Something went wrong");
assert_eq!(msg.to_text(None), "Error: Something went wrong\n");
}
#[test]
fn test_to_text_with_code() {
let msg = DiagnosticMessage::error("Something went wrong").with_code("Q-1-1");
assert_eq!(msg.to_text(None), "Error [Q-1-1]: Something went wrong\n");
}
#[test]
fn test_to_text_full_message() {
use crate::builder::DiagnosticMessageBuilder;
let msg = DiagnosticMessageBuilder::error("Invalid input")
.problem("Values must be numeric")
.add_detail("Found text in column 3")
.add_info("Columns should contain only numbers")
.add_hint("Convert to numbers first?")
.build();
let text = msg.to_text(None);
assert!(text.contains("Error: Invalid input"));
assert!(text.contains("Values must be numeric"));
assert!(text.contains("✖ Found text in column 3"));
assert!(text.contains("ℹ Columns should contain only numbers"));
assert!(text.contains("ℹ Convert to numbers first?"));
}
#[test]
fn test_to_json_simple() {
let msg = DiagnosticMessage::error("Something went wrong");
let json = msg.to_json();
assert_eq!(json["kind"], "error");
assert_eq!(json["title"], "Something went wrong");
assert!(json.get("code").is_none());
assert!(json.get("problem").is_none());
}
#[test]
fn test_to_json_with_code() {
let msg = DiagnosticMessage::error("Something went wrong").with_code("Q-1-1");
let json = msg.to_json();
assert_eq!(json["kind"], "error");
assert_eq!(json["title"], "Something went wrong");
assert_eq!(json["code"], "Q-1-1");
}
#[test]
fn test_to_json_full_message() {
use crate::builder::DiagnosticMessageBuilder;
let msg = DiagnosticMessageBuilder::error("Invalid input")
.with_code("Q-1-2") .problem("Values must be numeric")
.add_detail("Found text in column 3")
.add_info("Expected numbers")
.add_hint("Convert to numbers first?")
.build();
let json = msg.to_json();
assert_eq!(json["kind"], "error");
assert_eq!(json["title"], "Invalid input");
assert_eq!(json["code"], "Q-1-2"); assert_eq!(json["problem"]["type"], "markdown");
assert_eq!(json["problem"]["content"], "Values must be numeric");
assert_eq!(json["details"][0]["kind"], "error");
assert_eq!(json["details"][0]["content"]["type"], "markdown");
assert_eq!(
json["details"][0]["content"]["content"],
"Found text in column 3"
);
assert_eq!(json["details"][1]["kind"], "info");
assert_eq!(json["details"][1]["content"]["type"], "markdown");
assert_eq!(json["details"][1]["content"]["content"], "Expected numbers");
assert_eq!(json["hints"][0]["type"], "markdown");
assert_eq!(json["hints"][0]["content"], "Convert to numbers first?");
}
#[test]
fn test_to_json_warning() {
let msg = DiagnosticMessage::warning("Be careful");
let json = msg.to_json();
assert_eq!(json["kind"], "warning");
assert_eq!(json["title"], "Be careful");
}
#[test]
fn test_location_in_to_text_without_context() {
use crate::builder::DiagnosticMessageBuilder;
let location =
quarto_source_map::SourceInfo::original(quarto_source_map::FileId(0), 100, 110);
let msg = DiagnosticMessageBuilder::error("Invalid syntax")
.with_location(location)
.build();
let text = msg.to_text(None);
assert!(text.contains("Invalid syntax"));
assert!(text.contains("at offset 100"));
}
#[test]
fn test_location_in_to_text_with_context() {
use crate::builder::DiagnosticMessageBuilder;
let mut ctx = quarto_source_map::SourceContext::new();
let file_id = ctx.add_file(
"test.qmd".to_string(),
Some("line 1\nline 2\nline 3\nline 4".to_string()),
);
let location = quarto_source_map::SourceInfo::original(
file_id, 7, 13, );
let msg = DiagnosticMessageBuilder::error("Invalid syntax")
.with_location(location)
.build();
let text = msg.to_text(Some(&ctx));
assert!(text.contains("Invalid syntax"));
assert!(text.contains("test.qmd"));
assert!(text.contains("2:1")); }
#[test]
fn test_location_in_to_json() {
use crate::builder::DiagnosticMessageBuilder;
let location =
quarto_source_map::SourceInfo::original(quarto_source_map::FileId(0), 100, 110);
let msg = DiagnosticMessageBuilder::error("Invalid syntax")
.with_location(location)
.build();
let json = msg.to_json();
assert!(json.get("location").is_some());
let loc = &json["location"];
assert!(loc.get("Original").is_some());
let original = &loc["Original"];
assert_eq!(original["file_id"], 0);
assert_eq!(original["start_offset"], 100);
assert_eq!(original["end_offset"], 110);
}
#[test]
fn test_location_optional_in_to_json() {
let msg = DiagnosticMessage::error("No location");
let json = msg.to_json();
assert!(json.get("location").is_none());
}
#[test]
fn test_text_render_options_disable_hyperlinks() {
use crate::builder::DiagnosticMessageBuilder;
let mut ctx = quarto_source_map::SourceContext::new();
let file_id = ctx.add_file("test.qmd".to_string(), Some("test content".to_string()));
let location = quarto_source_map::SourceInfo::original(file_id, 0, 4);
let msg = DiagnosticMessageBuilder::error("Test error")
.with_location(location)
.build();
let with_hyperlinks = msg.to_text(Some(&ctx));
let options = TextRenderOptions {
enable_hyperlinks: false,
};
let without_hyperlinks = msg.to_text_with_options(Some(&ctx), &options);
if with_hyperlinks.contains("\x1b]8;") {
assert!(
!without_hyperlinks.contains("\x1b]8;"),
"Disabled hyperlinks should not contain OSC 8 codes"
);
}
}
#[test]
fn test_text_render_options_default() {
let options = TextRenderOptions::default();
assert!(
options.enable_hyperlinks,
"Default should enable hyperlinks"
);
}
#[test]
fn test_render_with_custom_options() {
use crate::builder::DiagnosticMessageBuilder;
let msg = DiagnosticMessageBuilder::error("Test")
.problem("Something went wrong")
.add_detail("Detail 1")
.add_hint("Try this")
.build();
let options = TextRenderOptions {
enable_hyperlinks: false,
};
let text = msg.to_text_with_options(None, &options);
assert!(text.contains("Error: Test"));
assert!(text.contains("Something went wrong"));
assert!(text.contains("Detail 1"));
assert!(text.contains("Try this"));
}
#[cfg(feature = "annotate-snippets")]
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for n in chars.by_ref() {
if n == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[cfg(feature = "annotate-snippets")]
#[test]
fn annotate_snippets_renderer_produces_rust_style_output() {
use crate::builder::DiagnosticMessageBuilder;
let mut ctx = quarto_source_map::SourceContext::new();
let file_id = ctx.add_file(
"test.qmd".to_string(),
Some("line 1\nline 2\nline 3".to_string()),
);
let location = quarto_source_map::SourceInfo::original(file_id, 7, 13);
let msg = DiagnosticMessageBuilder::error("Bad thing")
.with_code("Q-9-9")
.with_location(location)
.problem("this is wrong")
.build();
let opts = TextRenderOptions {
enable_hyperlinks: false,
};
let raw =
msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
let text = strip_ansi(&raw);
assert!(
text.contains("error[Q-9-9]"),
"expected rust-style code header; got: {text:?}"
);
assert!(
text.contains("-->"),
"expected rust-style origin arrow; got: {text:?}"
);
assert!(
text.contains("test.qmd:2:1"),
"expected mapped location; got: {text:?}"
);
assert!(
!text.contains('\u{256D}'),
"annotate-snippets must not draw ariadne's box corner; got: {text:?}"
);
assert!(
!raw.contains("\u{1b}]8;"),
"annotate-snippets emits no OSC 8 hyperlinks; got: {raw:?}"
);
}
#[cfg(all(feature = "ariadne", feature = "annotate-snippets"))]
#[test]
fn renderer_selection_switches_styles() {
use crate::builder::DiagnosticMessageBuilder;
let mut ctx = quarto_source_map::SourceContext::new();
let file_id = ctx.add_file("a.qmd".to_string(), Some("alpha\nbeta\ngamma".to_string()));
let location = quarto_source_map::SourceInfo::original(file_id, 6, 10); let msg = DiagnosticMessageBuilder::error("Pick a style")
.with_location(location)
.build();
let opts = TextRenderOptions {
enable_hyperlinks: false,
};
let ariadne = msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::Ariadne));
let snippets =
msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
assert!(ariadne.contains('\u{256D}'), "ariadne draws a box corner");
assert!(
!strip_ansi(&snippets).contains('\u{256D}'),
"annotate-snippets does not"
);
assert!(strip_ansi(&snippets).contains("-->"));
}
}