Menu
Public repository / master
AkurAI-Framework / crates/css/src/scan.rs
Latest change 3aae9f4e85241bcb775c31f5c03094becd3da56a - Pure-Rust UI layer: akurai-ui, akurai-crypto, Tailwind-grade akurai-css by Ólafur Búi Ólafsson
//! Class-token scanners.
//!
//! - [`classes`] extracts the class names used in `class="…"` / `class='…'`
//! attributes from HTML/template source. Matching is byte-oriented and
//! quote-agnostic; it requires a word boundary before `class` so identifiers
//! like `className` or `myclass` are not matched.
//! - [`candidates`] extracts every *possible* class token from any source text
//! (Rust string literals, JS, HTML): it splits on whitespace and code
//! punctuation, but keeps a `[...]` arbitrary value together, so
//! `w-[calc(100%-2rem)]` and `data-[state=open]:bg-panel` survive intact.
//!
//! Both return tokens in first-seen order, de-duplicated.
/// Extract the de-duplicated, first-seen-ordered set of class tokens used in
/// `class` attributes within `source`.
pub fn classes(source: &str) -> Vec<String> {
let bytes = source.as_bytes();
let mut out: Vec<String> = Vec::new();
let mut i = 0;
while i < bytes.len() {
match find_class_attr(bytes, i) {
Some((value_start, value_end, next)) => {
let value = &source[value_start..value_end];
for token in value.split_whitespace() {
if !out.iter().any(|c| c == token) {
out.push(token.to_string());
}
}
i = next;
}
None => break,
}
}
out
}
/// Bytes that end a candidate token outside a `[...]` arbitrary value.
const DELIMITERS: &[u8] = b"\"'`,;(){}<>=";
/// Bytes that end a candidate token even inside `[...]`: whitespace and quotes
/// (arbitrary values spell spaces as `_` and never contain raw quotes, so a
/// quote means the bracket was code such as `vec!["p-2"]`, not a value).
fn ends_bracket(b: u8) -> bool {
b.is_ascii_whitespace() || b == b'"' || b == b'\'' || b == b'`'
}
/// Extract every candidate utility token from arbitrary source text, in
/// first-seen order, de-duplicated.
///
/// Tokens are split on whitespace and on `"` `'` `` ` `` `,` `;` `(` `)` `{`
/// `}` `<` `>` `=`. Inside square brackets only whitespace and quotes split, so
/// arbitrary values keep their commas, parentheses and `=`. Most tokens will
/// not be utilities; the generator ignores those.
pub fn candidates(source: &str) -> Vec<String> {
let bytes = source.as_bytes();
let mut out = Unique::default();
let mut start: Option<usize> = None;
let mut depth = 0usize;
for (i, &b) in bytes.iter().enumerate() {
let splits = if depth > 0 {
ends_bracket(b)
} else {
b.is_ascii_whitespace() || DELIMITERS.contains(&b)
};
if splits {
if let Some(s) = start.take() {
out.push(&source[s..i]);
}
depth = 0;
continue;
}
start.get_or_insert(i);
match b {
b'[' => depth += 1,
b']' => depth = depth.saturating_sub(1),
_ => {}
}
}
if let Some(s) = start {
out.push(&source[s..]);
}
out.tokens
}
/// First-seen-ordered set of tokens (linear time for large sources).
#[derive(Default)]
struct Unique<'a> {
seen: std::collections::HashSet<&'a str>,
tokens: Vec<String>,
}
impl<'a> Unique<'a> {
fn push(&mut self, token: &'a str) {
if !token.is_empty() && self.seen.insert(token) {
self.tokens.push(token.to_string());
}
}
}
/// Locate the next `class=…"value"` attribute at or after `from`.
///
/// Returns `(value_start, value_end, next_index)` where the value byte range is
/// the text between the quotes and `next_index` is just past the closing quote.
fn find_class_attr(bytes: &[u8], from: usize) -> Option<(usize, usize, usize)> {
let mut i = from;
while i + 5 <= bytes.len() {
if matches_keyword(bytes, i) {
// Word boundary before `class`: start-of-input or a non-identifier byte.
let boundary = i == 0 || !is_ident_byte(bytes[i - 1]);
if boundary {
let mut j = i + 5;
j = skip_ws(bytes, j);
if j < bytes.len() && bytes[j] == b'=' {
j = skip_ws(bytes, j + 1);
if j < bytes.len() && (bytes[j] == b'"' || bytes[j] == b'\'') {
let quote = bytes[j];
let value_start = j + 1;
if let Some(close) = find_byte(bytes, value_start, quote) {
return Some((value_start, close, close + 1));
}
}
}
}
}
i += 1;
}
None
}
fn matches_keyword(bytes: &[u8], i: usize) -> bool {
bytes[i..].starts_with(b"class")
}
fn skip_ws(bytes: &[u8], mut i: usize) -> usize {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
i
}
fn find_byte(bytes: &[u8], from: usize, target: u8) -> Option<usize> {
(from..bytes.len()).find(|&i| bytes[i] == target)
}
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_attribute_multiple_classes() {
assert_eq!(classes(r#"<div class="a b c">"#), vec!["a", "b", "c"]);
}
#[test]
fn deduplicates_across_attributes() {
let src = r#"<p class="a b"></p><span class="b c"></span>"#;
assert_eq!(classes(src), vec!["a", "b", "c"]);
}
#[test]
fn handles_single_quotes() {
assert_eq!(classes("<div class='x y'>"), vec!["x", "y"]);
}
#[test]
fn ignores_non_class_identifiers() {
// `className` (JS) and a stray `myclass=` must not match.
let src = r#"<div className="nope" myclass="no"><b class="yes">"#;
assert_eq!(classes(src), vec!["yes"]);
}
#[test]
fn tolerates_whitespace_around_equals() {
assert_eq!(classes(r#"<div class = "a">"#), vec!["a"]);
}
#[test]
fn collapses_extra_internal_whitespace() {
assert_eq!(classes("<div class=\" a b \">"), vec!["a", "b"]);
}
#[test]
fn candidates_split_on_code_punctuation() {
let src = r#"el("div").class("flex p-2"); x = {a: 'm-1', b: `gap-2`}"#;
let got = candidates(src);
for want in ["flex", "p-2", "m-1", "gap-2"] {
assert!(got.iter().any(|c| c == want), "missing {want}: {got:?}");
}
assert!(got.contains(&"el".to_string()));
assert!(!got.iter().any(|c| c.contains('"') || c.contains('(')));
}
#[test]
fn candidates_keep_arbitrary_values_together() {
let src = r#"class("w-[220px] grid-cols-[1fr_2fr] data-[state=open]:bg-panel w-[calc(100%-2rem)] max-w-[min(1px,2px)]")"#;
assert_eq!(
candidates(src),
vec![
"class",
"w-[220px]",
"grid-cols-[1fr_2fr]",
"data-[state=open]:bg-panel",
"w-[calc(100%-2rem)]",
"max-w-[min(1px,2px)]",
]
);
}
#[test]
fn candidates_do_not_swallow_code_brackets() {
let src = r#"let xs = vec!["p-2", "m-1"]; arr[i] = "flex";"#;
let got = candidates(src);
for want in ["p-2", "m-1", "flex"] {
assert!(got.iter().any(|c| c == want), "missing {want}: {got:?}");
}
}
#[test]
fn candidates_dedupe_in_first_seen_order() {
assert_eq!(candidates("b a\nb 'a' c"), vec!["b", "a", "c"]);
assert!(candidates(" \n\t ").is_empty());
}
#[test]
fn empty_and_no_class() {
assert!(classes("<div>no classes</div>").is_empty());
assert!(classes(r#"<div class="">"#).is_empty());
}
}