Skip to main content

omena_transform_passes/runtime/
lex_cache.rs

1use std::{cell::RefCell, collections::BTreeMap, rc::Rc};
2
3use cstree::text::{TextRange, TextSize};
4use omena_parser::{LexedToken, StyleDialect, lex};
5
6use crate::{TransformLexCacheSpliceTelemetryV0, TransformProvenanceMutationSpanV0};
7
8#[derive(Debug, Clone)]
9pub(crate) struct CachedLexResultV0 {
10    tokens: Rc<Vec<LexedToken>>,
11}
12
13impl CachedLexResultV0 {
14    pub(crate) fn tokens(&self) -> &[LexedToken] {
15        self.tokens.as_slice()
16    }
17}
18
19#[derive(Default)]
20struct TransformLexCacheV0 {
21    entries: BTreeMap<(StyleDialect, String), Rc<Vec<LexedToken>>>,
22}
23
24thread_local! {
25    static ACTIVE_TRANSFORM_LEX_CACHES: RefCell<Vec<TransformLexCacheV0>> =
26        const { RefCell::new(Vec::new()) };
27    static TRANSFORM_LEX_CACHE_SPLICE_TELEMETRY:
28        RefCell<TransformLexCacheSpliceTelemetryV0> = const {
29            RefCell::new(TransformLexCacheSpliceTelemetryV0 {
30                splice_hit_count: 0,
31                full_relex_fallback_count: 0,
32                window_derivation_fallback_count: 0,
33                full_output_window_fallback_count: 0,
34                token_offset_fallback_count: 0,
35            })
36        };
37}
38
39struct TransformLexCacheScopeGuard;
40
41impl Drop for TransformLexCacheScopeGuard {
42    fn drop(&mut self) {
43        ACTIVE_TRANSFORM_LEX_CACHES.with(|caches| {
44            caches.borrow_mut().pop();
45        });
46    }
47}
48
49pub(crate) fn with_transform_lex_cache<T>(operation: impl FnOnce() -> T) -> T {
50    ACTIVE_TRANSFORM_LEX_CACHES.with(|caches| {
51        caches.borrow_mut().push(TransformLexCacheV0::default());
52    });
53    let _guard = TransformLexCacheScopeGuard;
54    operation()
55}
56
57/// Reset per-thread lex-splice counters before a measurement run.
58pub fn reset_transform_lex_cache_splice_telemetry() {
59    TRANSFORM_LEX_CACHE_SPLICE_TELEMETRY.with(|telemetry| {
60        *telemetry.borrow_mut() = TransformLexCacheSpliceTelemetryV0::default();
61    });
62}
63
64/// Snapshot per-thread lex-splice counters without mutating them.
65pub fn transform_lex_cache_splice_telemetry_snapshot() -> TransformLexCacheSpliceTelemetryV0 {
66    TRANSFORM_LEX_CACHE_SPLICE_TELEMETRY.with(|telemetry| *telemetry.borrow())
67}
68
69pub(crate) fn lex_cached(source: &str, dialect: StyleDialect) -> CachedLexResultV0 {
70    ACTIVE_TRANSFORM_LEX_CACHES.with(|caches| {
71        let mut caches = caches.borrow_mut();
72        let Some(cache) = caches.last_mut() else {
73            return CachedLexResultV0 {
74                tokens: Rc::new(materialize_lex_tokens(source, dialect)),
75            };
76        };
77
78        let key = (dialect, source.to_string());
79        if let Some(cached) = cache.entries.get(&key) {
80            return CachedLexResultV0 {
81                tokens: Rc::clone(cached),
82            };
83        }
84
85        let tokens = Rc::new(materialize_lex_tokens(source, dialect));
86        cache.entries.insert(key, Rc::clone(&tokens));
87        CachedLexResultV0 { tokens }
88    })
89}
90
91pub(crate) fn update_cached_lex_from_splice(
92    input: &str,
93    output: &str,
94    dialect: StyleDialect,
95    mutation_spans: &[TransformProvenanceMutationSpanV0],
96) {
97    if input == output || mutation_spans.is_empty() {
98        return;
99    }
100
101    ACTIVE_TRANSFORM_LEX_CACHES.with(|caches| {
102        let mut caches = caches.borrow_mut();
103        let Some(cache) = caches.last_mut() else {
104            return;
105        };
106
107        let input_key = (dialect, input.to_string());
108        let input_tokens = cache
109            .entries
110            .entry(input_key)
111            .or_insert_with(|| Rc::new(materialize_lex_tokens(input, dialect)))
112            .clone();
113        let Some(windows) = restart_windows_for_mutation_spans(
114            input,
115            output,
116            input_tokens.as_slice(),
117            mutation_spans,
118        ) else {
119            record_splice_fallback(TransformLexCacheSpliceFallbackReasonV0::WindowDerivation);
120            return;
121        };
122        let relex_byte_len = windows
123            .iter()
124            .map(|window| window.output_end.saturating_sub(window.output_start))
125            .sum::<usize>();
126        if relex_byte_len >= output.len() {
127            record_splice_fallback(TransformLexCacheSpliceFallbackReasonV0::FullOutputWindow);
128            return;
129        }
130        let Some(output_tokens) =
131            spliced_tokens_for_windows(output, dialect, input_tokens.as_slice(), windows)
132        else {
133            record_splice_fallback(TransformLexCacheSpliceFallbackReasonV0::TokenOffset);
134            return;
135        };
136        record_splice_hit();
137        cache
138            .entries
139            .insert((dialect, output.to_string()), Rc::new(output_tokens));
140    });
141}
142
143fn materialize_lex_tokens(source: &str, dialect: StyleDialect) -> Vec<LexedToken> {
144    lex(source, dialect).tokens().to_vec()
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148enum TransformLexCacheSpliceFallbackReasonV0 {
149    WindowDerivation,
150    FullOutputWindow,
151    TokenOffset,
152}
153
154fn record_splice_hit() {
155    TRANSFORM_LEX_CACHE_SPLICE_TELEMETRY.with(|telemetry| {
156        let mut telemetry = telemetry.borrow_mut();
157        telemetry.splice_hit_count = telemetry.splice_hit_count.saturating_add(1);
158    });
159}
160
161fn record_splice_fallback(reason: TransformLexCacheSpliceFallbackReasonV0) {
162    TRANSFORM_LEX_CACHE_SPLICE_TELEMETRY.with(|telemetry| {
163        let mut telemetry = telemetry.borrow_mut();
164        telemetry.full_relex_fallback_count = telemetry.full_relex_fallback_count.saturating_add(1);
165        match reason {
166            TransformLexCacheSpliceFallbackReasonV0::WindowDerivation => {
167                telemetry.window_derivation_fallback_count =
168                    telemetry.window_derivation_fallback_count.saturating_add(1);
169            }
170            TransformLexCacheSpliceFallbackReasonV0::FullOutputWindow => {
171                telemetry.full_output_window_fallback_count = telemetry
172                    .full_output_window_fallback_count
173                    .saturating_add(1);
174            }
175            TransformLexCacheSpliceFallbackReasonV0::TokenOffset => {
176                telemetry.token_offset_fallback_count =
177                    telemetry.token_offset_fallback_count.saturating_add(1);
178            }
179        }
180    });
181}
182
183#[cfg(test)]
184fn spliced_tokens_for_output(
185    input: &str,
186    output: &str,
187    dialect: StyleDialect,
188    input_tokens: &[LexedToken],
189    mutation_spans: &[TransformProvenanceMutationSpanV0],
190) -> Option<Vec<LexedToken>> {
191    if input == output {
192        return Some(input_tokens.to_vec());
193    }
194    if mutation_spans.is_empty() {
195        return None;
196    }
197
198    let windows = restart_windows_for_mutation_spans(input, output, input_tokens, mutation_spans)?;
199    spliced_tokens_for_windows(output, dialect, input_tokens, windows)
200}
201
202fn spliced_tokens_for_windows(
203    output: &str,
204    dialect: StyleDialect,
205    input_tokens: &[LexedToken],
206    windows: Vec<SpliceRestartWindowV0>,
207) -> Option<Vec<LexedToken>> {
208    let mut tokens = Vec::with_capacity(input_tokens.len());
209    let mut source_cursor = 0usize;
210    let mut current_delta = 0isize;
211
212    for window in windows {
213        tokens.extend(
214            input_tokens
215                .iter()
216                .filter(|token| {
217                    token_start(token) >= source_cursor && token_end(token) <= window.source_start
218                })
219                .cloned()
220                .map(|token| offset_token(token, current_delta))
221                .collect::<Option<Vec<_>>>()?,
222        );
223        tokens.extend(
224            materialize_lex_tokens(&output[window.output_start..window.output_end], dialect)
225                .into_iter()
226                .map(|token| offset_token(token, window.output_start as isize))
227                .collect::<Option<Vec<_>>>()?,
228        );
229        source_cursor = window.source_end;
230        current_delta = window.output_end as isize - window.source_end as isize;
231    }
232    tokens.extend(
233        input_tokens
234            .iter()
235            .filter(|token| token_start(token) >= source_cursor)
236            .cloned()
237            .map(|token| offset_token(token, current_delta))
238            .collect::<Option<Vec<_>>>()?,
239    );
240    Some(tokens)
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244struct SpliceRestartWindowV0 {
245    source_start: usize,
246    source_end: usize,
247    output_start: usize,
248    output_end: usize,
249}
250
251fn restart_windows_for_mutation_spans(
252    input: &str,
253    output: &str,
254    input_tokens: &[LexedToken],
255    mutation_spans: &[TransformProvenanceMutationSpanV0],
256) -> Option<Vec<SpliceRestartWindowV0>> {
257    let mut windows = mutation_spans
258        .iter()
259        .map(|span| restart_window_for_mutation_span(input, output, input_tokens, span))
260        .collect::<Option<Vec<_>>>()?;
261    windows.sort_by(|left, right| {
262        left.source_start
263            .cmp(&right.source_start)
264            .then_with(|| left.source_end.cmp(&right.source_end))
265    });
266
267    let mut merged = Vec::<SpliceRestartWindowV0>::new();
268    for window in windows {
269        let Some(last) = merged.last_mut() else {
270            merged.push(window);
271            continue;
272        };
273        if window.source_start <= last.source_end || window.output_start <= last.output_end {
274            last.source_end = last.source_end.max(window.source_end);
275            last.output_end = last.output_end.max(window.output_end);
276        } else {
277            merged.push(window);
278        }
279    }
280    Some(merged)
281}
282
283fn restart_window_for_mutation_span(
284    input: &str,
285    output: &str,
286    input_tokens: &[LexedToken],
287    span: &TransformProvenanceMutationSpanV0,
288) -> Option<SpliceRestartWindowV0> {
289    let source_start = span.source_span_start.min(input.len());
290    let source_end = span.source_span_end.min(input.len());
291    let generated_start = span.generated_span_start.min(output.len());
292    let generated_end = span.generated_span_end.min(output.len());
293    if source_start > source_end || generated_start > generated_end {
294        return None;
295    }
296
297    let (source_window_start, source_window_end) =
298        source_restart_window(input, input_tokens, source_start, source_end);
299    let left_context_len = source_start.saturating_sub(source_window_start);
300    let right_context_len = source_window_end.saturating_sub(source_end);
301    let output_window_start = floor_char_boundary(
302        output,
303        generated_start
304            .saturating_sub(left_context_len)
305            .min(output.len()),
306    );
307    let output_window_end = ceil_char_boundary(
308        output,
309        generated_end
310            .saturating_add(right_context_len)
311            .min(output.len()),
312    );
313    if output_window_start > output_window_end {
314        return None;
315    }
316
317    Some(SpliceRestartWindowV0 {
318        source_start: source_window_start,
319        source_end: source_window_end,
320        output_start: output_window_start,
321        output_end: output_window_end,
322    })
323}
324
325fn source_restart_window(
326    input: &str,
327    input_tokens: &[LexedToken],
328    source_start: usize,
329    source_end: usize,
330) -> (usize, usize) {
331    if input_tokens.is_empty() {
332        return (0, input.len());
333    }
334
335    let first_touching = input_tokens
336        .iter()
337        .position(|token| token_end(token) > source_start)
338        .unwrap_or(input_tokens.len().saturating_sub(1));
339    let last_touching = input_tokens
340        .iter()
341        .rposition(|token| token_start(token) < source_end)
342        .unwrap_or(first_touching);
343
344    let left_index = first_touching.saturating_sub(1);
345    let right_index = (last_touching + 1).min(input_tokens.len().saturating_sub(1));
346
347    (
348        floor_char_boundary(input, token_start(&input_tokens[left_index])),
349        ceil_char_boundary(input, token_end(&input_tokens[right_index])),
350    )
351}
352
353fn offset_token(token: LexedToken, offset: isize) -> Option<LexedToken> {
354    let start = apply_offset(token_start(&token), offset)?;
355    let end = apply_offset(token_end(&token), offset)?;
356    Some(LexedToken {
357        kind: token.kind,
358        range: text_range(start, end),
359        text: token.text,
360    })
361}
362
363fn apply_offset(value: usize, offset: isize) -> Option<usize> {
364    if offset >= 0 {
365        value.checked_add(offset as usize)
366    } else {
367        value.checked_sub((-offset) as usize)
368    }
369}
370
371fn token_start(token: &LexedToken) -> usize {
372    token.range.start().into()
373}
374
375fn token_end(token: &LexedToken) -> usize {
376    token.range.end().into()
377}
378
379fn text_range(start: usize, end: usize) -> TextRange {
380    TextRange::new(TextSize::from(start as u32), TextSize::from(end as u32))
381}
382
383fn floor_char_boundary(source: &str, mut index: usize) -> usize {
384    index = index.min(source.len());
385    while index > 0 && !source.is_char_boundary(index) {
386        index -= 1;
387    }
388    index
389}
390
391fn ceil_char_boundary(source: &str, mut index: usize) -> usize {
392    index = index.min(source.len());
393    while index < source.len() && !source.is_char_boundary(index) {
394        index += 1;
395    }
396    index
397}
398
399#[cfg(test)]
400mod tests {
401    use super::{
402        lex_cached, materialize_lex_tokens, reset_transform_lex_cache_splice_telemetry,
403        spliced_tokens_for_output, transform_lex_cache_splice_telemetry_snapshot,
404        update_cached_lex_from_splice, with_transform_lex_cache,
405    };
406    use omena_parser::{StyleDialect, with_omena_parser_lex_instrumentation};
407
408    use crate::TransformProvenanceMutationSpanV0;
409    use crate::runtime::provenance::derive_transform_mutation_spans;
410
411    #[test]
412    fn transform_lex_cache_materializes_identical_source_once_per_scope() {
413        let source = ".button { color: red; }";
414        let (token_kinds, instrumentation) = with_omena_parser_lex_instrumentation(|| {
415            with_transform_lex_cache(|| {
416                let first = lex_cached(source, StyleDialect::Css);
417                let second = lex_cached(source, StyleDialect::Css);
418
419                first
420                    .tokens()
421                    .iter()
422                    .zip(second.tokens())
423                    .map(|(left, right)| {
424                        assert_eq!(left, right);
425                        left.kind
426                    })
427                    .collect::<Vec<_>>()
428            })
429        });
430
431        assert!(!token_kinds.is_empty());
432        assert_eq!(instrumentation.lex_invocation_count, 1);
433    }
434
435    #[test]
436    fn transform_lex_cache_is_scoped_to_an_execution() {
437        let source = ".button { color: red; }";
438        let (_, instrumentation) = with_omena_parser_lex_instrumentation(|| {
439            with_transform_lex_cache(|| {
440                let _ = lex_cached(source, StyleDialect::Css);
441            });
442            with_transform_lex_cache(|| {
443                let _ = lex_cached(source, StyleDialect::Css);
444            });
445        });
446
447        assert_eq!(instrumentation.lex_invocation_count, 2);
448    }
449
450    #[test]
451    fn transform_lex_cache_splice_telemetry_records_hits_and_safe_fallbacks() {
452        reset_transform_lex_cache_splice_telemetry();
453        with_transform_lex_cache(|| {
454            let input = ".a { color: red; margin: 0px; }";
455            let output = ".a { color: blue; margin: 0px; }";
456            let _ = lex_cached(input, StyleDialect::Css);
457            let mutation_spans = derive_transform_mutation_spans(input, output);
458            update_cached_lex_from_splice(
459                input,
460                output,
461                StyleDialect::Css,
462                mutation_spans.as_slice(),
463            );
464        });
465        let hit = transform_lex_cache_splice_telemetry_snapshot();
466        assert_eq!(hit.splice_hit_count, 1);
467        assert_eq!(hit.full_relex_fallback_count, 0);
468
469        reset_transform_lex_cache_splice_telemetry();
470        with_transform_lex_cache(|| {
471            let input = ".a { color: red; }";
472            let output =
473                "body, main, section { background: linear-gradient(red, blue); padding: 10px; }";
474            let _ = lex_cached(input, StyleDialect::Css);
475            let mutation_spans = vec![TransformProvenanceMutationSpanV0 {
476                source_span_start: input.len(),
477                source_span_end: 0,
478                generated_span_start: 0,
479                generated_span_end: output.len(),
480                node_key: None,
481            }];
482            update_cached_lex_from_splice(
483                input,
484                output,
485                StyleDialect::Css,
486                mutation_spans.as_slice(),
487            );
488        });
489        let fallback = transform_lex_cache_splice_telemetry_snapshot();
490        assert_eq!(fallback.splice_hit_count, 0);
491        assert_eq!(fallback.full_relex_fallback_count, 1);
492        assert_eq!(fallback.window_derivation_fallback_count, 1);
493    }
494
495    #[test]
496    fn lex_splice_equivalence_property_covers_generated_edits() {
497        for (input, output) in splice_equivalence_cases() {
498            assert_splice_equivalent_to_full_relex(&input, &output, StyleDialect::Css);
499        }
500    }
501
502    fn assert_splice_equivalent_to_full_relex(input: &str, output: &str, dialect: StyleDialect) {
503        let input_tokens = materialize_lex_tokens(input, dialect);
504        let mutation_spans = derive_transform_mutation_spans(input, output);
505        let incremental = spliced_tokens_for_output(
506            input,
507            output,
508            dialect,
509            input_tokens.as_slice(),
510            mutation_spans.as_slice(),
511        );
512        let full = materialize_lex_tokens(output, dialect);
513
514        assert!(
515            incremental.is_some(),
516            "splice equivalence case fell back before producing tokens\ninput: {input}\noutput: {output}",
517        );
518        if let Some(incremental) = incremental {
519            assert_eq!(incremental, full, "input: {input}\noutput: {output}");
520        }
521    }
522
523    fn splice_equivalence_cases() -> Vec<(String, String)> {
524        let mut cases = vec![
525            (
526                ".a { color: red; margin: 0px; }".to_string(),
527                ".a { color: blue; margin: 0px; }".to_string(),
528            ),
529            (
530                ".a { content: \"open\"; color: red; }".to_string(),
531                ".a { content: \"opened\"; color: red; }".to_string(),
532            ),
533            (
534                ".한글 { color: red; margin: 0px; }".to_string(),
535                ".한글 { color: blue; margin: 0px; }".to_string(),
536            ),
537            (
538                ".a { color: red; }\n.b { color: blue; }".to_string(),
539                ".a { color: green; }\n.b { color: navy; }".to_string(),
540            ),
541            (
542                ".a { --x: 1px; }".to_string(),
543                ".a { --xy: 1px; }".to_string(),
544            ),
545            (
546                ".a { color: red; }".to_string(),
547                ".a { /*c*/ color: red; }".to_string(),
548            ),
549        ];
550
551        let seed = concat!(
552            ".a { color: red; margin: 0px; padding: 1px; }\n",
553            ".b { content: \"open\"; --x: 1px; }\n",
554            ".한글 { transform: translateX(1px); }\n",
555        );
556        for (from, to) in [
557            ("red", "blue"),
558            ("0px", "10px"),
559            ("1px", "2px"),
560            ("\"open\"", "\"opened\""),
561            ("--x", "--xy"),
562            ("translateX(1px)", "translateX(2px)"),
563        ] {
564            push_replacement_case(&mut cases, seed, from, to);
565        }
566
567        let mut cumulative = seed.to_string();
568        for (from, to) in [
569            ("red", "green"),
570            ("0px", "4px"),
571            ("padding: 1px", "padding: calc(1px + 1px)"),
572            ("\"open\"", "\"열림\""),
573            ("translateX(1px)", "translateX(3px) rotate(1deg)"),
574        ] {
575            let Some(next) = replace_once(&cumulative, from, to) else {
576                assert!(
577                    cumulative.contains(from),
578                    "missing generated edit fixture token: {from}",
579                );
580                continue;
581            };
582            cases.push((cumulative, next.clone()));
583            cumulative = next;
584        }
585
586        push_replacement_case(
587            &mut cases,
588            ".a { color: red; }\n.b { color: blue; }",
589            "red; }\n.b { color",
590            "green; }\n.b { background-color",
591        );
592        push_replacement_case(
593            &mut cases,
594            ".a { color: red; margin: 0px; padding: 1px; }",
595            "red; margin: 0px",
596            "blue; margin: 10px",
597        );
598
599        cases
600    }
601
602    fn push_replacement_case(cases: &mut Vec<(String, String)>, input: &str, from: &str, to: &str) {
603        let Some(output) = replace_once(input, from, to) else {
604            assert!(
605                input.contains(from),
606                "missing generated edit fixture token: {from}"
607            );
608            return;
609        };
610        cases.push((input.to_string(), output));
611    }
612
613    fn replace_once(input: &str, from: &str, to: &str) -> Option<String> {
614        let start = input.find(from)?;
615        let end = start + from.len();
616        let mut output = String::with_capacity(input.len() + to.len().saturating_sub(from.len()));
617        output.push_str(&input[..start]);
618        output.push_str(to);
619        output.push_str(&input[end..]);
620        Some(output)
621    }
622}