cnat 0.0.7

Systematically apply certain modifications to classes, class names, used in your frontend codebase.
Documentation
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;

/// Systematically apply certain modifications to classes, class names, used
/// in your frontend codebase.
#[derive(Parser)]
#[clap(about, author, version)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Apply a prefix to all the tailwind classes in every js file in a project.
    Prefix(PrefixArgs),

    /// Generate completions for a specified shell
    Completion {
        // The shell for which to generate completions
        shell: clap_complete::Shell,
    },
}

#[derive(Args)]
struct PrefixArgs {
    /// The output css file generated by calling `npx tailwindcss -i input.css -o output.css`
    #[arg(short = 'i', value_hint = ValueHint::FilePath)]
    css_file: PathBuf,

    /// The prefix to apply to all the tailwind class names found
    #[arg(short, long)]
    prefix: String,

    /// Define scope within which prefixing happens. Example: --scopes 'att:className,*ClassName prop:classes fn:cva'
    #[arg(short, long, num_args = 1.., value_delimiter = ' ', default_value = "att:class,className fn:createElement")]
    scopes: Vec<Scope>,

    /// The directories in which to find js/ts files.
    #[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());
        });
    }
}