1#![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#[cfg(doctest)]
39#[doc = include_str!("../README.md")]
40struct ReadmeDoctests;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub struct Position {
45 pub line: usize,
47 pub column: usize,
49}
50
51#[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 #[must_use]
61 pub fn new() -> Self {
62 Self::default()
63 }
64
65 #[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 #[must_use]
75 pub fn one_based_line(mut self, value: bool) -> Self {
76 self.one_based_line = value;
77 self
78 }
79
80 #[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#[must_use]
106pub fn index_to_position(text: &str, index: usize) -> Position {
107 index_to_position_with(text, index, Options::new())
108}
109
110#[must_use]
115#[allow(clippy::naive_bytecount)] pub 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 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#[derive(Debug, Clone)]
160pub struct PositionFinder<'a> {
161 text: &'a str,
162 line_starts: Vec<usize>,
164 options: Options,
165}
166
167impl<'a> PositionFinder<'a> {
168 #[must_use]
170 pub fn new(text: &'a str) -> Self {
171 Self::with_options(text, Options::new())
172 }
173
174 #[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 #[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 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 let s = "é\nb"; 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}