@@ -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+
125131pub 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
272278fn 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+
301378fn 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