use std::time::{Instant, SystemTime, UNIX_EPOCH};
use regex::Regex;
use std::sync::LazyLock;
use crate::extraction::grammars::{is_language_supported, language_key};
use crate::extraction::tree_sitter::extract_from_source;
use crate::extraction::tree_sitter_helpers::generate_node_id;
use crate::types::{
Edge, EdgeKind, ExtractionError, ExtractionResult, ExtractionSeverity, Language, Node,
NodeKind, ReferenceKind, UnresolvedReference,
};
const SVELTE_RUNES: &[&str] = &[
"$props",
"$state",
"$derived",
"$effect",
"$bindable",
"$inspect",
"$host",
"$snippet",
];
static SCRIPT_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)<script(\s[^>]*)?>(.*?)</script>").unwrap());
static LANG_TS_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"lang\s*=\s*["'](ts|typescript)["']"#).unwrap());
static CONTEXT_MODULE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"context\s*=\s*["']module["']"#).unwrap());
static BLOCK_OPEN_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"<(script|style)(\s[^>]*)?>").unwrap());
static TEMPLATE_EXPR_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{([^}#/:@][^}]*)\}").unwrap());
static CALL_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b([a-zA-Z_$][A-Za-z0-9_$.]*)\s*\(").unwrap());
static COMPONENT_TAG_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"<([A-Z][a-zA-Z0-9_$]*)\b").unwrap());
#[derive(Debug, Clone)]
struct ScriptBlock {
content: String,
start_line: usize,
is_module: bool,
is_type_script: bool,
}
pub struct SvelteExtractor {
file_path: String,
source: String,
nodes: Vec<Node>,
edges: Vec<Edge>,
unresolved_references: Vec<UnresolvedReference>,
errors: Vec<ExtractionError>,
}
impl SvelteExtractor {
pub fn new(file_path: impl Into<String>, source: impl Into<String>) -> Self {
Self {
file_path: file_path.into(),
source: source.into(),
nodes: Vec::new(),
edges: Vec::new(),
unresolved_references: Vec::new(),
errors: Vec::new(),
}
}
pub fn extract(mut self) -> ExtractionResult {
let start = Instant::now();
let component_node = self.create_component_node();
let script_blocks = self.extract_script_blocks();
for block in &script_blocks {
self.process_script_block(block, &component_node.id);
}
self.extract_template_calls(&component_node.id, &script_blocks);
self.extract_template_components(&component_node.id);
self.unresolved_references
.retain(|reference| !SVELTE_RUNES.contains(&reference.reference_name.as_str()));
ExtractionResult {
nodes: self.nodes,
edges: self.edges,
unresolved_references: self.unresolved_references,
errors: self.errors,
duration_ms: start.elapsed().as_millis() as u64,
}
}
fn create_component_node(&mut self) -> Node {
let lines = self.source.split('\n').collect::<Vec<_>>();
let file_name = basename(&self.file_path);
let component_name = file_name
.strip_suffix(".svelte")
.unwrap_or(&file_name)
.to_owned();
let id = generate_node_id(&self.file_path, NodeKind::Component, &component_name, 1);
let node = Node {
id,
kind: NodeKind::Component,
name: component_name.clone(),
qualified_name: format!("{}::{}", self.file_path, component_name),
file_path: self.file_path.clone(),
language: Language::Svelte,
start_line: 1,
end_line: lines.len() as u64,
start_column: 0,
end_column: lines.last().map_or(0, |line| line.len() as u64),
docstring: None,
signature: None,
visibility: None,
is_exported: Some(true),
is_async: None,
is_static: None,
is_abstract: None,
decorators: None,
type_parameters: None,
return_type: None,
updated_at: now_ms(),
};
self.nodes.push(node.clone());
node
}
fn extract_script_blocks(&self) -> Vec<ScriptBlock> {
SCRIPT_REGEX
.captures_iter(&self.source)
.filter_map(|caps| {
let full = caps.get(0)?;
let attrs = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let content = caps.get(2).map(|m| m.as_str()).unwrap_or("").to_owned();
let before_script = &self.source[..full.start()];
let script_tag_line = before_script.matches('\n').count();
let opening_tag = full
.as_str()
.split_once('>')
.map(|(tag, _)| format!("{tag}>"))
.unwrap_or_default();
let opening_tag_lines = opening_tag.matches('\n').count();
Some(ScriptBlock {
content,
start_line: script_tag_line + opening_tag_lines,
is_module: CONTEXT_MODULE_REGEX.is_match(attrs),
is_type_script: LANG_TS_REGEX.is_match(attrs),
})
})
.collect()
}
fn process_script_block(&mut self, block: &ScriptBlock, component_node_id: &str) {
let _is_module = block.is_module;
let script_language = if block.is_type_script {
Language::TypeScript
} else {
Language::JavaScript
};
if !is_language_supported(script_language) {
self.errors.push(ExtractionError {
message: format!(
"Parser for {} not available, cannot parse Svelte script block",
language_key(&script_language)
),
file_path: None,
line: None,
column: None,
severity: ExtractionSeverity::Warning,
code: None,
});
return;
}
let result =
extract_from_source(&self.file_path, &block.content, Some(script_language), None);
self.merge_embedded_result(
result,
component_node_id,
block.start_line,
Language::Svelte,
);
}
fn merge_embedded_result(
&mut self,
result: ExtractionResult,
component_node_id: &str,
line_offset: usize,
language: Language,
) {
for mut node in result.nodes {
node.start_line += line_offset as u64;
node.end_line += line_offset as u64;
node.language = language;
let node_id = node.id.clone();
self.nodes.push(node);
self.edges.push(contains_edge(component_node_id, &node_id));
}
for mut edge in result.edges {
if let Some(line) = edge.line.as_mut() {
*line += line_offset as u64;
}
self.edges.push(edge);
}
for mut reference in result.unresolved_references {
reference.line += line_offset as u64;
reference.file_path = Some(self.file_path.clone());
reference.language = Some(language);
self.unresolved_references.push(reference);
}
for mut error in result.errors {
if let Some(line) = error.line.as_mut() {
*line += line_offset as u64;
}
self.errors.push(error);
}
}
fn extract_template_calls(&mut self, component_node_id: &str, _script_blocks: &[ScriptBlock]) {
let covered_ranges = covered_tag_ranges(&self.source);
let lines = self.source.split('\n').collect::<Vec<_>>();
for (line_idx, line) in lines.iter().enumerate() {
if covered_ranges
.iter()
.any(|(start, end)| line_idx >= *start && line_idx <= *end)
{
continue;
}
for expr_match in TEMPLATE_EXPR_REGEX.captures_iter(line) {
let Some(full) = expr_match.get(0) else {
continue;
};
let Some(expr) = expr_match.get(1).map(|m| m.as_str()) else {
continue;
};
for call_match in CALL_REGEX.captures_iter(expr) {
let Some(call_full) = call_match.get(0) else {
continue;
};
let Some(callee_name) = call_match.get(1).map(|m| m.as_str()) else {
continue;
};
if SVELTE_RUNES.contains(&callee_name)
|| matches!(callee_name, "if" | "else" | "each" | "await")
{
continue;
}
self.unresolved_references.push(UnresolvedReference {
from_node_id: component_node_id.to_owned(),
reference_name: callee_name.to_owned(),
reference_kind: ReferenceKind::Calls,
line: (line_idx + 1) as u64,
column: (full.start() + call_full.start()) as u64,
file_path: Some(self.file_path.clone()),
language: Some(Language::Svelte),
candidates: None,
});
}
}
}
}
fn extract_template_components(&mut self, component_node_id: &str) {
let covered_ranges = covered_tag_ranges(&self.source);
let lines = self.source.split('\n').collect::<Vec<_>>();
for (line_idx, line) in lines.iter().enumerate() {
if covered_ranges
.iter()
.any(|(start, end)| line_idx >= *start && line_idx <= *end)
{
continue;
}
for caps in COMPONENT_TAG_REGEX.captures_iter(line) {
let Some(full) = caps.get(0) else {
continue;
};
let Some(component_name) = caps.get(1).map(|m| m.as_str()) else {
continue;
};
self.unresolved_references.push(UnresolvedReference {
from_node_id: component_node_id.to_owned(),
reference_name: component_name.to_owned(),
reference_kind: ReferenceKind::References,
line: (line_idx + 1) as u64,
column: (full.start() + 1) as u64,
file_path: Some(self.file_path.clone()),
language: Some(Language::Svelte),
candidates: None,
});
}
}
}
}
fn covered_tag_ranges(source: &str) -> Vec<(usize, usize)> {
let mut ranges = Vec::new();
let mut search_start = 0usize;
while let Some(caps) = BLOCK_OPEN_REGEX.captures(&source[search_start..]) {
let Some(open) = caps.get(0) else {
break;
};
let Some(tag) = caps.get(1).map(|m| m.as_str()) else {
break;
};
let start = search_start + open.start();
let open_end = search_start + open.end();
let close_tag = format!("</{tag}>");
let end = source[open_end..]
.find(&close_tag)
.map(|idx| open_end + idx + close_tag.len())
.unwrap_or(open_end);
let start_line = source[..start].matches('\n').count();
let end_line = start_line + source[start..end].matches('\n').count();
ranges.push((start_line, end_line));
search_start = end;
}
ranges
}
fn contains_edge(source: &str, target: &str) -> Edge {
Edge {
source: source.to_owned(),
target: target.to_owned(),
kind: EdgeKind::Contains,
metadata: None,
line: None,
column: None,
provenance: None,
}
}
fn basename(path: &str) -> String {
path.rsplit(['/', '\\']).next().unwrap_or(path).to_owned()
}
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or(0)
}