Skip to main content

nifty_lsp/
document.rs

1//! One source file of a project: its text, its syntax tree, and conversion between byte
2//! offsets and LSP positions.
3
4use lsp_types::{ClientCapabilities, Position, PositionEncodingKind, Range};
5use nifty_syntax::{FileId, Parsed, Span};
6
7/// The unit LSP positions count columns in, agreed with the client at startup.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub(crate) enum Encoding {
10    Utf8,
11    Utf16,
12}
13
14impl Encoding {
15    /// UTF-8 matches nifty's byte spans, so it is used when the client offers it. UTF-16
16    /// is the protocol's default and every client supports it.
17    pub(crate) fn negotiate(capabilities: &ClientCapabilities) -> Self {
18        let offers_utf8 = capabilities
19            .general
20            .as_ref()
21            .and_then(|general| general.position_encodings.as_ref())
22            .is_some_and(|encodings| encodings.contains(&PositionEncodingKind::UTF8));
23        if offers_utf8 { Self::Utf8 } else { Self::Utf16 }
24    }
25
26    pub(crate) fn kind(self) -> PositionEncodingKind {
27        match self {
28            Self::Utf8 => PositionEncodingKind::UTF8,
29            Self::Utf16 => PositionEncodingKind::UTF16,
30        }
31    }
32
33    fn units(self, c: char) -> usize {
34        match self {
35            Self::Utf8 => c.len_utf8(),
36            Self::Utf16 => c.len_utf16(),
37        }
38    }
39
40    pub(crate) fn len(self, text: &str) -> u32 {
41        to_u32(text.chars().map(|c| self.units(c)).sum())
42    }
43}
44
45#[derive(Debug)]
46pub(crate) struct Document {
47    pub file: FileId,
48    text: String,
49    /// The byte offset where each line starts; the first is always 0.
50    line_starts: Vec<usize>,
51    pub parsed: Parsed,
52}
53
54impl Document {
55    pub(crate) fn new(text: String, file: FileId) -> Self {
56        let line_starts = std::iter::once(0)
57            .chain(text.match_indices('\n').map(|(newline, _)| newline + 1))
58            .collect();
59        let parsed = nifty_syntax::parse(&text, file);
60        Self {
61            file,
62            text,
63            line_starts,
64            parsed,
65        }
66    }
67
68    pub(crate) fn source(&self) -> &str {
69        &self.text
70    }
71
72    /// The text of `span`, which points into this file.
73    pub(crate) fn text(&self, span: Span) -> &str {
74        &self.text[span.range()]
75    }
76
77    /// Where the name ending at `offset` starts; `offset` itself when there is none.
78    pub(crate) fn word_start(&self, offset: usize) -> usize {
79        self.text[..offset]
80            .rfind(|c: char| !is_name_char(c))
81            .map_or(0, |boundary| boundary + 1)
82    }
83
84    pub(crate) fn range(&self, span: Span, encoding: Encoding) -> Range {
85        Range::new(
86            self.position(span.start, encoding),
87            self.position(span.end, encoding),
88        )
89    }
90
91    pub(crate) fn position(&self, offset: usize, encoding: Encoding) -> Position {
92        let line = self.line_starts.partition_point(|&start| start <= offset) - 1;
93        let line_text = &self.text[self.line_starts[line]..offset];
94        Position::new(to_u32(line), encoding.len(line_text))
95    }
96
97    /// The byte offset of `position`. A column past the end of its line means the end of
98    /// the line, and a line past the end of the file means the end of the file, as the
99    /// protocol requires.
100    pub(crate) fn offset(&self, position: Position, encoding: Encoding) -> usize {
101        let Some(&line_start) = self.line_starts.get(position.line as usize) else {
102            return self.text.len();
103        };
104        let line_end = self.text[line_start..]
105            .find('\n')
106            .map_or(self.text.len(), |newline| line_start + newline);
107        let mut column = 0;
108        for (index, c) in self.text[line_start..line_end].char_indices() {
109            if column >= position.character as usize {
110                return line_start + index;
111            }
112            column += encoding.units(c);
113        }
114        line_end
115    }
116}
117
118/// Names are ASCII, so a non-ASCII character always ends one.
119fn is_name_char(c: char) -> bool {
120    c.is_ascii_alphanumeric() || c == '_'
121}
122
123/// LSP counts in `u32`; a source file never comes near that size.
124pub(crate) fn to_u32(value: usize) -> u32 {
125    u32::try_from(value).unwrap_or(u32::MAX)
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn converts_between_offsets_and_positions_in_both_encodings() {
134        // `é` is 2 bytes and 1 UTF-16 unit; `𝄞` is 4 bytes and 2 UTF-16 units.
135        let document = Document::new("a\n\"é𝄞\" x".to_owned(), FileId::default());
136        let x = document.text.find('x').unwrap_or_default();
137        assert_eq!(document.position(x, Encoding::Utf8), Position::new(1, 9));
138        assert_eq!(document.position(x, Encoding::Utf16), Position::new(1, 6));
139        assert_eq!(document.offset(Position::new(1, 9), Encoding::Utf8), x);
140        assert_eq!(document.offset(Position::new(1, 6), Encoding::Utf16), x);
141    }
142
143    #[test]
144    fn clamps_positions_past_the_end_of_a_line_or_file() {
145        let document = Document::new("ab\ncd".to_owned(), FileId::default());
146        assert_eq!(document.offset(Position::new(0, 99), Encoding::Utf16), 2);
147        assert_eq!(document.offset(Position::new(9, 0), Encoding::Utf16), 5);
148    }
149}