mod collect;
mod transform;
use std::path::PathBuf;
use anyhow::anyhow;
use clap::{crate_name, Args, CommandFactory, Parser, Subcommand, ValueHint};
use cnat::scope::Scope;
use collect::ClassNamesCollector;
use colored::Colorize;
use crate::transform::ApplyTailwindPrefix;
#[derive(Parser)]
#[clap(about, author, version)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Prefix(PrefixArgs),
Completion {
shell: clap_complete::Shell,
},
}
#[derive(Args)]
struct PrefixArgs {
#[arg(short = 'i', value_hint = ValueHint::FilePath)]
css_file: PathBuf,
#[arg(short, long)]
prefix: String,
#[arg(short, long, num_args = 1.., value_delimiter = ' ', default_value = "att:class,className fn:createElement")]
scopes: Vec<Scope>,
#[arg(value_hint = ValueHint::DirPath)]
contexts: Vec<PathBuf>,
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let cli = match cli.command {
Command::Prefix(cli) => cli,
Command::Completion { shell } => {
clap_complete::generate(
shell,
&mut Cli::command(),
crate_name!(),
&mut std::io::stdout(),
);
return Ok(());
}
};
for context in &cli.contexts {
if !context.is_dir() {
return Err(anyhow!(
"context should be a directory, got {}",
context.display()
));
}
}
let c = ClassNamesCollector::parse(cli.css_file)?;
eprintln!("[INFO] extracted selectors");
println!("{:?}", c.class_names);
let mut ppc = ApplyTailwindPrefix::new(&cli.prefix, &c.class_names, &cli.scopes);
let mut count = 0;
for context in &cli.contexts {
count += ppc.prefix_all_classes_in_dir(context)?;
}
eprintln!(
"{}",
format!("[DONE] {} files were tranformed.", count).green()
);
Ok(())
}
#[cfg(test)]
mod tests {
use assert_cmd::Command;
use insta::assert_snapshot;
use std::{fs, path::PathBuf};
struct JsFile(PathBuf, Vec<u8>);
impl JsFile {
fn prep(path: &'static str, temp_dir: &str) -> Self {
let js_file_content_before = fs::read(path).expect("failed to read js fixture file");
let new_path = std::path::PathBuf::from(format!("{}/{}", temp_dir, path));
fs::create_dir_all(new_path.parent().unwrap()).unwrap();
fs::copy(path, &new_path).unwrap();
Self(new_path, js_file_content_before)
}
fn content_now(&self) -> String {
let js_file_content_after = fs::read(&self.0).expect("failed to read js fixture file");
String::from_utf8_lossy(&js_file_content_after).to_string()
}
}
impl Drop for JsFile {
fn drop(&mut self) {
fs::remove_file(&self.0).expect("failed to remove a file")
}
}
#[test]
fn it_works_with_default_scopes() {
let context_dir = "basic";
let jsfiles = [
JsFile::prep("fixtures/sample.tsx", context_dir),
JsFile::prep("fixtures/nested/sample.tsx", context_dir),
JsFile::prep("fixtures/nested/nested/sample.tsx", context_dir),
JsFile::prep("fixtures/sample2.tsx", context_dir),
JsFile::prep("fixtures/nested/sample2.tsx", context_dir),
];
let cssfile = "fixtures/sample.css";
let mut cmd = Command::cargo_bin("cnat").unwrap();
let cmd = cmd
.args(["prefix", "-i", cssfile, "--prefix", "tw-", context_dir])
.assert()
.success();
let output = cmd.get_output();
let output = String::from_utf8_lossy(&output.stdout);
insta::with_settings!({
info => &cssfile,
omit_expression => true
}, {
assert_snapshot!(output);
});
for jsfile in jsfiles {
insta::with_settings!({
snapshot_suffix => jsfile.0.to_string_lossy(),
info => &jsfile.0,
description => output.clone(),
omit_expression => true
}, {
assert_snapshot!(jsfile.content_now());
});
}
}
#[test]
fn it_works_with_cva_fn_scope() {
let context_dir = "cva";
let jsfiles = [
JsFile::prep("fixtures/nested/sample.tsx", context_dir),
JsFile::prep("fixtures/sample2.tsx", context_dir),
];
let cssfile = "fixtures/sample.css";
let scopes = "fn:cva";
let mut cmd = Command::cargo_bin("cnat").unwrap();
let cmd = cmd
.args([
"prefix",
"-i",
cssfile,
"--prefix",
"tw-",
context_dir,
"--scopes",
scopes,
])
.assert()
.success();
let output = cmd.get_output();
let output = String::from_utf8_lossy(&output.stdout);
insta::with_settings!({
info => &cssfile,
omit_expression => true
}, {
assert_snapshot!(output);
});
for jsfile in jsfiles {
insta::with_settings!({
snapshot_suffix => jsfile.0.to_string_lossy(),
info => &jsfile.0,
description => scopes,
omit_expression => true
}, {
assert_snapshot!(jsfile.content_now());
});
}
}
#[test]
fn it_works_with_custom_jsx_attribute() {
let context_dir = "object_inside";
let jsfiles = [
JsFile::prep("fixtures/nested/sample.tsx", context_dir),
JsFile::prep("fixtures/nested/nested/sample.tsx", context_dir),
JsFile::prep("fixtures/sample2.tsx", context_dir),
JsFile::prep("fixtures/nested/sample2.tsx", context_dir),
];
let cssfile = "fixtures/sample.css";
let scopes = "att:classes,*ClassName";
let mut cmd = Command::cargo_bin("cnat").unwrap();
let cmd = cmd
.args([
"prefix",
"-i",
cssfile,
"--prefix",
"tw-",
context_dir,
"--scopes",
scopes,
])
.assert()
.success();
let output = cmd.get_output();
let output = String::from_utf8_lossy(&output.stdout);
insta::with_settings!({
info => &cssfile,
omit_expression => true
}, {
assert_snapshot!(output);
});
for jsfile in jsfiles {
insta::with_settings!({
snapshot_suffix => jsfile.0.to_string_lossy(),
info => &jsfile.0,
description => scopes,
omit_expression => true
}, {
assert_snapshot!(jsfile.content_now());
});
}
}
#[test]
fn it_works_with_classes_or_classname_object_entries() {
let context_dir = "object_outside";
let jsfiles = [
JsFile::prep("fixtures/sample.tsx", context_dir),
JsFile::prep("fixtures/nested/sample.tsx", context_dir),
JsFile::prep("fixtures/nested/nested/sample.tsx", context_dir),
JsFile::prep("fixtures/sample2.tsx", context_dir),
JsFile::prep("fixtures/nested/sample2.tsx", context_dir),
];
let cssfile = "fixtures/sample.css";
let scopes = "prop:classes prop:className";
let mut cmd = Command::cargo_bin("cnat").unwrap();
let cmd = cmd
.args([
"prefix",
"-i",
cssfile,
"--prefix",
"tw-",
context_dir,
"--scopes",
scopes,
])
.assert()
.success();
let output = cmd.get_output();
let output = String::from_utf8_lossy(&output.stdout);
insta::with_settings!({
info => &cssfile,
omit_expression => true
}, {
assert_snapshot!(output);
});
for jsfile in jsfiles {
insta::with_settings!({
snapshot_suffix => jsfile.0.to_string_lossy(),
info => &jsfile.0,
description => scopes,
omit_expression => true
}, {
assert_snapshot!(jsfile.content_now());
});
}
}
#[test]
fn it_preserves_comments() {
let context_dir = "preserves_comments";
let jsfile = JsFile::prep("fixtures/sample_comments.jsx", context_dir);
let cssfile = "fixtures/sample.css";
let mut cmd = Command::cargo_bin("cnat").unwrap();
let cmd = cmd
.args(["prefix", "-i", cssfile, "--prefix", "tw-", context_dir])
.assert()
.success();
let output = cmd.get_output();
let output = String::from_utf8_lossy(&output.stdout);
insta::with_settings!({
info => &cssfile,
omit_expression => true
}, {
assert_snapshot!(output);
});
insta::with_settings!({
snapshot_suffix => jsfile.0.to_string_lossy(),
info => &jsfile.0,
omit_expression => true
}, {
assert_snapshot!(jsfile.content_now());
});
}
#[test]
fn it_leaves_alone_files_without_classes_to_prefix() {
let context_dir = "leave_unstyled";
let jsfile = JsFile::prep("fixtures/unstyled.tsx", context_dir);
let cssfile = "fixtures/sample.css";
let mut cmd = Command::cargo_bin("cnat").unwrap();
let cmd = cmd
.args(["prefix", "-i", cssfile, "--prefix", "tw-", context_dir])
.assert()
.success();
let output = cmd.get_output();
let output = String::from_utf8_lossy(&output.stdout);
insta::with_settings!({
info => &cssfile,
omit_expression => true
}, {
assert_snapshot!(output);
});
insta::with_settings!({
snapshot_suffix => jsfile.0.to_string_lossy(),
info => &jsfile.0,
omit_expression => true
}, {
assert_snapshot!(jsfile.content_now());
});
}
}