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