Revisiting SCSS with Tree-sitter: tree-sitter-scss, scss2-mode and emmet2-mode
I write a lot of SCSS, and I have always wanted highlighting that covers all of it. In 2013, when I still used Sublime Text, I wrote Syntax Highlighting for Sass, a package for Sass and SCSS that was fairly popular at the time. I learned regular expressions as I went, and getting the highlighting right took a lot of time and patience.
After I moved to Emacs, I never found the energy to port it. With Tree-sitter coming in Emacs 29, I started a grammar for SCSS in April 2023, but it was hard going. I got stuck on a problem, wrote down a rough plan and put the project away. It stayed there for three and a half years.
At the end of September, I had some unused credits for a coding agent and picked the project up again. This time I set three goals:
- Complete highlighting for SCSS and CSS. SCSS is a superset of CSS, which lets one grammar cover both. Every part of modern SCSS and CSS should get a color; syntax that only Internet Explorer understood can go. The older indented syntax (
.sass) is out of scope. - Structural editing from the start. Editing commands should act on the syntax rather than on characters or lines: with the cursor anywhere in
margin: 0 auto;, one command deletes the whole declaration and another clears only its value. I had tried this in JSX Jedi and liked the result. For the grammar, it meant shaping the tree for editing, not just for colors. - Narrow error recovery. Agents now write most of my code, and I mostly read it. Since completion and agents usually produce well-formed code, error recovery only has to keep the statement being typed from breaking the rest of the file. Everything else in the design favors a simple tree and fast parsing.
The result is P233/tree-sitter-scss, an independent grammar for SCSS and CSS. The official SCSS grammar instead extends the CSS grammar and fails on common Sass constructs such as !default, maps and @use ... as. On the 1,157 non-blank lines of rhythm-sass, including its tests and examples, the official grammar produces 700 ERROR nodes and this grammar produces none.
I learned of a third grammar only after finishing this one, when an AI suggested I look at it: simeonoff/tree-sitter-scss, which Zed’s SCSS & SASS extension uses for SCSS. Like the official grammar, it is based on the CSS grammar, but it parses rhythm-sass without errors. It fails on some Sass and CSS that this grammar parses, such as content block arguments (@include ... using) and escaped class names (.md\:flex), and parses a few valid forms that this grammar rejects, such as typed attr() unions. The comparison page lets you try all three on your own code.
Two Emacs packages grew alongside the grammar: scss2-mode, which is new, and emmet2-mode, which I rewrote. Together they turn a few ideas I had years ago into tools I use every day.
scss2-mode
The package provides SCSS and CSS major modes built on the grammar and requires Emacs 31.1 or later with Tree-sitter support. Structural editing is the part I care about most. I described the idea in an earlier post, and JSX Jedi grew out of it. Each command looks at the syntax node under the cursor before deciding what to act on.
Take the empty command, scss2-empty. It clears the contents of the structure the cursor is in, and what counts as contents depends on that structure:
- On a declaration,
margin: 0 auto;becomesmargin: |;. - Inside a function call,
calc(100% - 2rem)becomescalc(|). - On a selector,
.card { color: red; }becomes.card {|}.
Kill, copy, duplicate, comment and mark take the nearest whole declaration, map entry, rule or at-rule, and a user option decides which node types count. Inside an @if or @else if condition, kill removes just the condition and keeps the directive and its body. Each edit is one undo step.
Two smaller features complete the package. Completion knows the scope: it offers the Sass variables, functions and mixins visible at the cursor, and the members of local modules loaded with @use. Tab moves through a declaration, from the property name to the value and then past the semicolon. A block of declarations can be filled in with nothing but typing and Tab .
emmet2-mode
The first version of emmet2-mode ran the Emmet npm package in Deno through deno-bridge, with a layer of my own on top. It could expand an abbreviation from anywhere inside it and turned .card into className={css.card} for CSS Modules and m--gutter into margin: var(--gutter);. An uppercase letter started the value, as in mA for margin: auto;. A comma, easier to reach on a Dvorak keyboard, joined declarations in place of +. What it lacked was a preview: you pressed C-j and saw the result only afterwards.
Version 2.0 is written entirely in Emacs Lisp, runs no external process and keeps those features. Abbreviations now appear as completion choices, each previewing its expansion, and I can pick the right one before anything is inserted instead of fixing it afterwards.
CSS abbreviations are matched by their initials against a catalog of properties and values, built from MDN’s mdn-data and Microsoft’s VS Code Custom Data. Most Emmet habits carry over: bgc gives background-color: |; and tac gives text-align: center;.
The choices also depend on where the cursor is, something I first tried in Syntax Highlighting for Sass. Its regex-based scopes could tell property names from values, but every property shared the same value list. In scss2-mode, completion reads the property name and the structure around it from the syntax tree and offers values for that property alone. Even inside font: { weight: bo|; }, it recognizes font-weight and suggests bold.
Making it fast
With an agent doing most of the typing, every feature I had planned worked within a few days. Speed was another matter: scss2-mode took longer to load than the built-in scss-mode, highlighted large files more slowly and took almost a minute to reindent a 724 KB file. I had not reviewed closely enough, and the agent often reached a goal by a route that worked but was not the best one. Most of the cost came from things that did not need to be there: the mode still ran on top of css-mode, and half of the nodes the parser built were hidden wrappers with a single child.
Dropping css-mode was only one step. A series of optimizations and refactors across the grammar and the mode did the rest, from a smaller syntax tree and a lighter highlight query to reworked color previews and completion. I changed one thing at a time, measured each step against the same inputs and dropped the changes that did not pay off. On my M1 Pro, scss2-mode now loads in about 20 ms where scss-mode takes 135 ms, highlights large files faster than scss-mode does and reindents a 724 KB file in under a second instead of almost a minute. Unlike scss-mode, which highlights with regular expressions, scss2-mode keeps a syntax tree of the whole file. Building the tree adds 30 to 50 ms to opening a 724 KB file, and updating it takes at most 1.5 ms per keystroke in a 12 KB stylesheet, too little to feel while typing.
Without an agent, this project would still be where I left it in 2023. But the agent did not set the goals, and it did not notice when a route that worked was also slow. The result was only as good as my review, and these tools are built for the same job: I use them every day to read the stylesheets agents write and fix what needs fixing.