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 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 child.file_path = file_path.to_string();
54 child.parent_id = Some(block_id.clone());
55 child.start_line += block.inner_start_line - 1;
57 child.end_line += block.inner_start_line - 1;
58 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 if let Some(tag_info) = parse_opening_tag(trimmed) {
96 let start_line = i + 1; let closing_tag = format!("</{}>", tag_info.tag);
98 let inner_start = i + 1;
99
100 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, });
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 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 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 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 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}