pydocstring/text.rs
1//! Source location types (offset-only).
2//!
3//! This module provides [`TextSize`] (a byte offset) and [`TextRange`]
4//! (a half-open byte range) for tracking source positions.
5//! Inspired by ruff / rust-analyzer's `text-size` crate.
6
7use core::fmt;
8use core::ops;
9
10// =============================================================================
11// TextSize
12// =============================================================================
13
14/// A byte offset in the source text.
15///
16/// Newtype over `u32` for type safety (prevents mixing with line numbers, etc.).
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
18pub struct TextSize(u32);
19
20impl TextSize {
21 /// Creates a new text size from a raw byte offset.
22 pub const fn new(raw: u32) -> Self {
23 Self(raw)
24 }
25
26 /// Returns the raw byte offset.
27 pub const fn raw(self) -> u32 {
28 self.0
29 }
30}
31
32impl From<u32> for TextSize {
33 fn from(raw: u32) -> Self {
34 Self(raw)
35 }
36}
37
38impl From<TextSize> for u32 {
39 fn from(size: TextSize) -> Self {
40 size.0
41 }
42}
43
44impl From<TextSize> for usize {
45 fn from(size: TextSize) -> Self {
46 size.0 as usize
47 }
48}
49
50impl From<usize> for TextSize {
51 fn from(raw: usize) -> Self {
52 Self(raw as u32)
53 }
54}
55
56impl ops::Add for TextSize {
57 type Output = Self;
58 fn add(self, rhs: Self) -> Self {
59 Self(self.0 + rhs.0)
60 }
61}
62
63impl ops::Sub for TextSize {
64 type Output = Self;
65 fn sub(self, rhs: Self) -> Self {
66 Self(self.0 - rhs.0)
67 }
68}
69
70impl fmt::Display for TextSize {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 self.0.fmt(f)
73 }
74}
75
76// =============================================================================
77// TextRange
78// =============================================================================
79
80/// A range in the source text `[start, end)`, represented as byte offsets.
81///
82/// Stores only offsets.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
84pub struct TextRange {
85 start: TextSize,
86 end: TextSize,
87}
88
89impl TextRange {
90 /// Creates a new range from start (inclusive) and end (exclusive) offsets.
91 pub const fn new(start: TextSize, end: TextSize) -> Self {
92 Self { start, end }
93 }
94
95 /// Start offset (inclusive).
96 pub const fn start(self) -> TextSize {
97 self.start
98 }
99
100 /// End offset (exclusive).
101 pub const fn end(self) -> TextSize {
102 self.end
103 }
104
105 /// Length of the range in bytes.
106 pub const fn len(self) -> TextSize {
107 TextSize::new(self.end.0 - self.start.0)
108 }
109
110 /// Whether the range is empty.
111 pub const fn is_empty(self) -> bool {
112 self.start.0 == self.end.0
113 }
114
115 /// Whether `offset` is contained in this range.
116 pub const fn contains(self, offset: TextSize) -> bool {
117 self.start.0 <= offset.0 && offset.0 < self.end.0
118 }
119
120 /// Creates a range from an absolute byte offset and a length.
121 pub const fn from_offset_len(offset: usize, len: usize) -> Self {
122 Self {
123 start: TextSize::new(offset as u32),
124 end: TextSize::new((offset + len) as u32),
125 }
126 }
127
128 /// Extracts the corresponding slice from the source text.
129 ///
130 /// Returns an empty string if the range is empty, out of bounds, inverted,
131 /// or if an endpoint falls inside a multi-byte character.
132 ///
133 /// The bounds check is not paranoia: a `TextRange` is two numbers and can
134 /// be built by hand (the Python binding exposes the constructor), so this
135 /// is reachable. Indexing a `str` with a range that splits a character
136 /// panics — and a panic across the FFI boundary is an abort.
137 pub fn source_text<'a>(&self, source: &'a str) -> &'a str {
138 let start = self.start.0 as usize;
139 let end = self.end.0 as usize;
140 source.get(start..end).unwrap_or("")
141 }
142
143 /// Grow this range's end to cover `other`.
144 ///
145 /// The end only ever moves forward: if `other` ends before this range
146 /// does, the range is left unchanged (this can never shrink a range).
147 /// The start is not touched.
148 pub(crate) fn extend(&mut self, other: TextRange) {
149 if other.end > self.end {
150 self.end = other.end;
151 }
152 }
153}
154
155impl fmt::Display for TextRange {
156 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157 write!(f, "{}..{}", self.start, self.end)
158 }
159}
160
161// =============================================================================
162// LineColumn
163// =============================================================================
164
165/// A line/column position in the source text.
166///
167/// `lineno` is 1-based; `col` is the 0-based byte offset from the start of
168/// the line.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
170pub struct LineColumn {
171 /// 1-based line number.
172 pub lineno: u32,
173 /// 0-based byte column offset from the start of the line.
174 pub col: u32,
175}
176
177impl fmt::Display for LineColumn {
178 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179 write!(f, "{}:{}", self.lineno, self.col)
180 }
181}
182
183// =============================================================================
184// LineIndex
185// =============================================================================
186
187/// A lookup table for converting byte offsets to [`LineColumn`] positions.
188///
189/// Build once from the source text with [`LineIndex::new`], then call
190/// [`LineIndex::line_col`] for any [`TextSize`] offset.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct LineIndex {
193 /// Byte offset of the first character of each line.
194 /// `line_starts[0]` is always 0 (start of the first line).
195 line_starts: Vec<u32>,
196}
197
198impl LineIndex {
199 /// Build a `LineIndex` from the source text.
200 pub fn new(source: &str) -> Self {
201 let mut line_starts = vec![0u32];
202 for (i, b) in source.bytes().enumerate() {
203 if b == b'\n' {
204 line_starts.push((i + 1) as u32);
205 }
206 }
207 Self { line_starts }
208 }
209
210 /// Convert a byte offset to a [`LineColumn`] position.
211 ///
212 /// `lineno` is 1-based; `col` is the 0-based byte offset within the line.
213 pub fn line_col(&self, offset: TextSize) -> LineColumn {
214 let offset = offset.raw();
215 // The index of the last line that starts at or before `offset`.
216 let line = self.line_starts.partition_point(|&s| s <= offset) - 1;
217 let col = offset - self.line_starts[line];
218 LineColumn {
219 lineno: line as u32 + 1,
220 col,
221 }
222 }
223}