Skip to main content

ruff_python_trivia/
comment_ranges.rs

1use std::fmt::{Debug, Formatter};
2use std::ops::Deref;
3
4use itertools::Itertools;
5use rustc_hash::FxHashSet;
6
7use ruff_source_file::LineRanges;
8use ruff_text_size::{Ranged, TextRange, TextSize};
9
10use crate::{has_leading_content, has_trailing_content, is_python_whitespace};
11
12/// Token-derived range indexes shared by comment placement and formatting.
13#[derive(Clone, Default)]
14pub struct TriviaRanges {
15    comments: CommentRanges,
16    parenthesized_expressions: ParenthesizedExpressions,
17}
18
19impl TriviaRanges {
20    /// Creates a combined set of token-derived range indexes.
21    pub fn new(
22        comments: CommentRanges,
23        parenthesized_expressions: ParenthesizedExpressions,
24    ) -> Self {
25        Self {
26            comments,
27            parenthesized_expressions,
28        }
29    }
30
31    /// Returns the indexed comment ranges.
32    pub fn comments(&self) -> &CommentRanges {
33        &self.comments
34    }
35
36    /// Returns the indexed parenthesized expression ranges.
37    pub fn parenthesized(&self) -> &ParenthesizedExpressions {
38        &self.parenthesized_expressions
39    }
40}
41
42impl Debug for TriviaRanges {
43    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
44        self.comments.fmt(f)
45    }
46}
47
48/// Index of source ranges enclosed by matching parentheses.
49#[derive(Clone, Default)]
50pub struct ParenthesizedExpressions {
51    ranges: FxHashSet<TextRange>,
52}
53
54impl ParenthesizedExpressions {
55    /// Creates an index from parenthesized expression ranges.
56    pub fn new(ranges: FxHashSet<TextRange>) -> Self {
57        Self { ranges }
58    }
59
60    /// Returns `true` if the index contains `range`.
61    pub fn contains(&self, range: TextRange) -> bool {
62        self.ranges.contains(&range)
63    }
64}
65
66/// Stores the ranges of comments sorted by [`TextRange::start`] in increasing order. No two ranges
67/// are overlapping.
68#[derive(Clone, Default)]
69pub struct CommentRanges {
70    raw: Vec<TextRange>,
71}
72
73impl CommentRanges {
74    pub fn new(ranges: Vec<TextRange>) -> Self {
75        Self { raw: ranges }
76    }
77
78    /// Returns `true` if the given range intersects with any comment range.
79    pub fn intersects(&self, target: TextRange) -> bool {
80        self.raw
81            .binary_search_by(|range| {
82                if target.intersect(*range).is_some() {
83                    std::cmp::Ordering::Equal
84                } else if range.end() < target.start() {
85                    std::cmp::Ordering::Less
86                } else {
87                    std::cmp::Ordering::Greater
88                }
89            })
90            .is_ok()
91    }
92
93    /// Returns the comments who are within the range
94    pub fn comments_in_range(&self, range: TextRange) -> &[TextRange] {
95        let start = self
96            .raw
97            .partition_point(|comment| comment.start() < range.start());
98        // We expect there are few comments, so switching to find should be faster
99        match self.raw[start..]
100            .iter()
101            .find_position(|comment| comment.end() > range.end())
102        {
103            Some((in_range, _element)) => &self.raw[start..start + in_range],
104            None => &self.raw[start..],
105        }
106    }
107
108    /// Returns `true` if a statement or expression includes at least one comment.
109    pub fn has_comments<T>(&self, node: &T, source: &str) -> bool
110    where
111        T: Ranged,
112    {
113        let start = if has_leading_content(node.start(), source) {
114            node.start()
115        } else {
116            source.line_start(node.start())
117        };
118        let end = if has_trailing_content(node.end(), source) {
119            node.end()
120        } else {
121            source.line_end(node.end())
122        };
123
124        self.intersects(TextRange::new(start, end))
125    }
126
127    /// Given a [`CommentRanges`], determine which comments are grouped together
128    /// in "comment blocks". A "comment block" is a sequence of consecutive
129    /// own-line comments in which the comment hash (`#`) appears in the same
130    /// column in each line, and at least one comment is non-empty.
131    ///
132    /// Returns a sorted vector containing the offset of the leading hash (`#`)
133    /// for each comment in any block comment.
134    ///
135    /// ## Examples
136    /// ```python
137    /// # This is a block comment
138    /// # because it spans multiple lines
139    ///
140    ///     # This is also a block comment
141    ///     # even though it is indented
142    ///
143    /// # this is not a block comment
144    ///
145    /// x = 1  # this is not a block comment because
146    /// y = 2  # the lines do not *only* contain comments
147    ///
148    /// # This is not a block comment because
149    ///     # not all consecutive lines have the
150    /// # first `#` character in the same column
151    ///
152    /// """
153    /// # This is not a block comment because it is
154    /// # contained within a multi-line string/comment
155    /// """
156    /// ```
157    pub fn block_comments(&self, source: &str) -> Vec<TextSize> {
158        let mut block_comments: Vec<TextSize> = Vec::new();
159
160        let mut current_block: Vec<TextSize> = Vec::new();
161        let mut current_block_column: Option<TextSize> = None;
162        let mut current_block_non_empty = false;
163
164        let mut prev_line_end = None;
165
166        for comment_range in &self.raw {
167            let offset = comment_range.start();
168            let line_start = source.line_start(offset);
169            let line_end = source.full_line_end(offset);
170            let column = offset - line_start;
171
172            // If this is an end-of-line comment, reset the current block.
173            if !Self::is_own_line(offset, source) {
174                // Push the current block, and reset.
175                if current_block.len() > 1 && current_block_non_empty {
176                    block_comments.extend(current_block);
177                }
178                current_block = vec![];
179                current_block_column = None;
180                current_block_non_empty = false;
181                prev_line_end = Some(line_end);
182                continue;
183            }
184
185            // If there's a blank line between this comment and the previous
186            // comment, reset the current block.
187            if prev_line_end.is_some_and(|prev_line_end| {
188                source.contains_line_break(TextRange::new(prev_line_end, line_start))
189            }) {
190                // Push the current block.
191                if current_block.len() > 1 && current_block_non_empty {
192                    block_comments.extend(current_block);
193                }
194
195                // Reset the block state.
196                current_block = vec![offset];
197                current_block_column = Some(column);
198                current_block_non_empty = !Self::is_empty(*comment_range, source);
199                prev_line_end = Some(line_end);
200                continue;
201            }
202
203            if let Some(current_column) = current_block_column {
204                if column == current_column {
205                    // Add the comment to the current block.
206                    current_block.push(offset);
207                    current_block_non_empty |= !Self::is_empty(*comment_range, source);
208                    prev_line_end = Some(line_end);
209                } else {
210                    // Push the current block.
211                    if current_block.len() > 1 && current_block_non_empty {
212                        block_comments.extend(current_block);
213                    }
214
215                    // Reset the block state.
216                    current_block = vec![offset];
217                    current_block_column = Some(column);
218                    current_block_non_empty = !Self::is_empty(*comment_range, source);
219                    prev_line_end = Some(line_end);
220                }
221            } else {
222                // Push the current block.
223                if current_block.len() > 1 && current_block_non_empty {
224                    block_comments.extend(current_block);
225                }
226
227                // Reset the block state.
228                current_block = vec![offset];
229                current_block_column = Some(column);
230                current_block_non_empty = !Self::is_empty(*comment_range, source);
231                prev_line_end = Some(line_end);
232            }
233        }
234
235        // Push any lingering blocks.
236        if current_block.len() > 1 && current_block_non_empty {
237            block_comments.extend(current_block);
238        }
239
240        block_comments
241    }
242
243    /// Returns `true` if the given range is an empty comment.
244    fn is_empty(range: TextRange, source: &str) -> bool {
245        source[range].chars().skip(1).all(is_python_whitespace)
246    }
247
248    /// Returns `true` if a comment is an own-line comment (as opposed to an end-of-line comment).
249    pub fn is_own_line(offset: TextSize, source: &str) -> bool {
250        let range = TextRange::new(source.line_start(offset), offset);
251        source[range].chars().all(is_python_whitespace)
252    }
253}
254
255impl Deref for CommentRanges {
256    type Target = [TextRange];
257
258    fn deref(&self) -> &Self::Target {
259        self.raw.as_slice()
260    }
261}
262
263impl Debug for CommentRanges {
264    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
265        f.debug_tuple("CommentRanges").field(&self.raw).finish()
266    }
267}
268
269impl<'a> IntoIterator for &'a CommentRanges {
270    type Item = TextRange;
271    type IntoIter = std::iter::Copied<std::slice::Iter<'a, TextRange>>;
272
273    fn into_iter(self) -> Self::IntoIter {
274        self.raw.iter().copied()
275    }
276}