css-tooling

I’ve been thinking lately about what’s so bad about CSS? Why do engineers shy away from it so much? Why is Tailwind, a tool which in some ways helps you sidestep writing CSS, so popular?

Modern CSS is very powerful. The progressive additions to the language have made it more and more capable at meeting any design, and pulled more and more out of the realm of Javascript, effectively making User Interface design more declarative and less imperative.

Isolation

I use Tailwind at work, and honestly it is a joy to use. Once its hooked up to your design system it lets you build out components and pages quickly, and with consistent styling.

Before we incorporated Tailwind into our stack, we had a large monolith with a tangled web of SCSS files combined by some black magic into a semi-functioning design system. It was a nightmare to work with, and engineers cowered in fear at the prospect of touching any underlying styles.

Tailwind really shon here. It let us iterate on the small piece of the application each team was focused on in isolation. I think that’s the key, the isolation. If you want to paint a div red, you can add a class name. What could be simpler? No worrying about that other rule nestled somewhere in the mountain of SCSS which says all divs should be blue.

Breakpoints

Tailwind standardises breakpoints, and makes them easy to use. In CSS we are stuck with the @media query, which is a bit of a pain to use. Especially since you can’t inject CSS variables into it. More on CSS variables shortly.

Theming

Tailwind LSP (when setup and working correctly) is also a great tool. Everyone loves autocomplete! Having the actual values of your design system at your fingertips is a game changer. No more guessing what the colour names. No more inconsistency, and hopefully no more hardcoded colours.

But I’ve not seen a good solution for this in vanilla CSS. There is this VS Code plugin which is a good start. You have define paths which contain your CSS variables, and it will autocomplete them for you. But this is a solely Visual Studio Code solution.

Outcome

We need better CSS tooling!

We need to find ways of achieving the following in vanilla CSS:

I’m wondering if I can come up with a solution which answers each of the above points in a clean, unified way.

I discussed my ideas around this area of CSS tooling with my team lead @Chris. He was very receptive to the idea.

He expressed that the idea sounded similar in features to the Tailwind CSS LSP which is open source. He suggested that I should take a look at the project to see how they have implemented their features.

I dove into how I’ve been writing vanilla CSS for my personal site, and dang is it nice to be fully free to flex the cascade etc. It needs better guardrails to help improve the authoring experience.

I love it What ya waiting for!

The dreaded naming

Everyone knows that the hardest problem in computer science is naming things.

I played around with a ton of names for this project, but at the moment I’ve settled on Lime.

I like the idea of a fresh, zesty CSS tooling project. And the name is short and snappy, which is always a plus.

I also find the Limes song by Tommy Dockerz hilarious

https://www.youtube.com/watch?v=7QIqU-DoPsM

I have just come across one big drawback. There is no Lime emoji. 🍋 exists, but no lime.

This feels pretty racist towards limes.

The plan

In my mind the project will be made up of 3 pieces

Parsing the CSS

Lightning CSS already has a Rust library for parsing the CSS into an AST

https://lightningcss.dev/docs.html#from-rust

use lightningcss::stylesheet::{
  StyleSheet ParserOptions
};

// Parse a style sheet from a string.
let mut stylesheet = StyleSheet::parse(
  r#"
  .foo {
    color: red;
  }

  .bar {
    color: red;
  }
  "#,
  ParserOptions::default()
).unwrap();

Determining CSS selector usage

This is the tricky part. I think the best way to do this is to have a regex search of the codebase for class names as a first pass solution.

Getting it nailed perfectly would require a language specific parser. Like one for raw HTML which knows how to pick out class names/attributes, another for JSX, another for Vue etc etc. It doesn’t scale particularly well.

How will Limescale?!

The interface

Finally the insights need to be exposed as an LSP surface or CLI for use in vim / vscode etc

I’ll likely start with a CLI as that will be simpler to implement and test the idea works, but build it in a way that it can be extended to an LSP later.

Starting the project with cargo init

Parsing the CSS selectors from a given CSS file was pretty straightforward with LightningCSS. Rust’s strong type system made it easy to explore the parsed AST and extract what I needed.

Crawling @import statements

The first piece of actual functionality I added was the ability to crawl @import statements in CSS files, extracting the selectors from each file and adding them to the main list of selectors.

It was pretty trival to pick out the CssRule::Import type in a match statement and extract the file path, then std::fs::read_to_string the file contents and recursively follow any further imports.

/// Parse a CSS stylesheet and return a list of selectors
/// The filepath is required to provide context for the root CSS file
pub fn parse(filepath: &str, raw: &str) -> Vec<Selector> {
    // Parse a style sheet from a string.
    let stylesheet = StyleSheet::parse(raw, ParserOptions::default()).unwrap();

    // get selectors from rules
    let CssRuleList(rules) = stylesheet.rules;

    let imports = parse_imports(rules.clone());
    let mut selectors = imports
        .iter()
        // recursively parse the imported files
        .map(|i| parse(i.filepath.as_str(), i.raw.as_str()))
        .flatten()
        .collect::<Vec<Selector>>();

    for rule in rules {
        selectors.extend(parse_selectors_from_rule(filepath, rule));
    }

    selectors
}

Testing the CSS parser

The harder part came in how to easily test the parser, especially when nested import statements were involved.

After some digging into the recommended solution, I found that I could use the tempfile crate to create temporary files and directories for testing. This allowed me to create a test directory with a few CSS files and test the parser against them.

let tmp_dir = TempDir::new("lime").unwrap();
set_current_dir(tmp_dir.path()).unwrap();

let entrypoint_css = r#"
    @import "test2.css";
    @import "test3.css";
    "#;

let test_2_path = tmp_dir.path().join("test2.css");
let test_3_path = tmp_dir.path().join("test3.css");

std::fs::write(test_2_path, ".foo { color: red; } .bar { color: red; }").unwrap();
std::fs::write(
    test_3_path,
    r#"
    .foo { color: red; }
    @media (min-width: 768px) {
        .bar { color: red; }
    }
    "#,
)
.unwrap();
let result = parse("test.css", entrypoint_css);
let selectors: Vec<String> = result.iter().map(|r| r.selector.clone()).collect();
assert_eq!("test2.css", result.first().unwrap().filepath);
assert_eq!([".foo", ".bar", ".foo", ".bar"], selectors.as_slice());

I did keep finding that the tests were failing when I ran them in parallel. I had to add a #[serial] attribute (from the serial_test crate) to the test functions to ensure they ran in sequence.

use serial_test::serial;

#[test]
#[serial]
fn parses_imported_selectors() {
    // test code
}

Thoughts on colocation of tests

Very much a Rust thing, but the colocation of module tests inside the source code module is something which I didn’t think I would like as much as I do. It’s nice to have the tests right there, tied to the implementation, and not in a separate tests directory.

One killer feature though, is the ability to test private functions. I can write tests for the parse_selectors_from_rule function, which is private, and not have to expose it just for testing.

fn parse_selectors_from_rule(filepath: &str, rule: CssRule) -> Vec<Selector> {
    // implementation code
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn extracts_selectors_from_rule() {
        // test code
    }
}

Very cool.

But how to handle the content?

The CSS part is pretty easy to think about, get me the selectors I am currently using and their locations.

But what about the content? How do I know which selectors are being used in which files?

My initial attempt was to use the underlying Rust library that powers Ripgrep. They’re great libraries, nicely organised and very consumable.

I could use the grep crate to search for the selectors in the content of the files. But I quickly realised that this leaves a lot of processing of the content to me. Like how do I know that a specific string is actually related to the CSS? Its not a true reflection of the CSS.

Imagine you have a class name of “card” in your CSS. All it would take to break Lime would be for you to write a single comment or function name containing that 4 letter string.

Treesitter 🌴

From using Neovim I know all about how powerful Treesitter is. It seems to be underpinning every code editor these days! I’ve even written my own grammar for Treesitter in C once upon a time, a fun experience! Its extensible and powerful.

Fortunately there are Rust bindings for Treesitter, so I can use it to parse the content of the files and get a queryable AST of a source code file, as long as that language has a grammar defined for Treesitter.

My thinking is, if I can write some generic Rust functions which take a TS query and a source code file, using Treesitter I can extract a list of class names, ids and DOM attributes which might be used as CSS selectors.

The question is, do I pass in the CSS selectors of interest, and use them to focus in on the relevant parts of the AST? Or do I have each process be separate, and then reconcile the two lists at the end?

More experimentation required.