Skip to content

Commit c50d430

Browse files
committed
fix(core): 🐛 support setext headings and robust fence parsing
1 parent 1b9fbed commit c50d430

1 file changed

Lines changed: 123 additions & 12 deletions

File tree

src/audit.rs

Lines changed: 123 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ const CHECKS: [WeightedCheck; 10] = [
122122
},
123123
];
124124

125+
#[derive(Clone, Copy)]
126+
struct FenceSpec {
127+
marker: char,
128+
len: usize,
129+
}
130+
125131
pub fn audit_repo(
126132
repo: &RepoMetadata,
127133
readme: Option<&str>,
@@ -271,33 +277,104 @@ fn matches_check(check: &WeightedCheck, readme_lower: &str, headings: &[String])
271277

272278
fn extract_normalized_headings(readme_lower: &str) -> Vec<String> {
273279
let mut headings = Vec::new();
274-
let mut in_fenced_block = false;
275-
276-
for line in readme_lower.lines() {
277-
let trimmed = line.trim_start();
278-
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
279-
in_fenced_block = !in_fenced_block;
280+
let mut fence: Option<FenceSpec> = None;
281+
let lines: Vec<&str> = readme_lower.lines().collect();
282+
let mut index = 0usize;
283+
284+
while index < lines.len() {
285+
let trimmed = lines[index].trim_start();
286+
287+
if let Some(current_fence) = fence {
288+
if is_closing_fence(trimmed, current_fence) {
289+
fence = None;
290+
}
291+
index += 1;
280292
continue;
281293
}
282294

283-
if in_fenced_block || !trimmed.starts_with('#') {
295+
if let Some(opening_fence) = parse_opening_fence(trimmed) {
296+
fence = Some(opening_fence);
297+
index += 1;
284298
continue;
285299
}
286300

287-
let heading = trimmed.trim_start_matches('#').trim();
288-
if heading.is_empty() {
301+
if trimmed.starts_with('#') {
302+
let heading = trimmed.trim_start_matches('#').trim();
303+
if !heading.is_empty() {
304+
let normalized = normalize_phrase(heading);
305+
if !normalized.is_empty() {
306+
headings.push(normalized);
307+
}
308+
}
309+
index += 1;
289310
continue;
290311
}
291312

292-
let normalized = normalize_phrase(heading);
293-
if !normalized.is_empty() {
294-
headings.push(normalized);
313+
if index + 1 < lines.len() {
314+
let heading_candidate = lines[index].trim();
315+
let underline_candidate = lines[index + 1].trim();
316+
317+
if !heading_candidate.is_empty() && is_setext_underline(underline_candidate) {
318+
let normalized = normalize_phrase(heading_candidate);
319+
if !normalized.is_empty() {
320+
headings.push(normalized);
321+
}
322+
index += 2;
323+
continue;
324+
}
295325
}
326+
327+
index += 1;
296328
}
297329

298330
headings
299331
}
300332

333+
fn parse_opening_fence(trimmed_line: &str) -> Option<FenceSpec> {
334+
let mut chars = trimmed_line.chars();
335+
let marker = chars.next()?;
336+
if marker != '`' && marker != '~' {
337+
return None;
338+
}
339+
340+
let len = trimmed_line
341+
.chars()
342+
.take_while(|character| *character == marker)
343+
.count();
344+
if len < 3 {
345+
return None;
346+
}
347+
348+
Some(FenceSpec { marker, len })
349+
}
350+
351+
fn is_closing_fence(trimmed_line: &str, opening_fence: FenceSpec) -> bool {
352+
let marker_count = trimmed_line
353+
.chars()
354+
.take_while(|character| *character == opening_fence.marker)
355+
.count();
356+
if marker_count < opening_fence.len {
357+
return false;
358+
}
359+
360+
let trailing = trimmed_line.chars().skip(marker_count).collect::<String>();
361+
trailing.trim().is_empty()
362+
}
363+
364+
fn is_setext_underline(trimmed_line: &str) -> bool {
365+
if trimmed_line.len() < 3 {
366+
return false;
367+
}
368+
369+
let mut chars = trimmed_line.chars();
370+
let marker = chars.next().unwrap_or_default();
371+
if marker != '=' && marker != '-' {
372+
return false;
373+
}
374+
375+
chars.all(|character| character == marker)
376+
}
377+
301378
fn contains_token_sequence(heading: &str, alias: &str) -> bool {
302379
let heading_tokens: Vec<&str> = heading.split_whitespace().collect();
303380
let alias_tokens: Vec<&str> = alias.split_whitespace().collect();
@@ -457,4 +534,38 @@ quickstart-for-agents.vercel.app/api/header.svg
457534
let audit = audit_repo(&example_repo(), Some(readme), 70, false);
458535
assert!(audit.missing_required.contains(&"Features"));
459536
}
537+
538+
#[test]
539+
fn supports_setext_style_headings() {
540+
let readme = "
541+
Features
542+
--------
543+
Quick Start
544+
===========
545+
Architecture
546+
------------
547+
License
548+
-------
549+
";
550+
551+
let audit = audit_repo(&example_repo(), Some(readme), 70, false);
552+
assert!(audit.missing_required.is_empty());
553+
}
554+
555+
#[test]
556+
fn does_not_close_fence_with_different_marker() {
557+
let readme = "
558+
```markdown
559+
## Features
560+
~~~
561+
## Quick Start
562+
```
563+
## Architecture
564+
## License
565+
";
566+
567+
let audit = audit_repo(&example_repo(), Some(readme), 70, false);
568+
assert!(audit.missing_required.contains(&"Features"));
569+
assert!(audit.missing_required.contains(&"Quick Start"));
570+
}
460571
}

0 commit comments

Comments
 (0)