Repository navigation
Releases: pagebrooks/maxdop
Release list
maxdop 0.1.3
No formatting changes. A file formatted by 0.1.2 comes out of 0.1.3 byte for byte the same.
What changed is what happens around the format: how --write puts the result on disk, what the VS
Code extension tells you when it declines a file, and how much of the safety net is actually tested.
--write can no longer destroy the file it was formatting
Until now --write used File.WriteAllBytes, which truncates the file and then writes it. A write
that did not finish — a full volume, a killed process, a container out of memory — left the file
truncated. Every gate upstream exists so maxdop never writes output it cannot prove safe, and then
the write itself could lose the file for a reason none of those gates can see. Disk-full is the
realistic trigger, and it is likeliest exactly when --write is running across a large repository.
Output now goes to a temporary file beside the original, which is renamed over it only once the new
bytes are complete. If anything fails first, the original is untouched and the temporary file is
removed.
- Permissions carry over. On Linux and macOS the file mode is copied before the rename, so a
0640file does not come back0644. On Windows the replace goes throughReplaceFile, which keeps
the file's ACLs and attributes. - Symlinks are followed, not replaced. A symlink named on the command line has its target
rewritten, and stays a symlink. - Hard links are the trade. An atomic replace necessarily gives the file a new inode, so another
hard link to it keeps the old content. Writing in place instead would risk a half-old, half-new file,
and for SQL that is worse than truncation: a hybrid can still parse and mean something different.
Git makes the same call.
The temporary file is named .<file>.maxdop-<random>.tmp, so a concurrent *.sql walk never picks
it up.
VS Code: a declined file says so
A file that does not parse is handed back untouched, and until now the only trace of that was a line
in the maxdop output channel — so pressing Format appeared to do nothing. The status bar now shows
⚠ maxdop: parse error while that file is the active one. Its tooltip carries the parser's message,
and clicking it opens the output channel.
It is passive on purpose: no popup and no coloured background, since format-on-save runs on every
save while a statement is half typed. It clears as soon as the file formats, and it does not follow
you to other files.
The output channel is also available as maxdop: Show Output in the command palette.
Testing the safety net itself
The refusal path — hand back the input rather than output that failed a gate — had quietly become
the one behaviour no test executed. A working printer cannot produce output that trips the gates, and
every construct that used to has been fixed, so the refusal machinery lost its coverage as the bugs
went away. A refusal that returned the rejected text instead of the input would have kept every
suite green.
0.1.3 tests it directly:
- Refusal paths. The gates are handed deliberately damaged output, and the tests assert the
input comes back. - Batch seams. The same for the check that runs after
GO-separated batches are joined back
together — for example, a batch whose trailing newline went missing and weldedENDonto the next
GO. - Width sweep. The committed corpus is formatted at every width in a range, not only the
120-column default, and each result must pass the gates and be a fixed point. - Mutation testing. A weekly job deliberately breaks the five gate files — inverting a
comparison, dropping a negation — and fails if the tests stop noticing. It is scoped to the gates on
purpose: a surviving mutant there means a gate could stop working with the suite still green.
Distribution
- WinGet.
winget install --id pagebrooks.maxdop -e. The package is inmicrosoft/winget-pkgs
now. WinGet updates go through Microsoft's review, so a new version reaches it a little after the
release rather than with it. - Open VSX. The extension is published to Open VSX
alongside the Visual Studio Marketplace, for VSCodium, Cursor and other editors that use it. - Homebrew. The formula is now generated from each release's
SHA256SUMSand pushed to the tap
by the release workflow, rather than edited by hand.
maxdop 0.1.2
Casing on Built-in Functions and Globals
Built-in function names and global variables now take keyword casing. Until now CAST, COALESCE, NULLIF, LEFT, RIGHT and IIF were recased while len and
row_number beside them were not, so a single expression could come out in two cases.
New Configuration
maxdop now recases from a list of built-in names, and that makes it the only casing decision in
the formatter not derived from the parse tree, which is controllable with a new configuration value:
{
...
"recaseBuiltInFunctions": true
}It follows keywordCase, so "lower" brings GETDATE() and @@ROWCOUNT down rather than leaving
casing half-applied.
Setting it to false keeps the casing you wrote for everything the list covers — built-in function
names, global variables, the parser-matched table functions such as STRING_SPLIT, and the aggregate
a PIVOT names — so off means off rather than mostly off.
It does not reach casing that the parse tree proves. CAST, NVARCHAR, WITHIN GROUP and
DBCC CHECKDB … WITH NO_INFOMSGS stay cased under keywordCase with the switch off, because this
switch exists for the one decision made from a list instead of from the grammar.
Caveats:
- The call must have no qualifier. SQL Server requires at least a two-part name to invoke a
user-defined function, so an unqualifiedlen(x)can only ever bind to the built-in. If you have a
function of your own calledLen, you reach it asdbo.Len(x)— which has a call target and is
left exactly as you wrote it. - The name must be undelimited.
[len](x)is untouched. Brackets are how you say "this
identifier is spelled exactly like this". - The list is consulted in function-name position only. A column named
len, a table named
replace, an alias namedgetdate, a variable named@len. All plain names are untouched.
Global variables
IF @@ERROR <> 0 PRINT @@SERVERNAME; -- @@rowcount, @@trancount, @@fetch_status, …DECLARE @@MyVar INT is legal T-SQL, and ScriptDom resolves a later reference by
spelling rather than by scope, so in an expression position a
local @@MyVar arrives as the very same GlobalVariableExpression that @@ROWCOUNT does. Recasing
on the prefix quietly renames that variable under a case-sensitive collation. So the
documented globals are listed by name, and a local variable spelled like a system one keeps every
character you wrote.
WITHIN GROUP is one clause
-- 0.1.1 -- 0.1.2
STRING_AGG(a, ',') within GROUP (ORDER BY b) STRING_AGG(a, ',') WITHIN GROUP (ORDER BY b)GROUP is reserved and lexed as its own token; WITHIN is not reserved and lexed as an identifier.
The clause reached the output through a slice that recases neither names nor identifiers — because a
COLLATE Latin1_General_BIN can sit in that same region, and a collation name is a name. So half the
clause came up and half did not.
Fixed structurally rather than by spelling: WithinGroupClause is a node, so its head is a region the
grammar guarantees holds no name. The collation that shares the old region is not a node in the same
way, and keeps exactly the treatment it had. The clause also gained a layout, breaking at its own
parenthesis when the line is long, the same as OVER.
PIVOT aggregates
-- 0.1.1 -- 0.1.2
SELECT SUM(a) FROM t; SELECT SUM(a) FROM t;
… PIVOT (sum(amount) FOR m IN ([Jan])) p … PIVOT (SUM(amount) FOR m IN ([Jan])) pThe same split casing as WITHIN GROUP, in a different place. PIVOT names its aggregate through
AggregateFunctionIdentifier, which is a MultiPartIdentifier rather than a FunctionCall, so the
built-in list never saw it — a SUM in the select list came up while a sum two lines below did not.
DBCC
-- 0.1.1 -- 0.1.2
dbcc checkdb('MyDb') with no_infomsgs; DBCC CHECKDB('MyDb') WITH NO_INFOMSGS;
dbcc shrinkfile (MyFileName, 100); DBCC SHRINKFILE (MyFileName, 100);Administrative statements reach the output through a generic fallback that normalises spacing and
descends into children but deliberately recases nothing, because it cannot tell a keyword from a name
in a construct nobody wrote a handler for. That is still true of GRANT, BACKUP and the rest, and
it is the right default.
DBCC is different because the parser resolves it to an enum. checkdb and no_infomsgs are
non-reserved and lex as Identifier, so the tokens prove nothing; but ScriptDom reports
DbccCommand.CheckDB and DbccOptionKind.NoInfoMessages, which is the parser saying it matched fixed
vocabulary. Only those positions are claimed (the file name in the second line above is an ordinary
identifier and survives verbatim).
One case is excluded on purpose. DBCC mydll (FREE) names an extended-procedure library, and
ScriptDom still reports Command = Free, so the enum alone would have recased somebody's DLL name.
Those statements are handed back untouched.
Added 7 missing function families
| family | ||
|---|---|---|
| Graph | SQL Server 2017 | NODE_ID_FROM_PARTS, EDGE_ID_FROM_PARTS, OBJECT_ID_FROM_NODE_ID, … |
| Collation | COLLATIONPROPERTY, TERTIARY_WEIGHTS |
|
| Regular expression | SQL Server 2025 | REGEXP_LIKE, REGEXP_REPLACE, REGEXP_SUBSTR, REGEXP_INSTR, REGEXP_COUNT |
| Fuzzy string | SQL Server 2025 | EDIT_DISTANCE, JARO_WINKLER_SIMILARITY, … |
| Vector | SQL Server 2025 | VECTOR_DISTANCE, VECTOR_NORM, VECTOR_NORMALIZE, VECTORPROPERTY |
| AI | SQL Server 2025 | AI_TRANSLATE, AI_SUMMARIZE, AI_CLASSIFY, … |
| External | INVOKE_EXTERNAL_API |
Names that already had a node of their own are still not on the list, and this is the rule the
list is kept to: REGEXP_MATCHES and REGEXP_SPLIT_TO_TABLE return tables and arrive as a
GlobalFunctionTableReference, exactly as STRING_SPLIT does, so the parser has already matched them.
AI_GENERATE_EMBEDDINGS, AI_GENERATE_CHUNKS and VECTOR_SEARCH have syntax rather than arguments —
USE MODEL, SOURCE = …, TABLE = … AS x — and get nodes of their own. Those three are still
passed through unchanged; casing them is not in this release.
maxdop 0.1.1
What's Changed
- Bump actions/attest-build-provenance from 3.0.0 to 4.2.2 in the actions group by @dependabot[bot] in #1
- Bump Microsoft.NET.Test.Sdk from 17.14.1 to 18.9.0 by @dependabot[bot] in #3
- Bump xunit.runner.visualstudio from 3.1.4 to 4.0.0 by @dependabot[bot] in #4
- Bump coverlet.collector from 6.0.4 to 10.0.1 by @dependabot[bot] in #2
New Contributors
- @dependabot[bot] made their first contribution in #1
Full Changelog: v0.1.0...v0.1.1
maxdop 0.1.0
A T-SQL formatter that runs in CI, understands the whole language, and checks its own work.
First public release. One static binary, no runtime to install, MIT.
Why another SQL formatter
Most of them never parse your SQL — they split it into tokens and guess, which holds until the shape
of the code matters. maxdop is built on ScriptDom,
Microsoft's own T-SQL parser, the one behind DacFx and SQL database projects. Twelve grammars, SQL
Server 2000 through 2025 plus Fabric DW. Stored procedures, GO batches, custom delimiters and
2000-era syntax all read the way the server reads them.
Microsoft ships two ScriptDom-based formatters of its own, and both live inside an editor. Neither
has a command line, so neither can fail a build. That is the gap this fills: the same binary runs in
your editor and your pipeline, and the style lives in a .maxdop.json committed next to the code.
It verifies its own output
Every format is re-parsed and compared against the input — token stream, tree, and comments. On any
mismatch you get your original file back untouched and a distinct exit code. Below that sits a
byte-level gate: encodings round-trip or the file is not written, so a UTF-16-with-BOM file stays
UTF-16 with its BOM, CRLF stays CRLF, and a Windows-1252 file is declined rather than mangled.
Measured against 2,215 real-world files — AdventureWorks, WideWorldImporters, the First Responder
Kit, Ola Hallengren's Maintenance Solution, sp_WhoIsActive, and ScriptDom's own parser test suite —
formatted once per configuration option, ten variants each:
| Refused, crashed, or non-idempotent | 0, in every variant |
| Comments lost | 0 of 11,261 |
| Token coverage, corpus-wide | 98.6% |
| Per-file median coverage | 100% |
Those numbers are re-measured nightly in CI, not on a laptop. How it's measured →
Install
Download for your platform, or install maxdop from the VS Code Marketplace — the extension
bundles the binary and downloads nothing on first run.
# Linux x64
curl -fsSL -O https://github.com/pagebrooks/maxdop/releases/download/v0.1.0/maxdop-0.1.0-linux-x64.tar.gz
tar -xzf maxdop-0.1.0-linux-x64.tar.gz && sudo install maxdop-0.1.0-linux-x64/maxdop /usr/local/bin/Builds for linux-x64, linux-arm64, linux-musl-x64 (Alpine), win-x64, win-arm64, osx-x64
and osx-arm64. ~18 MB, ~3 ms cold start, no .NET runtime, no Node, no installer.
Verify what you downloaded
Every archive and VSIX carries build provenance,
signed by the workflow that produced it:
gh attestation verify maxdop-0.1.0-linux-x64.tar.gz --repo pagebrooks/maxdopSHA256SUMS is attached as well. The attestation is the stronger check — whoever could replace the
binaries could rewrite the checksum file alongside them.
These binaries are not signed with an Apple Developer ID or an Authenticode certificate, so macOS
Gatekeeper and Windows SmartScreen will warn on a direct download. The Marketplace extension is not
affected.
Using it
maxdop query.sql # formatted SQL to stdout
maxdop --write src/ # a file or a directory, searched to the bottom
maxdop --check src/ # exit 1 if anything would change
maxdop --parser-version 2016 # pin the grammar, so 2016 code is not reformatted under 2025 rules
cat query.sql | maxdop # stdin to stdout, how editors call itPoint it at directories rather than at a src/**/*.sql glob: globstar is off in a default bash,
including the shell GitHub Actions runs, so ** collapses to one directory level and quietly checks
a fraction of your repository.
Exit codes are the contract with CI. 0 nothing to do · 1 the input's problem and the file is
untouched · 2 maxdop's problem, please report it.
Only the files that changed
maxdop has no dependency on git and does not want one — it would mean a git binary in every CI
image, and it breaks in shallow checkouts and in Perforce and Mercurial shops. Hand it the list:
git diff --name-only --diff-filter=ACM -z origin/main | maxdop --check --files-from -Separators are detected, so -z and -print0 output work without a second flag. An empty list exits
0, because a pull request that touched no SQL is the commonest way to reach this command.
Adopting on a codebase nobody has formatted
--check on an established repository fails on every file at once, so nobody turns it on.
maxdop --write-baseline src/ # record today's unformatted files
maxdop --check --baseline .maxdop-baseline src/ # green, and gets stricter on its ownA baseline entry is the hash of a file's current, unformatted bytes. Edit the file and the hash stops
matching, so it has to be formatted to pass. The count only goes down, as people touch code they were
already touching. It is sorted plain text in sha256sum format — reviewable as a diff, mergeable line
by line, and it needs no version control history to work.
Configuration
One .maxdop.json at the repo root; the nearest one at or above the file wins.
{
"maxWidth": 100,
"indentSize": 4,
"useTabs": false,
"keywordCase": "upper",
"leadingCommas": false,
"alwaysBreakSelectList": false,
"alwaysBreakWhere": false,
"maxBlankLines": 1,
"parserVersion": "2022",
"initialQuotedIdentifiers": false,
"exclude": ["db/generated/**", "*.gen.sql"]
}There are no editor-level formatting settings, on purpose. A repository formats the same way whoever
opens it.
Known limitations
--rangeis reserved and not implemented. It is rejected rather than ignored, because
formatting the whole document when the caller asked for a selection is how "Format Selection ate my
file" happens. The VS Code extension therefore offers no range formatting.- 15 comments across 8 files moved in the corpus run — almost all commented-out DDL inside
statements that have no handler yet and are emitted verbatim. Nothing is lost; a comment can land on
the wrong side of such a construct. - 1.4% of tokens corpus-wide are still passed through verbatim rather than laid out. They are
reproduced byte-for-byte, so this is a completeness limit, not a correctness one. - Files that do not parse are left alone, with the reason on stderr. sqlcmd directives
(:setvar,$(Var)) are the usual cause. Multi-batch scripts are split atGOand formatted
batch by batch, so one bad batch no longer costs the file. - No Oracle, MySQL, PostgreSQL or Snowflake, and no plans for them.
Editors
The VS Code extension bundles the platform binary. Neovim works through
conform.nvim, and anything that can pipe a buffer through
a command works too — the whole interface is stdin in, stdout out, exit code back.
Thanks
Built on ScriptDom (MIT). Measured against public T-SQL
from Microsoft, Brent Ozar Unlimited,
Ola Hallengren and
Adam Machanic — fetched for measurement, never
vendored.
Bug reports and corrections welcome. Security issues: see SECURITY.md.