Alex Garbiak
← Notes

A SQL formatter that flags what you can't see

astrosql-formatterunicode

/tools went from a greyed-out “coming soon” nav link to a real section with the SQL Server Formatter: paste unformatted T-SQL, pick trailing or leading commas, and get a consistently formatted query back — entirely client-side. The CSV validator note guessed it would be the first thing to graduate from /lab into /tools. It wasn’t — this is a new build, not a promotion — but the prediction about what tools/ was for held up: something dependable enough to actually use, not just play with.

Pinning to a “deprecated” major version on purpose

The brief asked for both trailing and leading comma styles, which sounds like a one-line config flag — and it is, if you use [email protected]. The library’s commaPosition: 'before' | 'after' | 'tabular' option handles it cleanly, including nested subqueries:

SELECT
  a
, b
, (
    SELECT
      MAX(x)
    FROM
      bar
  ) AS d

The catch: sql-formatter@15 (the current major, and what npm install sql-formatter gives you today) removed commaPosition entirely as part of a full parser rewrite. Its replacement, indentStyle: 'tabularLeft' | 'tabularRight', does keyword-column alignment, not comma placement — a different feature that happens to occupy the same rough design space. Checking node_modules/sql-formatter/dist/esm/validateConfig.js confirms it’s not just undocumented — passing commaPosition to v15 throws a ConfigError explaining it’s “no more supported.”

src/lib/sqlFormat.ts pins sql-formatter to 13.1.0, the last release before that rewrite, specifically because it’s the only version where leading-comma output is both correct and someone else’s problem to maintain — rewriting a T-SQL-aware comma-repositioning pass by hand, on top of v15’s trailing-comma-only output, would mean re-solving indentation for every construct (subqueries, CASE, function calls) that the old parser already gets right.

Severity, not just detection

The hidden-character scanner (src/lib/detectHiddenChars.ts) sorts what it finds into three severities, and the ranking is deliberate rather than cosmetic:

  • High — bidirectional control characters. U+202A–U+202E, U+2066–U+2069, and U+061C can make code visually lie about what it does — the exact mechanism behind the 2021 “Trojan Source” disclosure. Pasting ‮evil‬ into the tool renders as live in the input box; the table still correctly reports it as a Right-To-Left Override at column 48.
  • Medium — invisible/zero-width and control characters. Zero-width spaces, joiners, soft hyphens, BOM, C0/C1 controls. These don’t lie, they just vanish — which is its own problem when a WHERE clause silently fails to match because two “identical-looking” strings differ by a U+200B.
  • Low — non-standard spaces and smart punctuation. NBSP, the U+2000–U+200A space family, curly quotes, en/em dashes. The most common in practice (anything copy-pasted from Word, Confluence, or a PDF), and the least dangerous.

Normalizing without guessing intent

The “normalize before formatting” toggle fixes what’s safe to fix and leaves the rest alone: curly quotes become straight quotes, NBSP becomes a real space, en/em dashes become a hyphen — all unambiguous substitutions. Zero-width and bidi-control characters are deleted, not replaced with a space, because there’s no way to know whether a real space belongs there.

That distinction shows up immediately on real input. Paste AND + zero-width space + x = 1 — visually indistinguishable from AND x = 1 — and normalizing produces ANDx = 1, not AND x = 1. That looks wrong until you notice it’s honest: there was never a real space there, only an invisible character standing in for one, so the tool surfaces the gap instead of quietly papering over it with a guess.

A Nearley grammar dump is not an error message

sql-formatter uses a Nearley-generated parser, and its parse errors are exhaustive — for one malformed query, the exception message lists every token type the grammar would have accepted at that position, running to dozens of lines. formatSql() in src/lib/sqlFormat.ts takes only the first line (Parse error at token: ) at line 1 column 15, say) and discards the rest. It’s a small function, but it’s the difference between a usable inline error message and a wall of parser internals.

The bug the dynamic stat cards caught

The severity counts and the character table are both built at runtime — document.createElement and replaceChildren inside the component’s <script> block, the same pattern the CSV validator uses for its results panel. First pass, the severity colors (red for bidi-control, amber for invisible/control) silently didn’t apply: every stat rendered in the default text color regardless of its sf-stat--high/sf-stat--medium class.

The cause is an Astro-specific gap. Scoped <style> blocks work by stamping a data-astro-cid-* attribute onto every element the compiler sees in the template, then appending an attribute selector to match it. Elements created in client-side JS after the page has rendered were never in that template, so they never get the attribute — and a scoped rule targeting them just never matches, with no warning at build or run time. The fix is :global() around every rule that targets a JS-created class:

:global(.sql-formatter .sf-stat--high .sf-stat-value) {
  color: var(--sf-danger);
}

Checking the CSV validator afterwards confirmed it has the identical bug — its valid/invalid stat colors and error-table cell padding have never actually applied, since both are built the same way. That one’s a bigger blast radius (an already-shipped page) so it’s tracked separately rather than folded into this change.

Ideas for later

  • A real inline annotated view. The character table is deliberately CSV-validator-shaped — line/column/name/codepoint/context — rather than an inline preview with each flagged character marked in place. That’s the more discoverable UX and the natural next step if this gets used enough to justify it.
  • More dialects. The formatter is T-SQL only by design (it’s the SQL Server formatter, not a generic one), but sql-formatter supports a dozen others for free if that scope ever changes.
  • Pasting detection into the CSV validator. The same hidden-character scanner would catch a non-breaking space silently breaking a CSV header match — worth wiring in once the scoped-CSS fix above lands.