use tree_sitter::Node;
use crate::model::{Edge, EdgeType, Language, Node as ModelNode, NodeLabel};
use crate::resolve::FqnGenerator;
use super::dedupe_qn;
use super::error::{ParseError, Result};
use super::extractor::{ExtractResult, Extractor};
use super::parser_factory::ParserFactory;
pub struct CssExtractor {
_priv: (),
}
impl CssExtractor {
#[must_use]
pub const fn new() -> Self {
Self { _priv: () }
}
}
impl Default for CssExtractor {
fn default() -> Self {
Self::new()
}
}
impl Extractor for CssExtractor {
fn language(&self) -> Language {
Language::Css
}
fn extract(&self, source: &str, file_path: &str, project: &str) -> Result<ExtractResult> {
let mut result = ExtractResult::new(file_path, Language::Css);
let mut parser = ParserFactory::create_parser(Language::Css)?;
let tree = parser
.parse(source, None)
.ok_or_else(|| ParseError::ParseFailed {
file_path: file_path.to_string(),
})?;
let root = tree.root_node();
let ctx = VisitContext { file_path, project };
for i in 0..root.named_child_count() as u32 {
if let Some(child) = root.named_child(i) {
visit_node(child, source, &ctx, &mut result);
}
}
Ok(result)
}
}
struct VisitContext<'a> {
file_path: &'a str,
project: &'a str,
}
fn visit_node(node: Node, source: &str, ctx: &VisitContext<'_>, result: &mut ExtractResult) {
match node.kind() {
"rule_set" => {
extract_rule_set(node, source, ctx, result);
visit_children(node, source, ctx, result);
}
"media_statement" => {
extract_media_statement(node, source, ctx, result);
visit_children(node, source, ctx, result);
}
_ => {
visit_children(node, source, ctx, result);
}
}
}
fn visit_children(node: Node, source: &str, ctx: &VisitContext<'_>, result: &mut ExtractResult) {
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
visit_node(child, source, ctx, result);
}
}
}
fn extract_rule_set(node: Node, source: &str, ctx: &VisitContext<'_>, result: &mut ExtractResult) {
let Some(selector_text) = rule_set_selector(node, source) else {
return;
};
let qn = dedupe_qn(
make_qn(ctx.file_path, &selector_text, ctx.project),
node.start_position().row as u32 + 1,
result,
);
let model_node = ModelNode::builder(NodeLabel::Class, selector_text, qn)
.file_path(ctx.file_path)
.start_line(node.start_position().row as u32 + 1)
.end_line(node.end_position().row as u32 + 1)
.language(Language::Css)
.project(ctx.project)
.is_global(true)
.build();
add_definition_edges(ctx.file_path, ctx.project, &model_node, result);
result.push_node(model_node);
}
fn extract_media_statement(
node: Node,
_source: &str,
ctx: &VisitContext<'_>,
result: &mut ExtractResult,
) {
let line = node.start_position().row as u32 + 1;
let name = format!("media_L{line}");
let qn = dedupe_qn(make_qn(ctx.file_path, &name, ctx.project), line, result);
let model_node = ModelNode::builder(NodeLabel::Namespace, name, qn)
.file_path(ctx.file_path)
.start_line(line)
.end_line(node.end_position().row as u32 + 1)
.language(Language::Css)
.project(ctx.project)
.is_global(true)
.build();
add_definition_edges(ctx.file_path, ctx.project, &model_node, result);
result.push_node(model_node);
}
fn rule_set_selector(node: Node, source: &str) -> Option<String> {
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
if child.kind() == "selectors" {
if let Some(first) = child.named_child(0) {
return node_text(first, source).map(String::from);
}
}
}
}
None
}
fn node_text<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
node.utf8_text(source.as_bytes()).ok()
}
fn make_qn(file_path: &str, name: &str, project: &str) -> String {
FqnGenerator::generate(project, file_path, name, Language::Css, None)
}
fn add_definition_edges(
file_path: &str,
project: &str,
node: &ModelNode,
result: &mut ExtractResult,
) {
result.edges.push(Edge::new(
file_path.to_string(),
node.id.clone(),
EdgeType::Defines,
project,
));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::NodeLabel;
fn extract(source: &str) -> ExtractResult {
let ext = CssExtractor::new();
ext.extract(source, "test.css", "proj")
.expect("extraction should succeed")
}
#[test]
fn language_returns_css() {
assert_eq!(CssExtractor::new().language(), Language::Css);
}
#[test]
fn default_creates_extractor() {
let ext = CssExtractor::default();
assert_eq!(ext.language(), Language::Css);
}
#[test]
fn extracts_tag_selector_rule() {
let result = extract("body { color: red; }\n");
let rules: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Class)
.collect();
assert_eq!(
rules.len(),
1,
"should extract 1 rule_set: {:?}",
result.nodes
);
assert_eq!(rules[0].name, "body");
assert_eq!(rules[0].language, Some(Language::Css));
assert_eq!(rules[0].project, "proj");
assert!(rules[0].is_global);
}
#[test]
fn extracts_class_selector_rule() {
let result = extract(".foo { color: blue; }\n");
let rule = result
.nodes
.iter()
.find(|n| n.label == NodeLabel::Class)
.expect("should find class rule");
assert_eq!(rule.name, ".foo");
}
#[test]
fn extracts_id_selector_rule() {
let result = extract("#main { color: green; }\n");
let rule = result
.nodes
.iter()
.find(|n| n.label == NodeLabel::Class)
.expect("should find id rule");
assert_eq!(rule.name, "#main");
}
#[test]
fn extracts_multiple_rules() {
let result = extract("body { color: red; }\n.foo { color: blue; }\n");
let rules: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Class)
.collect();
assert_eq!(rules.len(), 2, "should extract 2 rule_sets");
let names: Vec<_> = rules.iter().map(|n| n.name.as_str()).collect();
assert!(names.contains(&"body"), "should contain body: {:?}", names);
assert!(names.contains(&".foo"), "should contain .foo: {:?}", names);
}
#[test]
fn extracts_media_statement() {
let result = extract("@media screen { body { color: red; } }\n");
let media: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Namespace)
.collect();
assert_eq!(
media.len(),
1,
"should extract 1 media_statement: {:?}",
result.nodes
);
assert!(
media[0].name.starts_with("media_L"),
"media name should start with media_L: {}",
media[0].name
);
}
#[test]
fn media_statement_contains_nested_rule() {
let result = extract("@media screen { body { color: red; } }\n");
let body_rule = result
.nodes
.iter()
.find(|n| n.label == NodeLabel::Class && n.name == "body");
assert!(
body_rule.is_some(),
"media_statement should contain nested body rule: {:?}",
result.nodes
);
}
#[test]
fn empty_source_returns_empty_result() {
let result = extract("");
assert!(result.is_empty());
}
#[test]
fn result_language_is_css() {
let result = extract("body { color: red; }\n");
assert_eq!(result.language, Language::Css);
assert_eq!(result.file_path, "test.css");
}
#[test]
fn creates_defines_edges() {
let result = extract("body { color: red; }\n.foo { color: blue; }\n");
let defines_count = result
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::Defines)
.count();
let node_count = result.nodes.len();
assert_eq!(defines_count, node_count, "one DEFINES edge per node");
}
#[test]
fn qualified_name_uses_file_path_and_selector() {
let result = extract("body { color: red; }\n");
let body = result.nodes.iter().find(|n| n.name == "body").unwrap();
assert_eq!(body.qualified_name, "proj.test.css.body");
}
#[test]
fn duplicate_selectors_get_disambiguated_fqn() {
let result = extract("body { color: red; }\nbody { color: blue; }\n");
let bodies: Vec<_> = result.nodes.iter().filter(|n| n.name == "body").collect();
assert_eq!(bodies.len(), 2, "should extract 2 body rules");
assert_ne!(
bodies[0].qualified_name, bodies[1].qualified_name,
"duplicate selectors should have distinct FQNs"
);
}
}