Skip to main content

nginx_lint_parser/
line_index.rs

1//! Byte-offset → line/column conversion for rowan CST nodes.
2//!
3//! Rowan provides byte-offset ranges via `text_range()`. This module builds an
4//! index of newline positions so that offsets can be efficiently mapped to the
5//! 1-based `(line, column)` pairs used by the existing AST types.
6
7use crate::ast::{Position, Span};
8
9/// Pre-computed index of line-start byte offsets for a source string.
10///
11/// Construct with [`LineIndex::new`], then call [`position`](LineIndex::position)
12/// or [`span`](LineIndex::span) to convert rowan `TextRange` values into AST
13/// [`Position`] / [`Span`].
14pub struct LineIndex {
15    /// Byte offsets where each line begins. `line_starts[0]` is always `0`.
16    line_starts: Vec<usize>,
17}
18
19impl LineIndex {
20    /// Build a line index from the full source text.
21    pub fn new(source: &str) -> Self {
22        let mut line_starts = vec![0usize];
23        for (i, ch) in source.char_indices() {
24            if ch == '\n' {
25                line_starts.push(i + 1);
26            }
27        }
28        Self { line_starts }
29    }
30
31    /// Convert a byte offset to a 1-based `Position`.
32    pub fn position(&self, offset: usize) -> Position {
33        // Binary search for the line containing `offset`.
34        let line_idx = match self.line_starts.binary_search(&offset) {
35            Ok(exact) => exact,  // offset is at a line start
36            Err(ins) => ins - 1, // offset is within the preceding line
37        };
38        let col = offset - self.line_starts[line_idx];
39        Position::new(line_idx + 1, col + 1, offset)
40    }
41
42    /// Convert a rowan `TextRange` to an AST `Span`.
43    pub fn span(&self, range: rowan::TextRange) -> Span {
44        let start: usize = range.start().into();
45        let end: usize = range.end().into();
46        Span::new(self.position(start), self.position(end))
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    #[test]
55    fn single_line() {
56        let idx = LineIndex::new("listen 80;");
57        assert_eq!(idx.position(0), Position::new(1, 1, 0));
58        assert_eq!(idx.position(7), Position::new(1, 8, 7));
59    }
60
61    #[test]
62    fn multi_line() {
63        let src = "http {\n    listen 80;\n}\n";
64        let idx = LineIndex::new(src);
65        // line 1: "http {\n"  offsets 0..7
66        assert_eq!(idx.position(0), Position::new(1, 1, 0));
67        // line 2: "    listen 80;\n"  starts at offset 7
68        assert_eq!(idx.position(7), Position::new(2, 1, 7));
69        assert_eq!(idx.position(11), Position::new(2, 5, 11)); // 'l' of listen
70        // line 3: "}\n"  starts at offset 22
71        assert_eq!(idx.position(22), Position::new(3, 1, 22));
72    }
73
74    #[test]
75    fn span_conversion() {
76        let src = "listen 80;";
77        let idx = LineIndex::new(src);
78        let range = rowan::TextRange::new(0.into(), 6.into());
79        let span = idx.span(range);
80        assert_eq!(span.start, Position::new(1, 1, 0));
81        assert_eq!(span.end, Position::new(1, 7, 6));
82    }
83}