1use std::cell::RefCell;
2use std::collections::HashMap;
3
4use crate::model::entity::{build_entity_id, SemanticEntity};
5use crate::parser::plugin::SemanticParserPlugin;
6use crate::utils::hash::content_hash;
7
8thread_local! {
9 static ERB_PARSER: RefCell<tree_sitter::Parser> = RefCell::new({
10 let mut p = tree_sitter::Parser::new();
11 let lang: tree_sitter::Language = tree_sitter_embedded_template::LANGUAGE.into();
12 let _ = p.set_language(&lang);
13 p
14 });
15}
16
17pub struct ErbParserPlugin;
18
19impl SemanticParserPlugin for ErbParserPlugin {
20 fn id(&self) -> &str {
21 "erb"
22 }
23
24 fn extensions(&self) -> &[&str] {
25 &[".erb"]
26 }
27
28 fn extract_entities(&self, content: &str, file_path: &str) -> Vec<SemanticEntity> {
29 let lines: Vec<&str> = content.lines().collect();
30 if lines.is_empty() {
31 return Vec::new();
32 }
33
34 let mut entities = Vec::new();
35
36 let template_name = extract_template_name(file_path);
38 let template_id = build_entity_id(file_path, "template", &template_name, None);
39 entities.push(SemanticEntity {
40 id: template_id.clone(),
41 file_path: file_path.to_string(),
42 entity_type: "template".to_string(),
43 name: template_name,
44 parent_id: None,
45 content: content.to_string(),
46 content_hash: content_hash(content),
47 structural_hash: None,
48 start_line: 1,
49 end_line: lines.len(),
50 metadata: None,
51 });
52
53 let tags = ERB_PARSER.with(|parser| {
55 let mut parser = parser.borrow_mut();
56 match parser.parse(content.as_bytes(), None) {
57 Some(tree) => extract_tags_from_tree(&tree, content),
58 None => Vec::new(),
59 }
60 });
61
62 let mut block_stack: Vec<ErbTag> = Vec::new();
63 let mut name_counts: HashMap<String, usize> = HashMap::new();
64
65 for tag in tags {
66 match tag.kind {
67 TagKind::BlockOpen => {
68 block_stack.push(tag);
69 }
70 TagKind::BlockClose => {
71 if let Some(opener) = block_stack.pop() {
72 let block_content =
73 lines[opener.start_line - 1..tag.end_line].join("\n");
74 let name = unique_name(&opener.name, &mut name_counts);
75 entities.push(SemanticEntity {
76 id: build_entity_id(
77 file_path,
78 "erb_block",
79 &name,
80 Some(&template_id),
81 ),
82 file_path: file_path.to_string(),
83 entity_type: "erb_block".to_string(),
84 name,
85 parent_id: Some(template_id.clone()),
86 content: block_content.clone(),
87 content_hash: content_hash(&block_content),
88 structural_hash: None,
89 start_line: opener.start_line,
90 end_line: tag.end_line,
91 metadata: None,
92 });
93 }
94 }
95 TagKind::Expression => {
96 let expr_content =
97 lines[tag.start_line - 1..tag.end_line].join("\n");
98 let name = unique_name(&tag.name, &mut name_counts);
99 entities.push(SemanticEntity {
100 id: build_entity_id(
101 file_path,
102 "erb_expression",
103 &name,
104 Some(&template_id),
105 ),
106 file_path: file_path.to_string(),
107 entity_type: "erb_expression".to_string(),
108 name,
109 parent_id: Some(template_id.clone()),
110 content: expr_content.clone(),
111 content_hash: content_hash(&expr_content),
112 structural_hash: None,
113 start_line: tag.start_line,
114 end_line: tag.end_line,
115 metadata: None,
116 });
117 }
118 }
120 }
121
122 entities
123 }
124}
125
126#[derive(Debug)]
129enum TagKind {
130 BlockOpen,
131 BlockClose,
132 Expression,
133}
134
135#[derive(Debug)]
136struct ErbTag {
137 kind: TagKind,
138 name: String,
139 start_line: usize,
140 end_line: usize,
141}
142
143fn extract_template_name(file_path: &str) -> String {
146 let filename = file_path.rsplit('/').next().unwrap_or(file_path);
147 filename.strip_suffix(".erb").unwrap_or(filename).to_string()
148}
149
150fn extract_tags_from_tree(tree: &tree_sitter::Tree, source: &str) -> Vec<ErbTag> {
152 let mut tags = Vec::new();
153 let root = tree.root_node();
154 let mut cursor = root.walk();
155
156 for node in root.children(&mut cursor) {
157 let start_line = node.start_position().row + 1; let end_line = node.end_position().row + 1;
159
160 match node.kind() {
161 "directive" | "output_directive" => {
162 if let Some(code_text) = code_child_text(&node, source) {
163 let trimmed = code_text.trim();
164 if trimmed.is_empty() {
165 continue;
166 }
167
168 if let Some(tag) = classify_code(trimmed, start_line, end_line) {
169 tags.push(tag);
170 }
171 }
172 }
173 _ => {}
175 }
176 }
177
178 tags
179}
180
181fn classify_code(trimmed: &str, start_line: usize, end_line: usize) -> Option<ErbTag> {
184 let first_word = trimmed.split_whitespace().next().unwrap_or("");
185
186 if first_word == "end" {
187 Some(ErbTag {
188 kind: TagKind::BlockClose,
189 name: "end".to_string(),
190 start_line,
191 end_line,
192 })
193 } else if is_block_opener(trimmed) {
194 Some(ErbTag {
195 kind: TagKind::BlockOpen,
196 name: truncate_name(trimmed),
197 start_line,
198 end_line,
199 })
200 } else if is_mid_block_keyword(first_word) {
201 None
202 } else {
203 Some(ErbTag {
205 kind: TagKind::Expression,
206 name: truncate_name(trimmed),
207 start_line,
208 end_line,
209 })
210 }
211}
212
213fn code_child_text<'a>(node: &tree_sitter::Node, source: &'a str) -> Option<&'a str> {
214 let mut cursor = node.walk();
215 for child in node.children(&mut cursor) {
216 if child.kind() == "code" {
217 return child.utf8_text(source.as_bytes()).ok();
218 }
219 }
220 None
221}
222
223fn is_block_opener(content: &str) -> bool {
224 let first_word = content.split_whitespace().next().unwrap_or("");
225 if matches!(
226 first_word,
227 "if" | "unless" | "for" | "while" | "until" | "case" | "begin"
228 ) {
229 return true;
230 }
231 content.split_whitespace().any(|w| w == "do")
233}
234
235fn is_mid_block_keyword(word: &str) -> bool {
236 matches!(word, "else" | "elsif" | "when" | "rescue" | "ensure")
237}
238
239fn truncate_name(s: &str) -> String {
240 let s = s.trim();
241 if s.len() <= 60 {
242 s.to_string()
243 } else {
244 let mut boundary = 57.min(s.len());
245 while boundary > 0 && !s.is_char_boundary(boundary) {
246 boundary -= 1;
247 }
248 format!("{}...", &s[..boundary])
249 }
250}
251
252fn unique_name(base: &str, counts: &mut HashMap<String, usize>) -> String {
253 let count = counts.entry(base.to_string()).or_insert(0);
254 *count += 1;
255 if *count > 1 {
256 format!("{}#{}", base, count)
257 } else {
258 base.to_string()
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn test_erb_extraction() {
268 let erb = r#"<div class="container">
269 <% if @user.admin? %>
270 <h1>Admin Panel</h1>
271 <%= @user.name %>
272 <% else %>
273 <p>Access denied</p>
274 <% end %>
275
276 <% @items.each do |item| %>
277 <li><%= item.title %></li>
278 <% end %>
279
280 <%# This is a comment, should be skipped %>
281 <% @count = @items.length %>
282</div>
283"#;
284 let plugin = ErbParserPlugin;
285 let entities = plugin.extract_entities(erb, "views/dashboard.html.erb");
286
287 let names: Vec<&str> = entities.iter().map(|e| e.name.as_str()).collect();
288 let types: Vec<&str> = entities.iter().map(|e| e.entity_type.as_str()).collect();
289 eprintln!(
290 "ERB entities: {:?}",
291 names.iter().zip(types.iter()).collect::<Vec<_>>()
292 );
293
294 assert_eq!(entities[0].entity_type, "template");
296 assert_eq!(entities[0].name, "dashboard.html");
297 assert_eq!(entities[0].start_line, 1);
298
299 let if_block = entities.iter().find(|e| e.name == "if @user.admin?").unwrap();
301 assert_eq!(if_block.entity_type, "erb_block");
302 assert_eq!(if_block.start_line, 2);
303 assert_eq!(if_block.end_line, 7);
304 assert!(if_block.parent_id.is_some());
305
306 let each_block = entities
308 .iter()
309 .find(|e| e.name == "@items.each do |item|")
310 .unwrap();
311 assert_eq!(each_block.entity_type, "erb_block");
312 assert_eq!(each_block.start_line, 9);
313 assert_eq!(each_block.end_line, 11);
314
315 assert!(names.contains(&"@user.name"));
317 assert!(names.contains(&"item.title"));
318 let user_name = entities.iter().find(|e| e.name == "@user.name").unwrap();
319 assert_eq!(user_name.entity_type, "erb_expression");
320 assert_eq!(user_name.start_line, 4);
321
322 let code = entities
324 .iter()
325 .find(|e| e.name == "@count = @items.length")
326 .unwrap();
327 assert_eq!(code.entity_type, "erb_expression");
328 assert_eq!(code.start_line, 14);
329
330 assert!(!names.iter().any(|n| n.contains("comment")));
332
333 assert!(!names.iter().any(|n| *n == "else"));
335 }
336
337 #[test]
338 fn test_erb_nested_blocks() {
339 let erb = r#"<% if @show %>
340 <% @items.each do |item| %>
341 <%= item %>
342 <% end %>
343<% end %>
344"#;
345 let plugin = ErbParserPlugin;
346 let entities = plugin.extract_entities(erb, "nested.html.erb");
347
348 let blocks: Vec<&SemanticEntity> = entities
349 .iter()
350 .filter(|e| e.entity_type == "erb_block")
351 .collect();
352 assert_eq!(blocks.len(), 2, "Should have 2 blocks: {:?}",
353 blocks.iter().map(|b| &b.name).collect::<Vec<_>>());
354
355 let each = blocks.iter().find(|b| b.name.contains("each")).unwrap();
357 assert_eq!(each.start_line, 2);
358 assert_eq!(each.end_line, 4);
359
360 let if_block = blocks.iter().find(|b| b.name.contains("if")).unwrap();
362 assert_eq!(if_block.start_line, 1);
363 assert_eq!(if_block.end_line, 5);
364 }
365
366 #[test]
367 fn test_erb_template_name() {
368 assert_eq!(extract_template_name("views/best.html.erb"), "best.html");
369 assert_eq!(extract_template_name("loading.erb"), "loading");
370 assert_eq!(extract_template_name("app/views/_partial.html.erb"), "_partial.html");
371 }
372
373 #[test]
374 fn test_erb_dash_variant() {
375 let erb = r#"<header>
377 <%- if @show %>
378 <%= @title %>
379 <%- else %>
380 <p>nope</p>
381 <%- end if %>
382</header>
383"#;
384 let plugin = ErbParserPlugin;
385 let entities = plugin.extract_entities(erb, "test.html.erb");
386
387 let names: Vec<&str> = entities.iter().map(|e| e.name.as_str()).collect();
388 let types: Vec<&str> = entities.iter().map(|e| e.entity_type.as_str()).collect();
389 eprintln!("Dash variant: {:?}",
390 names.iter().zip(types.iter()).collect::<Vec<_>>());
391
392 let if_block = entities.iter().find(|e| e.name == "if @show").unwrap();
394 assert_eq!(if_block.entity_type, "erb_block");
395 assert_eq!(if_block.start_line, 2);
396 assert_eq!(if_block.end_line, 6);
397
398 assert!(!names.iter().any(|n| *n == "else"));
400 }
401
402 #[test]
403 fn test_erb_duplicate_expressions() {
404 let erb = r#"<%= @title %>
405<%= @title %>
406"#;
407 let plugin = ErbParserPlugin;
408 let entities = plugin.extract_entities(erb, "test.erb");
409
410 let exprs: Vec<&SemanticEntity> = entities
411 .iter()
412 .filter(|e| e.entity_type == "erb_expression")
413 .collect();
414 assert_eq!(exprs.len(), 2);
415 assert_eq!(exprs[0].name, "@title");
416 assert_eq!(exprs[1].name, "@title#2");
417 }
418}