use crate::models::{EntityKind, ParsedEntity, ReferenceIntent};
use crate::pipeline::parser::utils::node_text;
use tree_sitter::Node;
use uuid::Uuid;
fn parse_html_attribute_node(attr_node: Node<'_>, source: &[u8]) -> (String, String) {
let mut attr_name = String::new();
let mut attr_value = String::new();
for attr_part in attr_node.children(&mut attr_node.walk()) {
match attr_part.kind() {
"attribute_name" => {
attr_name =
String::from_utf8_lossy(&source[attr_part.start_byte()..attr_part.end_byte()])
.to_string();
}
"quoted_attribute_value" => {
let raw_value =
String::from_utf8_lossy(&source[attr_part.start_byte()..attr_part.end_byte()])
.to_string();
attr_value = raw_value
.trim_start_matches('"')
.trim_start_matches('\'')
.trim_end_matches('"')
.trim_end_matches('\'')
.to_string();
}
_ => {}
}
}
(attr_name, attr_value)
}
#[expect(
clippy::too_many_lines,
reason = "function is verbose but correct — extraction deferred"
)]
#[expect(
clippy::too_many_arguments,
reason = "function is verbose but correct — extraction deferred"
)]
fn extract_html_attribute(
attr_node: Node<'_>,
source: &[u8],
entities: &mut Vec<ParsedEntity>,
line: usize,
file_path: &str,
repo_name: &str,
) {
let mut attr_name = String::new();
let mut attr_value = String::new();
let mut child = attr_node.child(0);
while let Some(c) = child {
match c.kind() {
"attribute_name" => {
attr_name = node_text(c, source);
}
"quoted_attribute_value" => {
let mut value_child = c.child(0);
while let Some(vc) = value_child {
if vc.kind() == "attribute_value" {
attr_value = node_text(vc, source);
break;
}
value_child = vc.next_sibling();
}
}
_ => {}
}
child = c.next_sibling();
}
if attr_name == "id" && !attr_value.is_empty() {
entities.push(ParsedEntity {
uuid: Uuid::new_v4(),
name: attr_value.clone(),
kind: EntityKind::HtmlId,
fqn: format!("#{}", attr_value),
signature: None,
docstring: None,
inline_comments: Vec::new(),
decorators: Vec::new(),
language: "html".to_string(),
file_path: file_path.to_string(),
start_line: line,
end_line: line,
enclosing_class: None,
repo_name: repo_name.to_string(),
reference_intents: Vec::new(),
calls: Vec::new(),
relationships: Vec::new(),
embed_text: String::new(),
rust_attributes: None,
impl_trait: None,
impl_target: None,
generics: None,
lifetimes: None,
alias_module_path: None,
original_export_name: None,
enclosing_class_fqn: None,
default_export: None,
is_test_context: false,
});
} else if attr_name == "class" && !attr_value.is_empty() {
for class_name in attr_value.split_whitespace() {
if !class_name.is_empty() {
entities.push(ParsedEntity {
uuid: Uuid::new_v4(),
name: class_name.to_string(),
kind: EntityKind::HtmlClass,
fqn: format!(".{}", class_name),
signature: None,
docstring: None,
inline_comments: Vec::new(),
decorators: Vec::new(),
language: "html".to_string(),
file_path: file_path.to_string(),
start_line: line,
end_line: line,
enclosing_class: None,
repo_name: repo_name.to_string(),
reference_intents: Vec::new(),
calls: Vec::new(),
relationships: Vec::new(),
embed_text: String::new(),
rust_attributes: None,
impl_trait: None,
impl_target: None,
generics: None,
lifetimes: None,
alias_module_path: None,
original_export_name: None,
enclosing_class_fqn: None,
default_export: None,
is_test_context: false,
});
}
}
}
}
fn extract_html_elements(
node: Node<'_>,
source: &[u8],
entities: &mut Vec<ParsedEntity>,
file_path: &str,
repo_name: &str,
) {
if node.kind() == "element" {
let line = node.start_position().row + 1;
let mut child = node.child(0);
while let Some(c) = child {
if c.kind() == "start_tag" {
let mut tag_child = c.child(0);
while let Some(tc) = tag_child {
match tc.kind() {
"tag_name" => {
let tag_name = node_text(tc, source);
#[expect(
clippy::excessive_nesting,
reason = "function is verbose but correct — extraction deferred"
)]
if tag_name.contains('-') {
entities.push(ParsedEntity {
uuid: Uuid::new_v4(),
name: tag_name.clone(),
kind: EntityKind::HtmlElement,
fqn: format!("<{}>", tag_name),
signature: None,
docstring: None,
inline_comments: Vec::new(),
decorators: Vec::new(),
language: "html".to_string(),
file_path: file_path.to_string(),
start_line: line,
end_line: line,
enclosing_class: None,
repo_name: repo_name.to_string(),
reference_intents: Vec::new(),
calls: Vec::new(),
relationships: Vec::new(),
embed_text: String::new(),
rust_attributes: None,
impl_trait: None,
impl_target: None,
generics: None,
lifetimes: None,
alias_module_path: None,
original_export_name: None,
enclosing_class_fqn: None,
default_export: None,
is_test_context: false,
});
}
}
"attribute" => {
extract_html_attribute(
tc, source, entities, line, file_path, repo_name,
);
}
_ => {}
}
tag_child = tc.next_sibling();
}
}
child = c.next_sibling();
}
}
let mut child = node.child(0);
while let Some(c) = child {
extract_html_elements(c, source, entities, file_path, repo_name);
child = c.next_sibling();
}
}
pub(crate) fn extract_entities_html(
root: Node<'_>,
source: &[u8],
file_path: &str,
repo_name: &str,
) -> Vec<ParsedEntity> {
let mut entities = Vec::new();
extract_html_elements(root, source, &mut entities, file_path, repo_name);
extract_html_file_imports(root, source, &mut entities, file_path, repo_name);
entities
}
pub(crate) fn handle_html_capture(
cap_name: &str,
text: &str,
node: Node<'_>,
) -> Option<(String, EntityKind, usize)> {
let start_line = node.start_position().row + 1;
match cap_name {
"html_id_value" => {
let clean_id = text
.trim_start_matches('"')
.trim_start_matches('\'')
.trim_end_matches('"')
.trim_end_matches('\'')
.to_string();
Some((clean_id, EntityKind::HtmlId, start_line))
}
"html_class_value" => {
let clean_class = text
.trim_start_matches('"')
.trim_start_matches('\'')
.trim_end_matches('"')
.trim_end_matches('\'')
.to_string();
if !clean_class.is_empty() {
Some((clean_class, EntityKind::HtmlClass, start_line))
} else {
None
}
}
"html_element_name" => Some((text.to_string(), EntityKind::HtmlElement, start_line)),
_ => None,
}
}
#[expect(
clippy::too_many_lines,
reason = "function is verbose but correct — extraction deferred"
)]
#[expect(
clippy::cognitive_complexity,
reason = "function is verbose but correct — extraction deferred"
)]
fn extract_html_file_imports(
node: Node<'_>,
source: &[u8],
entities: &mut Vec<ParsedEntity>,
file_path: &str,
repo_name: &str,
) {
if node.kind() == "script_element" {
for child in node.children(&mut node.walk()) {
if child.kind() == "start_tag" {
for attr_child in child.children(&mut child.walk()) {
if attr_child.kind() == "attribute" {
let (attr_name, attr_value) = parse_html_attribute_node(attr_child, source);
#[expect(
clippy::excessive_nesting,
reason = "function is verbose but correct — extraction deferred"
)]
if attr_name == "src" && !attr_value.is_empty() {
let line = attr_child.start_position().row + 1;
let mut entity = ParsedEntity::new(
format!("import({})", attr_value),
EntityKind::Function,
format!("{}::import({})", file_path, attr_value),
None,
None,
"html",
file_path,
line,
line,
None,
repo_name,
);
entity
.reference_intents
.push(ReferenceIntent::HtmlFileImport {
file_path: attr_value,
line: attr_child.start_position().row + 1,
});
entities.push(entity);
}
}
}
}
}
}
else if node.kind() == "element" {
let tag_name = if let Some(start_tag) = node.child(0).filter(|n| n.kind() == "start_tag") {
let mut found_tag = String::new();
for child in start_tag.children(&mut start_tag.walk()) {
if child.kind() == "tag_name" {
found_tag =
String::from_utf8_lossy(&source[child.start_byte()..child.end_byte()])
.to_string();
break;
}
}
found_tag
} else {
String::new()
};
if tag_name == "link" {
let mut is_stylesheet = false;
let mut href_value = String::new();
for child in node.children(&mut node.walk()) {
if child.kind() == "start_tag" {
for attr_child in child.children(&mut child.walk()) {
#[expect(
clippy::excessive_nesting,
reason = "function is verbose but correct — extraction deferred"
)]
if attr_child.kind() == "attribute" {
let (attr_name, attr_value) =
parse_html_attribute_node(attr_child, source);
if attr_name == "rel" && attr_value.contains("stylesheet") {
is_stylesheet = true;
}
if attr_name == "href" {
href_value = attr_value;
}
}
}
}
}
if is_stylesheet && !href_value.is_empty() {
let line = node.start_position().row + 1;
let mut entity = ParsedEntity::new(
format!("import({})", href_value),
EntityKind::Constant,
format!("{}::import({})", file_path, href_value),
None,
None,
"html",
file_path,
line,
line,
None,
repo_name,
);
entity
.reference_intents
.push(ReferenceIntent::CssFileImport {
file_path: href_value,
line: node.start_position().row + 1,
});
entities.push(entity);
}
}
}
let mut child = node.child(0);
while let Some(c) = child {
extract_html_file_imports(c, source, entities, file_path, repo_name);
child = c.next_sibling();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_html_snippet(source: &str) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter_html::LANGUAGE.into())
.expect("Failed to load HTML grammar");
parser.parse(source, None).expect("Failed to parse HTML")
}
#[test]
fn test_extract_custom_elements() {
let source = r#"
<app-user-profile id="profile-main" class="card shadow">
<p>User content</p>
</app-user-profile>
"#;
let tree = parse_html_snippet(source);
let entities = extract_entities_html(
tree.root_node(),
source.as_bytes(),
"/test/template.html",
"test_repo",
);
let elements: Vec<_> = entities
.iter()
.filter(|e| e.kind == EntityKind::HtmlElement)
.collect();
assert_eq!(elements.len(), 1);
assert_eq!(elements[0].name, "app-user-profile");
let ids: Vec<_> = entities
.iter()
.filter(|e| e.kind == EntityKind::HtmlId)
.collect();
assert_eq!(ids.len(), 1);
assert_eq!(ids[0].name, "profile-main");
let classes: Vec<_> = entities
.iter()
.filter(|e| e.kind == EntityKind::HtmlClass)
.collect();
assert_eq!(classes.len(), 2);
assert!(classes.iter().any(|c| c.name == "card"));
assert!(classes.iter().any(|c| c.name == "shadow"));
}
#[test]
fn test_extract_id_attributes() {
let source = r#"<div id="app-root" class="container">Content</div>"#;
let tree = parse_html_snippet(source);
let entities = extract_entities_html(
tree.root_node(),
source.as_bytes(),
"/test/index.html",
"test_repo",
);
let ids: Vec<_> = entities
.iter()
.filter(|e| e.kind == EntityKind::HtmlId)
.collect();
assert_eq!(ids.len(), 1);
assert_eq!(ids[0].name, "app-root");
assert_eq!(ids[0].fqn, "#app-root");
}
#[test]
fn test_split_class_attributes() {
let source = r#"<div class="btn btn-primary btn-lg">Button</div>"#;
let tree = parse_html_snippet(source);
let entities = extract_entities_html(
tree.root_node(),
source.as_bytes(),
"/test/button.html",
"test_repo",
);
let classes: Vec<_> = entities
.iter()
.filter(|e| e.kind == EntityKind::HtmlClass)
.collect();
assert_eq!(classes.len(), 3);
assert!(classes.iter().any(|c| c.name == "btn"));
assert!(classes.iter().any(|c| c.name == "btn-primary"));
assert!(classes.iter().any(|c| c.name == "btn-lg"));
assert!(classes.iter().all(|c| c.fqn.starts_with('.')));
}
#[test]
fn test_ignore_standard_html_elements() {
let source = r#"<div><span>Content</span></div>"#;
let tree = parse_html_snippet(source);
let entities = extract_entities_html(
tree.root_node(),
source.as_bytes(),
"/test/standard.html",
"test_repo",
);
let elements: Vec<_> = entities
.iter()
.filter(|e| e.kind == EntityKind::HtmlElement)
.collect();
assert_eq!(elements.len(), 0);
}
#[test]
fn test_extract_multiple_custom_elements() {
let source = r#"
<my-header id="header">
<my-nav class="main-nav"></my-nav>
</my-header>
<my-footer class="footer sticky"></my-footer>
"#;
let tree = parse_html_snippet(source);
let entities = extract_entities_html(
tree.root_node(),
source.as_bytes(),
"/test/layout.html",
"test_repo",
);
let elements: Vec<_> = entities
.iter()
.filter(|e| e.kind == EntityKind::HtmlElement)
.collect();
assert_eq!(elements.len(), 3);
assert!(elements.iter().any(|e| e.name == "my-header"));
assert!(elements.iter().any(|e| e.name == "my-nav"));
assert!(elements.iter().any(|e| e.name == "my-footer"));
let ids: Vec<_> = entities
.iter()
.filter(|e| e.kind == EntityKind::HtmlId)
.collect();
assert_eq!(ids.len(), 1);
assert_eq!(ids[0].name, "header");
let classes: Vec<_> = entities
.iter()
.filter(|e| e.kind == EntityKind::HtmlClass)
.collect();
assert_eq!(classes.len(), 3);
assert!(classes.iter().any(|c| c.name == "main-nav"));
assert!(classes.iter().any(|c| c.name == "footer"));
assert!(classes.iter().any(|c| c.name == "sticky"));
}
}