Skip to main content

cpd_tokenizer/
sfc.rs

1use std::collections::BTreeMap;
2
3use cpd_core::models::{DetectionToken, Token};
4
5use crate::embedded::{blank_ranges_preserve_newlines, html_only_maps, tokenize_blocks_shifted};
6use crate::line_index::LineIndex;
7use crate::markdown::{offset_detection_tokens, tokens_to_detection};
8use crate::tokenizer::{Mode, TokenMap, TokenizeOptions, tokenize_format_to_detection};
9
10#[derive(Debug, Clone)]
11pub struct Block {
12    pub block_format: String,
13    pub content: String,
14    pub start_offset: usize,
15    pub start_line: u32,
16}
17
18struct SfcBlock {
19    block_format: String,
20    block_start: usize,
21    inner_start: usize,
22    inner_end: usize,
23    block_end: usize,
24}
25
26pub fn tokenize_sfc_maps(
27    source: &str,
28    file_format: &str,
29    options: &TokenizeOptions,
30) -> Vec<TokenMap> {
31    if source.is_empty() {
32        return Vec::new();
33    }
34
35    let blocks = find_sfc_blocks(source, file_format);
36    if blocks.is_empty() {
37        return html_only_maps(source, options);
38    }
39
40    let blank_ranges: Vec<[usize; 2]> = blocks
41        .iter()
42        .filter_map(|b| {
43            if b.inner_start < b.inner_end {
44                Some([b.inner_start, b.inner_end])
45            } else {
46                None
47            }
48        })
49        .collect();
50
51    let sanitized = blank_ranges_preserve_newlines(source, &blank_ranges);
52    let line_index = LineIndex::new(source.as_bytes());
53
54    let mut grouped: BTreeMap<String, Vec<DetectionToken>> = BTreeMap::new();
55
56    // A Vue file is top-level blocks only: all markup lives inside
57    // `<template>`, so what remains outside the blocks is just the wrapper
58    // tags — `<template>`, `</template>`, `<script ...>`, `</script>`,
59    // `<style ...>`, `</style>`. Those tags are identical in every Vue file
60    // and sit at both ends of it; fed into the html stream they would stretch
61    // every template clone to the last `</style>` and count the script and
62    // style bodies as duplicated html lines. That holds with or without a
63    // template block (a script-only component has nothing but wrapper tags
64    // outside its blocks). Svelte and Astro have no template wrapper: their
65    // top-level markup is the skeleton and is kept.
66    let wrapper_tags_only = file_format == "vue";
67
68    let markup_tokens = crate::generic::tokenize_generic(&sanitized, "html");
69    let mut markup_detection = tokens_to_detection(markup_tokens, options);
70    markup_detection.retain(|t| t.range[0] < t.range[1]);
71    if !markup_detection.is_empty() && !wrapper_tags_only {
72        grouped
73            .entry("html".to_string())
74            .or_default()
75            .extend(markup_detection);
76    }
77
78    for block in &blocks {
79        if block.inner_start >= block.inner_end {
80            continue;
81        }
82        let inner = &source[block.inner_start..block.inner_end];
83        let inner_start_loc = line_index.location(block.inner_start);
84
85        let mut inner_tokens = tokenize_format_to_detection(&block.block_format, inner, options);
86        offset_detection_tokens(&mut inner_tokens, block.inner_start, &inner_start_loc);
87
88        grouped
89            .entry(block.block_format.clone())
90            .or_default()
91            .extend(inner_tokens);
92    }
93
94    // Skeleton and block-inner tokens are collected separately — restore
95    // source order so clone endpoints follow the file.
96    if let Some(tokens) = grouped.get_mut("html") {
97        tokens.sort_by_key(|token| token.range[0]);
98    }
99
100    grouped
101        .into_iter()
102        .filter(|(_, tokens)| !tokens.is_empty())
103        .map(|(format, tokens)| TokenMap { format, tokens })
104        .collect()
105}
106
107/// The code blocks of a Vue, Svelte or Astro file that functions live in —
108/// `<script>` blocks and Astro frontmatter — with their format and the byte
109/// range of their content in `source`.
110pub fn script_blocks(source: &str, file_format: &str) -> Vec<(String, std::ops::Range<usize>)> {
111    find_sfc_blocks(source, file_format)
112        .into_iter()
113        .filter(|b| b.inner_start < b.inner_end)
114        .filter(|b| {
115            !matches!(
116                b.block_format.as_str(),
117                "html" | "pug" | "css" | "scss" | "less"
118            )
119        })
120        .map(|b| (b.block_format, b.inner_start..b.inner_end))
121        .collect()
122}
123
124fn sfc_tag_names(file_format: &str) -> &[&'static str] {
125    match file_format {
126        "svelte" | "astro" => &["script", "style"],
127        _ => &["template", "script", "style"],
128    }
129}
130
131fn find_sfc_blocks(source: &str, file_format: &str) -> Vec<SfcBlock> {
132    let source_lower = source.to_ascii_lowercase();
133    let mut blocks = Vec::new();
134
135    if file_format == "astro"
136        && let Some(fm) = astro_frontmatter_block(source)
137    {
138        blocks.push(fm);
139    }
140
141    for tag in sfc_tag_names(file_format) {
142        let mut search_from = 0usize;
143        while let Some(block) = find_sfc_tag_block(source, &source_lower, tag, search_from) {
144            search_from = block.block_end;
145            blocks.push(block);
146        }
147    }
148
149    blocks.sort_by_key(|b| b.block_start);
150    let mut deduped = Vec::new();
151    for block in blocks {
152        let nested = deduped.iter().any(|existing: &SfcBlock| {
153            block.block_start >= existing.block_start && block.block_start < existing.block_end
154        });
155        if !nested {
156            deduped.push(block);
157        }
158    }
159    deduped
160}
161
162fn find_tag_bounds(
163    _source: &str,
164    source_lower: &str,
165    tag: &str,
166    from: usize,
167) -> Option<(usize, usize, usize)> {
168    let open_needle = format!("<{}", tag);
169    let close_needle = format!("</{}>", tag);
170
171    let open_start = source_lower[from..].find(&open_needle)? + from;
172    let after_tag_name = open_start + 1 + tag.len();
173    if source_lower
174        .as_bytes()
175        .get(after_tag_name)
176        .is_some_and(|b| b.is_ascii_alphabetic())
177    {
178        return None;
179    }
180    let tag_end = source_lower[open_start..].find('>')? + open_start + 1;
181    let close_start = source_lower[tag_end..].find(&close_needle)? + tag_end;
182
183    Some((open_start, tag_end, close_start))
184}
185
186fn find_sfc_tag_block(
187    source: &str,
188    source_lower: &str,
189    tag: &str,
190    from: usize,
191) -> Option<SfcBlock> {
192    let (open_start, tag_end, close_start) = find_tag_bounds(source, source_lower, tag, from)?;
193    let close_needle = format!("</{}>", tag);
194
195    let attrs = &source[open_start + 1 + tag.len()..tag_end];
196    let inner_start = tag_end;
197    let inner_end = close_start;
198    let block_end = source_lower[close_start..]
199        .find('>')
200        .map(|i| close_start + i + 1)
201        .unwrap_or(close_start + close_needle.len());
202    let block_end = block_end.min(source.len());
203
204    let block_format = detect_sfc_block_format(attrs, tag);
205
206    Some(SfcBlock {
207        block_format,
208        block_start: open_start,
209        inner_start,
210        inner_end: inner_end.max(inner_start),
211        block_end,
212    })
213}
214
215fn detect_block_format(attrs: &str, tag: &str, strict: bool) -> String {
216    let lang = extract_lang_attr_value(attrs);
217    match tag {
218        "script" => match lang.as_deref() {
219            Some("ts" | "typescript") => "typescript".to_string(),
220            Some("js" | "javascript") => "javascript".to_string(),
221            Some(other) if strict => {
222                if crate::formats::get_format_by_extension(other).is_some()
223                    || crate::formats::SUPPORTED_FORMATS
224                        .iter()
225                        .any(|e| e.name == other)
226                {
227                    other.to_string()
228                } else {
229                    "javascript".to_string()
230                }
231            }
232            _ => "javascript".to_string(),
233        },
234        "style" => match lang.as_deref() {
235            Some("scss" | "sass") => "scss".to_string(),
236            Some("less") => "less".to_string(),
237            _ => "css".to_string(),
238        },
239        "template" => match lang.as_deref() {
240            Some(v) if v == "pug" || v == "jade" => "pug".to_string(),
241            _ => "html".to_string(),
242        },
243        _ => "html".to_string(),
244    }
245}
246
247fn detect_sfc_block_format(attrs: &str, tag: &str) -> String {
248    detect_block_format(attrs, tag, true)
249}
250
251fn detect_display_block_format(attrs: &str, tag: &str) -> String {
252    detect_block_format(attrs, tag, false)
253}
254
255fn astro_frontmatter_block(source: &str) -> Option<SfcBlock> {
256    if !(source.starts_with("---\n") || source.starts_with("---\r\n")) {
257        return None;
258    }
259    let lines = crate::markdown::line_spans(source);
260    let close_idx = lines
261        .iter()
262        .enumerate()
263        .skip(1)
264        .find(|(_, span)| source[span.start..span.end].trim() == "---")
265        .map(|(idx, _)| idx)?;
266    let inner_start = lines.get(1)?.start;
267    let inner_end = source[..lines[close_idx].start]
268        .strip_suffix('\n')
269        .map(|prefix: &str| prefix.len())
270        .unwrap_or(lines[close_idx].start);
271    let block_end = lines[close_idx].next_start.min(source.len());
272    Some(SfcBlock {
273        block_format: "typescript".to_string(),
274        block_start: 0,
275        inner_start,
276        inner_end: inner_end.max(inner_start),
277        block_end,
278    })
279}
280
281fn extract_lang_attr_value(attrs: &str) -> Option<String> {
282    let lower = attrs.to_ascii_lowercase();
283    let lang_pos = lower.find("lang=")?;
284    let rest = &attrs[lang_pos + 5..];
285    let quote = if rest.starts_with('"') {
286        '"'
287    } else if rest.starts_with('\'') {
288        '\''
289    } else {
290        return None;
291    };
292    let value_start = 1;
293    let value_end = rest[value_start..].find(quote)? + value_start;
294    Some(rest[value_start..value_end].to_ascii_lowercase())
295}
296
297/// Extract blocks from a Vue/Svelte/Astro file (display path).
298pub fn extract_blocks(source: &str, file_format: &str) -> Vec<Block> {
299    let source_lower = source.to_ascii_lowercase();
300    let mut blocks = Vec::new();
301    for tag in sfc_tag_names(file_format) {
302        let mut search_from = 0;
303        while let Some((block, next_from)) =
304            find_display_block(source, &source_lower, tag, search_from)
305        {
306            search_from = next_from;
307            blocks.push(block);
308        }
309    }
310    blocks.sort_by_key(|b: &Block| b.start_offset);
311    blocks
312}
313
314fn find_display_block(
315    source: &str,
316    source_lower: &str,
317    tag: &str,
318    from: usize,
319) -> Option<(Block, usize)> {
320    let (open_start, tag_end, close_start) = find_tag_bounds(source, source_lower, tag, from)?;
321
322    let attrs = &source[open_start + 1 + tag.len()..tag_end];
323    let content = source[tag_end..close_start].to_string();
324    let content_len = content.len();
325    let start_line = source[..tag_end].lines().count() as u32 + 1;
326    let block_format = detect_display_block_format(attrs, tag);
327
328    Some((
329        Block {
330            block_format,
331            content,
332            start_offset: tag_end,
333            start_line,
334        },
335        tag_end + content_len,
336    ))
337}
338
339pub fn tokenize_sfc(source: &str, file_format: &str, mode: Mode) -> Vec<Token> {
340    let blocks = extract_blocks(source, file_format);
341    tokenize_blocks_shifted(
342        blocks
343            .iter()
344            .map(|b| (b.block_format.as_str(), b.content.as_str(), b.start_line)),
345        mode,
346    )
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    const VUE_FILE: &str = r#"<template>
354  <div>Hello</div>
355</template>
356
357<script>
358export default { name: 'Foo' }
359</script>
360
361<style>
362.foo { color: red; }
363</style>
364"#;
365
366    const VUE_TS_FILE: &str = r#"<template>
367  <div>Hello</div>
368</template>
369
370<script lang="ts">
371const x: number = 5;
372</script>
373
374<style lang="scss">
375.foo { color: red; }
376</style>
377"#;
378
379    #[test]
380    fn vue_file_extracts_three_blocks() {
381        let blocks = extract_blocks(VUE_FILE, "vue");
382        assert_eq!(blocks.len(), 3, "must find template, script, style blocks");
383    }
384
385    #[test]
386    fn script_block_default_format_is_javascript() {
387        let blocks = extract_blocks(VUE_FILE, "vue");
388        let script = blocks.iter().find(|b| b.block_format == "javascript");
389        assert!(script.is_some(), "plain <script> must be javascript format");
390    }
391
392    #[test]
393    fn script_lang_ts_produces_typescript_format() {
394        let blocks = extract_blocks(VUE_TS_FILE, "vue");
395        let ts_block = blocks.iter().find(|b| b.block_format == "typescript");
396        assert!(
397            ts_block.is_some(),
398            "<script lang=\"ts\"> must produce typescript format"
399        );
400    }
401
402    #[test]
403    fn unknown_lang_does_not_panic() {
404        let source = "<script lang=\"unknownlang123\">\nconst x = 1;\n</script>\n";
405        let result = std::panic::catch_unwind(|| extract_blocks(source, "vue"));
406        assert!(result.is_ok(), "unknown lang must not panic");
407    }
408
409    #[test]
410    fn no_blocks_returns_empty() {
411        let source = "just plain text no tags";
412        let blocks = extract_blocks(source, "vue");
413        assert!(blocks.is_empty());
414    }
415
416    #[test]
417    fn start_offset_is_after_opening_tag() {
418        let blocks = extract_blocks(VUE_FILE, "vue");
419        for block in &blocks {
420            assert!(block.start_offset > 0);
421        }
422    }
423
424    #[test]
425    fn vue_sfc_maps_produces_multiple_formats() {
426        let options = TokenizeOptions::new(Mode::Mild);
427        let maps = tokenize_sfc_maps(VUE_FILE, "vue", &options);
428        let formats: Vec<&str> = maps.iter().map(|m| m.format.as_str()).collect();
429        assert!(formats.contains(&"javascript"), "must have javascript map");
430        assert!(formats.contains(&"css"), "must have css map");
431        assert!(formats.contains(&"html"), "must have html map");
432    }
433
434    #[test]
435    fn vue_html_map_tokens_remain_in_source_order() {
436        let maps = tokenize_sfc_maps(VUE_FILE, "vue", &TokenizeOptions::new(Mode::Mild));
437        let html = maps.iter().find(|map| map.format == "html").unwrap();
438
439        assert!(
440            html.tokens
441                .windows(2)
442                .all(|pair| pair[0].range[0] <= pair[1].range[0]),
443            "HTML tokens must remain in source order"
444        );
445    }
446
447    #[test]
448    fn vue_html_map_holds_only_the_template_body() {
449        let maps = tokenize_sfc_maps(VUE_FILE, "vue", &TokenizeOptions::new(Mode::Mild));
450        let html = maps.iter().find(|map| map.format == "html").unwrap();
451        let template_start = VUE_FILE.find("<template>").unwrap() + "<template>".len();
452        let template_end = VUE_FILE.find("</template>").unwrap();
453        assert!(
454            html.tokens
455                .iter()
456                .all(|t| t.range[0] >= template_start && t.range[1] <= template_end),
457            "wrapper tags must not enter the html stream: {:?}",
458            html.tokens.iter().map(|t| t.range).collect::<Vec<_>>()
459        );
460        assert_eq!(html.tokens.first().unwrap().start.line, 2);
461        assert_eq!(html.tokens.last().unwrap().end.line, 2);
462    }
463
464    #[test]
465    fn vue_without_template_has_no_html_map() {
466        let source = "<script>\nexport default { render: (h) => h('div', 'x') }\n</script>\n<style>\n.x { margin: 0; }\n</style>\n";
467        let maps = tokenize_sfc_maps(source, "vue", &TokenizeOptions::new(Mode::Mild));
468        assert!(
469            maps.iter().all(|map| map.format != "html"),
470            "wrapper tags alone must not form an html map: {:?}",
471            maps.iter().map(|m| m.format.as_str()).collect::<Vec<_>>()
472        );
473    }
474
475    #[test]
476    fn svelte_keeps_top_level_markup() {
477        let source = "<script>\nlet n = 1;\n</script>\n<h1>Count {n}</h1>\n<button on:click={() => n++}>add</button>\n";
478        let maps = tokenize_sfc_maps(source, "svelte", &TokenizeOptions::new(Mode::Mild));
479        let html = maps.iter().find(|map| map.format == "html").unwrap();
480        assert!(
481            html.tokens.iter().any(|t| t.start.line == 4),
482            "top-level markup is the html map"
483        );
484    }
485
486    #[test]
487    fn vue_ts_maps_produces_typescript() {
488        let options = TokenizeOptions::new(Mode::Mild);
489        let maps = tokenize_sfc_maps(VUE_TS_FILE, "vue", &options);
490        let formats: Vec<&str> = maps.iter().map(|m| m.format.as_str()).collect();
491        assert!(formats.contains(&"typescript"), "must have typescript map");
492        assert!(formats.contains(&"scss"), "must have scss map");
493    }
494
495    #[test]
496    fn empty_sfc_returns_empty() {
497        let options = TokenizeOptions::new(Mode::Mild);
498        let maps = tokenize_sfc_maps("", "vue", &options);
499        assert!(maps.is_empty());
500    }
501
502    #[test]
503    fn svelte_sfc_maps_produces_multiple_formats() {
504        let source = r#"<script>
505  let count = 0;
506</script>
507
508<style>
509  .count { color: blue; }
510</style>
511"#;
512        let options = TokenizeOptions::new(Mode::Mild);
513        let maps = tokenize_sfc_maps(source, "svelte", &options);
514        let formats: Vec<&str> = maps.iter().map(|m| m.format.as_str()).collect();
515        assert!(
516            formats.contains(&"javascript"),
517            "svelte must have javascript map"
518        );
519        assert!(formats.contains(&"css"), "svelte must have css map");
520        assert!(
521            formats.contains(&"html"),
522            "svelte must have html markup map"
523        );
524    }
525
526    #[test]
527    fn svelte_script_containing_style_text_no_panic() {
528        let source = r#"<script>
529  const x = "<style>.red{color:red}</style>";
530</script>
531
532<style>
533  .blue { color: blue; }
534</style>
535"#;
536        let result = std::panic::catch_unwind(|| {
537            let options = TokenizeOptions::new(Mode::Mild);
538            tokenize_sfc_maps(source, "svelte", &options)
539        });
540        assert!(
541            result.is_ok(),
542            "must not panic when <style> text appears inside <script>"
543        );
544        let maps = result.unwrap();
545        let formats: Vec<&str> = maps.iter().map(|m| m.format.as_str()).collect();
546        assert!(
547            formats.contains(&"javascript"),
548            "must have javascript block"
549        );
550        assert!(formats.contains(&"css"), "must have real css block");
551        assert!(formats.contains(&"html"), "must have html markup");
552    }
553}