Skip to main content
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Vicky Ryder
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Vicky Ryder

Building My Own Syntax Highlighter API With Claude Code

By
Published

As much as possible, I've always wanted to have complete ownership over the mechanics of my blogging platform. And while I use advanced 3rd-party libraries, like jSoup, Flexmark, and AntiSamy to implement workflows, I still feel like I "own" the orchestration of those workflows. Syntax highlighting — for fenced code blocks — has been the last strong-hold in my ownership journey. For years, I've been relying upon the GitHub Gist API in order to apply syntax highlighting to my code snippets. With the use of Claude Code, however, I'm finally ready to bring syntax highlighting in-house and under my control.

View my Syntax Highlighting Lambda Function on GitHub.

I Steered, Claude Code Executed

To start with, I pulled in the Starry Night syntax highlighter by Titus Wormer. Then I vendored the CFMLEditor grammar (ie, I committed it to my repository with license attribution). Then, I worked with Claude Code over the last few weeks to make many, many tweaks to the various was in which grammars work.

And while I steered this project with my prompts, I truly have no idea how any of it works. I mean, at a high level, I kind of understand how TextMate grammars get applied using regular expressions and pattern matching precedence and scoping. But, the degree of tomfoolery that Claude Code has applied feels — to me — staggering. It's been monkey-patching grammars using a Frankensteinian spirit of adventure with a commensurate lack of concern as to how the code looks aesthetically.

As an example, I had Claude Code patch the stock SQL grammar to add several words (EXPLAIN, EXPLAIN ANALYZE, WITH, WITH RECURSIVE, COLLATE, ENGINE, DEFAULT and CHARSET) to the list of keywords that receive special tokenization. Here's what Claude Code generated:

/**
* SQL patches. Two grammars carry SQL rules here, and they are not related:
*
*   source.sql            starry-night's stock grammar, the old TextMate
*                         sql.tmbundle, reached by a ```sql fence
*   embedding.cfml(.cfs)  cfmleditor's inlined copy of Microsoft's
*                         vscode-mssql grammar (`source-sql` in the
*                         repository), reached by cfquery bodies and
*                         queryExecute strings
*
*   statement-keywords   `EXPLAIN`, `EXPLAIN ANALYZE`, `WITH` and
*                        `WITH RECURSIVE` color as keywords in a sql fence
*   ddl-keywords         `COLLATE`, `ENGINE`, `DEFAULT` and `CHARSET` color as
*                        keywords in a sql fence and in a cfquery body
*   column-words         common column names are not colored as keywords in
*                        a cfquery body
*/

import {CFML_TAG, CFML_SCRIPT} from "../scopes.js";
import {count, named} from "./walk.js";

/**
* `EXPLAIN` and `WITH` render as plain text in a `sql` fence.
*
* The stock grammar is the old TextMate sql.tmbundle, whose statement
* keywords are one short DML alternation -- select, insert into, update,
* delete, the joins. Neither word is in it: the bundle predates EXPLAIN's
* spread beyond MySQL and Postgres and predates common table expressions
* altogether, and GitHub ships the same rule, so it leaves both plain too. In
* the archive EXPLAIN opens whole posts about query plans and WITH opens
* every CTE, and an unhighlighted first word above a highlighted SELECT reads
* as a typo.
*
* The patch prepends both to that alternation, with `ANALYZE` and `RECURSIVE`
* folded in the way the rule already folds `union(\s+all)?`: a bare `explain`
* entry would color half of `EXPLAIN ANALYZE` and leave the other half plain,
* and likewise `WITH RECURSIVE`. This DIVERGES from GitHub the way
* comment-hyphens does: correct beats parity.
*
* Only the stock grammar needs this. cfquery bodies and queryExecute strings
* route to cfmleditor's inlined vscode-mssql grammar, whose generic keyword
* list already carries `with` and `recursive` (columnWordsInCfml edits that
* list).
*/
var patchStatementKeywords = {
	name: "statement-keywords",
	branches: new Map([
		["source.sql", statementKeywordsInSql]
	])
};

function statementKeywordsInSql(grammar) {
	var SCOPE = "keyword.other.DML.sql";
	var HEAD = "(?i:\\b(select(\\s+distinct)?|";

	// The DML rule lives in the grammar's top-level patterns, not the repository.
	var rules = grammar.patterns.filter((p) => p.name === SCOPE);

	// Anchor check on the alternation's opening. Anything else means upstream
	// reworked the rule -- re-derive rather than splicing into a stranger. An
	// `explain` or `with` already present means upstream added it; drop that
	// word from this patch.
	if (
		rules.length !== 1
		|| !rules[0].match.startsWith(HEAD)
		|| /\b(explain|with)\b/i.test(rules[0].match)
	) {
		throw new Error(`${grammar.scopeName}: the ${SCOPE} rule no longer has the expected alternation; re-derive statementKeywordsInSql`);
	}

	rules[0].match = `${HEAD}explain(\\s+analyze)?|with(\\s+recursive)?|${rules[0].match.slice(HEAD.length)}`;

	// The top-level rule must be the ONLY carrier of the scope.
	if (count(grammar, named(SCOPE)) !== 1) {
		throw new Error(`${grammar.scopeName}: a ${SCOPE} rule now appears outside the top-level patterns; extend statementKeywordsInSql to cover it`);
	}
}

/**
* `COLLATE`, `ENGINE`, `DEFAULT` and `CHARSET` render as plain text in a
* `sql` fence and inside a cfquery body; `CHARACTER SET` too in the fence.
*
* The stock grammar's DDL coverage is a handful of rules for CREATE, types,
* `PRIMARY KEY` and `NOT NULL`; the table-option and column-option words
* around them are absent, so a MySQL CREATE TABLE ends in a line where only
* the `=` signs are colored, and `slug COLLATE utf8_bin = 'x'` in a WHERE
* clause colors the column and the operator but not the word between them.
* GitHub ships the same rule and renders the same gap. Found via fixtures
* 3918-4.sql and 4233-1.sql.
*
* cfmleditor's inlined vscode-mssql grammar has the same gap for a different
* reason. Its generic keyword list -- the one columnWordsInCfml prunes -- was
* copied from SQL Server Management Studio, and these four are not in it:
* three are MySQL table options SSMS never sees, and DEFAULT is colored there
* by a different mechanism. In this archive a CREATE TABLE inside a cfquery is
* MySQL, and its last line renders with only the `=` signs colored.
*
* All five are reserved in MySQL, and COLLATE and DEFAULT in Postgres, SQL
* Server and SQLite too, so none can be an unquoted column name: coloring
* them cannot misfire the way `name` or `status` would. The collision that
* argues for pruning `name` argues for adding these.
*
* In the stock grammar the words are prepended to the DDL rule that already
* carries `ON` and `NOT NULL`, so they render in that rule's scope beside
* them, with `CHARACTER SET` folded in as one token the way the DML rule folds
* `union(\s+all)?` -- or the stock `SET` rule would color half the phrase. In
* the cfmleditor copy the four words join the generic roster; `CHARACTER SET`
* needs nothing there, both words are already in the list. The collation,
* engine and charset names that follow stay plain in both: they are
* identifiers.
*/
var patchDdlKeywords = {
	name: "ddl-keywords",
	branches: new Map([
		["source.sql", ddlKeywordsInSql],
		[CFML_TAG, ddlKeywordsInCfml],
		[CFML_SCRIPT, ddlKeywordsInCfml]
	])
};

function ddlKeywordsInSql(grammar) {
	var SCOPE = "keyword.other.DDL.create.II.sql";
	var STOCK = "(?i:\\b(on|((is\\s+)?not\\s+)?null)\\b)";
	var HEAD = "(?i:\\b(";

	// Like the DML rule, this one lives in the grammar's top-level patterns.
	var rules = grammar.patterns.filter((p) => p.name === SCOPE);

	// Anchor on the whole stock alternation: it is short enough that any
	// change to it means re-deriving rather than splicing.
	if (rules.length !== 1 || rules[0].match !== STOCK) {
		throw new Error(`${grammar.scopeName}: the ${SCOPE} rule no longer has the expected alternation; re-derive ddlKeywordsInSql`);
	}

	rules[0].match = `${HEAD}collate|engine|default|charset|character\\s+set|${STOCK.slice(HEAD.length)}`;
}

function ddlKeywordsInCfml(grammar) {
	var WORDS = ["collate", "engine", "default", "charset"];

	var roster = sqlKeywordRoster(grammar, "ddlKeywordsInCfml");

	// A word already present means upstream added it; drop it from this list.
	for (var word of WORDS) {
		if (roster.words.includes(word)) {
			throw new Error(`${grammar.scopeName}: source-sql keyword list already contains "${word}"; remove it from ddlKeywordsInCfml`);
		}
	}

	roster.write(roster.words.concat(WORDS));
}

/**
* Column names render as SQL keywords: `SELECT id, name, created` puts `name`
* in the same pl-k as SELECT.
*
* cfmleditor's inlined `source-sql` is Microsoft's vscode-mssql grammar, whose
* generic keyword rule is one ~830-word alternation copied from SQL Server
* Management Studio's coloring list. That list was assembled for SSMS parity,
* not correctness: `name`, `type`, `value`, `status`, `data` are all in it,
* none is a reserved word in any SQL dialect, and all five are among the most
* common column names there are. Upstream has only ever added to the
* list, and the SSMS behaviour it mirrors is a two-decade-old known quirk, so
* this is not going to be fixed by an update.
*
* Removing the six costs their T-SQL keyword uses -- `WITH (NAME = ...)`,
* `sp_rename ... 'COLUMN'`, `CREATE TYPE`. Those are admin and DDL statements
* that almost never appear in a cfquery body, whereas a SELECT list with one
* of these columns appears in nearly every one. A keyword-list grammar cannot
* tell the two apart, so the roster is a judgment about which usage dominates
* in this archive, not a claim about SQL.
*
* Do not add `key` to the roster, column name though it is. This list matches
* `PRIMARY` before the `primary key` storage-modifier rule further down the
* entry gets a look, so `KEY` in `PRIMARY KEY` is colored by this list alone
* and removing it leaves half the phrase plain in every CREATE TABLE. Do not
* add `date` either: the data-type rule in the same entry matches it as a
* type, so pruning it from this list changes nothing a reader can see.
*
* Top-level ```sql fences are unaffected: those route to starry-night's stock
* `source.sql` (the old TextMate bundle), whose keyword list has none of these
* words. Only cfquery bodies and queryExecute strings reach this rule.
*/
var patchColumnWords = {
	name: "column-words",
	branches: new Map([
		[CFML_TAG, columnWordsInCfml],
		[CFML_SCRIPT, columnWordsInCfml]
	])
};

function columnWordsInCfml(grammar) {
	var WORDS = ["name", "type", "value", "status", "data"];

	var roster = sqlKeywordRoster(grammar, "columnWordsInCfml");

	// Anchor check: every word to remove must be present exactly once, spelled
	// bare. A word already gone means upstream pruned it and this roster should
	// shrink; a word present twice or wrapped in regex means the list changed
	// shape and a plain string removal is no longer sound.
	for (var word of WORDS) {
		if (roster.words.filter((w) => w === word).length !== 1) {
			throw new Error(`${grammar.scopeName}: source-sql keyword list no longer contains "${word}" exactly once; re-derive columnWordsInCfml`);
		}
	}

	roster.write(roster.words.filter((w) => !WORDS.includes(w)));
}

/**
* The generic keyword alternation of cfmleditor's inlined vscode-mssql grammar
* -- the one ~830-word `keyword.other.sql` rule in source-sql -- with its
* word list split out and a writer that puts a new list back. Shared by the
* two branches that edit the roster; `caller` names the branch for the error
* messages.
*
* The generic rule is the only `keyword.other.sql` rule whose match is a bare
* word alternation -- the DML/DDL siblings carry dotted suffixes
* (keyword.other.DML.sql) and are not what is being edited. It must also be
* the ONLY carrier of that alternation in the whole grammar.
*/
function sqlKeywordRoster(grammar, caller) {
	var HEAD = "\\b(?i)(";
	var TAIL = ")\\b";

	function isCarrier(node) {
		return (
			node.name === "keyword.other.sql"
			&& typeof node.match === "string"
			&& node.match.startsWith(HEAD)
			&& node.match.endsWith(TAIL)
		);
	}

	var matches = grammar.repository["source-sql"].patterns.filter(isCarrier);

	if (matches.length !== 1) {
		throw new Error(`${grammar.scopeName}: source-sql no longer has exactly one generic keyword alternation; re-derive ${caller}`);
	}

	if (count(grammar, isCarrier) !== 1) {
		throw new Error(`${grammar.scopeName}: a generic SQL keyword alternation now appears outside source-sql; extend ${caller} to cover it`);
	}

	var rule = matches[0];

	return {
		words: rule.match.slice(HEAD.length, -TAIL.length).split("|"),
		write(words) {
			rule.match = HEAD + words.join("|") + TAIL;
		}
	};
}

// Last in the file: `var` hoists the binding, not the value, so this list has
// to follow the descriptors it names.
export var PATCHES = [patchStatementKeywords, patchColumnWords, patchDdlKeywords];

When I read this Node.js code, I understand all of the JavaScript mechanics; but I grasp none of the meaning. And this was one of the relatively simple grammar patches. To see the full list of ways in which I'm patching the grammars, read the Local Grammar Changes section in my repositories' README.

Maintenance Without AI Is Now Impossible

As much as I'm quite shocked by how much Claude Code has been able to do in my syntax highlighting project, I'm simultaneously terrified and saddened. Because I know now — without question — that I've created something that I'll never be able to maintain on my own without the help of AI.

And that's kind of messed up for something that's a core pillar of my blogging platform. I think it's going to take me a while to get comfortable with this reality.

I'm A Bad Steward Of Open Source

As much as I'm thrilled that Claude Code allows me to make extensive local changes to grammars in order to meet my particular set of needs and preferences, I also know that it's made me a bad steward of open source. Every time I fix or augment a grammar that someone else published, I know that the "right thing to do" would be to open a ticket or file a PR (Pull Request) with the upstream repository.

So while I might be making these grammars better for myself, I'm doing fuck-all for anyone else. And that makes me feel like a moocher.

I know that part of it is just a level of effort issue — filing a PR isn't a straightforward process in most repositories. But, more so, I think it stems from a deep insecurity about the code itself. After all, I didn't write it, so who the heck am I to think I can slop-cannon it into another repository? I don't even know if it's any good. Sure, it passes my rendering fixture tests; but that doesn't mean it's well considered and / or resilient to a wide range of inputs. There's no reason for me to think this code is any good at all.

The Value of Everything, The Cost of Nothing

When I hear people talk about what they're building with AI, I can't help but consider the Rich Hickey quote:

Programmers know the benefits of everything and the trade-offs of nothing.

All anyone seems to talk about is how generative code is pure value-add that multiples productivity. And all I can see is the unknown cost of maintenance stretching out forever into my future. Yes, Claude Code allowed me to do something that I could not have done otherwise. But, at what cost? I don't know. And I may not know for years to come.

Incremental Adoption

So anyway, I built my own code syntax highlighter. But I don't fully trust it yet. In the mid-term/long-term, this will be a server-side rendering feature. Meaning, the fenced code blocks will be extracted in ColdFusion, highlighted via the AWS Lambda Function, and then saved back into the database for future, pain-free rendering. But for now, I'm only server-side rendering the vanilla code blocks; then, I'm applying the syntax highlighting after the page has rendered.

This gives me a chance to get a feel for the edge-cases and mistakes in the grammars before I commit to back-filling 20-years of content.

Reader Comments

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel
Managed ColdFusion hosting services provided by:
xByte Cloud Logo