Skip to main content

index_to_position/
lib.rs

1//! # index-to-position — convert a string index to a line and column
2//!
3//! Given a byte offset into a string, find its line and column. Useful for turning a parser
4//! or lexer error offset into a human-readable `line:column`. A Rust port of the
5//! [`index-to-position`](https://www.npmjs.com/package/index-to-position) npm package.
6//!
7//! ```
8//! use index_to_position::index_to_position;
9//!
10//! let text = "foo\nbar\nbaz";
11//! let pos = index_to_position(text, 5); // the 'a' in "bar"
12//! assert_eq!((pos.line, pos.column), (1, 1));
13//! ```
14//!
15//! Indices are **byte offsets** (the idiomatic Rust string index, as produced by `str`
16//! slicing and most parsers), and lines and columns are zero-based by default. Use
17//! [`index_to_position_with`] for one-based output, and [`PositionFinder`] when you need to
18//! resolve many indices into the same text efficiently.
19//!
20//! ```
21//! use index_to_position::{index_to_position_with, Options};
22//!
23//! let pos = index_to_position_with("foo\nbar", 4, Options::new().one_based(true));
24//! assert_eq!((pos.line, pos.column), (2, 1));
25//! ```
26//!
27//! **Zero dependencies** and `#![no_std]`.
28
29#![no_std]
30#![forbid(unsafe_code)]
31#![doc(html_root_url = "https://docs.rs/index-to-position/0.1.0")]
32
33extern crate alloc;
34
35use alloc::vec::Vec;
36
37// Compile-test the README's examples as part of `cargo test`.
38#[cfg(doctest)]
39#[doc = include_str!("../README.md")]
40struct ReadmeDoctests;
41
42/// A line and column within a string.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub struct Position {
45    /// The line number (zero-based by default).
46    pub line: usize,
47    /// The column, as a byte offset within the line (zero-based by default).
48    pub column: usize,
49}
50
51/// Whether the line and/or column should be one-based.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub struct Options {
54    one_based_line: bool,
55    one_based_column: bool,
56}
57
58impl Options {
59    /// Default options: zero-based line and column.
60    #[must_use]
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Make both the line and column one-based.
66    #[must_use]
67    pub fn one_based(mut self, value: bool) -> Self {
68        self.one_based_line = value;
69        self.one_based_column = value;
70        self
71    }
72
73    /// Make the line one-based.
74    #[must_use]
75    pub fn one_based_line(mut self, value: bool) -> Self {
76        self.one_based_line = value;
77        self
78    }
79
80    /// Make the column one-based.
81    #[must_use]
82    pub fn one_based_column(mut self, value: bool) -> Self {
83        self.one_based_column = value;
84        self
85    }
86
87    fn line_offset(self) -> usize {
88        usize::from(self.one_based_line)
89    }
90
91    fn column_offset(self) -> usize {
92        usize::from(self.one_based_column)
93    }
94}
95
96/// Convert a byte `index` into `text` to a zero-based [`Position`].
97///
98/// # Panics
99/// Panics if `index` is greater than `text.len()`.
100///
101/// ```
102/// # use index_to_position::index_to_position;
103/// assert_eq!(index_to_position("a\nb", 2).line, 1);
104/// ```
105#[must_use]
106pub fn index_to_position(text: &str, index: usize) -> Position {
107    index_to_position_with(text, index, Options::new())
108}
109
110/// Convert a byte `index` into `text` to a [`Position`] with the given [`Options`].
111///
112/// # Panics
113/// Panics if `index` is greater than `text.len()`.
114#[must_use]
115#[allow(clippy::naive_bytecount)] // Keep the crate dependency-free; this is O(n) once per call.
116pub fn index_to_position_with(text: &str, index: usize, options: Options) -> Position {
117    assert!(
118        index <= text.len(),
119        "index out of bounds: the length is {} but the index is {index}",
120        text.len()
121    );
122
123    let bytes = text.as_bytes();
124
125    // The last newline strictly before `index`.
126    let line_break_before = if index == 0 {
127        None
128    } else {
129        bytes[..index].iter().rposition(|&b| b == b'\n')
130    };
131
132    match line_break_before {
133        None => Position {
134            line: options.line_offset(),
135            column: index + options.column_offset(),
136        },
137        Some(newline) => {
138            let line =
139                bytes[..=newline].iter().filter(|&&b| b == b'\n').count() + options.line_offset();
140            Position {
141                line,
142                column: index - newline - 1 + options.column_offset(),
143            }
144        }
145    }
146}
147
148/// Resolves many byte indices into the same text efficiently.
149///
150/// Building a `PositionFinder` precomputes the offset of every line start, so each
151/// [`position`](PositionFinder::position) lookup is `O(log lines)` instead of `O(text len)`.
152///
153/// ```
154/// use index_to_position::PositionFinder;
155///
156/// let finder = PositionFinder::new("foo\nbar\nbaz");
157/// assert_eq!((finder.position(0).line, finder.position(9).line), (0, 2));
158/// ```
159#[derive(Debug, Clone)]
160pub struct PositionFinder<'a> {
161    text: &'a str,
162    /// Byte offset of the start of each line (always begins with `0`).
163    line_starts: Vec<usize>,
164    options: Options,
165}
166
167impl<'a> PositionFinder<'a> {
168    /// Build a finder over `text` producing zero-based positions.
169    #[must_use]
170    pub fn new(text: &'a str) -> Self {
171        Self::with_options(text, Options::new())
172    }
173
174    /// Build a finder over `text` with the given [`Options`].
175    #[must_use]
176    pub fn with_options(text: &'a str, options: Options) -> Self {
177        let mut line_starts = Vec::new();
178        line_starts.push(0);
179        for (offset, &byte) in text.as_bytes().iter().enumerate() {
180            if byte == b'\n' {
181                line_starts.push(offset + 1);
182            }
183        }
184        Self {
185            text,
186            line_starts,
187            options,
188        }
189    }
190
191    /// Resolve a byte `index` to a [`Position`].
192    ///
193    /// # Panics
194    /// Panics if `index` is greater than the text length.
195    #[must_use]
196    pub fn position(&self, index: usize) -> Position {
197        assert!(
198            index <= self.text.len(),
199            "index out of bounds: the length is {} but the index is {index}",
200            self.text.len()
201        );
202        // The last line start at or before `index`.
203        let line = self.line_starts.partition_point(|&start| start <= index) - 1;
204        Position {
205            line: line + self.options.line_offset(),
206            column: index - self.line_starts[line] + self.options.column_offset(),
207        }
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn zero_based() {
217        let s = "foo\nbar\r\nbaz\n";
218        assert_eq!(index_to_position(s, 0), Position { line: 0, column: 0 });
219        assert_eq!(index_to_position(s, 3), Position { line: 0, column: 3 });
220        assert_eq!(index_to_position(s, 4), Position { line: 1, column: 0 });
221        assert_eq!(index_to_position(s, 8), Position { line: 1, column: 4 });
222        assert_eq!(index_to_position(s, 9), Position { line: 2, column: 0 });
223        assert_eq!(index_to_position(s, 13), Position { line: 3, column: 0 });
224    }
225
226    #[test]
227    fn one_based() {
228        let o = Options::new().one_based(true);
229        assert_eq!(
230            index_to_position_with("foo\nbar", 0, o),
231            Position { line: 1, column: 1 }
232        );
233        assert_eq!(
234            index_to_position_with("foo\nbar", 4, o),
235            Position { line: 2, column: 1 }
236        );
237    }
238
239    #[test]
240    fn separate_offsets() {
241        let s = "ab\ncde\nf";
242        assert_eq!(
243            index_to_position_with(s, 5, Options::new().one_based_line(true)),
244            Position { line: 2, column: 2 }
245        );
246        assert_eq!(
247            index_to_position_with(s, 5, Options::new().one_based_column(true)),
248            Position { line: 1, column: 3 }
249        );
250    }
251
252    #[test]
253    fn at_end_and_empty() {
254        assert_eq!(
255            index_to_position("ab\ncde\nf", 8),
256            Position { line: 2, column: 1 }
257        );
258        assert_eq!(index_to_position("", 0), Position { line: 0, column: 0 });
259    }
260
261    #[test]
262    fn multibyte_byte_offsets() {
263        // "é" is two bytes; the 'b' after it begins at byte 2.
264        let s = "é\nb"; // bytes: C3 A9 0A 62
265        assert_eq!(index_to_position(s, 3), Position { line: 1, column: 0 });
266        assert_eq!(index_to_position(s, 0), Position { line: 0, column: 0 });
267    }
268
269    #[test]
270    #[should_panic(expected = "index out of bounds")]
271    fn out_of_bounds_panics() {
272        let _ = index_to_position("abc", 4);
273    }
274
275    #[test]
276    fn finder_matches_function() {
277        let s = "foo\nbar\r\nbaz\n\nlast";
278        let finder = PositionFinder::new(s);
279        for i in 0..=s.len() {
280            assert_eq!(finder.position(i), index_to_position(s, i), "index {i}");
281        }
282    }
283
284    #[test]
285    fn finder_one_based() {
286        let finder = PositionFinder::with_options("a\nb", Options::new().one_based(true));
287        assert_eq!(finder.position(2), Position { line: 2, column: 1 });
288    }
289}