use crate::{CliError, CliResult, GlobalConfig, InputArgs, commands::format_diagnostic_error, green, magenta};
use bumpalo::Bump;
use clap::{Args, ValueEnum};
use css_ast::{CssAtomSet, StyleSheet, Visitable, visit::NodeId};
use css_lexer::{Cursor, Lexer, SourceOffset};
use css_parse::{NodeWithMetadata, Parser, SourceCursor, SourceCursorSink};
use csskit_ast::{CsskitAtomSet, QuerySelectorList, SelectorMatcher};
use csskit_highlight::{AnsiHighlightCursorStream, DefaultAnsiTheme, TokenHighlighter};
use itertools::Itertools;
use serde::Serialize;
use std::io::Read;
use strsim::levenshtein;
#[derive(Serialize)]
struct JsonMatch {
file: String,
#[serde(rename = "type")]
kind: String,
line: usize,
column: usize,
start: usize,
end: usize,
text: String,
}
#[derive(Serialize)]
struct JsonCount {
count: usize,
}
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub enum OutputFormat {
#[default]
Text,
Json,
}
#[derive(Debug, Args)]
#[command(after_help = "Examples:
csskit find style-rule *.css # Find all style rules
csskit find ':important' *.css # Find all declarations with `!important`
csskit find ':prefixed' *.css # Find all vendor prefixed rules and declarations
csskit find 'media-rule > style-rule' *.css # Find all style-rules within media-rules
csskit find '[name=color]' *.css # Find all rules or declarations with the name `color`.
Try using `csskit tree file.css` to see what can be selected for.
")]
pub struct Find {
selector: String,
#[command(flatten)]
input: InputArgs,
#[arg(long)]
count: bool,
#[arg(short, long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
}
fn line_bounds(source: &str, offset: usize) -> (usize, usize) {
let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
let end = source[offset..].find('\n').map_or(source.len(), |i| offset + i);
(start, end)
}
impl Find {
pub fn run(&self, config: GlobalConfig) -> CliResult {
let selector_bump = Bump::default();
let lexer = Lexer::new(&CsskitAtomSet::ATOMS, &self.selector);
let mut parser = Parser::new(&selector_bump, &self.selector, lexer);
let result = parser.parse_entirely::<QuerySelectorList>();
if !result.errors.is_empty() || result.output.as_ref().is_some_and(|n| n.metadata().is_invalid) {
if let Some(err) = result.errors.first() {
eprintln!("error: {}", err.message(&self.selector));
} else {
eprintln!("Invalid selector '{}'", &self.selector);
}
self.suggest_types(&self.selector);
return Err(CliError::ParseFailed);
}
let Some(selectors) = result.output else {
eprintln!("error: failed to parse selector");
self.suggest_types(&self.selector);
return Err(CliError::ParseFailed);
};
match self.format {
OutputFormat::Text => self.output_text(&selectors, &self.selector, config.colors()),
OutputFormat::Json => self.output_json(&selectors, &self.selector),
}
}
fn output_text(&self, selectors: &QuerySelectorList, selector_str: &str, color: bool) -> CliResult {
let mut total = 0;
let mut files = 0;
self.process_files(selectors, selector_str, |filename, src, stylesheet, matches| {
if files > 0 && !self.count {
println!();
}
files += 1;
total += matches.len();
if self.count {
println!("{filename}:{}", matches.len());
return;
}
let mut highlighter = TokenHighlighter::new();
stylesheet.accept(&mut highlighter);
if color {
println!("{}", magenta(filename));
} else {
println!("{filename}");
}
for m in matches {
let (line, col) = m.span.line_and_column(src);
let (start, end) = line_bounds(src, m.span.start().into());
if color {
print!("{}:{}:", green(line + 1), green(col + 1));
} else {
print!("{}:{}:", line + 1, col + 1);
}
if color {
let line_text = &src[start..end];
let line_lexer = Lexer::new(&CssAtomSet::ATOMS, line_text);
let mut line_output = String::new();
let mut cursor_stream =
AnsiHighlightCursorStream::new(&mut line_output, &highlighter, DefaultAnsiTheme);
for cursor in line_lexer {
let global_offset = SourceOffset(cursor.offset().0 + start as u32);
let global_cursor = Cursor::new(global_offset, cursor.token());
let sc = SourceCursor::from(global_cursor, cursor.str_slice(line_text));
cursor_stream.append(sc);
}
println!("{}", line_output);
} else {
let line_text = &src[start..end];
println!("{}", line_text);
}
}
})?;
if self.count && files > 1 {
println!("\nTotal: {total}");
}
Ok(())
}
fn output_json(&self, selectors: &QuerySelectorList, selector_str: &str) -> CliResult {
if self.count {
let mut count = 0;
self.process_files(selectors, selector_str, |_filename, _src, _stylesheet, matches| {
count += matches.len();
})?;
println!("{}", serde_json::to_string(&JsonCount { count })?);
} else {
let mut all_matches = Vec::new();
self.process_files(selectors, selector_str, |filename, src, _stylesheet, matches| {
for m in matches {
let (line, col) = m.span.line_and_column(src);
all_matches.push(JsonMatch {
file: filename.to_string(),
kind: m.node_id.tag_name().to_string(),
line: (line + 1) as usize,
column: (col + 1) as usize,
start: usize::from(m.span.start()),
end: usize::from(m.span.end()),
text: src[m.span.start().into()..m.span.end().into()].to_string(),
});
}
})?;
println!("{}", serde_json::to_string_pretty(&all_matches)?);
}
Ok(())
}
fn process_files<F>(&self, selectors: &QuerySelectorList, selector_str: &str, mut callback: F) -> CliResult
where
F: FnMut(&str, &str, &StyleSheet, &[csskit_ast::MatchOutput]),
{
let bump = Bump::default();
for (filename, mut source) in self.input.sources()? {
let mut src = String::new();
source.read_to_string(&mut src)?;
let lexer = Lexer::new(&CssAtomSet::ATOMS, &src);
let mut parser = Parser::new(&bump, &src, lexer);
let result = parser.parse_entirely::<StyleSheet>();
let Some(stylesheet) = result.output.as_ref() else {
if matches!(self.format, OutputFormat::Text) {
for err in result.errors {
eprintln!("{}", format_diagnostic_error(&err, &src, filename));
}
}
continue;
};
let matches: Vec<_> = SelectorMatcher::new(selectors, selector_str, &src).run(stylesheet).collect();
if matches.is_empty() {
continue;
}
callback(filename, &src, stylesheet, &matches);
}
Ok(())
}
fn suggest_types(&self, input: &str) {
let type_name = input.split(|c: char| c == ':' || c.is_whitespace()).next().unwrap_or(input);
if type_name == "*" || type_name.is_empty() {
return;
}
let suggestions: Vec<_> = NodeId::all_variants()
.map(|id| id.tag_name())
.map(|name| (name, levenshtein(name, type_name)))
.sorted_by(|(_, a), (_, b)| a.cmp(b))
.enumerate()
.take_while_inclusive(|(i, (_, score))| *i < 4 && *score < 4)
.collect();
if !suggestions.is_empty() {
eprintln!("\nDid you mean:");
for (_, (s, _)) in suggestions {
eprintln!(" {s}");
}
}
eprintln!("\nRun 'csskit tree' to see all node types.");
}
}