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
57pub fn reset_transform_lex_cache_splice_telemetry() {
59 TRANSFORM_LEX_CACHE_SPLICE_TELEMETRY.with(|telemetry| {
60 *telemetry.borrow_mut() = TransformLexCacheSpliceTelemetryV0::default();
61 });
62}
63
64pub 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}