11use core:: fmt;
2+ use ruff_source_file:: LineRanges ;
23use std:: { error:: Error , fmt:: Formatter } ;
34use thiserror:: Error ;
45
5- use ruff_python_trivia:: { CommentRanges , Cursor } ;
6+ use ruff_python_trivia:: { CommentRanges , Cursor , is_python_whitespace } ;
67use ruff_text_size:: { TextRange , TextSize } ;
78use smallvec:: { SmallVec , smallvec} ;
89
@@ -12,20 +13,7 @@ enum SuppressionAction {
1213 Enable ,
1314}
1415
15- #[ allow( unused) ]
1616#[ derive( Clone , Debug , Eq , PartialEq ) ]
17- enum SuppressionKind {
18- /// multi-line range suppression
19- Range ,
20-
21- /// next-line suppression
22- NextLine ,
23-
24- /// end-of-line suppression
25- EndOfLine ,
26- }
27-
28- #[ derive( Debug , Eq , PartialEq ) ]
2917pub ( crate ) struct SuppressionComment {
3018 /// Range containing the entire suppression comment
3119 range : TextRange ,
@@ -40,30 +28,136 @@ pub(crate) struct SuppressionComment {
4028 reason : TextRange ,
4129}
4230
31+ impl SuppressionComment {
32+ /// Return the suppressed codes as strings
33+ fn codes_as_str ( & self , source : & str ) -> Vec < String > {
34+ self . codes
35+ . iter ( )
36+ . map ( |range| source[ * range] . to_string ( ) )
37+ . collect ( )
38+ }
39+
40+ /// Whether the comment is an own-line comment, and how indented it is
41+ fn own_line_indent ( & self , source : & str ) -> Option < usize > {
42+ let before =
43+ & source[ TextRange :: new ( source. line_start ( self . range . start ( ) ) , self . range . start ( ) ) ] ;
44+ let is_own_line = before. chars ( ) . all ( is_python_whitespace) ;
45+ is_own_line. then ( || before. chars ( ) . count ( ) )
46+ }
47+ }
48+
4349#[ allow( unused) ]
4450#[ derive( Debug ) ]
4551pub ( crate ) struct Suppression {
46- kind : SuppressionKind ,
47-
4852 /// The lint code being suppressed
4953 code : String ,
5054
5155 /// Range for which the suppression applies
5256 range : TextRange ,
5357
5458 /// Any comments associated with the suppression
55- comments : Vec < SuppressionComment > ,
59+ comments : SmallVec < [ SuppressionComment ; 2 ] > ,
60+ }
61+
62+ #[ allow( unused) ]
63+ #[ derive( Debug ) ]
64+ pub ( crate ) struct Suppressions {
65+ valid : Vec < Suppression > ,
66+ invalid : Vec < SuppressionComment > ,
67+ errors : Vec < ParseError > ,
5668}
5769
58- impl Suppression {
59- pub ( crate ) fn load ( source : & str , comment_ranges : & CommentRanges ) -> Vec < SuppressionComment > {
60- comment_ranges
70+ impl Suppressions {
71+ pub ( crate ) fn load ( source : & str , comment_ranges : & CommentRanges ) -> Self {
72+ let mut suppression_comments = comment_ranges
6173 . iter ( )
62- . flat_map ( |comment_range| {
74+ . map ( |comment_range| {
6375 let mut parser = SuppressionParser :: new ( source, * comment_range) ;
6476 parser. parse_comment ( )
6577 } )
66- . collect :: < Vec < _ > > ( )
78+ . collect :: < Vec < _ > > ( ) ;
79+
80+ let mut index = 0 ;
81+ let mut suppressions: Vec < Suppression > = Vec :: new ( ) ;
82+ let mut invalid: Vec < SuppressionComment > = Vec :: new ( ) ;
83+ let mut errors: Vec < ParseError > = Vec :: new ( ) ;
84+
85+ // Process all parsed comments in order generating appropriate suppression ranges
86+ while index < suppression_comments. len ( ) {
87+ let mut remove_index: Option < usize > = None ;
88+ match & suppression_comments[ index] {
89+ Ok ( comment) => {
90+ match comment. action {
91+ SuppressionAction :: Enable => {
92+ let Some ( _indent) = comment. own_line_indent ( source) else {
93+ invalid. push ( comment. clone ( ) ) ;
94+ continue ;
95+ } ;
96+
97+ invalid. push ( comment. clone ( ) ) ;
98+ }
99+ SuppressionAction :: Disable => {
100+ let Some ( indent) = comment. own_line_indent ( source) else {
101+ invalid. push ( comment. clone ( ) ) ;
102+ continue ;
103+ } ;
104+
105+ // Look for matching "enable" comments.
106+ // Match by indentation and suppressed codes.
107+ // TODO: search only within the same scope
108+ let codes = comment. codes_as_str ( source) ;
109+ if let Some ( other_index) =
110+ suppression_comments[ index + 1 ..] . iter ( ) . position ( |k| {
111+ k. as_ref ( ) . is_ok_and ( |other| {
112+ other. action == SuppressionAction :: Enable
113+ && other. own_line_indent ( source) == Some ( indent)
114+ && other. codes_as_str ( source) == codes
115+ } )
116+ } )
117+ {
118+ // Offset from current position
119+ let other_index = index + 1 + other_index;
120+
121+ // Create a suppression range spanning from the starting disable
122+ // comment to the ending enable comment.
123+ let other = suppression_comments[ other_index] . as_ref ( ) . unwrap ( ) ;
124+ let combined_range =
125+ TextRange :: new ( comment. range . start ( ) , other. range . end ( ) ) ;
126+ for code in codes {
127+ suppressions. push ( Suppression {
128+ code,
129+ range : combined_range,
130+ comments : smallvec ! [ comment. clone( ) , other. clone( ) ] ,
131+ } ) ;
132+ }
133+ // Mark the matched enable comment to be removed from the vector
134+ // so that it doesn't get processed and treated as unmatched.
135+ remove_index = Some ( other_index) ;
136+ } else {
137+ invalid. push ( comment. clone ( ) ) ;
138+ }
139+ }
140+ }
141+ }
142+ Err ( error) => {
143+ if error. kind != ParseErrorKind :: NotASuppression {
144+ errors. push ( error. clone ( ) ) ;
145+ }
146+ }
147+ }
148+ // Remove a marked comment from the vector.
149+ if let Some ( remove_index) = remove_index {
150+ suppression_comments. remove ( remove_index) . ok ( ) ;
151+ }
152+ index += 1 ;
153+ }
154+
155+ suppressions. shrink_to_fit ( ) ;
156+ Self {
157+ valid : suppressions,
158+ invalid,
159+ errors,
160+ }
67161 }
68162}
69163
@@ -248,17 +342,19 @@ impl<'src> SuppressionParser<'src> {
248342#[ cfg( test) ]
249343mod tests {
250344 use insta:: assert_debug_snapshot;
345+ use ruff_python_trivia:: CommentRanges ;
251346 use ruff_text_size:: { TextRange , TextSize } ;
252347 use similar:: DiffableStr ;
253348
254349 use crate :: suppression:: {
255- ParseError , SuppressionAction , SuppressionComment , SuppressionParser ,
350+ ParseError , SuppressionAction , SuppressionComment , SuppressionParser , Suppressions ,
256351 } ;
257352
258353 fn parse_suppression_comment ( source : & str ) -> Result < SuppressionComment , ParseError > {
354+ let offset = TextSize :: new ( source. find ( '#' ) . unwrap_or ( 0 ) . try_into ( ) . unwrap ( ) ) ;
259355 let mut parser = SuppressionParser :: new (
260356 source,
261- TextRange :: new ( 0 . into ( ) , TextSize :: try_from ( source. len ( ) ) . unwrap ( ) ) ,
357+ TextRange :: new ( offset , TextSize :: try_from ( source. len ( ) ) . unwrap ( ) ) ,
262358 ) ;
263359 parser. parse_comment ( )
264360 }
@@ -430,6 +526,156 @@ mod tests {
430526 ) ;
431527 }
432528
529+ #[ test]
530+ fn trailing_comment ( ) {
531+ let source = "print('hello world') # ruff: enable[some-thing]" ;
532+ let comment = parse_suppression_comment ( source) ;
533+ assert_debug_snapshot ! (
534+ comment,
535+ @r"
536+ Ok(
537+ SuppressionComment {
538+ range: 22..48,
539+ action: Enable,
540+ codes: [
541+ 37..47,
542+ ],
543+ reason: 48..48,
544+ },
545+ )
546+ " ,
547+ ) ;
548+ assert_debug_snapshot ! (
549+ comment. unwrap( ) . own_line_indent( source) ,
550+ @"None" ,
551+ ) ;
552+ }
553+
554+ #[ test]
555+ fn indented_comment ( ) {
556+ let source = " # ruff: enable[some-thing]" ;
557+ let comment = parse_suppression_comment ( source) ;
558+ assert_debug_snapshot ! (
559+ comment,
560+ @r"
561+ Ok(
562+ SuppressionComment {
563+ range: 4..30,
564+ action: Enable,
565+ codes: [
566+ 19..29,
567+ ],
568+ reason: 30..30,
569+ },
570+ )
571+ " ,
572+ ) ;
573+ assert_debug_snapshot ! (
574+ comment. unwrap( ) . own_line_indent( source) ,
575+ @r"
576+ Some(
577+ 4,
578+ )
579+ " ,
580+ ) ;
581+ }
582+
583+ #[ test]
584+ fn load_no_comments ( ) {
585+ let source = "print('hello world')" ;
586+ let suppressions = Suppressions :: load ( source, & CommentRanges :: new ( vec ! [ ] ) ) ;
587+ assert_debug_snapshot ! (
588+ suppressions,
589+ @r"
590+ Suppressions {
591+ valid: [],
592+ invalid: [],
593+ errors: [],
594+ }
595+ " ,
596+ ) ;
597+ }
598+
599+ #[ test]
600+ fn load_matched_range ( ) {
601+ let source = "
602+ # ruff: disable[foo]
603+ print('hello world')
604+ # ruff: enable[foo]
605+ " ;
606+ let ranges = vec ! [
607+ TextRange :: at( 1 . into( ) , 20 . into( ) ) ,
608+ TextRange :: at( 43 . into( ) , 19 . into( ) ) ,
609+ ] ;
610+ let suppressions = Suppressions :: load ( source, & CommentRanges :: new ( ranges) ) ;
611+ assert_debug_snapshot ! (
612+ suppressions,
613+ @r#"
614+ Suppressions {
615+ valid: [
616+ Suppression {
617+ code: "foo",
618+ range: 1..62,
619+ comments: [
620+ SuppressionComment {
621+ range: 1..21,
622+ action: Disable,
623+ codes: [
624+ 17..20,
625+ ],
626+ reason: 21..21,
627+ },
628+ SuppressionComment {
629+ range: 43..62,
630+ action: Enable,
631+ codes: [
632+ 58..61,
633+ ],
634+ reason: 62..62,
635+ },
636+ ],
637+ },
638+ ],
639+ invalid: [],
640+ errors: [],
641+ }
642+ "# ,
643+ ) ;
644+ }
645+
646+ #[ test]
647+ fn load_unmatched_range ( ) {
648+ let source = "
649+ # ruff: disable[foo]
650+ print('hello world')
651+ # unrelated comment
652+ " ;
653+ let ranges = vec ! [
654+ TextRange :: at( 1 . into( ) , 20 . into( ) ) ,
655+ TextRange :: at( 43 . into( ) , 19 . into( ) ) ,
656+ ] ;
657+ let suppressions = Suppressions :: load ( source, & CommentRanges :: new ( ranges) ) ;
658+ assert_debug_snapshot ! (
659+ suppressions,
660+ @r"
661+ Suppressions {
662+ valid: [],
663+ invalid: [
664+ SuppressionComment {
665+ range: 1..21,
666+ action: Disable,
667+ codes: [
668+ 17..20,
669+ ],
670+ reason: 21..21,
671+ },
672+ ],
673+ errors: [],
674+ }
675+ " ,
676+ ) ;
677+ }
678+
433679 #[ test]
434680 fn comment_attributes ( ) {
435681 let source = "# ruff: disable[foo, bar] hello world" ;
0 commit comments