Skip to main content

sem_core/parser/plugins/
vue.rs

1use crate::model::entity::{build_entity_id, SemanticEntity};
2use crate::parser::plugin::SemanticParserPlugin;
3use crate::utils::hash::content_hash;
4
5use super::code::CodeParserPlugin;
6
7pub struct VueParserPlugin;
8
9impl SemanticParserPlugin for VueParserPlugin {
10    fn id(&self) -> &str {
11        "vue"
12    }
13
14    fn extensions(&self) -> &[&str] {
15        &[".vue"]
16    }
17
18    fn extract_entities(&self, content: &str, file_path: &str) -> Vec<SemanticEntity> {
19        let mut entities = Vec::new();
20        let blocks = extract_sfc_blocks(content);
21
22        for block in &blocks {
23            let entity = SemanticEntity {
24                id: build_entity_id(file_path, "sfc_block", &block.name, None),
25                file_path: file_path.to_string(),
26                entity_type: "sfc_block".to_string(),
27                name: block.name.clone(),
28                parent_id: None,
29                content_hash: content_hash(&block.full_content),
30                structural_hash: None,
31                content: block.full_content.clone(),
32                start_line: block.start_line,
33                end_line: block.end_line,
34                metadata: None,
35            };
36
37            let block_id = entity.id.clone();
38            entities.push(entity);
39
40            // For <script> blocks, delegate to the TS/JS parser for inner entities
41            if block.tag == "script" && !block.inner_content.is_empty() {
42                let ext = if block.lang == "ts" || block.lang == "tsx" {
43                    "script.ts"
44                } else {
45                    "script.js"
46                };
47                let virtual_path = format!("{}:{}", file_path, ext);
48                let code_plugin = CodeParserPlugin;
49                let inner = code_plugin.extract_entities(&block.inner_content, &virtual_path);
50
51                for mut child in inner {
52                    // Reparent: set file_path to the real .vue file, set parent to the script block
53                    child.file_path = file_path.to_string();
54                    child.parent_id = Some(block_id.clone());
55                    // Adjust line numbers to be relative to the .vue file
56                    child.start_line += block.inner_start_line - 1;
57                    child.end_line += block.inner_start_line - 1;
58                    // Rebuild ID with correct file_path
59                    child.id = build_entity_id(
60                        file_path,
61                        &child.entity_type,
62                        &child.name,
63                        child.parent_id.as_deref(),
64                    );
65                    entities.push(child);
66                }
67            }
68        }
69
70        entities
71    }
72}
73
74struct SfcBlock {
75    tag: String,
76    name: String,
77    lang: String,
78    full_content: String,
79    inner_content: String,
80    start_line: usize,
81    end_line: usize,
82    inner_start_line: usize,
83}
84
85fn extract_sfc_blocks(content: &str) -> Vec<SfcBlock> {
86    let mut blocks = Vec::new();
87    let lines: Vec<&str> = content.lines().collect();
88    let mut i = 0;
89    let mut script_count = 0;
90
91    while i < lines.len() {
92        let trimmed = lines[i].trim();
93
94        // Match opening tags: <template>, <script>, <script setup>, <script lang="ts">, <style>, etc.
95        if let Some(tag_info) = parse_opening_tag(trimmed) {
96            let start_line = i + 1; // 1-indexed
97            let closing_tag = format!("</{}>", tag_info.tag);
98            let inner_start = i + 1;
99
100            // Find closing tag
101            let mut j = i + 1;
102            while j < lines.len() {
103                if lines[j].trim().starts_with(&closing_tag) {
104                    break;
105                }
106                j += 1;
107            }
108
109            let end_line = if j < lines.len() { j + 1 } else { lines.len() };
110
111            let full_content = lines[i..end_line].join("\n");
112            let inner_content = if inner_start < j {
113                lines[inner_start..j].join("\n")
114            } else {
115                String::new()
116            };
117
118            let name = if tag_info.tag == "script" {
119                script_count += 1;
120                if tag_info.setup {
121                    "script setup".to_string()
122                } else if script_count > 1 {
123                    format!("script:{}", script_count)
124                } else {
125                    "script".to_string()
126                }
127            } else {
128                tag_info.tag.clone()
129            };
130
131            blocks.push(SfcBlock {
132                tag: tag_info.tag,
133                name,
134                lang: tag_info.lang,
135                full_content,
136                inner_content,
137                start_line,
138                end_line,
139                inner_start_line: inner_start + 1, // 1-indexed
140            });
141
142            i = end_line;
143        } else {
144            i += 1;
145        }
146    }
147
148    blocks
149}
150
151struct TagInfo {
152    tag: String,
153    lang: String,
154    setup: bool,
155}
156
157fn parse_opening_tag(line: &str) -> Option<TagInfo> {
158    let tags = ["template", "script", "style"];
159    for tag in &tags {
160        let prefix = format!("<{}", tag);
161        if !line.starts_with(&prefix) {
162            continue;
163        }
164        // Must be followed by '>', ' ', or nothing more (self-closing not typical for SFC)
165        let rest = &line[prefix.len()..];
166        if rest.is_empty() || rest.starts_with('>') || rest.starts_with(' ') {
167            let lang = extract_attr(line, "lang").unwrap_or_default();
168            let setup = line.contains("setup");
169            return Some(TagInfo {
170                tag: tag.to_string(),
171                lang,
172                setup,
173            });
174        }
175    }
176    None
177}
178
179fn extract_attr(tag_line: &str, attr: &str) -> Option<String> {
180    let pattern = format!("{}=\"", attr);
181    if let Some(start) = tag_line.find(&pattern) {
182        let value_start = start + pattern.len();
183        if let Some(end) = tag_line[value_start..].find('"') {
184            return Some(tag_line[value_start..value_start + end].to_string());
185        }
186    }
187    // Also handle single quotes
188    let pattern = format!("{}='", attr);
189    if let Some(start) = tag_line.find(&pattern) {
190        let value_start = start + pattern.len();
191        if let Some(end) = tag_line[value_start..].find('\'') {
192            return Some(tag_line[value_start..value_start + end].to_string());
193        }
194    }
195    None
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn test_vue_sfc_extraction() {
204        let code = r#"<template>
205  <div class="app">
206    <h1>{{ message }}</h1>
207  </div>
208</template>
209
210<script lang="ts">
211import { defineComponent, ref } from 'vue'
212
213export default defineComponent({
214  name: 'App',
215  setup() {
216    const message = ref('Hello')
217    return { message }
218  }
219})
220
221function helper(x: number): number {
222  return x * 2
223}
224</script>
225
226<style scoped>
227.app {
228  color: red;
229}
230</style>
231"#;
232        let plugin = VueParserPlugin;
233        let entities = plugin.extract_entities(code, "App.vue");
234        let names: Vec<&str> = entities.iter().map(|e| e.name.as_str()).collect();
235        let types: Vec<&str> = entities.iter().map(|e| e.entity_type.as_str()).collect();
236        eprintln!("Vue entities: {:?}", names.iter().zip(types.iter()).collect::<Vec<_>>());
237
238        assert!(names.contains(&"template"), "Should find template block, got: {:?}", names);
239        assert!(names.contains(&"script"), "Should find script block, got: {:?}", names);
240        assert!(names.contains(&"style"), "Should find style block, got: {:?}", names);
241        assert!(names.contains(&"helper"), "Should find helper function from script, got: {:?}", names);
242    }
243
244    #[test]
245    fn test_vue_script_setup() {
246        let code = r#"<script setup lang="ts">
247import { ref, computed } from 'vue'
248
249const count = ref(0)
250
251function increment() {
252  count.value++
253}
254
255class Counter {
256  value: number = 0
257  increment() {
258    this.value++
259  }
260}
261</script>
262
263<template>
264  <button @click="increment">{{ count }}</button>
265</template>
266"#;
267        let plugin = VueParserPlugin;
268        let entities = plugin.extract_entities(code, "Counter.vue");
269        let names: Vec<&str> = entities.iter().map(|e| e.name.as_str()).collect();
270        eprintln!("Vue setup entities: {:?}", entities.iter().map(|e| (&e.name, &e.entity_type, &e.parent_id)).collect::<Vec<_>>());
271
272        assert!(names.contains(&"script setup"), "Should find script setup block, got: {:?}", names);
273        assert!(names.contains(&"template"), "Should find template block, got: {:?}", names);
274        assert!(names.contains(&"increment"), "Should find increment function, got: {:?}", names);
275        assert!(names.contains(&"Counter"), "Should find Counter class, got: {:?}", names);
276
277        // Functions inside script should be children of the script block
278        let increment = entities.iter().find(|e| e.name == "increment").unwrap();
279        assert!(increment.parent_id.is_some(), "increment should be child of script block");
280    }
281
282    #[test]
283    fn test_vue_line_numbers() {
284        let code = "<template>\n  <div>hi</div>\n</template>\n\n<script lang=\"ts\">\nfunction hello() {\n  return 'hello'\n}\n</script>\n";
285        let plugin = VueParserPlugin;
286        let entities = plugin.extract_entities(code, "test.vue");
287
288        let template = entities.iter().find(|e| e.name == "template").unwrap();
289        assert_eq!(template.start_line, 1);
290        assert_eq!(template.end_line, 3);
291
292        let script = entities.iter().find(|e| e.name == "script").unwrap();
293        assert_eq!(script.start_line, 5);
294        assert_eq!(script.end_line, 9);
295
296        // hello function is on lines 6-8 in the .vue file
297        let hello = entities.iter().find(|e| e.name == "hello").unwrap();
298        assert_eq!(hello.start_line, 6);
299        assert_eq!(hello.end_line, 8);
300    }
301}