Skip to main content

ruff_source_file/
line_index.rs

1use std::fmt;
2use std::fmt::{Debug, Formatter};
3use std::num::{NonZeroUsize, ParseIntError};
4use std::ops::Deref;
5use std::str::FromStr;
6use std::sync::Arc;
7
8use crate::{LineColumn, SourceLocation};
9use ruff_text_size::{TextLen, TextRange, TextSize};
10#[cfg(feature = "serde")]
11use serde::{Deserialize, Serialize};
12
13/// Index for fast [byte offset](TextSize) to [`LineColumn`] conversions.
14///
15/// Cloning a [`LineIndex`] is cheap because it only requires bumping a reference count.
16#[derive(Clone, Eq, PartialEq)]
17#[cfg_attr(feature = "get-size", derive(get_size2::GetSize))]
18pub struct LineIndex {
19    inner: Arc<LineIndexInner>,
20}
21
22#[derive(Eq, PartialEq)]
23#[cfg_attr(feature = "get-size", derive(get_size2::GetSize))]
24struct LineIndexInner {
25    line_starts: Vec<TextSize>,
26    kind: IndexKind,
27}
28
29impl LineIndex {
30    /// Builds the [`LineIndex`] from the source text of a file.
31    pub fn from_source_text(text: &str) -> Self {
32        let mut line_starts: Vec<TextSize> = Vec::with_capacity(text.len() / 88);
33        line_starts.push(TextSize::default());
34
35        let bytes = text.as_bytes();
36
37        assert!(u32::try_from(bytes.len()).is_ok());
38
39        for i in memchr::memchr2_iter(b'\n', b'\r', bytes) {
40            // Skip `\r` in `\r\n` sequences (only count the `\n`).
41            if bytes[i] == b'\r' && bytes.get(i + 1) == Some(&b'\n') {
42                continue;
43            }
44            // SAFETY: Assertion above guarantees `i <= u32::MAX`
45            #[expect(clippy::cast_possible_truncation)]
46            line_starts.push(TextSize::from(i as u32) + TextSize::from(1));
47        }
48
49        // Determine whether the source text is ASCII.
50        //
51        // Empirically, this simple loop is auto-vectorized by LLVM and benchmarks faster than both
52        // `str::is_ascii()` and hand-written SIMD.
53        let mut has_non_ascii = false;
54        for byte in bytes {
55            has_non_ascii |= !byte.is_ascii();
56        }
57
58        let kind = if has_non_ascii {
59            IndexKind::Utf8
60        } else {
61            IndexKind::Ascii
62        };
63
64        Self {
65            inner: Arc::new(LineIndexInner { line_starts, kind }),
66        }
67    }
68
69    fn kind(&self) -> IndexKind {
70        self.inner.kind
71    }
72
73    /// Returns the line and column number for an UTF-8 byte offset.
74    ///
75    /// The `column` number is the nth-character of the line, except for the first line
76    /// where it doesn't include the UTF-8 BOM marker at the start of the file.
77    ///
78    /// ### BOM handling
79    ///
80    /// For files starting with a UTF-8 BOM marker, the byte offsets
81    /// in the range `0...3` are all mapped to line 0 and column 0.
82    /// Because of this, the conversion isn't lossless.
83    ///
84    /// ## Examples
85    ///
86    /// ```
87    /// # use ruff_text_size::TextSize;
88    /// # use ruff_source_file::{LineIndex, OneIndexed, LineColumn};
89    /// let source = format!("\u{FEFF}{}", "def a():\n    pass");
90    /// let index = LineIndex::from_source_text(&source);
91    ///
92    /// // Before BOM, maps to after BOM
93    /// assert_eq!(
94    ///     index.line_column(TextSize::from(0), &source),
95    ///     LineColumn { line: OneIndexed::from_zero_indexed(0), column: OneIndexed::from_zero_indexed(0) }
96    /// );
97    ///
98    /// // After BOM, maps to after BOM
99    /// assert_eq!(
100    ///     index.line_column(TextSize::from(3), &source),
101    ///     LineColumn { line: OneIndexed::from_zero_indexed(0), column: OneIndexed::from_zero_indexed(0) }
102    /// );
103    ///
104    /// assert_eq!(
105    ///     index.line_column(TextSize::from(7), &source),
106    ///     LineColumn { line: OneIndexed::from_zero_indexed(0), column: OneIndexed::from_zero_indexed(4) }
107    /// );
108    /// assert_eq!(
109    ///     index.line_column(TextSize::from(16), &source),
110    ///     LineColumn { line: OneIndexed::from_zero_indexed(1), column: OneIndexed::from_zero_indexed(4) }
111    /// );
112    /// ```
113    ///
114    /// ## Panics
115    ///
116    /// If the byte offset isn't within the bounds of `content`.
117    pub fn line_column(&self, offset: TextSize, content: &str) -> LineColumn {
118        let location = self.source_location(offset, content, PositionEncoding::Utf32);
119
120        // Don't count the BOM character as a column, but only on the first line.
121        let column = if location.line.to_zero_indexed() == 0 && content.starts_with('\u{feff}') {
122            location.character_offset.saturating_sub(1)
123        } else {
124            location.character_offset
125        };
126
127        LineColumn {
128            line: location.line,
129            column,
130        }
131    }
132
133    /// Given a UTF-8 byte offset, returns the line and character offset according to the given encoding.
134    ///
135    /// ### BOM handling
136    ///
137    /// Unlike [`Self::line_column`], this method does not skip the BOM character at the start of the file.
138    /// This allows for bidirectional mapping between [`SourceLocation`] and [`TextSize`] (see [`Self::offset`]).
139    ///
140    /// ## Examples
141    ///
142    /// ```
143    /// # use ruff_text_size::TextSize;
144    /// # use ruff_source_file::{LineIndex, OneIndexed, LineColumn, SourceLocation, PositionEncoding, Line};
145    /// let source = format!("\u{FEFF}{}", "def a():\n    pass");
146    /// let index = LineIndex::from_source_text(&source);
147    ///
148    /// // Before BOM, maps to character 0
149    /// assert_eq!(
150    ///     index.source_location(TextSize::from(0), &source, PositionEncoding::Utf32),
151    ///     SourceLocation { line: OneIndexed::from_zero_indexed(0), character_offset: OneIndexed::from_zero_indexed(0) }
152    /// );
153    ///
154    /// // After BOM, maps to after BOM
155    /// assert_eq!(
156    ///     index.source_location(TextSize::from(3), &source, PositionEncoding::Utf32),
157    ///     SourceLocation { line: OneIndexed::from_zero_indexed(0), character_offset: OneIndexed::from_zero_indexed(1) }
158    /// );
159    ///
160    /// assert_eq!(
161    ///     index.source_location(TextSize::from(7), &source, PositionEncoding::Utf32),
162    ///     SourceLocation { line: OneIndexed::from_zero_indexed(0), character_offset: OneIndexed::from_zero_indexed(5) }
163    /// );
164    /// assert_eq!(
165    ///     index.source_location(TextSize::from(16), &source, PositionEncoding::Utf32),
166    ///     SourceLocation { line: OneIndexed::from_zero_indexed(1), character_offset: OneIndexed::from_zero_indexed(4) }
167    /// );
168    /// ```
169    ///
170    /// ## Panics
171    ///
172    /// If the UTF-8 byte offset is out of bounds of `text`.
173    pub fn source_location(
174        &self,
175        offset: TextSize,
176        text: &str,
177        encoding: PositionEncoding,
178    ) -> SourceLocation {
179        let line = self.line_index(offset);
180        let line_start = self.line_start(line, text);
181
182        let character_offset =
183            self.characters_between(TextRange::new(line_start, offset), text, encoding);
184
185        SourceLocation {
186            line,
187            character_offset: OneIndexed::from_zero_indexed(character_offset),
188        }
189    }
190
191    fn characters_between(
192        &self,
193        range: TextRange,
194        text: &str,
195        encoding: PositionEncoding,
196    ) -> usize {
197        if self.is_ascii() {
198            return (range.end() - range.start()).to_usize();
199        }
200
201        match encoding {
202            PositionEncoding::Utf8 => (range.end() - range.start()).to_usize(),
203            PositionEncoding::Utf16 => {
204                let up_to_character = &text[range];
205                up_to_character.encode_utf16().count()
206            }
207            PositionEncoding::Utf32 => {
208                let up_to_character = &text[range];
209                up_to_character.chars().count()
210            }
211        }
212    }
213
214    /// Returns the length of the line in characters, respecting the given encoding
215    pub fn line_len(&self, line: OneIndexed, text: &str, encoding: PositionEncoding) -> usize {
216        let line_range = self.line_range(line, text);
217
218        self.characters_between(line_range, text, encoding)
219    }
220
221    /// Return the number of lines in the source code.
222    pub fn line_count(&self) -> usize {
223        self.line_starts().len()
224    }
225
226    /// Returns `true` if the text only consists of ASCII characters
227    fn is_ascii(&self) -> bool {
228        self.kind().is_ascii()
229    }
230
231    /// Returns the row number for a given offset.
232    ///
233    /// ## Examples
234    ///
235    /// ```
236    /// # use ruff_text_size::TextSize;
237    /// # use ruff_source_file::{LineIndex, OneIndexed, LineColumn};
238    /// let source = "def a():\n    pass";
239    /// let index = LineIndex::from_source_text(source);
240    ///
241    /// assert_eq!(index.line_index(TextSize::from(0)), OneIndexed::from_zero_indexed(0));
242    /// assert_eq!(index.line_index(TextSize::from(4)), OneIndexed::from_zero_indexed(0));
243    /// assert_eq!(index.line_index(TextSize::from(13)), OneIndexed::from_zero_indexed(1));
244    /// ```
245    ///
246    /// ## Panics
247    ///
248    /// If the offset is out of bounds.
249    pub fn line_index(&self, offset: TextSize) -> OneIndexed {
250        match self.line_starts().binary_search(&offset) {
251            // Offset is at the start of a line
252            Ok(row) => OneIndexed::from_zero_indexed(row),
253            Err(row) => {
254                // SAFETY: Safe because the index always contains an entry for the offset 0
255                OneIndexed::from_zero_indexed(row - 1)
256            }
257        }
258    }
259
260    /// Returns the [byte offset](TextSize) for the `line` with the given index.
261    pub fn line_start(&self, line: OneIndexed, contents: &str) -> TextSize {
262        let row_index = line.to_zero_indexed();
263        let starts = self.line_starts();
264
265        // If start-of-line position after last line
266        if row_index == starts.len() {
267            contents.text_len()
268        } else {
269            starts[row_index]
270        }
271    }
272
273    /// Returns the [byte offset](TextSize) of the `line`'s end.
274    /// The offset is the end of the line, up to and including the newline character ending the line (if any).
275    pub fn line_end(&self, line: OneIndexed, contents: &str) -> TextSize {
276        let row_index = line.to_zero_indexed();
277        let starts = self.line_starts();
278
279        // If start-of-line position after last line
280        if row_index.saturating_add(1) >= starts.len() {
281            contents.text_len()
282        } else {
283            starts[row_index + 1]
284        }
285    }
286
287    /// Returns the [byte offset](TextSize) of the `line`'s end.
288    /// The offset is the end of the line, excluding the newline character ending the line (if any).
289    pub(crate) fn line_end_exclusive(&self, line: OneIndexed, contents: &str) -> TextSize {
290        let row_index = line.to_zero_indexed();
291        let starts = self.line_starts();
292
293        // If start-of-line position after last line
294        if row_index.saturating_add(1) >= starts.len() {
295            contents.text_len()
296        } else {
297            let next_line_start = starts[row_index + 1].to_usize();
298            let bytes = contents.as_bytes();
299
300            let line_ending_len = if bytes[..next_line_start].ends_with(b"\r\n") {
301                2
302            } else {
303                1
304            };
305            starts[row_index + 1] - TextSize::new(line_ending_len)
306        }
307    }
308
309    /// Returns the [`TextRange`] of the `line` with the given index.
310    /// The start points to the first character's [byte offset](TextSize), the end up to, and including
311    /// the newline character ending the line (if any).
312    pub fn line_range(&self, line: OneIndexed, contents: &str) -> TextRange {
313        let starts = self.line_starts();
314
315        if starts.len() == line.to_zero_indexed() {
316            TextRange::empty(contents.text_len())
317        } else {
318            TextRange::new(
319                self.line_start(line, contents),
320                self.line_start(line.saturating_add(1), contents),
321            )
322        }
323    }
324
325    /// Returns the [UTF-8 byte offset](TextSize) at `line` and `character` where character is counted using the given encoding.
326    ///
327    /// ## Examples
328    ///
329    /// ### ASCII only source text
330    ///
331    /// ```
332    /// # use ruff_source_file::{SourceLocation, LineIndex, OneIndexed, PositionEncoding};
333    /// # use ruff_text_size::TextSize;
334    /// let source = r#"a = 4
335    /// c = "some string"
336    /// x = b"#;
337    ///
338    /// let index = LineIndex::from_source_text(source);
339    ///
340    /// // First line, first character
341    /// assert_eq!(
342    ///     index.offset(
343    ///         SourceLocation {
344    ///             line: OneIndexed::from_zero_indexed(0),
345    ///             character_offset: OneIndexed::from_zero_indexed(0)
346    ///         },
347    ///         source,
348    ///         PositionEncoding::Utf32,
349    ///     ),
350    ///     TextSize::new(0)
351    ///  );
352    ///
353    /// assert_eq!(
354    ///     index.offset(
355    ///         SourceLocation {
356    ///             line: OneIndexed::from_zero_indexed(1),
357    ///             character_offset: OneIndexed::from_zero_indexed(4)
358    ///         },
359    ///         source,
360    ///         PositionEncoding::Utf32,
361    ///     ),
362    ///     TextSize::new(10)
363    ///  );
364    ///
365    /// // Offset past the end of the first line
366    /// assert_eq!(
367    ///     index.offset(
368    ///         SourceLocation {
369    ///             line: OneIndexed::from_zero_indexed(0),
370    ///             character_offset: OneIndexed::from_zero_indexed(10)
371    ///         },
372    ///         source,
373    ///         PositionEncoding::Utf32,
374    ///     ),
375    ///     TextSize::new(6)
376    ///  );
377    ///
378    /// // Offset past the end of the file
379    /// assert_eq!(
380    ///     index.offset(
381    ///         SourceLocation {
382    ///             line: OneIndexed::from_zero_indexed(3),
383    ///             character_offset: OneIndexed::from_zero_indexed(0)
384    ///         },
385    ///         source,
386    ///         PositionEncoding::Utf32,
387    ///     ),
388    ///     TextSize::new(29)
389    ///  );
390    /// ```
391    ///
392    /// ### Non-ASCII source text
393    ///
394    /// ```
395    /// use ruff_source_file::{LineIndex, OneIndexed, SourceLocation, PositionEncoding};
396    /// use ruff_text_size::TextSize;
397    /// let source = format!("\u{FEFF}{}", r#"a = 4
398    /// c = "❤️"
399    /// x = b"#);
400    ///
401    /// let index = LineIndex::from_source_text(&source);
402    ///
403    /// // First line, first character, points at the BOM
404    /// assert_eq!(
405    ///     index.offset(
406    ///         SourceLocation {
407    ///             line: OneIndexed::from_zero_indexed(0),
408    ///             character_offset: OneIndexed::from_zero_indexed(0)
409    ///         },
410    ///         &source,
411    ///         PositionEncoding::Utf32,
412    ///     ),
413    ///     TextSize::new(0)
414    ///  );
415    ///
416    /// // First line, after the BOM
417    /// assert_eq!(
418    ///     index.offset(
419    ///         SourceLocation {
420    ///             line: OneIndexed::from_zero_indexed(0),
421    ///             character_offset: OneIndexed::from_zero_indexed(1)
422    ///         },
423    ///         &source,
424    ///         PositionEncoding::Utf32,
425    ///     ),
426    ///     TextSize::new(3)
427    ///  );
428    ///
429    /// // second line, 7th character, after emoji, UTF32
430    /// assert_eq!(
431    ///     index.offset(
432    ///         SourceLocation {
433    ///             line: OneIndexed::from_zero_indexed(1),
434    ///             character_offset: OneIndexed::from_zero_indexed(7)
435    ///         },
436    ///         &source,
437    ///         PositionEncoding::Utf32,
438    ///     ),
439    ///     TextSize::new(20)
440    ///  );
441    ///
442    /// // Second line, 7th character, after emoji, UTF 16
443    /// assert_eq!(
444    ///     index.offset(
445    ///         SourceLocation {
446    ///             line: OneIndexed::from_zero_indexed(1),
447    ///             character_offset: OneIndexed::from_zero_indexed(7)
448    ///         },
449    ///         &source,
450    ///         PositionEncoding::Utf16,
451    ///     ),
452    ///     TextSize::new(20)
453    ///  );
454    ///
455    ///
456    /// // Offset past the end of the second line
457    /// assert_eq!(
458    ///     index.offset(
459    ///         SourceLocation {
460    ///             line: OneIndexed::from_zero_indexed(1),
461    ///             character_offset: OneIndexed::from_zero_indexed(10)
462    ///         },
463    ///         &source,
464    ///         PositionEncoding::Utf32,
465    ///     ),
466    ///     TextSize::new(22)
467    ///  );
468    ///
469    /// // Offset past the end of the file
470    /// assert_eq!(
471    ///     index.offset(
472    ///         SourceLocation {
473    ///             line: OneIndexed::from_zero_indexed(3),
474    ///             character_offset: OneIndexed::from_zero_indexed(0)
475    ///         },
476    ///         &source,
477    ///         PositionEncoding::Utf32,
478    ///     ),
479    ///     TextSize::new(27)
480    ///  );
481    /// ```
482    pub fn offset(
483        &self,
484        position: SourceLocation,
485        text: &str,
486        position_encoding: PositionEncoding,
487    ) -> TextSize {
488        // If start-of-line position after last line
489        if position.line.to_zero_indexed() > self.line_starts().len() {
490            return text.text_len();
491        }
492
493        let line_range = self.line_range(position.line, text);
494
495        let character_offset = position.character_offset.to_zero_indexed();
496        let character_byte_offset = if self.is_ascii() {
497            TextSize::try_from(character_offset).unwrap()
498        } else {
499            let line = &text[line_range];
500
501            match position_encoding {
502                PositionEncoding::Utf8 => {
503                    TextSize::try_from(position.character_offset.to_zero_indexed()).unwrap()
504                }
505                PositionEncoding::Utf16 => {
506                    let mut byte_offset = TextSize::new(0);
507                    let mut utf16_code_unit_offset = 0;
508
509                    for c in line.chars() {
510                        if utf16_code_unit_offset >= character_offset {
511                            break;
512                        }
513
514                        // Count characters encoded as two 16 bit words as 2 characters.
515                        byte_offset += c.text_len();
516                        utf16_code_unit_offset += c.len_utf16();
517                    }
518
519                    byte_offset
520                }
521                PositionEncoding::Utf32 => line
522                    .chars()
523                    .take(position.character_offset.to_zero_indexed())
524                    .map(ruff_text_size::TextLen::text_len)
525                    .sum(),
526            }
527        };
528
529        line_range.start() + character_byte_offset.clamp(TextSize::new(0), line_range.len())
530    }
531
532    /// Returns the [byte offsets](TextSize) for every line
533    pub fn line_starts(&self) -> &[TextSize] {
534        &self.inner.line_starts
535    }
536}
537
538impl Deref for LineIndex {
539    type Target = [TextSize];
540
541    fn deref(&self) -> &Self::Target {
542        self.line_starts()
543    }
544}
545
546impl Debug for LineIndex {
547    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
548        f.debug_list().entries(self.line_starts()).finish()
549    }
550}
551
552#[derive(Debug, Clone, Copy, Eq, PartialEq)]
553#[cfg_attr(feature = "get-size", derive(get_size2::GetSize))]
554enum IndexKind {
555    /// Optimized index for an ASCII only document
556    Ascii,
557
558    /// Index for UTF8 documents
559    Utf8,
560}
561
562impl IndexKind {
563    const fn is_ascii(self) -> bool {
564        matches!(self, IndexKind::Ascii)
565    }
566}
567
568/// Type-safe wrapper for a value whose logical range starts at `1`, for
569/// instance the line or column numbers in a file
570///
571/// Internally this is represented as a [`NonZeroUsize`], this enables some
572/// memory optimizations
573#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
574#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
575pub struct OneIndexed(NonZeroUsize);
576
577impl OneIndexed {
578    /// The largest value that can be represented by this integer type
579    pub const MAX: Self = Self::new(usize::MAX).unwrap();
580    // SAFETY: These constants are being initialized with non-zero values
581    /// The smallest value that can be represented by this integer type.
582    pub const MIN: Self = Self::new(1).unwrap();
583    const ONE: NonZeroUsize = NonZeroUsize::new(1).unwrap();
584
585    /// Creates a non-zero if the given value is not zero.
586    pub const fn new(value: usize) -> Option<Self> {
587        match NonZeroUsize::new(value) {
588            Some(value) => Some(Self(value)),
589            None => None,
590        }
591    }
592
593    /// Construct a new [`OneIndexed`] from a zero-indexed value
594    pub const fn from_zero_indexed(value: usize) -> Self {
595        Self(Self::ONE.saturating_add(value))
596    }
597
598    /// Returns the value as a primitive type.
599    pub const fn get(self) -> usize {
600        self.0.get()
601    }
602
603    /// Return the zero-indexed primitive value for this [`OneIndexed`]
604    pub const fn to_zero_indexed(self) -> usize {
605        self.0.get() - 1
606    }
607
608    /// Saturating integer addition. Computes `self + rhs`, saturating at
609    /// the numeric bounds instead of overflowing.
610    #[must_use]
611    pub const fn saturating_add(self, rhs: usize) -> Self {
612        match NonZeroUsize::new(self.0.get().saturating_add(rhs)) {
613            Some(value) => Self(value),
614            None => Self::MAX,
615        }
616    }
617
618    /// Saturating integer subtraction. Computes `self - rhs`, saturating
619    /// at the numeric bounds instead of overflowing.
620    #[must_use]
621    pub const fn saturating_sub(self, rhs: usize) -> Self {
622        match NonZeroUsize::new(self.0.get().saturating_sub(rhs)) {
623            Some(value) => Self(value),
624            None => Self::MIN,
625        }
626    }
627
628    /// Checked addition. Returns `None` if overflow occurred.
629    #[must_use]
630    pub fn checked_add(self, rhs: Self) -> Option<Self> {
631        self.0.checked_add(rhs.0.get()).map(Self)
632    }
633
634    /// Checked subtraction. Returns `None` if overflow occurred.
635    #[must_use]
636    pub fn checked_sub(self, rhs: Self) -> Option<Self> {
637        self.0.get().checked_sub(rhs.get()).and_then(Self::new)
638    }
639
640    /// Calculate the number of digits in `self`.
641    ///
642    /// This is primarily intended for computing the length of the string representation for
643    /// formatted printing.
644    ///
645    /// # Examples
646    ///
647    /// ```
648    /// use ruff_source_file::OneIndexed;
649    ///
650    /// let one = OneIndexed::new(1).unwrap();
651    /// assert_eq!(one.digits().get(), 1);
652    ///
653    /// let hundred = OneIndexed::new(100).unwrap();
654    /// assert_eq!(hundred.digits().get(), 3);
655    ///
656    /// let thousand = OneIndexed::new(1000).unwrap();
657    /// assert_eq!(thousand.digits().get(), 4);
658    /// ```
659    pub const fn digits(self) -> NonZeroUsize {
660        // Safety: the 1+ ensures this is always non-zero, and
661        // `usize::MAX.ilog10()` << `usize::MAX`, so the result is always safe
662        // to cast to a usize, even though it's returned as a u32
663        // (u64::MAX.ilog10() is 19).
664        NonZeroUsize::new(1 + self.0.get().ilog10() as usize).unwrap()
665    }
666}
667
668impl Default for OneIndexed {
669    fn default() -> Self {
670        Self::MIN
671    }
672}
673
674impl fmt::Display for OneIndexed {
675    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
676        std::fmt::Debug::fmt(&self.0.get(), f)
677    }
678}
679
680impl FromStr for OneIndexed {
681    type Err = ParseIntError;
682    fn from_str(s: &str) -> Result<Self, Self::Err> {
683        Ok(OneIndexed(NonZeroUsize::from_str(s)?))
684    }
685}
686
687#[derive(Copy, Clone, Debug)]
688pub enum PositionEncoding {
689    /// Character offsets count the number of bytes from the start of the line.
690    Utf8,
691
692    /// Character offsets count the number of UTF-16 code units from the start of the line.
693    Utf16,
694
695    /// Character offsets count the number of UTF-32 code points units (the same as number of characters in Rust)
696    /// from the start of the line.
697    Utf32,
698}
699
700#[cfg(test)]
701mod tests {
702    use ruff_text_size::TextSize;
703
704    use crate::line_index::LineIndex;
705    use crate::{LineColumn, OneIndexed};
706
707    #[test]
708    fn ascii_index() {
709        let index = LineIndex::from_source_text("");
710        assert_eq!(index.line_starts(), &[TextSize::from(0)]);
711
712        let index = LineIndex::from_source_text("x = 1");
713        assert_eq!(index.line_starts(), &[TextSize::from(0)]);
714
715        let index = LineIndex::from_source_text("x = 1\n");
716        assert_eq!(index.line_starts(), &[TextSize::from(0), TextSize::from(6)]);
717
718        let index = LineIndex::from_source_text("x = 1\ny = 2\nz = x + y\n");
719        assert_eq!(
720            index.line_starts(),
721            &[
722                TextSize::from(0),
723                TextSize::from(6),
724                TextSize::from(12),
725                TextSize::from(22)
726            ]
727        );
728    }
729
730    #[test]
731    fn ascii_source_location() {
732        let contents = "x = 1\ny = 2";
733        let index = LineIndex::from_source_text(contents);
734
735        // First row.
736        let loc = index.line_column(TextSize::from(2), contents);
737        assert_eq!(
738            loc,
739            LineColumn {
740                line: OneIndexed::from_zero_indexed(0),
741                column: OneIndexed::from_zero_indexed(2)
742            }
743        );
744
745        // Second row.
746        let loc = index.line_column(TextSize::from(6), contents);
747        assert_eq!(
748            loc,
749            LineColumn {
750                line: OneIndexed::from_zero_indexed(1),
751                column: OneIndexed::from_zero_indexed(0)
752            }
753        );
754
755        let loc = index.line_column(TextSize::from(11), contents);
756        assert_eq!(
757            loc,
758            LineColumn {
759                line: OneIndexed::from_zero_indexed(1),
760                column: OneIndexed::from_zero_indexed(5)
761            }
762        );
763    }
764
765    #[test]
766    fn ascii_carriage_return() {
767        let contents = "x = 4\ry = 3";
768        let index = LineIndex::from_source_text(contents);
769        assert_eq!(index.line_starts(), &[TextSize::from(0), TextSize::from(6)]);
770
771        assert_eq!(
772            index.line_column(TextSize::from(4), contents),
773            LineColumn {
774                line: OneIndexed::from_zero_indexed(0),
775                column: OneIndexed::from_zero_indexed(4)
776            }
777        );
778        assert_eq!(
779            index.line_column(TextSize::from(6), contents),
780            LineColumn {
781                line: OneIndexed::from_zero_indexed(1),
782                column: OneIndexed::from_zero_indexed(0)
783            }
784        );
785        assert_eq!(
786            index.line_column(TextSize::from(7), contents),
787            LineColumn {
788                line: OneIndexed::from_zero_indexed(1),
789                column: OneIndexed::from_zero_indexed(1)
790            }
791        );
792    }
793
794    #[test]
795    fn ascii_carriage_return_newline() {
796        let contents = "x = 4\r\ny = 3";
797        let index = LineIndex::from_source_text(contents);
798        assert_eq!(index.line_starts(), &[TextSize::from(0), TextSize::from(7)]);
799
800        assert_eq!(
801            index.line_column(TextSize::from(4), contents),
802            LineColumn {
803                line: OneIndexed::from_zero_indexed(0),
804                column: OneIndexed::from_zero_indexed(4)
805            }
806        );
807        assert_eq!(
808            index.line_column(TextSize::from(7), contents),
809            LineColumn {
810                line: OneIndexed::from_zero_indexed(1),
811                column: OneIndexed::from_zero_indexed(0)
812            }
813        );
814        assert_eq!(
815            index.line_column(TextSize::from(8), contents),
816            LineColumn {
817                line: OneIndexed::from_zero_indexed(1),
818                column: OneIndexed::from_zero_indexed(1)
819            }
820        );
821    }
822
823    #[test]
824    fn line_end_exclusive_handles_different_line_endings() {
825        let lf_contents = "a\nb";
826        let lf_index = LineIndex::from_source_text(lf_contents);
827        assert_eq!(
828            lf_index.line_end_exclusive(OneIndexed::from_zero_indexed(0), lf_contents),
829            TextSize::from(1)
830        );
831        assert_eq!(
832            lf_index.line_end_exclusive(OneIndexed::from_zero_indexed(1), lf_contents),
833            TextSize::from(3)
834        );
835
836        let crlf_contents = "a\r\nb";
837        let crlf_index = LineIndex::from_source_text(crlf_contents);
838        assert_eq!(
839            crlf_index.line_end_exclusive(OneIndexed::from_zero_indexed(0), crlf_contents),
840            TextSize::from(1)
841        );
842        assert_eq!(
843            crlf_index.line_end_exclusive(OneIndexed::from_zero_indexed(1), crlf_contents),
844            TextSize::from(4)
845        );
846
847        let cr_contents = "a\rb";
848        let cr_index = LineIndex::from_source_text(cr_contents);
849        assert_eq!(
850            cr_index.line_end_exclusive(OneIndexed::from_zero_indexed(0), cr_contents),
851            TextSize::from(1)
852        );
853        assert_eq!(
854            cr_index.line_end_exclusive(OneIndexed::from_zero_indexed(1), cr_contents),
855            TextSize::from(3)
856        );
857    }
858
859    #[test]
860    fn utf8_index() {
861        let index = LineIndex::from_source_text("x = '🫣'");
862        assert_eq!(index.line_count(), 1);
863        assert_eq!(index.line_starts(), &[TextSize::from(0)]);
864
865        let index = LineIndex::from_source_text("x = '🫣'\n");
866        assert_eq!(index.line_count(), 2);
867        assert_eq!(
868            index.line_starts(),
869            &[TextSize::from(0), TextSize::from(11)]
870        );
871
872        let index = LineIndex::from_source_text("x = '🫣'\ny = 2\nz = x + y\n");
873        assert_eq!(index.line_count(), 4);
874        assert_eq!(
875            index.line_starts(),
876            &[
877                TextSize::from(0),
878                TextSize::from(11),
879                TextSize::from(17),
880                TextSize::from(27)
881            ]
882        );
883
884        let index = LineIndex::from_source_text("# 🫣\nclass Foo:\n    \"\"\".\"\"\"");
885        assert_eq!(index.line_count(), 3);
886        assert_eq!(
887            index.line_starts(),
888            &[TextSize::from(0), TextSize::from(7), TextSize::from(18)]
889        );
890    }
891
892    #[test]
893    fn utf8_carriage_return() {
894        let contents = "x = '🫣'\ry = 3";
895        let index = LineIndex::from_source_text(contents);
896        assert_eq!(index.line_count(), 2);
897        assert_eq!(
898            index.line_starts(),
899            &[TextSize::from(0), TextSize::from(11)]
900        );
901
902        // Second '
903        assert_eq!(
904            index.line_column(TextSize::from(9), contents),
905            LineColumn {
906                line: OneIndexed::from_zero_indexed(0),
907                column: OneIndexed::from_zero_indexed(6)
908            }
909        );
910        assert_eq!(
911            index.line_column(TextSize::from(11), contents),
912            LineColumn {
913                line: OneIndexed::from_zero_indexed(1),
914                column: OneIndexed::from_zero_indexed(0)
915            }
916        );
917        assert_eq!(
918            index.line_column(TextSize::from(12), contents),
919            LineColumn {
920                line: OneIndexed::from_zero_indexed(1),
921                column: OneIndexed::from_zero_indexed(1)
922            }
923        );
924    }
925
926    #[test]
927    fn utf8_carriage_return_newline() {
928        let contents = "x = '🫣'\r\ny = 3";
929        let index = LineIndex::from_source_text(contents);
930        assert_eq!(index.line_count(), 2);
931        assert_eq!(
932            index.line_starts(),
933            &[TextSize::from(0), TextSize::from(12)]
934        );
935
936        // Second '
937        assert_eq!(
938            index.line_column(TextSize::from(9), contents),
939            LineColumn {
940                line: OneIndexed::from_zero_indexed(0),
941                column: OneIndexed::from_zero_indexed(6)
942            }
943        );
944        assert_eq!(
945            index.line_column(TextSize::from(12), contents),
946            LineColumn {
947                line: OneIndexed::from_zero_indexed(1),
948                column: OneIndexed::from_zero_indexed(0)
949            }
950        );
951        assert_eq!(
952            index.line_column(TextSize::from(13), contents),
953            LineColumn {
954                line: OneIndexed::from_zero_indexed(1),
955                column: OneIndexed::from_zero_indexed(1)
956            }
957        );
958    }
959
960    #[test]
961    fn utf8_byte_offset() {
962        let contents = "x = '☃'\ny = 2";
963        let index = LineIndex::from_source_text(contents);
964        assert_eq!(
965            index.line_starts(),
966            &[TextSize::from(0), TextSize::from(10)]
967        );
968
969        // First row.
970        let loc = index.line_column(TextSize::from(0), contents);
971        assert_eq!(
972            loc,
973            LineColumn {
974                line: OneIndexed::from_zero_indexed(0),
975                column: OneIndexed::from_zero_indexed(0)
976            }
977        );
978
979        let loc = index.line_column(TextSize::from(5), contents);
980        assert_eq!(
981            loc,
982            LineColumn {
983                line: OneIndexed::from_zero_indexed(0),
984                column: OneIndexed::from_zero_indexed(5)
985            }
986        );
987
988        let loc = index.line_column(TextSize::from(8), contents);
989        assert_eq!(
990            loc,
991            LineColumn {
992                line: OneIndexed::from_zero_indexed(0),
993                column: OneIndexed::from_zero_indexed(6)
994            }
995        );
996
997        // Second row.
998        let loc = index.line_column(TextSize::from(10), contents);
999        assert_eq!(
1000            loc,
1001            LineColumn {
1002                line: OneIndexed::from_zero_indexed(1),
1003                column: OneIndexed::from_zero_indexed(0)
1004            }
1005        );
1006
1007        // One-past-the-end.
1008        let loc = index.line_column(TextSize::from(15), contents);
1009        assert_eq!(
1010            loc,
1011            LineColumn {
1012                line: OneIndexed::from_zero_indexed(1),
1013                column: OneIndexed::from_zero_indexed(5)
1014            }
1015        );
1016    }
1017}