use crate::facts::{Declaration, DeclarationKind, Facts, Import, Reference, ReferenceKind, Span};
use crate::syntax::Language;
use crate::token::{Mode, Token, TokenKind, Tokenizer};
#[must_use]
pub fn extract(source: &str, language: Language) -> Facts {
let tokens = Tokenizer::new(source, language)
.mode(Mode::Lite)
.collect::<Vec<_>>();
let mut state = Extractor {
source,
tokens: &tokens,
facts: Facts::default(),
nesting: Vec::new(),
};
state.run();
state.facts
}
const AT_IMPORTS: &[&str] = &["import", "use", "forward"];
struct Extractor<'source, 'tokens> {
source: &'source str,
tokens: &'tokens [Token],
facts: Facts,
nesting: Vec<String>,
}
impl Extractor<'_, '_> {
fn run(&mut self) {
let mut index = 0;
while index < self.tokens.len() {
index = self.step(index);
}
}
fn text(&self, index: usize) -> &str {
self.tokens
.get(index)
.map_or("", |token| token.text(self.source))
}
fn kind(&self, index: usize) -> Option<TokenKind> {
self.tokens.get(index).map(|token| token.kind)
}
fn punct(&self, index: usize, mark: &str) -> bool {
self.kind(index) == Some(TokenKind::Punctuation) && self.text(index) == mark
}
fn span(&self, start: usize, end: usize) -> Span {
let last_index = self.tokens.len().saturating_sub(1);
let first = &self.tokens[start.min(last_index)];
let last = &self.tokens[end.min(last_index)];
Span {
start: first.start,
end: last.end,
line: first.line,
column: first.column,
end_line: last.line,
end_column: last.column,
}
}
fn step(&mut self, index: usize) -> usize {
if self.punct(index, "}") {
self.nesting.pop();
return index + 1;
}
if (self.punct(index, "@") || self.text(index).starts_with('@'))
&& let Some(next) = self.at_rule(index)
{
return next;
}
if let Some(open) = self.selector_block(index) {
return open;
}
index + 1
}
fn at_rule(&mut self, index: usize) -> Option<usize> {
let (keyword, mut cursor) = if self.punct(index, "@") {
(self.text(index + 1).to_owned(), index + 2)
} else {
(
self.text(index).trim_start_matches('@').to_owned(),
index + 1,
)
};
if !AT_IMPORTS
.iter()
.any(|word| word.eq_ignore_ascii_case(&keyword))
{
return None;
}
let limit = (cursor + 64).min(self.tokens.len());
let mut found = false;
while cursor < limit && !self.punct(cursor, ";") && !self.punct(cursor, "{") {
if self.kind(cursor) == Some(TokenKind::String) {
let specifier = self.text(cursor).trim_matches(['"', '\'']).to_owned();
if !specifier.is_empty() {
self.facts.imports.push(Import {
specifier,
span: self.span(index, cursor),
type_only: false,
reexport: keyword.eq_ignore_ascii_case("forward"),
names: Vec::new(),
bindings: Vec::new(),
});
found = true;
}
}
cursor += 1;
}
found.then_some(cursor)
}
fn selector_block(&mut self, start: usize) -> Option<usize> {
let limit = (start + 256).min(self.tokens.len());
let mut cursor = start;
while cursor < limit {
if self.punct(cursor, "{") {
break;
}
if self.punct(cursor, ";") || self.punct(cursor, "}") {
return None;
}
cursor += 1;
}
if cursor >= limit || !self.punct(cursor, "{") {
return None;
}
let parent = self.nesting.last().cloned().unwrap_or_default();
let mut last_selector = String::new();
let mut scan = start;
while scan < cursor {
if let Some((name, after)) = self.selector_at(scan, &parent) {
self.facts.declarations.push(Declaration {
name: name.clone(),
kind: DeclarationKind::Selector,
span: self.span(scan, after.saturating_sub(1)),
owner: (!parent.is_empty()).then(|| parent.clone()),
exported: true,
});
last_selector = name;
scan = after;
continue;
}
scan += 1;
}
self.nesting.push(if last_selector.is_empty() {
parent
} else {
last_selector
});
Some(cursor + 1)
}
fn selector_at(&self, index: usize, parent: &str) -> Option<(String, usize)> {
if self.punct(index, "&") {
let suffix = self.text(index + 1);
if self.kind(index + 1) == Some(TokenKind::Identifier) && !parent.is_empty() {
return Some((format!("{parent}{suffix}"), index + 2));
}
return None;
}
let marker = if self.punct(index, ".") {
'.'
} else if self.punct(index, "#") {
'#'
} else {
return None;
};
if self.kind(index + 1) != Some(TokenKind::Identifier) {
return None;
}
let name = self.text(index + 1);
if name.starts_with(|character: char| character.is_ascii_digit()) {
return None;
}
Some((format!("{marker}{name}"), index + 2))
}
}
pub(crate) fn selector_use(facts: &mut Facts, name: String, span: Span) {
facts.references.push(Reference {
name,
kind: ReferenceKind::Uses,
receiver: None,
span,
owner: None,
string_arguments: Vec::new(),
name_arguments: Vec::new(),
});
}
#[cfg(test)]
mod tests {
use super::extract;
use crate::facts::DeclarationKind;
use crate::syntax::Language;
fn declared(source: &str, language: Language) -> Vec<String> {
extract(source, language)
.declarations
.into_iter()
.map(|item| item.name)
.collect()
}
#[test]
fn class_and_id_selectors_are_declarations() {
let source = ".panel { color: red; }\n\
#header, .nav .item { margin: 0; }\n\
a:hover { color: blue; }\n";
assert_eq!(
declared(source, Language::Css),
[".panel", "#header", ".nav", ".item"],
"a pseudo-class on a bare element declares no selector"
);
}
#[test]
fn nested_scss_selectors_are_resolved_to_the_names_they_produce() {
let source = ".card {\n\
\x20 color: red;\n\
\x20 &__title { font-weight: bold; }\n\
\x20 &--wide { width: 100%; }\n\
\x20 .inner { padding: 0; }\n\
}\n";
let names = declared(source, Language::Scss);
assert!(names.contains(&".card".to_owned()), "got {names:?}");
assert!(
names.contains(&".card__title".to_owned()),
"an ampersand joins the child onto its parent, got {names:?}"
);
assert!(names.contains(&".card--wide".to_owned()), "got {names:?}");
assert!(names.contains(&".inner".to_owned()), "got {names:?}");
}
#[test]
fn stylesheets_name_the_stylesheets_they_pull_in() {
let source = "@import \"./base.css\";\n\
@use \"sass:math\";\n\
@forward \"./theme\";\n\
.x { background: url(\"./bg.png\"); }\n";
let imports = extract(source, Language::Scss)
.imports
.into_iter()
.map(|import| (import.specifier, import.reexport))
.collect::<Vec<_>>();
assert_eq!(
imports,
[
("./base.css".to_owned(), false),
("sass:math".to_owned(), false),
("./theme".to_owned(), true),
],
"a forward re-exports, and a url() inside a property is not an import"
);
}
#[test]
fn a_comment_declares_no_selector_and_a_decimal_is_not_a_class() {
let source = "/* .ghost { } */\n\
.real { margin: .5em; padding: 0 .25rem; }\n";
assert_eq!(declared(source, Language::Css), [".real"]);
}
#[test]
fn a_double_slash_is_a_comment_in_scss_and_not_in_css() {
let scss = "// .ghost { }\n.real { }\n";
assert_eq!(declared(scss, Language::Scss), [".real"]);
assert_eq!(
extract("// x\n.real { }\n", Language::Css)
.declarations
.len(),
1
);
}
#[test]
fn selectors_carry_the_kind_the_graph_stores_them_under() {
let facts = extract(".only { }\n", Language::Css);
assert_eq!(facts.declarations[0].kind, DeclarationKind::Selector);
}
}