Skip to content

Commit 8cccaea

Browse files
committed
--json output
1 parent 3bc9345 commit 8cccaea

File tree

15 files changed

+726
-88
lines changed

15 files changed

+726
-88
lines changed

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

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ impl Rule for DefinitionListConverter {
192192
"Convert definition lists to div-based format"
193193
}
194194

195-
fn check(&self, file_path: &Path, verbose: bool) -> Result<CheckResult> {
195+
fn check(&self, file_path: &Path, verbose: bool) -> Result<Vec<CheckResult>> {
196196
let content = read_file(file_path)?;
197197
let lists = self.find_definition_lists(&content);
198198

@@ -204,17 +204,22 @@ impl Rule for DefinitionListConverter {
204204
}
205205
}
206206

207-
Ok(CheckResult {
208-
rule_name: self.name().to_string(),
209-
file_path: file_path.to_string_lossy().to_string(),
210-
has_issue: !lists.is_empty(),
211-
issue_count: lists.len(),
212-
message: if lists.is_empty() {
213-
None
214-
} else {
215-
Some(format!("Found {} definition list(s)", lists.len()))
216-
},
217-
})
207+
let mut results = Vec::new();
208+
for list in lists {
209+
results.push(CheckResult {
210+
rule_name: self.name().to_string(),
211+
file_path: file_path.to_string_lossy().to_string(),
212+
has_issue: true,
213+
issue_count: 1,
214+
message: Some("Definition list found".to_string()),
215+
location: Some(crate::rule::SourceLocation {
216+
row: list.start_line + 1, // Convert 0-indexed to 1-indexed
217+
column: 1,
218+
}),
219+
});
220+
}
221+
222+
Ok(results)
218223
}
219224

220225
fn convert(

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

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,17 @@ impl DivWhitespaceConverter {
6464
}
6565

6666
let json_str = error_messages.join("");
67-
let errors: Vec<ParseError> =
68-
serde_json::from_str(&json_str).context("Failed to parse JSON error output")?;
6967

70-
Ok(errors)
68+
// Try to parse as JSON array
69+
match serde_json::from_str::<Vec<ParseError>>(&json_str) {
70+
Ok(errors) => Ok(errors),
71+
Err(_) => {
72+
// If parsing fails, the messages are likely plain text warnings/debug messages
73+
// rather than actual syntax errors. These don't indicate div whitespace issues,
74+
// so we can safely ignore them for this specific rule.
75+
Ok(Vec::new())
76+
}
77+
}
7178
}
7279
}
7380
}
@@ -130,6 +137,29 @@ impl DivWhitespaceConverter {
130137
fix_positions
131138
}
132139

140+
/// Convert byte offset to row/column (1-indexed)
141+
fn byte_offset_to_location(&self, content: &str, byte_offset: usize) -> crate::rule::SourceLocation {
142+
let mut row = 1;
143+
let mut column = 1;
144+
let mut current_offset = 0;
145+
146+
for ch in content.chars() {
147+
if current_offset >= byte_offset {
148+
break;
149+
}
150+
current_offset += ch.len_utf8();
151+
152+
if ch == '\n' {
153+
row += 1;
154+
column = 1;
155+
} else {
156+
column += 1;
157+
}
158+
}
159+
160+
crate::rule::SourceLocation { row, column }
161+
}
162+
133163
/// Apply fixes to content by inserting spaces at specified positions
134164
fn apply_fixes(&self, content: &str, fix_positions: &[usize]) -> String {
135165
let mut result = String::with_capacity(content.len() + fix_positions.len());
@@ -220,7 +250,7 @@ impl Rule for DivWhitespaceConverter {
220250
"Fix div fences missing whitespace (:::{ -> ::: {)"
221251
}
222252

223-
fn check(&self, file_path: &Path, verbose: bool) -> Result<CheckResult> {
253+
fn check(&self, file_path: &Path, verbose: bool) -> Result<Vec<CheckResult>> {
224254
let content = read_file(file_path)?;
225255
let errors = self.get_parse_errors(file_path)?;
226256
let fix_positions = self.find_div_whitespace_errors(&content, &errors);
@@ -236,20 +266,20 @@ impl Rule for DivWhitespaceConverter {
236266
}
237267
}
238268

239-
Ok(CheckResult {
240-
rule_name: self.name().to_string(),
241-
file_path: file_path.to_string_lossy().to_string(),
242-
has_issue: !fix_positions.is_empty(),
243-
issue_count: fix_positions.len(),
244-
message: if fix_positions.is_empty() {
245-
None
246-
} else {
247-
Some(format!(
248-
"Found {} div fence(s) needing whitespace fixes",
249-
fix_positions.len()
250-
))
251-
},
252-
})
269+
let mut results = Vec::new();
270+
for &pos in &fix_positions {
271+
let location = self.byte_offset_to_location(&content, pos);
272+
results.push(CheckResult {
273+
rule_name: self.name().to_string(),
274+
file_path: file_path.to_string_lossy().to_string(),
275+
has_issue: true,
276+
issue_count: 1,
277+
message: Some("Div fence missing whitespace (:::{ should be ::: {)".to_string()),
278+
location: Some(location),
279+
});
280+
}
281+
282+
Ok(results)
253283
}
254284

255285
fn convert(

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

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ impl Rule for GridTableConverter {
153153
"Convert grid tables to list-table format"
154154
}
155155

156-
fn check(&self, file_path: &Path, verbose: bool) -> Result<CheckResult> {
156+
fn check(&self, file_path: &Path, verbose: bool) -> Result<Vec<CheckResult>> {
157157
let content = read_file(file_path)?;
158158
let tables = self.find_grid_tables(&content);
159159

@@ -165,17 +165,22 @@ impl Rule for GridTableConverter {
165165
}
166166
}
167167

168-
Ok(CheckResult {
169-
rule_name: self.name().to_string(),
170-
file_path: file_path.to_string_lossy().to_string(),
171-
has_issue: !tables.is_empty(),
172-
issue_count: tables.len(),
173-
message: if tables.is_empty() {
174-
None
175-
} else {
176-
Some(format!("Found {} grid table(s)", tables.len()))
177-
},
178-
})
168+
let mut results = Vec::new();
169+
for table in tables {
170+
results.push(CheckResult {
171+
rule_name: self.name().to_string(),
172+
file_path: file_path.to_string_lossy().to_string(),
173+
has_issue: true,
174+
issue_count: 1,
175+
message: Some("Grid table found".to_string()),
176+
location: Some(crate::rule::SourceLocation {
177+
row: table.start_line + 1, // Convert 0-indexed to 1-indexed
178+
column: 1,
179+
}),
180+
});
181+
}
182+
183+
Ok(results)
179184
}
180185

181186
fn convert(
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
pub mod parse_check;
12
// pub mod syntax_check; // Unused - kept for reference only
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
use anyhow::{Context, Result};
2+
use std::fs;
3+
use std::path::Path;
4+
5+
use crate::rule::{CheckResult, ConvertResult, Rule};
6+
7+
pub struct ParseChecker {}
8+
9+
impl ParseChecker {
10+
pub fn new() -> Result<Self> {
11+
Ok(Self {})
12+
}
13+
14+
/// Check if a file parses successfully
15+
fn check_parse(&self, file_path: &Path) -> Result<bool> {
16+
let content = fs::read_to_string(file_path)
17+
.with_context(|| format!("Failed to read file: {}", file_path.display()))?;
18+
19+
let mut sink = std::io::sink();
20+
let filename = file_path.to_string_lossy();
21+
22+
let result = quarto_markdown_pandoc::readers::qmd::read(
23+
content.as_bytes(),
24+
false,
25+
&filename,
26+
&mut sink,
27+
Some(
28+
quarto_markdown_pandoc::readers::qmd_error_messages::produce_json_error_messages
29+
as fn(
30+
&[u8],
31+
&quarto_markdown_pandoc::utils::tree_sitter_log_observer::TreeSitterLogObserver,
32+
&str,
33+
) -> Vec<String>,
34+
),
35+
);
36+
37+
Ok(result.is_ok())
38+
}
39+
}
40+
41+
impl Rule for ParseChecker {
42+
fn name(&self) -> &str {
43+
"parse"
44+
}
45+
46+
fn description(&self) -> &str {
47+
"Check if file parses successfully"
48+
}
49+
50+
fn check(&self, file_path: &Path, _verbose: bool) -> Result<Vec<CheckResult>> {
51+
let parses = self.check_parse(file_path)?;
52+
53+
if parses {
54+
Ok(vec![])
55+
} else {
56+
Ok(vec![CheckResult {
57+
rule_name: self.name().to_string(),
58+
file_path: file_path.to_string_lossy().to_string(),
59+
has_issue: true,
60+
issue_count: 1,
61+
message: Some("File failed to parse".to_string()),
62+
location: None, // Parse errors don't have specific locations
63+
}])
64+
}
65+
}
66+
67+
fn convert(
68+
&self,
69+
file_path: &Path,
70+
_in_place: bool,
71+
_check_mode: bool,
72+
_verbose: bool,
73+
) -> Result<ConvertResult> {
74+
// Parse errors can't be auto-fixed
75+
Ok(ConvertResult {
76+
rule_name: self.name().to_string(),
77+
file_path: file_path.to_string_lossy().to_string(),
78+
fixes_applied: 0,
79+
message: Some("Parse errors cannot be automatically fixed".to_string()),
80+
})
81+
}
82+
}

crates/qmd-syntax-helper/src/diagnostics/syntax_check.rs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,14 @@ impl SyntaxChecker {
7171
false, // not loose mode
7272
&filename,
7373
&mut sink,
74-
None::<
75-
fn(
76-
&[u8],
77-
&quarto_markdown_pandoc::utils::tree_sitter_log_observer::TreeSitterLogObserver,
78-
&str,
79-
) -> Vec<String>,
80-
>, // no custom error formatter
74+
Some(
75+
quarto_markdown_pandoc::readers::qmd_error_messages::produce_json_error_messages
76+
as fn(
77+
&[u8],
78+
&quarto_markdown_pandoc::utils::tree_sitter_log_observer::TreeSitterLogObserver,
79+
&str,
80+
) -> Vec<String>,
81+
), // Use JSON error formatter for machine-readable errors
8182
);
8283

8384
match result {

crates/qmd-syntax-helper/src/main.rs

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,12 @@ fn main() -> Result<()> {
9696

9797
for rule in &rules {
9898
match rule.check(&file_path, verbose && !json) {
99-
Ok(result) => {
100-
all_results.push(result.clone());
101-
if !json && result.has_issue {
102-
println!(" {} {}", "✗".red(), result.message.unwrap_or_default());
99+
Ok(results) => {
100+
for result in results {
101+
all_results.push(result.clone());
102+
if !json && result.has_issue {
103+
println!(" {} {}", "✗".red(), result.message.unwrap_or_default());
104+
}
103105
}
104106
}
105107
Err(e) => {
@@ -111,6 +113,11 @@ fn main() -> Result<()> {
111113
}
112114
}
113115

116+
// Print summary if not in JSON mode
117+
if !json && !all_results.is_empty() {
118+
print_check_summary(&all_results);
119+
}
120+
114121
// Output handling
115122
if json {
116123
for result in &all_results {
@@ -202,3 +209,73 @@ fn resolve_rules(
202209
Ok(rules)
203210
}
204211
}
212+
213+
fn print_check_summary(results: &[rule::CheckResult]) {
214+
use std::collections::{HashMap, HashSet};
215+
216+
// Get unique files checked
217+
let unique_files: HashSet<&str> = results.iter().map(|r| r.file_path.as_str()).collect();
218+
let total_files = unique_files.len();
219+
220+
// Count files with issues (at least one result with has_issue=true)
221+
let mut files_with_issues = HashSet::new();
222+
let mut total_issues = 0;
223+
224+
// Track issues by rule type
225+
let mut issues_by_rule: HashMap<String, usize> = HashMap::new();
226+
let mut files_by_rule: HashMap<String, HashSet<String>> = HashMap::new();
227+
228+
for result in results {
229+
if result.has_issue {
230+
files_with_issues.insert(&result.file_path);
231+
total_issues += result.issue_count;
232+
233+
// Track by rule
234+
*issues_by_rule.entry(result.rule_name.clone()).or_insert(0) += result.issue_count;
235+
files_by_rule
236+
.entry(result.rule_name.clone())
237+
.or_insert_with(HashSet::new)
238+
.insert(result.file_path.clone());
239+
}
240+
}
241+
242+
let files_with_issues_count = files_with_issues.len();
243+
let files_clean = total_files - files_with_issues_count;
244+
245+
println!("\n{}", "=== Summary ===".bold());
246+
println!("Total files: {}", total_files);
247+
println!(
248+
"Files with issues: {} {}",
249+
files_with_issues_count,
250+
if files_with_issues_count > 0 {
251+
"✗".red()
252+
} else {
253+
"✓".green()
254+
}
255+
);
256+
println!("Clean files: {} {}", files_clean, "✓".green());
257+
258+
if !issues_by_rule.is_empty() {
259+
println!("\n{}", "Issues by rule:".bold());
260+
let mut rule_names: Vec<_> = issues_by_rule.keys().collect();
261+
rule_names.sort();
262+
263+
for rule_name in rule_names {
264+
let count = issues_by_rule[rule_name];
265+
let file_count = files_by_rule[rule_name].len();
266+
println!(
267+
" {}: {} issue(s) in {} file(s)",
268+
rule_name.cyan(),
269+
count,
270+
file_count
271+
);
272+
}
273+
}
274+
275+
println!("\nTotal issues found: {}", total_issues);
276+
277+
if total_files > 0 {
278+
let success_rate = (files_clean as f64 / total_files as f64) * 100.0;
279+
println!("Success rate: {:.1}%", success_rate);
280+
}
281+
}

0 commit comments

Comments
 (0)