Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,532 changes: 766 additions & 766 deletions crates/pampa/resources/error-corpus/_autogen-table.json

Large diffs are not rendered by default.

113 changes: 113 additions & 0 deletions crates/pampa/src/pandoc/treesitter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,119 @@ fn native_visitor<T: Write>(
"pipe_table_cell" => process_pipe_table_cell(node, children, context),
"caption" => process_caption(node, children, context),
"pipe_table" => process_pipe_table(node, children, context),
"grid_table" => {
use crate::pandoc::location::{SourceInfoOptions, node_source_info_with_options};

let raw_text = node.utf8_text(input_bytes).unwrap();
let input_str = std::str::from_utf8(input_bytes).unwrap_or("");
let file_id = context.current_file_id();

// Layered Ariadne rendering:
// 1. Multi-line main label spans the whole grid table to
// provide the `╭─▶ … ╰─` corner decoration and red
// highlighting on the table content.
// 2. For every line *after* the opening border that has a
// block-quote prefix (`>`/`> >` etc.), a high-priority
// faded label covers just that prefix range. Ariadne
// picks the shortest covering label per column, so the
// faded label wins on prefix columns and overrides the
// multi-line label's red with the dim grey it uses for
// unlabelled text.
// 3. For interior body lines (lines that aren't the open or
// close), an empty-message detail label spanning the
// table content forces Ariadne to display the line —
// otherwise the multi-line label's middle would be
// elided to a single `┆` row.
let node_start = node.start_byte();
let node_end = node.end_byte();
let lines: Vec<&str> = raw_text.split('\n').collect();
let nonempty_count = lines.iter().filter(|s| !s.is_empty()).count();

let mut faded_prefixes: Vec<quarto_source_map::SourceInfo> = Vec::new();
let mut interior_contents: Vec<quarto_source_map::SourceInfo> = Vec::new();

let mut offset_in_node = 0usize;
let mut nonempty_idx = 0usize;
for line in &lines {
let line_global_start = node_start + offset_in_node;
offset_in_node += line.len() + 1;
if line.is_empty() {
continue;
}
let is_first = nonempty_idx == 0;
let is_last = nonempty_idx + 1 == nonempty_count;
nonempty_idx += 1;

let content_offset = line.find(['+', '|']).unwrap_or(line.len());

// Faded label for the leading `>` prefix, but only when
// the multi-line main label actually covers it. On the
// opening line the main label starts at the first `+`,
// so the prefix is already outside every label.
if !is_first && content_offset > 0 {
let prefix_start = line_global_start;
let prefix_end = line_global_start + content_offset;
if prefix_start >= node_start
&& prefix_end <= node_end
&& let (Some(start_loc), Some(end_loc)) = (
quarto_source_map::utils::offset_to_location(input_str, prefix_start),
quarto_source_map::utils::offset_to_location(input_str, prefix_end),
)
{
faded_prefixes.push(quarto_source_map::SourceInfo::from_range(
file_id,
quarto_source_map::Range {
start: start_loc,
end: end_loc,
},
));
}
}

// Force interior lines to display by attaching a label.
// The opening line has the multi-line label's start margin
// and the closing line has its end margin, so neither
// needs an extra label.
if !is_first && !is_last {
let content_start = line_global_start + content_offset;
let content_end = line_global_start + line.len();
if let (Some(start_loc), Some(end_loc)) = (
quarto_source_map::utils::offset_to_location(input_str, content_start),
quarto_source_map::utils::offset_to_location(input_str, content_end),
) {
interior_contents.push(quarto_source_map::SourceInfo::from_range(
file_id,
quarto_source_map::Range {
start: start_loc,
end: end_loc,
},
));
}
}
}

let main_loc =
node_source_info_with_options(node, context, &SourceInfoOptions::trim_all());

let mut builder = DiagnosticMessageBuilder::error("Grid tables are not supported")
.with_code("Q-2-36")
.with_location(main_loc)
.problem("Grid tables aren't supported. Use a pipe table instead.");
for prefix_loc in &faded_prefixes {
builder = builder.add_faded_at("", prefix_loc.clone());
}
for content_loc in &interior_contents {
builder = builder.add_detail_at("", content_loc.clone());
}
let msg = builder.build();
error_collector.add(msg);

PandocNativeIntermediate::IntermediateBlock(Block::RawBlock(RawBlock {
format: "qmd-grid-table".to_string(),
text: raw_text.to_string(),
source_info: node_source_info_with_context(node, context),
}))
}
"comment" => {
// HTML comments (<!-- ... -->) are preserved as RawInline(html)
// so they survive round-tripping through the AST and are not lost
Expand Down
146 changes: 146 additions & 0 deletions crates/pampa/tests/test_grid_table_error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
use pampa::readers;
use quarto_error_reporting::DiagnosticKind;

fn parse_and_get_diagnostics(input: &str) -> Vec<quarto_error_reporting::DiagnosticMessage> {
let result = readers::qmd::read(
input.as_bytes(),
false,
"test.qmd",
&mut std::io::sink(),
true,
None,
);

match result {
Ok((_, _, diagnostics)) => diagnostics,
Err(diagnostics) => diagnostics,
}
}

/// Reconstruct the captured grid-table text from the diagnostic. The
/// diagnostic anchors on the first line (so Ariadne renders a clean
/// single-line main label) and uses per-line `note_at` details for the
/// remaining lines (so every body line shows in the snippet instead of
/// being elided to `┆`). To recover the full table, we union the main
/// location with all detail locations and slice the input accordingly.
fn captured_text<'a>(
diag: &quarto_error_reporting::DiagnosticMessage,
full_input: &'a str,
) -> &'a str {
let main = diag
.location
.as_ref()
.expect("grid_table diagnostic must carry a source location");
let mut start = main.start_offset();
let mut end = main.end_offset();
for detail in &diag.details {
if let Some(loc) = &detail.location {
start = start.min(loc.start_offset());
end = end.max(loc.end_offset());
}
}
&full_input[start..end]
}

fn assert_grid_table_diagnostic(
diagnostics: &[quarto_error_reporting::DiagnosticMessage],
expected_capture: &str,
full_input: &str,
) {
let grid = diagnostics
.iter()
.find(|d| d.code.as_deref() == Some("Q-2-36"))
.unwrap_or_else(|| {
panic!(
"no Q-2-36 diagnostic found. all diagnostics: {:?}",
diagnostics
.iter()
.map(|d| (d.code.clone(), d.title.clone(), d.kind))
.collect::<Vec<_>>()
)
});

assert_eq!(
grid.kind,
DiagnosticKind::Error,
"grid_table diagnostic must be an error, got {:?}",
grid.kind
);

let captured = captured_text(grid, full_input);
assert_eq!(
captured.trim_end(),
expected_capture.trim_end(),
"captured text {captured:?} does not match expected {expected_capture:?}"
);
}

#[test]
fn plain_grid_table_emits_q_2_36_with_full_text() {
let input = "+----+\n| oh |\n+----+\n";
let diagnostics = parse_and_get_diagnostics(input);
assert_grid_table_diagnostic(&diagnostics, "+----+\n| oh |\n+----+", input);
}

#[test]
fn grid_table_inside_block_quote_emits_q_2_36() {
let input = "> +----+\n> | oh |\n> +----+\n";
let diagnostics = parse_and_get_diagnostics(input);
let grid = diagnostics
.iter()
.find(|d| d.code.as_deref() == Some("Q-2-36"))
.expect("expected Q-2-36 diagnostic for grid table inside block quote");
assert_eq!(grid.kind, DiagnosticKind::Error);

let captured = captured_text(grid, input);
assert!(
captured.contains("+----+") && captured.contains("| oh |"),
"captured grid-table text {captured:?} should contain both border and body lines"
);
}

#[test]
fn nested_block_quotes_with_two_grid_tables_emit_two_q_2_36() {
let input = "> > +----+\n> > | oh |\n> > +----+\n> | pipe-table-now |\n> |-|\n> | no |\n> > +----+\n> > | no |\n> > +----+\n";
let diagnostics = parse_and_get_diagnostics(input);
let grid_diagnostics: Vec<_> = diagnostics
.iter()
.filter(|d| d.code.as_deref() == Some("Q-2-36"))
.collect();
assert_eq!(
grid_diagnostics.len(),
2,
"expected exactly two Q-2-36 diagnostics (one per nested grid table), got {}",
grid_diagnostics.len()
);

for grid in &grid_diagnostics {
assert_eq!(grid.kind, DiagnosticKind::Error);
let captured = captured_text(grid, input);
assert!(
captured.contains("+----+"),
"captured text {captured:?} should contain a grid-table border"
);
}
}

#[test]
fn lone_border_line_does_not_emit_grid_table_diagnostic() {
// A single "+----+" line is just a paragraph string and should not trip the
// grid-table detector.
let input = "+----+\n";
let diagnostics = parse_and_get_diagnostics(input);
assert!(
!diagnostics
.iter()
.any(|d| d.code.as_deref() == Some("Q-2-36")),
"lone border line should not be flagged as a grid table"
);
}

#[test]
fn grid_table_with_equals_separator_emits_q_2_36() {
let input = "+====+\n| oh |\n+----+\n";
let diagnostics = parse_and_get_diagnostics(input);
assert_grid_table_diagnostic(&diagnostics, "+====+\n| oh |\n+----+", input);
}
7 changes: 7 additions & 0 deletions crates/quarto-error-reporting/error_catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,13 @@
"docs_url": "https://quarto.org/docs/errors/Q-2-35",
"since_version": "99.9.9"
},
"Q-2-36": {
"subsystem": "markdown",
"title": "Grid tables are not supported",
"message_template": "Grid tables aren't supported. Use a pipe table instead.",
"docs_url": "https://quarto.org/docs/errors/Q-2-36",
"since_version": "99.9.9"
},

"Q-3-1": {
"subsystem": "writer",
Expand Down
20 changes: 20 additions & 0 deletions crates/quarto-error-reporting/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,26 @@ impl DiagnosticMessageBuilder {
self
}

/// Add a faded detail with a source location.
///
/// Rendered with the same dim grey colour Ariadne uses for unlabelled
/// source characters, so it visually "punches a hole" in any wider
/// label that also covers the same column range. Useful for excluding
/// block-quote prefixes or other prefix decorations from the highlight
/// of a multi-line span.
pub fn add_faded_at(
mut self,
content: impl Into<MessageContent>,
location: quarto_source_map::SourceInfo,
) -> Self {
self.details.push(DetailItem {
kind: DetailKind::Faded,
content: content.into(),
location: Some(location),
});
self
}

/// Add a hint for fixing the error.
///
/// Following tidyverse guidelines, hints should:
Expand Down
Loading