Skip to content

Commit ca7b40a

Browse files
committed
html comments, autolinks, more qmd-syntax-helpers
1 parent 8fb941e commit ca7b40a

File tree

144 files changed

+204563
-198151
lines changed

Some content is hidden

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

144 files changed

+204563
-198151
lines changed
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
use anyhow::{Context, Result, anyhow};
2+
use regex::Regex;
3+
use std::fs;
4+
use std::io::Write;
5+
use std::path::Path;
6+
use std::process::{Command, Stdio};
7+
8+
use crate::rule::{CheckResult, ConvertResult, Rule, SourceLocation};
9+
use crate::utils::file_io::read_file;
10+
11+
pub struct AttributeOrderingConverter {
12+
// Regex for extracting normalized attributes from Pandoc output
13+
pandoc_output_regex: Regex,
14+
}
15+
16+
#[derive(Debug, Clone)]
17+
struct AttributeOrderingViolation {
18+
start_offset: usize, // Offset of '{'
19+
end_offset: usize, // Offset of '}' + 1
20+
original: String, // Original attrs including braces
21+
error_location: Option<SourceLocation>, // For reporting
22+
}
23+
24+
impl AttributeOrderingConverter {
25+
pub fn new() -> Result<Self> {
26+
let pandoc_output_regex = Regex::new(r"^\[\]\{(.+)\}\s*$")
27+
.context("Failed to compile pandoc output regex")?;
28+
29+
Ok(Self {
30+
pandoc_output_regex,
31+
})
32+
}
33+
34+
/// Get parse errors and extract attribute ordering violations
35+
fn get_attribute_ordering_errors(
36+
&self,
37+
file_path: &Path,
38+
) -> Result<Vec<AttributeOrderingViolation>> {
39+
let content = fs::read_to_string(file_path)
40+
.with_context(|| format!("Failed to read file: {}", file_path.display()))?;
41+
42+
// Parse with quarto-markdown-pandoc to get diagnostics
43+
let mut sink = std::io::sink();
44+
let filename = file_path.to_string_lossy();
45+
46+
let result = quarto_markdown_pandoc::readers::qmd::read(
47+
content.as_bytes(),
48+
false, // not loose mode
49+
&filename,
50+
&mut sink,
51+
);
52+
53+
let diagnostics = match result {
54+
Ok(_) => return Ok(Vec::new()), // No errors
55+
Err(diagnostics) => diagnostics,
56+
};
57+
58+
let mut violations = Vec::new();
59+
60+
for diagnostic in diagnostics {
61+
// Check if this is an attribute ordering error
62+
if diagnostic.title != "Key-value Pair Before Class Specifier in Attribute" {
63+
continue;
64+
}
65+
66+
// Extract location
67+
let location = diagnostic.location.as_ref();
68+
if location.is_none() {
69+
continue;
70+
}
71+
72+
let start_offset = location.as_ref().unwrap().start_offset();
73+
74+
// Find the full attribute block
75+
match self.find_attribute_block(&content, start_offset) {
76+
Ok((block_start, block_end)) => {
77+
let original = content[block_start..block_end].to_string();
78+
79+
violations.push(AttributeOrderingViolation {
80+
start_offset: block_start,
81+
end_offset: block_end,
82+
original,
83+
error_location: Some(SourceLocation {
84+
row: self.offset_to_row(&content, start_offset),
85+
column: self.offset_to_column(&content, start_offset),
86+
}),
87+
});
88+
}
89+
Err(e) => {
90+
eprintln!("Warning: Could not locate attribute block: {}", e);
91+
continue;
92+
}
93+
}
94+
}
95+
96+
Ok(violations)
97+
}
98+
99+
/// Find the full attribute block given an error location
100+
fn find_attribute_block(&self, content: &str, error_offset: usize) -> Result<(usize, usize)> {
101+
let bytes = content.as_bytes();
102+
103+
if error_offset >= bytes.len() {
104+
return Err(anyhow!("Error offset {} is beyond content length {}", error_offset, bytes.len()));
105+
}
106+
107+
// Search backward for '{'
108+
let mut start = error_offset;
109+
while start > 0 && bytes[start] != b'{' {
110+
start -= 1;
111+
}
112+
if bytes[start] != b'{' {
113+
return Err(anyhow!("Could not find opening brace before offset {}", error_offset));
114+
}
115+
116+
// Search forward for '}'
117+
let mut end = error_offset;
118+
while end < bytes.len() && bytes[end] != b'}' {
119+
end += 1;
120+
}
121+
if end >= bytes.len() || bytes[end] != b'}' {
122+
return Err(anyhow!("Could not find closing brace after offset {}", error_offset));
123+
}
124+
125+
Ok((start, end + 1)) // +1 to include the '}'
126+
}
127+
128+
/// Normalize attributes using Pandoc
129+
fn normalize_with_pandoc(&self, attrs: &str) -> Result<String> {
130+
// Create input: []{ + attrs_content + }
131+
// attrs is already "{...}" so wrap with []
132+
let input = format!("[]{}", attrs);
133+
134+
// Run pandoc
135+
let mut child = Command::new("pandoc")
136+
.arg("-t")
137+
.arg("markdown")
138+
.stdin(Stdio::piped())
139+
.stdout(Stdio::piped())
140+
.stderr(Stdio::piped())
141+
.spawn()
142+
.context("Failed to spawn pandoc. Is pandoc installed?")?;
143+
144+
if let Some(mut stdin) = child.stdin.take() {
145+
stdin.write_all(input.as_bytes())
146+
.context("Failed to write to pandoc stdin")?;
147+
}
148+
149+
let output = child.wait_with_output()
150+
.context("Failed to wait for pandoc")?;
151+
152+
if !output.status.success() {
153+
let stderr = String::from_utf8_lossy(&output.stderr);
154+
return Err(anyhow!("Pandoc failed: {}", stderr));
155+
}
156+
157+
let stdout = String::from_utf8(output.stdout)
158+
.context("Pandoc output is not valid UTF-8")?;
159+
160+
// Extract normalized attrs from "[]{...}"
161+
if let Some(caps) = self.pandoc_output_regex.captures(stdout.trim()) {
162+
Ok(format!("{{{}}}", &caps[1]))
163+
} else {
164+
Err(anyhow!("Unexpected pandoc output: {}", stdout))
165+
}
166+
}
167+
168+
/// Apply fixes to the content
169+
fn apply_fixes(
170+
&self,
171+
content: &str,
172+
mut violations: Vec<AttributeOrderingViolation>,
173+
) -> Result<String> {
174+
if violations.is_empty() {
175+
return Ok(content.to_string());
176+
}
177+
178+
// Sort violations in reverse order to avoid offset invalidation
179+
violations.sort_by_key(|v| std::cmp::Reverse(v.start_offset));
180+
181+
let mut result = content.to_string();
182+
183+
for violation in violations {
184+
let normalized = self.normalize_with_pandoc(&violation.original)
185+
.with_context(|| format!("Failed to normalize attributes: {}", violation.original))?;
186+
187+
// Replace original with normalized
188+
result.replace_range(
189+
violation.start_offset..violation.end_offset,
190+
&normalized
191+
);
192+
}
193+
194+
Ok(result)
195+
}
196+
197+
/// Convert byte offset to row number (0-indexed)
198+
fn offset_to_row(&self, content: &str, offset: usize) -> usize {
199+
content[..offset].matches('\n').count()
200+
}
201+
202+
/// Convert byte offset to column number (0-indexed)
203+
fn offset_to_column(&self, content: &str, offset: usize) -> usize {
204+
let line_start = content[..offset].rfind('\n').map(|pos| pos + 1).unwrap_or(0);
205+
offset - line_start
206+
}
207+
}
208+
209+
impl Rule for AttributeOrderingConverter {
210+
fn name(&self) -> &str {
211+
"attribute-ordering"
212+
}
213+
214+
fn description(&self) -> &str {
215+
"Fix attribute ordering: reorder {key=value .class #id} to {#id .class key=\"value\"}"
216+
}
217+
218+
fn check(&self, file_path: &Path, _verbose: bool) -> Result<Vec<CheckResult>> {
219+
let violations = self.get_attribute_ordering_errors(file_path)?;
220+
221+
let results: Vec<CheckResult> = violations
222+
.into_iter()
223+
.map(|v| CheckResult {
224+
rule_name: self.name().to_string(),
225+
file_path: file_path.to_string_lossy().to_string(),
226+
has_issue: true,
227+
issue_count: 1,
228+
message: Some(format!(
229+
"Attribute ordering violation: {}",
230+
v.original
231+
)),
232+
location: v.error_location,
233+
error_code: None,
234+
error_codes: None,
235+
})
236+
.collect();
237+
238+
Ok(results)
239+
}
240+
241+
fn convert(
242+
&self,
243+
file_path: &Path,
244+
in_place: bool,
245+
check_mode: bool,
246+
_verbose: bool,
247+
) -> Result<ConvertResult> {
248+
let content = read_file(file_path)?;
249+
let violations = self.get_attribute_ordering_errors(file_path)?;
250+
251+
if violations.is_empty() {
252+
return Ok(ConvertResult {
253+
rule_name: self.name().to_string(),
254+
file_path: file_path.to_string_lossy().to_string(),
255+
fixes_applied: 0,
256+
message: Some("No attribute ordering issues found".to_string()),
257+
});
258+
}
259+
260+
let fixed_content = self.apply_fixes(&content, violations.clone())?;
261+
262+
if check_mode {
263+
// Just report what would be done
264+
return Ok(ConvertResult {
265+
rule_name: self.name().to_string(),
266+
file_path: file_path.to_string_lossy().to_string(),
267+
fixes_applied: violations.len(),
268+
message: Some(format!(
269+
"Would fix {} attribute ordering violation(s)",
270+
violations.len()
271+
)),
272+
});
273+
}
274+
275+
if in_place {
276+
// Write back to file
277+
crate::utils::file_io::write_file(file_path, &fixed_content)?;
278+
Ok(ConvertResult {
279+
rule_name: self.name().to_string(),
280+
file_path: file_path.to_string_lossy().to_string(),
281+
fixes_applied: violations.len(),
282+
message: Some(format!(
283+
"Fixed {} attribute ordering violation(s)",
284+
violations.len()
285+
)),
286+
})
287+
} else {
288+
// Return the converted content in message
289+
Ok(ConvertResult {
290+
rule_name: self.name().to_string(),
291+
file_path: file_path.to_string_lossy().to_string(),
292+
fixes_applied: violations.len(),
293+
message: Some(fixed_content),
294+
})
295+
}
296+
}
297+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,8 @@ impl Rule for DefinitionListConverter {
230230
row: list.start_line + 1, // Convert 0-indexed to 1-indexed
231231
column: 1,
232232
}),
233+
error_code: None,
234+
error_codes: None,
233235
});
234236
}
235237

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,8 @@ impl Rule for DivWhitespaceConverter {
273273
issue_count: 1,
274274
message: Some("Div fence missing whitespace (:::{ should be ::: {)".to_string()),
275275
location: Some(location),
276+
error_code: None,
277+
error_codes: None,
276278
});
277279
}
278280

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ impl Rule for GridTableConverter {
177177
row: table.start_line + 1, // Convert 0-indexed to 1-indexed
178178
column: 1,
179179
}),
180+
error_code: None,
181+
error_codes: None,
180182
});
181183
}
182184

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod attribute_ordering;
12
pub mod definition_lists;
23
pub mod div_whitespace;
34
pub mod grid_tables;

0 commit comments

Comments
 (0)