Skip to content

Commit 959d2f1

Browse files
authored
Updates/2025 11 21 (#116)
* 2025-11-21 updates * sync case files * new snapshots
1 parent 6b08a6c commit 959d2f1

File tree

980 files changed

+122109
-107128
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

980 files changed

+122109
-107128
lines changed

crates/qmd-syntax-helper/src/conversions/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ pub mod apostrophe_quotes;
22
pub mod attribute_ordering;
33
pub mod definition_lists;
44
pub mod grid_tables;
5-
pub mod q_2_5;
65
pub mod q_2_11;
76
pub mod q_2_12;
87
pub mod q_2_13;
@@ -18,3 +17,7 @@ pub mod q_2_23;
1817
pub mod q_2_24;
1918
pub mod q_2_25;
2019
pub mod q_2_26;
20+
pub mod q_2_28;
21+
pub mod q_2_33;
22+
pub mod q_2_5;
23+
pub mod q_2_7;
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
// Q-2-28: Line Break Before Escaped Shortcode Close
2+
//
3+
// This conversion rule fixes Q-2-28 errors by removing line breaks
4+
// immediately before the escaped shortcode closing delimiter >}}}
5+
//
6+
// Error catalog entry: crates/quarto-error-reporting/error_catalog.json
7+
// Error code: Q-2-28
8+
// Title: "Line Break Before Escaped Shortcode Close"
9+
// Message: "Line breaks are not allowed immediately before the escaped shortcode closing delimiter `>}}}`."
10+
//
11+
// Example:
12+
// Input: {{{< include file.qmd
13+
// >}}}
14+
// Output: {{{< include file.qmd >}}}
15+
//
16+
17+
use anyhow::{Context, Result};
18+
use std::fs;
19+
use std::path::Path;
20+
21+
use crate::rule::{CheckResult, ConvertResult, Rule, SourceLocation};
22+
use crate::utils::file_io::read_file;
23+
24+
pub struct Q228Converter {}
25+
26+
#[derive(Debug, Clone)]
27+
struct Q228Violation {
28+
// We'll store the offset of the newline that needs to be removed
29+
newline_start: usize,
30+
// And the offset where >}}} starts (after whitespace)
31+
close_delimiter_start: usize,
32+
error_location: Option<SourceLocation>,
33+
}
34+
35+
impl Q228Converter {
36+
pub fn new() -> Result<Self> {
37+
Ok(Self {})
38+
}
39+
40+
/// Get parse errors and extract Q-2-28 line break violations
41+
fn get_violations(&self, file_path: &Path) -> Result<Vec<Q228Violation>> {
42+
let content = fs::read_to_string(file_path)
43+
.with_context(|| format!("Failed to read file: {}", file_path.display()))?;
44+
45+
// Parse with quarto-markdown-pandoc to get diagnostics
46+
let mut sink = std::io::sink();
47+
let filename = file_path.to_string_lossy();
48+
49+
let result = quarto_markdown_pandoc::readers::qmd::read(
50+
content.as_bytes(),
51+
false, // not loose mode
52+
&filename,
53+
&mut sink,
54+
false, // don't prune errors - we need them!
55+
None,
56+
);
57+
58+
// Get diagnostics from either Ok or Err variant
59+
let diagnostics = match result {
60+
Ok((_pandoc, _context, diags)) => diags,
61+
Err(diags) => diags,
62+
};
63+
64+
let mut violations = Vec::new();
65+
66+
for diagnostic in diagnostics {
67+
// Check if this is a Q-2-28 error
68+
if diagnostic.code.as_deref() != Some("Q-2-28") {
69+
continue;
70+
}
71+
72+
// Extract location - this points to where the error occurs
73+
let location = diagnostic.location.as_ref();
74+
if location.is_none() {
75+
continue;
76+
}
77+
78+
// The error location can span multiple tokens. Use end_offset to ensure
79+
// we're after any tokens that might be part of the error
80+
let error_offset = location.as_ref().unwrap().end_offset();
81+
82+
// Now we need to find the newline before >}}} and the start of >}}}
83+
// We'll scan backwards from error_offset to find the newline,
84+
// then scan forward to find >}}}
85+
86+
if let Some(violation) = self.find_violation_offsets(&content, error_offset) {
87+
violations.push(violation);
88+
}
89+
}
90+
91+
Ok(violations)
92+
}
93+
94+
/// Find the exact offsets to fix for a Q-2-28 violation
95+
fn find_violation_offsets(&self, content: &str, error_offset: usize) -> Option<Q228Violation> {
96+
// Scan backwards from error_offset to find a newline
97+
// Include error_offset itself in case it points to the newline
98+
let mut newline_pos = None;
99+
for i in (0..=error_offset).rev() {
100+
if i < content.len() && content.as_bytes()[i] == b'\n' {
101+
newline_pos = Some(i);
102+
break;
103+
}
104+
}
105+
106+
let newline_start = newline_pos?;
107+
108+
// Now scan forward from newline to find where >}}} starts (skip whitespace)
109+
let mut close_delimiter_start = newline_start + 1;
110+
while close_delimiter_start < content.len() {
111+
let ch = content.as_bytes()[close_delimiter_start];
112+
if ch != b' ' && ch != b'\t' {
113+
break;
114+
}
115+
close_delimiter_start += 1;
116+
}
117+
118+
// Verify that we're actually at >}}}
119+
if close_delimiter_start + 4 <= content.len() {
120+
let slice = &content[close_delimiter_start..close_delimiter_start + 4];
121+
if slice == ">}}}" {
122+
return Some(Q228Violation {
123+
newline_start,
124+
close_delimiter_start,
125+
error_location: Some(SourceLocation {
126+
row: self.offset_to_row(content, newline_start),
127+
column: self.offset_to_column(content, newline_start),
128+
}),
129+
});
130+
}
131+
}
132+
133+
None
134+
}
135+
136+
/// Apply fixes to the content by removing line breaks before >}}}
137+
fn apply_fixes(&self, content: &str, mut violations: Vec<Q228Violation>) -> Result<String> {
138+
if violations.is_empty() {
139+
return Ok(content.to_string());
140+
}
141+
142+
// Sort violations in reverse order to avoid offset invalidation
143+
violations.sort_by_key(|v| std::cmp::Reverse(v.newline_start));
144+
145+
let mut result = content.to_string();
146+
147+
for violation in violations {
148+
// Remove everything from the newline to just before >}}}
149+
// This removes the \n and any leading whitespace
150+
let remove_start = violation.newline_start;
151+
let remove_end = violation.close_delimiter_start;
152+
153+
// Replace with a single space to keep >}}} separated from content
154+
result.replace_range(remove_start..remove_end, " ");
155+
}
156+
157+
Ok(result)
158+
}
159+
160+
/// Convert byte offset to row number (0-indexed)
161+
fn offset_to_row(&self, content: &str, offset: usize) -> usize {
162+
content[..offset].matches('\n').count()
163+
}
164+
165+
/// Convert byte offset to column number (0-indexed)
166+
fn offset_to_column(&self, content: &str, offset: usize) -> usize {
167+
let line_start = content[..offset]
168+
.rfind('\n')
169+
.map(|pos| pos + 1)
170+
.unwrap_or(0);
171+
offset - line_start
172+
}
173+
}
174+
175+
impl Rule for Q228Converter {
176+
fn name(&self) -> &str {
177+
"q-2-28"
178+
}
179+
180+
fn description(&self) -> &str {
181+
"Fix Q-2-28: Remove line breaks before escaped shortcode closing delimiter >}}}"
182+
}
183+
184+
fn check(&self, file_path: &Path, _verbose: bool) -> Result<Vec<CheckResult>> {
185+
let violations = self.get_violations(file_path)?;
186+
187+
let results: Vec<CheckResult> = violations
188+
.into_iter()
189+
.map(|v| CheckResult {
190+
rule_name: self.name().to_string(),
191+
file_path: file_path.to_string_lossy().to_string(),
192+
has_issue: true,
193+
issue_count: 1,
194+
message: Some(format!(
195+
"Q-2-28 line break before escaped shortcode close at line {}",
196+
v.error_location.as_ref().map(|l| l.row + 1).unwrap_or(0)
197+
)),
198+
location: v.error_location,
199+
error_code: Some("Q-2-28".to_string()),
200+
error_codes: None,
201+
})
202+
.collect();
203+
204+
Ok(results)
205+
}
206+
207+
fn convert(
208+
&self,
209+
file_path: &Path,
210+
in_place: bool,
211+
check_mode: bool,
212+
_verbose: bool,
213+
) -> Result<ConvertResult> {
214+
let content = read_file(file_path)?;
215+
let violations = self.get_violations(file_path)?;
216+
217+
if violations.is_empty() {
218+
return Ok(ConvertResult {
219+
rule_name: self.name().to_string(),
220+
file_path: file_path.to_string_lossy().to_string(),
221+
fixes_applied: 0,
222+
message: Some("No Q-2-28 line break issues found".to_string()),
223+
});
224+
}
225+
226+
let fixed_content = self.apply_fixes(&content, violations.clone())?;
227+
228+
if check_mode {
229+
// Just report what would be done
230+
return Ok(ConvertResult {
231+
rule_name: self.name().to_string(),
232+
file_path: file_path.to_string_lossy().to_string(),
233+
fixes_applied: violations.len(),
234+
message: Some(format!(
235+
"Would fix {} Q-2-28 line break violation(s)",
236+
violations.len()
237+
)),
238+
});
239+
}
240+
241+
if in_place {
242+
// Write back to file
243+
crate::utils::file_io::write_file(file_path, &fixed_content)?;
244+
Ok(ConvertResult {
245+
rule_name: self.name().to_string(),
246+
file_path: file_path.to_string_lossy().to_string(),
247+
fixes_applied: violations.len(),
248+
message: Some(format!(
249+
"Fixed {} Q-2-28 line break violation(s)",
250+
violations.len()
251+
)),
252+
})
253+
} else {
254+
// Return the converted content in message
255+
Ok(ConvertResult {
256+
rule_name: self.name().to_string(),
257+
file_path: file_path.to_string_lossy().to_string(),
258+
fixes_applied: violations.len(),
259+
message: Some(fixed_content),
260+
})
261+
}
262+
}
263+
}

0 commit comments

Comments
 (0)