Skip to main content

lsp_max_ast/core/document/
mod.rs

1/*
2This file is part of auto-lsp.
3Copyright (C) 2025 CLAUZEL Adrien
4
5auto-lsp is free software: you can redistribute it and/or modify
6it under the terms of the GNU General Public License as published by
7the Free Software Foundation, either version 3 of the License, or
8(at your option) any later version.
9
10This program is distributed in the hope that it will be useful,
11but WITHOUT ANY WARRANTY; without even the implied warranty of
12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13GNU General Public License for more details.
14
15You should have received a copy of the GNU General Public License
16along with this program.  If not, see <http://www.gnu.org/licenses/>
17*/
18
19use lsp_types_max::PositionEncodingKind;
20use texter::{change::GridIndex, core::text::Text};
21use texter_impl::{change::WrapChange, updateable::WrapTree};
22use tree_sitter::{Point, Tree};
23
24use crate::core::errors::{DocumentError, TreeSitterError};
25
26pub(crate) mod texter_impl;
27
28/// Represents a text document that combines plain text [`texter`] with its parsed syntax tree
29/// [`tree_sitter::Tree`].
30///
31/// Encoding-aware position conversions are delegated to texter via
32/// [`GridIndex::normalize`]/[`GridIndex::denormalize`]; the underlying [`Text`] knows the
33/// negotiated encoding internally, so it is no longer duplicated on `Document`.
34#[derive(Debug, Clone)]
35pub struct Document {
36    pub texter: Text,
37    pub tree: Tree,
38}
39
40impl Document {
41    /// Creates a new `Document` instance with the provided source, syntax tree, and encoding.
42    ///
43    /// Defaults to UTF-16 if the encoding is not specified or unrecognized.
44    pub fn new(source: String, tree: Tree, encoding: Option<&PositionEncodingKind>) -> Self {
45        let texter = match encoding.map(|e| e.as_str()) {
46            Some("utf-8") => Text::new(source),
47            Some("utf-32") => Text::new_utf32(source),
48            _ => Text::new_utf16(source),
49        };
50        Self { texter, tree }
51    }
52
53    pub fn as_str(&self) -> &str {
54        &self.texter.text
55    }
56
57    pub fn as_bytes(&self) -> &[u8] {
58        self.texter.text.as_bytes()
59    }
60
61    pub fn is_empty(&self) -> bool {
62        self.texter.text.is_empty()
63    }
64
65    /// Updates the document based on the provided list of text changes.
66    ///
67    /// Applies the changes to both the text [`texter`] and the syntax tree [`Tree`], using
68    /// incremental parsing to minimize the cost of updating the syntax tree.
69    ///
70    /// # Errors
71    /// Returns an error if Tree-sitter fails to reparse the updated text.
72    pub fn update(
73        &mut self,
74        parser: &mut tree_sitter::Parser,
75        changes: &[lsp_types_max::TextDocumentContentChangeEvent],
76    ) -> Result<(), DocumentError> {
77        let mut new_tree = WrapTree::from(&mut self.tree);
78
79        for change in changes {
80            self.texter
81                .update(WrapChange::from(change).change, &mut new_tree)?;
82        }
83
84        self.tree = parser
85            .parse(self.texter.text.as_bytes(), Some(&self.tree))
86            .ok_or_else(|| DocumentError::from(TreeSitterError::TreeSitterParser))?;
87
88        Ok(())
89    }
90
91    /// Converts an LSP [`lsp_types_max::Range`] from the client encoding to UTF-8, returning a new range.
92    /// Mirrors texter's own [`GridIndex::normalize`].
93    pub fn normalize_range(
94        &self,
95        position: &lsp_types_max::Range,
96    ) -> Result<lsp_types_max::Range, DocumentError> {
97        let start = self.normalize_position(&position.start)?;
98        let end = self.normalize_position(&position.end)?;
99
100        Ok(lsp_types_max::Range { start, end })
101    }
102
103    /// Converts an LSP [`lsp_types_max::Position`] from the client encoding to UTF-8, returning a new position.
104    /// Mirrors texter's own [`GridIndex::normalize`].
105    pub fn normalize_position(
106        &self,
107        position: &lsp_types_max::Position,
108    ) -> Result<lsp_types_max::Position, DocumentError> {
109        let mut grid = GridIndex {
110            row: position.line as usize,
111            col: position.character as usize,
112        };
113        grid.normalize(&self.texter)?;
114
115        Ok(lsp_types_max::Position {
116            line: grid.row as u32,
117            character: grid.col as u32,
118        })
119    }
120
121    /// Converts a tree-sitter [`tree_sitter::Range`] to an LSP [`lsp_types_max::Range`],
122    /// adjusting columns to the LSP client encoding.
123    /// Mirrors texter's own [`GridIndex::denormalize`].
124    pub fn denormalize_range(
125        &self,
126        range: &tree_sitter::Range,
127    ) -> Result<lsp_types_max::Range, DocumentError> {
128        let start = self.denormalize_point(range.start_point)?;
129        let end = self.denormalize_point(range.end_point)?;
130        Ok(lsp_types_max::Range {
131            start: lsp_types_max::Position {
132                line: start.row as u32,
133                character: start.column as u32,
134            },
135            end: lsp_types_max::Position {
136                line: end.row as u32,
137                character: end.column as u32,
138            },
139        })
140    }
141
142    /// Converts a tree-sitter [`tree_sitter::Point`] to an LSP [`lsp_types_max::Position`],
143    /// adjusting columns to the LSP client encoding.
144    /// Mirrors texter's own [`GridIndex::denormalize`].
145    pub fn denormalize_point(&self, point: Point) -> Result<Point, DocumentError> {
146        let mut grid = GridIndex::from(point);
147        grid.denormalize(&self.texter)?;
148        Ok(Point::from(grid))
149    }
150}
151
152#[cfg(test)]
153mod test {
154    use super::*;
155    use crate::core::errors::TexterError;
156    use lsp_types_max::{Position, PositionEncodingKind};
157    use rstest::{fixture, rstest};
158    use tree_sitter::Parser;
159
160    /// Local test parameter so each `#[case(...)]` can drive both the encoding
161    /// kind passed to `Document::new` and the encoding-specific expected values.
162    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
163    enum Encoding {
164        UTF8,
165        UTF16,
166        UTF32,
167    }
168
169    impl Encoding {
170        fn kind(self) -> PositionEncodingKind {
171            match self {
172                Encoding::UTF8 => PositionEncodingKind::UTF8,
173                Encoding::UTF16 => PositionEncodingKind::UTF16,
174                Encoding::UTF32 => PositionEncodingKind::UTF32,
175            }
176        }
177    }
178
179    #[fixture]
180    fn parser() -> Parser {
181        let mut p = Parser::new();
182        p.set_language(&tree_sitter_html::LANGUAGE.into()).unwrap();
183        p
184    }
185
186    #[rstest]
187    fn normalize(mut parser: Parser) {
188        // All-ASCII source, so encoded columns equal UTF-8 columns.
189        let source = "Apples\nBashdjad\nashdkasdh\nasdsad";
190        let document = Document::new(
191            source.into(),
192            parser.parse(source, None).unwrap(),
193            Some(&PositionEncodingKind::UTF16),
194        );
195
196        assert_eq!(&document.texter.br_indexes.0, &[0, 6, 15, 25]);
197
198        let normalized = |line, character| {
199            let pos = Position { line, character };
200            document.normalize_position(&pos).unwrap()
201        };
202
203        assert_eq!(
204            normalized(0, 0),
205            Position {
206                line: 0,
207                character: 0
208            }
209        );
210        assert_eq!(
211            normalized(0, 5),
212            Position {
213                line: 0,
214                character: 5
215            }
216        );
217        assert_eq!(
218            normalized(1, 3),
219            Position {
220                line: 1,
221                character: 3
222            }
223        );
224        assert_eq!(
225            normalized(3, 5),
226            Position {
227                line: 3,
228                character: 5
229            }
230        );
231
232        // Line out of bounds surfaces as a texter error wrapped in DocumentError.
233        let oob = Position {
234            line: 10,
235            character: 0,
236        };
237        assert!(matches!(
238            document.normalize_position(&oob),
239            Err(DocumentError::Texter(TexterError::TexterError(
240                texter::error::Error::OutOfBoundsRow { .. }
241            )))
242        ));
243
244        // Column past the line length is clamped by texter to the end of the line ("Bashdjad").
245        assert_eq!(
246            normalized(1, 100),
247            Position {
248                line: 1,
249                character: 8
250            }
251        );
252    }
253
254    #[rstest]
255    #[case(Encoding::UTF8, 20)]
256    #[case(Encoding::UTF16, 10)]
257    #[case(Encoding::UTF32, 10)]
258    fn ts_range_to_range_from_cst(
259        mut parser: Parser,
260        #[case] encoding: Encoding,
261        #[case] end_character: u32,
262    ) {
263        let source = "<div>こんにちは</div>";
264        let tree = parser.parse(source, None).unwrap();
265        let document = Document::new(source.into(), tree.clone(), Some(&encoding.kind()));
266
267        let element = tree.root_node().named_child(0).expect("element");
268        let text_node = element.named_child(1).expect("text node");
269
270        assert_eq!(
271            document.denormalize_range(&text_node.range()).unwrap(),
272            lsp_types_max::Range {
273                start: Position {
274                    line: 0,
275                    character: 5,
276                },
277                end: Position {
278                    line: 0,
279                    character: end_character,
280                },
281            }
282        );
283    }
284}