Skip to main content

oxilean_parse/incremental/
api.rs

1//! Incremental re-lex API for LSP `textDocument/didChange` events.
2//!
3//! # Overview
4//!
5//! `parse_incremental_change` takes the old token stream and the already-applied
6//! new source string, re-lexes only the "dirty" region (the changed portion plus
7//! tokens that straddle the edit boundary), splices the result into the stream,
8//! and shifts all post-edit token positions by the byte delta.
9//!
10//! # Position model
11//!
12//! The `Lexer` tracks positions as **char indices** (indices into `Vec<char>`),
13//! not raw byte offsets.  `TextChange.range` similarly operates in char-index
14//! space (i.e. "number of Unicode scalar values from the start of the source").
15//! All offset arithmetic below is therefore in char-index units; conversion to
16//! byte offsets is only needed when slicing the UTF-8 source string.
17
18use super::types::TextChange;
19use crate::lexer::Lexer;
20use crate::tokens::{Span, Token, TokenKind};
21
22// ── Public result type ──────────────────────────────────────────────────────
23
24/// The result of an incremental re-lex triggered by a single text edit.
25#[derive(Debug, Clone)]
26pub struct IncrementalChangeResult {
27    /// Sub-range of `tokens` that was re-lexed.
28    ///
29    /// Tokens with indices in `changed_token_range` are new; tokens outside
30    /// that range are (potentially shifted) copies from the old stream.
31    pub changed_token_range: std::ops::Range<usize>,
32    /// Complete new token stream for the post-edit source.
33    pub tokens: Vec<Token>,
34    /// `true` if the first unaffected token after the dirty region retained
35    /// the same start position after shifting — i.e. the re-lex did not
36    /// cascade further changes into the tail of the stream.
37    pub is_stable: bool,
38}
39
40// ── Main API ────────────────────────────────────────────────────────────────
41
42/// Incrementally re-lex a single text edit.
43///
44/// Given the **old** token stream and the **new** source string (after applying
45/// `change`), this function:
46///
47/// 1. Computes the dirty char-index range in the new source.
48/// 2. Finds the first token overlapping the dirty region and the first token
49///    entirely after it (in the old stream).
50/// 3. Re-lexes the dirty sub-region of the new source.
51/// 4. Shifts all surviving tail tokens by `byte_delta`.
52/// 5. Splices the new tokens in and returns the combined stream.
53///
54/// # Parameters
55/// - `old_tokens` — token stream from the previous successful lex.
56/// - `new_source` — full source string after the edit has been applied.
57/// - `change` — the edit that was applied (char-index range + new text).
58///
59/// # Notes
60/// The approach is O(k + m) where k is the number of re-lexed tokens and m is
61/// the tail length.  For edits that are small relative to the file size this is
62/// substantially cheaper than a full re-lex (O(n)).
63pub fn parse_incremental_change(
64    old_tokens: &[Token],
65    new_source: &str,
66    change: &TextChange,
67) -> IncrementalChangeResult {
68    // ── Step 1: dirty region in char-index space ─────────────────────────
69    let edit_start_char = change.range.start;
70    let old_edit_end_char = change.range.end;
71    let new_edit_end_char = edit_start_char + char_count(&change.new_text);
72    // char delta: how much positions shift after the edit
73    let char_delta: i64 = new_edit_end_char as i64 - old_edit_end_char as i64;
74
75    // ── Step 2: find affected token range in old stream ──────────────────
76    // first_affected: first token whose end > edit_start (i.e. it overlaps)
77    let first_affected = old_tokens
78        .iter()
79        .position(|t| token_end_char(t) > edit_start_char)
80        .unwrap_or(old_tokens.len());
81
82    // first_unaffected: first token (after first_affected) that starts
83    // entirely after the OLD edit end (no overlap with deleted region)
84    let first_unaffected = old_tokens[first_affected..]
85        .iter()
86        .position(|t| token_start_char(t) >= old_edit_end_char)
87        .map(|p| p + first_affected)
88        .unwrap_or(old_tokens.len());
89
90    // ── Step 3: determine re-lex start position in the NEW source ────────
91    // Re-lex starting from the beginning of the first affected token so that
92    // we restore any token that merely straddles the edit boundary.
93    let relex_start_char = old_tokens
94        .get(first_affected)
95        .map(token_start_char)
96        .unwrap_or(edit_start_char);
97
98    // ── Step 4: re-lex from relex_start_char until we have passed the dirty end
99    // We lex the full new source and collect tokens in the dirty window.
100    // Although this is O(n) for the lex itself, the hot path only visits
101    // chars from relex_start_char onward.
102    //
103    // An alternative would be to build a Lexer positioned at relex_start_char;
104    // the current Lexer API (`new(&str)` only) does not support mid-stream
105    // starts, so we do a single pass and discard the pre-dirty prefix.
106    let new_tokens_in_range = relex_dirty_range(new_source, relex_start_char, new_edit_end_char);
107
108    // ── Step 5: shift surviving tail tokens by char_delta ────────────────
109    let shifted_tail: Vec<Token> = old_tokens[first_unaffected..]
110        .iter()
111        .map(|t| shift_token(t, char_delta))
112        .collect();
113
114    // ── Step 6: stability check ──────────────────────────────────────────
115    // The stream is "stable" if the last re-lexed token ends exactly where
116    // the first tail token begins (after shifting).
117    let is_stable = check_boundary_stable(&new_tokens_in_range, &shifted_tail);
118
119    // ── Step 7: splice ───────────────────────────────────────────────────
120    let prefix = &old_tokens[..first_affected];
121    let new_len = prefix.len() + new_tokens_in_range.len() + shifted_tail.len();
122    let mut result_tokens: Vec<Token> = Vec::with_capacity(new_len);
123    result_tokens.extend_from_slice(prefix);
124    result_tokens.extend(new_tokens_in_range.iter().cloned());
125    result_tokens.extend(shifted_tail);
126
127    let changed_range_start = first_affected;
128    let changed_range_end = first_affected + new_tokens_in_range.len();
129
130    IncrementalChangeResult {
131        changed_token_range: changed_range_start..changed_range_end,
132        tokens: result_tokens,
133        is_stable,
134    }
135}
136
137// ── Private helpers ─────────────────────────────────────────────────────────
138
139/// Number of Unicode scalar values (chars) in `s`.
140#[inline]
141fn char_count(s: &str) -> usize {
142    s.chars().count()
143}
144
145/// Start position of a token (char index).
146#[inline]
147fn token_start_char(token: &Token) -> usize {
148    token.span.start
149}
150
151/// End position of a token (char index, exclusive).
152#[inline]
153fn token_end_char(token: &Token) -> usize {
154    token.span.end
155}
156
157/// Convert a char index into a byte offset for slicing a `&str`.
158fn char_to_byte_offset(source: &str, char_index: usize) -> usize {
159    source
160        .char_indices()
161        .nth(char_index)
162        .map(|(byte_pos, _)| byte_pos)
163        .unwrap_or(source.len())
164}
165
166/// Re-lex the dirty window [`relex_start_char`, `dirty_end_char`) of `source`.
167///
168/// The Lexer always starts from position 0, so we full-lex and filter.
169/// We stop collecting once we have passed `dirty_end_char` AND we land on a
170/// clean token boundary (token start >= dirty_end_char).
171fn relex_dirty_range(source: &str, relex_start_char: usize, dirty_end_char: usize) -> Vec<Token> {
172    let mut lexer = Lexer::new(source);
173    let all_tokens = lexer.tokenize();
174
175    all_tokens
176        .into_iter()
177        .filter(|t| {
178            // Skip tokens that ended before the relex window
179            if token_end_char(t) <= relex_start_char {
180                return false;
181            }
182            // Stop at (but include) the first token that starts at or after the
183            // dirty_end boundary — that token is the first unambiguously clean one.
184            // We include it in the re-lex range only if it overlaps the dirty window.
185            token_start_char(t) < dirty_end_char
186        })
187        .collect()
188}
189
190/// Shift all char-index positions in a token by `char_delta`.
191fn shift_token(token: &Token, char_delta: i64) -> Token {
192    let new_start = (token.span.start as i64 + char_delta).max(0) as usize;
193    let new_end = (token.span.end as i64 + char_delta).max(0) as usize;
194    Token::new(
195        token.kind.clone(),
196        Span::new(new_start, new_end, token.span.line, token.span.column),
197    )
198}
199
200/// Returns `true` if the last re-lexed token ends exactly where the first tail
201/// token begins (clean splice boundary = no cascading invalidation).
202fn check_boundary_stable(new_tokens: &[Token], tail: &[Token]) -> bool {
203    match (new_tokens.last(), tail.first()) {
204        (Some(last), Some(first)) => token_end_char(last) == token_start_char(first),
205        // No tail means nothing to stabilise against — treat as stable.
206        (_, None) => true,
207        // No new tokens but there is a tail — vacuously stable.
208        (None, Some(_)) => true,
209    }
210}
211
212// ── Tests ────────────────────────────────────────────────────────────────────
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::incremental::types::TextChange;
218
219    fn lex_full(source: &str) -> Vec<Token> {
220        Lexer::new(source).tokenize()
221    }
222
223    /// Helper: kind discriminant string for comparison (ignores payload).
224    fn kind_tag(t: &Token) -> String {
225        match &t.kind {
226            TokenKind::Ident(_) => "Ident".into(),
227            TokenKind::Nat(_) => "Nat".into(),
228            TokenKind::Float(_) => "Float".into(),
229            TokenKind::String(_) => "String".into(),
230            TokenKind::Char(_) => "Char".into(),
231            TokenKind::DocComment(_) => "DocComment".into(),
232            TokenKind::InterpolatedString(_) => "InterpolatedString".into(),
233            TokenKind::Error(_) => "Error".into(),
234            TokenKind::Eof => "Eof".into(),
235            other => format!("{:?}", other),
236        }
237    }
238
239    #[test]
240    fn test_empty_change_is_noop() {
241        let source = "theorem foo : True := trivial";
242        let old_tokens = lex_full(source);
243        let change = TextChange::new(0, 0, "");
244        let result = parse_incremental_change(&old_tokens, source, &change);
245        assert_eq!(
246            result.tokens.len(),
247            old_tokens.len(),
248            "empty edit must produce same token count"
249        );
250    }
251
252    #[test]
253    fn test_same_length_replacement_token_count() {
254        // Replace "foo" (3 chars) with "bar" (3 chars) — same length
255        let source = "theorem foo : True := trivial";
256        let old_tokens = lex_full(source);
257        let new_source = "theorem bar : True := trivial";
258        // "foo" starts at char 8, ends at char 11
259        let change = TextChange::new(8, 11, "bar");
260        let result = parse_incremental_change(&old_tokens, new_source, &change);
261        let full_tokens = lex_full(new_source);
262        assert_eq!(
263            result.tokens.len(),
264            full_tokens.len(),
265            "incremental and full lex should produce same token count"
266        );
267    }
268
269    #[test]
270    fn test_same_length_replacement_kinds_match() {
271        let source = "theorem foo : True := trivial";
272        let old_tokens = lex_full(source);
273        let new_source = "theorem bar : True := trivial";
274        let change = TextChange::new(8, 11, "bar");
275        let result = parse_incremental_change(&old_tokens, new_source, &change);
276        let full_tokens = lex_full(new_source);
277        let inc_kinds: Vec<String> = result.tokens.iter().map(kind_tag).collect();
278        let full_kinds: Vec<String> = full_tokens.iter().map(kind_tag).collect();
279        assert_eq!(
280            inc_kinds, full_kinds,
281            "token kind sequences must match between incremental and full lex"
282        );
283    }
284
285    #[test]
286    fn test_insertion_changes_token_count() {
287        let source = "def x := 1";
288        let old_tokens = lex_full(source);
289        // Insert " + 2" after "1" (char 10)
290        let new_source = "def x := 1 + 2";
291        let change = TextChange::new(10, 10, " + 2");
292        let result = parse_incremental_change(&old_tokens, new_source, &change);
293        let full_tokens = lex_full(new_source);
294        assert_eq!(
295            result.tokens.len(),
296            full_tokens.len(),
297            "incremental token count after insertion must match full lex"
298        );
299    }
300
301    #[test]
302    fn test_deletion_changes_token_count() {
303        let source = "def x := 1 + 2";
304        let old_tokens = lex_full(source);
305        // Delete " + 2" (chars 10..14)
306        let new_source = "def x := 1";
307        let change = TextChange::new(10, 14, "");
308        let result = parse_incremental_change(&old_tokens, new_source, &change);
309        let full_tokens = lex_full(new_source);
310        assert_eq!(
311            result.tokens.len(),
312            full_tokens.len(),
313            "incremental token count after deletion must match full lex"
314        );
315    }
316
317    #[test]
318    fn test_relex_matches_full_lex_various_edits() {
319        // (old_source, new_source, change_start_char, change_end_char, new_text)
320        let cases: Vec<(&str, &str, usize, usize, &str)> = vec![
321            // rename "x" to "y" in a def
322            ("def x := 1", "def y := 1", 4, 5, "y"),
323            // rename identifier at end
324            ("def x := foo", "def x := bar", 9, 12, "bar"),
325            // change a number
326            ("def x := 1", "def x := 42", 9, 10, "42"),
327        ];
328        for (old_src, new_src, cs, ce, new_text) in cases {
329            let old_tokens = lex_full(old_src);
330            let change = TextChange::new(cs, ce, new_text);
331            let result = parse_incremental_change(&old_tokens, new_src, &change);
332            let full_tokens = lex_full(new_src);
333            assert_eq!(
334                result.tokens.len(),
335                full_tokens.len(),
336                "count mismatch for edit '{old_src}' -> '{new_src}'"
337            );
338            let inc_kinds: Vec<String> = result.tokens.iter().map(kind_tag).collect();
339            let full_kinds: Vec<String> = full_tokens.iter().map(kind_tag).collect();
340            assert_eq!(
341                inc_kinds, full_kinds,
342                "kind mismatch for edit '{old_src}' -> '{new_src}'"
343            );
344        }
345    }
346
347    #[test]
348    fn test_changed_token_range_is_non_empty_on_real_edit() {
349        let source = "def x := 1";
350        let old_tokens = lex_full(source);
351        let new_source = "def x := 42";
352        let change = TextChange::new(9, 10, "42");
353        let result = parse_incremental_change(&old_tokens, new_source, &change);
354        assert!(
355            !result.changed_token_range.is_empty(),
356            "changed_token_range must be non-empty for a real edit"
357        );
358    }
359
360    #[test]
361    fn test_eof_token_is_present_after_incremental() {
362        let source = "def x := 1";
363        let old_tokens = lex_full(source);
364        let new_source = "def x := 42";
365        let change = TextChange::new(9, 10, "42");
366        let result = parse_incremental_change(&old_tokens, new_source, &change);
367        assert!(
368            result
369                .tokens
370                .last()
371                .map(|t| matches!(t.kind, TokenKind::Eof))
372                .unwrap_or(false),
373            "last token must be Eof"
374        );
375    }
376
377    #[test]
378    fn test_prefix_tokens_unmodified() {
379        // Tokens before the edit should be bit-for-bit identical.
380        let source = "def x := 1 + 2";
381        let old_tokens = lex_full(source);
382        // Change "2" (char 13..14) to "99"
383        let new_source = "def x := 1 + 99";
384        let change = TextChange::new(13, 14, "99");
385        let result = parse_incremental_change(&old_tokens, new_source, &change);
386        // Find first_affected: tokens before char 13
387        let first_affected = old_tokens
388            .iter()
389            .position(|t| t.span.end > 13)
390            .unwrap_or(old_tokens.len());
391        for (i, old_tok) in old_tokens.iter().enumerate().take(first_affected) {
392            assert_eq!(
393                result.tokens[i].span.start, old_tok.span.start,
394                "prefix token {i} span.start must be unmodified"
395            );
396        }
397    }
398}