commit 181077afe2b6d7d6a7eb6edd4d57300624f92203
parent 40c09addfb781c1dfaf92b230257421166af39c4
Author: James Eversole <james@eversole.co>
Date: Fri, 23 Oct 2026 19:11:33 -0500
Add bounded local source alignment with independent parser checks
Align adjacent definition heads, equals signs and shared-call arguments within conservative padding/layout boundaries. Preserve spelling, comments, CRLF and authored grouping. Isolate preparation and test idempotence, complete native AST equivalence and body/scope negative controls. Applicable Nix checks,41 formatter cases, parser comparison, source freshness and shipped compiler authentication pass atop main.
Diffstat:
6 files changed, 386 insertions(+), 43 deletions(-)
diff --git a/docs/language.md b/docs/language.md
@@ -290,17 +290,22 @@ transformed values, higher-order proxies, and effects.
## Source formatting
-The formatter aligns column-one groups with at least two same-name, same-arity,
-positive-arity definitions and single-line heads and bodies. It changes only
-spaces between the name, complete argument-pattern cells, and `=`. Cell spelling
-and the suffix starting at `=` remain verbatim. Columns use character widths
-and at least one separating space.
-
-Comments and blank lines stay in place. A multiline or unsupported member leaves
-the whole group unchanged. Local definitions, singleton groups, zero-argument
-bindings, and inconsistent arities are preserved. Tabs and comments in gaps are
-preserved; malformed lexing returns the original source. Header masking is used
-only for recognition. Line endings and final-newline presence survive formatting.
+The formatter has two conservative column-one alignment passes:
+
+- Same-name, same-arity equation groups align complete argument-pattern cells
+ and `=`. They require at least two positive-arity, single-line definitions.
+ An unsupported member leaves the whole equation group unchanged.
+- Adjacent distinct definitions align names, argument tails and `=`, including
+ constant bindings. A shared named-call prefix can also align its first simple
+ argument on the right-hand side. This local pass adds at most eight padding
+ spaces beyond the required separator per gap; otherwise the group is preserved.
+
+Blank lines, comments, headers, continuation bodies and unsupported rows separate
+local groups. Layout forms (`let`, `where`, `case`, `do`) retain their arrangement.
+The formatter preserves argument-pattern interiors, literal spellings, comments,
+line endings and final-newline presence. Authors set group boundaries with
+blank lines and comments. Tabs and comments in gaps remain intact. Malformed lexing returns the original source.
+Header masking is used only for recognition.
The formatter uses located syntax without resolving names or requiring complete
equation coverage. See [Tools](tri-tools.md.html#tri-equation-alignment) for preparation
diff --git a/docs/tri-tools.md b/docs/tri-tools.md
@@ -324,10 +324,26 @@ result/bin/tricu format path/to/source.tri \
--formatter "$FORMATTER_MANIFEST" --tool-store "$FORMAT_DIRECTORY/store"
```
-Preparation builds native dependencies, checks the formatting corpus and CLI
-controls, and records source hashes and compiler identity. Its bounds are 8 GiB
+Preparation creates an isolated workspace, builds native dependencies, checks
+the formatting corpus, idempotence and CLI controls, and records source hashes
+and compiler identity. Its bounds are 8 GiB
child heap, 900 seconds compiling the formatter, and 1200 seconds overall.
+For an independent native-parser comparison, including complete ASTs with only
+source locations removed, run:
+
+```sh
+python3 test/formatter_equivalence.py result/bin/tricu \
+ --formatter-directory "$FORMAT_DIRECTORY" --directory "$WORK/format-equivalence" \
+ lib/tools/format-tri.tri
+```
+
+The check writes candidates and evidence to its new output directory and leaves
+the source files unchanged. It also checks body/scope mutations against the parser comparison.
+Use `--baseline DIRECTORY` to compare named files against saved pre-formatting
+sources, including manually authored grouping changes. Historical invalid corpus
+members must remain rejected; successful AST comparisons count valid inputs only.
+
Default output is UTF-8 source on stdout. `--check` fails when formatting would
change bytes. `--write` replaces the file atomically, preserving permissions and
refusing symbolic links. Select one of those modes at a time. `--seconds` bounds
diff --git a/lib/tools/format-tri.tri b/lib/tools/format-tri.tri
@@ -2,8 +2,9 @@
!import recursion as R
!export formatTri
--- Conservative source alignment, not an AST printer. Token spans select exact
--- source cells; only spaces between head cells are replaced. Layout stays put.
+-- Conservative source alignment. Token spans select exact
+-- source cells. Head gaps and bounded repeated-call gaps may change; layout
+-- and authored group boundaries stay put.
tfSlice start end input = (let rec
walk remaining skip count = (lazyBool
(_ : [])
@@ -23,16 +24,16 @@ tfSpaces input = (let
tfPad number = (lazyBool (_ : [])
(_ : pair 32 (tfPad (pred number))) (isZero_? number))
-tfMaskChar 9 = 32
-tfMaskChar 13 = 32
+tfMaskChar 9 = 32
+tfMaskChar 13 = 32
tfMaskChar char = char
tfMaskLine [33 | rest] = map (_ : 32) (pair 33 rest)
-tfMaskLine line = map tfMaskChar line
+tfMaskLine line = map tfMaskChar line
-tfOpen ^sourceTokenOpen = just sourceTokenClose
+tfOpen ^sourceTokenOpen = just sourceTokenClose
tfOpen ^sourceTokenLeftBracket = just sourceTokenRightBracket
-tfOpen ^sourceTokenLeftBrace = just sourceTokenRightBrace
-tfOpen kind = nothing
+tfOpen ^sourceTokenLeftBrace = just sourceTokenRightBrace
+tfOpen kind = nothing
tfClosing kind = (or? (equal? kind sourceTokenClose)
(or? (equal? kind sourceTokenRightBracket) (equal? kind sourceTokenRightBrace)))
tfDepthStep kind stack = (lazyMaybe
@@ -41,9 +42,9 @@ tfDepthStep kind stack = (lazyMaybe
(equal? kind close)) stack)
(_ : stack) (tfClosing kind))
(close : pair close stack) (tfOpen kind))
-tfDepth [] stack = stack
+tfDepth [] stack = stack
tfDepth [token | rest] stack = tfDepth rest (tfDepthStep (sourceTokenKind token) stack)
-tfDepth input stack = [0]
+tfDepth input stack = [0]
-- Partition tokens by their physical source line, including newline tokens.
tfTokens end input = (let
@@ -64,17 +65,17 @@ tfSpanCell line offset start end = (tfSlice
(sub (sourcePositionOffset end) offset) line)
tfPatternCell line offset pattern = tfSpanCell line offset (sourceAstStart pattern) (sourceAstEnd pattern)
--- Cells include the name. Reject comments/tabs in inter-cell gaps: those are
--- not disposable whitespace. A whole capture's interior belongs to its cell.
+-- Cells include the name. Comments/tabs in inter-cell gaps preserve the row
+-- verbatim. A whole capture's interior belongs to its cell.
tfGaps patterns line offset end = (let
step pattern previous = (lazyBool
(_ : R.listContinue (sub (sourcePositionOffset (sourceAstEnd pattern)) offset))
(_ : R.listStop previous)
(tfSpaces (tfSlice previous (sub (sourcePositionOffset (sourceAstStart pattern)) offset) line)))
in emptyList? (snd (R.listFoldUntil patterns end step)))
-tfLastEnd [] end = end
+tfLastEnd [] end = end
tfLastEnd [pattern | rest] end = tfLastEnd rest (sourcePositionOffset (sourceAstEnd pattern))
-tfLastEnd input end = end
+tfLastEnd input end = end
tfWithin tokens end = (let
step token state = (lazyBool
@@ -82,13 +83,13 @@ tfWithin tokens end = (let
(lt_? end (sourcePositionOffset (sourceTokenEnd token))))
in fst (R.listFoldUntil tokens true step))
-tfParsed line offset tokens = (let
+tfParsedWith constants line offset tokens = (let
failed _ _ _ _ = nothing
parsed (name, (patterns, assign)) body _ _ = (let
nameEnd = sub (sourcePositionOffset (sourceTokenEnd name)) offset
end = sub (tfLastEnd patterns (sourcePositionOffset (sourceTokenEnd name))) offset
equalStart = sub (sourcePositionOffset (sourceTokenStart assign)) offset
- populated = and? (not? (emptyList? patterns)) (not? (emptyList? body))
+ populated = and? (or? constants (not? (emptyList? patterns))) (not? (emptyList? body))
singleLine = and? (emptyList? (tfDepth body [])) (tfWithin tokens (add offset (length line)))
cleanGaps = and? (tfGaps patterns line offset nameEnd) (tfSpaces (tfSlice end equalStart line))
accept ^true = (just (pair
@@ -101,30 +102,31 @@ tfParsed line offset tokens = (let
in parserMatchReply failed parsed (tfHead tokens))
-- row = (original, (group name or empty, (optional cells/suffix, obstruction))).
-tfRow line offset tokens depth = (lazyList
+tfRowWith constants line offset tokens depth = (lazyList
(_ : pair line (pair [] (pair nothing false)))
(token rest : lazyBool
(_ : pair line (pair (sourceTokenPayload token)
- (pair (tfParsed line offset tokens) false)))
+ (pair (tfParsedWith constants line offset tokens) false)))
(_ : pair line (pair [] (pair nothing true)))
(and? (emptyList? depth)
(and? (equal? (sourceTokenKind token) sourceTokenName)
(equal? (sourcePositionOffset (sourceTokenStart token)) offset)))) tokens)
-tfRows rows tokens = (let rec
+tfRowsWith constants rows tokens = (let rec
walk [] tokens offset depth = []
walk [line | rest] tokens offset depth = (let end = add offset (succ (length line)) in
let partition = tfTokens end tokens in let located = fst partition in
- pair (tfRow line offset (tfNoNewlines located) depth)
+ pair (tfRowWith constants line offset (tfNoNewlines located) depth)
(walk rest (snd partition) end (tfDepth located depth)))
walk input tokens offset depth = []
in walk rows tokens 0 [])
+tfRows = tfRowsWith false
tfOriginal (raw, metadata) = raw
-tfOriginal row = []
+tfOriginal row = []
tfName (raw, (name, metadata)) = name
-tfName row = []
+tfName row = []
tfCandidate (raw, (name, (candidate, obstruction))) = candidate
-tfCandidate row = nothing
--- Obstructed rows never inspect their candidate. A missing candidate is
+tfCandidate row = nothing
+-- Obstructed rows keep their candidate opaque. A missing candidate is
-- ignorable only for nameless rows; a named unparsed head blocks the group.
tfEligible rows = (let rec
walk [] arity count = not? (lt_? count 2)
@@ -146,10 +148,10 @@ tfEligible rows = (let rec
walk input arity count = false
in walk rows 0 0)
tfMax a b = lazyBool (_ : b) (_ : a) (lt_? a b)
-tfWidths [] prior = prior
-tfWidths [cell | rest] [] = pair (length cell) (tfWidths rest [])
+tfWidths [] prior = prior
+tfWidths [cell | rest] [] = pair (length cell) (tfWidths rest [])
tfWidths [cell | rest] [width | widths] = pair (tfMax (length cell) width) (tfWidths rest widths)
-tfWidths cells prior = prior
+tfWidths cells prior = prior
tfRowWidths widths row = lazyMaybe (_ : widths) (head : tfWidths (fst head) widths) (tfCandidate row)
tfRender [] widths suffix = suffix
tfRender [cell | rest] [width | widths] suffix = (append cell
@@ -169,10 +171,122 @@ tfGroups rows = (let rec
walk input name reversed = tfEmit (reverse reversed)
in walk rows [] [])
+-- A second, deliberately local pass aligns adjacent distinct definition names.
+-- Blank/comment/header/unsupported rows are barriers. Existing equation groups
+-- keep their pattern columns. Never add more than eight padding spaces per gap.
+tfLocalParts [name | arguments] = pair name (intercalate [32] arguments)
+tfLocalParts cells = pair [] []
+tfLocalHead row = lazyMaybe (_ : pair [] [])
+ (candidate : tfLocalParts (fst candidate)) (tfCandidate row)
+tfLocalLayout token = case sourceTokenKind token of
+ ^sourceTokenLet = true
+ ^sourceTokenWhere = true
+ ^sourceTokenDo = true
+ ^sourceTokenCase = true
+ _ = false
+tfLocalSafe line = parserMatchReply (_ _ _ _ : false)
+ (tokens _ _ _ : not? (any? tfLocalLayout tokens)) (lexGateSource (map tfMaskChar line))
+tfLocalReady row = lazyMaybe (_ : false)
+ (_ : tfLocalSafe (tfOriginal row)) (tfCandidate row)
+tfLocalUnique rows = (let rec
+ walk [] names = true
+ walk [row | rest] names = lazyBool (_ : false)
+ (_ : walk rest (pair (tfName row) names)) (lExist? (tfName row) names)
+ walk input names = false
+ in walk rows [])
+tfLocalNameWidth width row = case tfLocalHead row of
+ (name, []) = width
+ (name, arguments) = tfMax width (length name)
+ _ = width
+tfLocalArgumentWidth width row = tfMax width (length (snd (tfLocalHead row)))
+tfLocalEnd width row = tfMax width (succ (length (fst (tfLocalHead row))))
+tfLocalWidth rows = (let
+ name = foldl tfLocalNameWidth 0 rows
+ arguments = foldl tfLocalArgumentWidth 0 rows
+ end = foldl tfLocalEnd (add name (add arguments 2)) rows
+ in pair name end)
+tfLocalBound count = not? (lt_? 9 count)
+tfLocalFits widths row = case tfLocalHead row of
+ (name, []) = tfLocalBound (sub (snd widths) (length name))
+ (name, arguments) = and? (tfLocalBound (succ (sub (fst widths) (length name))))
+ (tfLocalBound (sub (snd widths) (add (succ (fst widths)) (length arguments))))
+ _ = false
+
+-- Only a repeated named call's first simple argument is column-aligned. The
+-- rest of the RHS, comments and quoting remain verbatim. Comment/tab gaps and
+-- different call prefixes disable RHS alignment for the entire local group.
+tfLocalAtom token = case sourceTokenKind token of
+ ^sourceTokenName = true
+ ^sourceTokenNumber = true
+ ^sourceTokenString = true
+ _ = false
+tfLocalPrefix suffix = (let
+ inspect [assign callee argument next | rest] = (let
+ start = sourcePositionOffset (sourceTokenStart argument)
+ end = sourcePositionOffset (sourceTokenEnd argument)
+ nextStart = sourcePositionOffset (sourceTokenStart next)
+ clean = and? (tfSpaces (tfSlice (sourcePositionOffset (sourceTokenEnd callee)) start suffix))
+ (tfSpaces (tfSlice end nextStart suffix))
+ in lazyBool
+ (_ : just [ (tfSlice 0 start suffix) (tfSlice start end suffix) (tfSlice nextStart (length suffix) suffix) ])
+ (_ : nothing)
+ (and? clean (and? (equal? (sourceTokenKind callee) sourceTokenName) (tfLocalAtom argument))))
+ inspect tokens = nothing
+ in parserMatchReply (_ _ _ _ : nothing) (tokens _ _ _ : inspect tokens) (lexGateSource (map tfMaskChar suffix)))
+tfLocalSuffix row = lazyMaybe (_ : []) snd (tfCandidate row)
+tfLocalPrefixRow row = tfLocalPrefix (tfLocalSuffix row)
+tfLocalBodyWidth width row = lazyMaybe (_ : width)
+ (cells : tfMax width (length (head (tail cells)))) (tfLocalPrefixRow row)
+tfLocalBodyMatches prefix width row = lazyMaybe (_ : false)
+ (cells : and? (equal? prefix (head cells))
+ (tfLocalBound (succ (sub width (length (head (tail cells))))))) (tfLocalPrefixRow row)
+tfLocalBodies rows = case rows of
+ [row | rest] = lazyMaybe (_ : nothing)
+ (cells : let width = foldl tfLocalBodyWidth 0 rows in
+ lazyBool (_ : just width) (_ : nothing)
+ (not? (any? (item : not? (tfLocalBodyMatches (head cells) width item)) rows)))
+ (tfLocalPrefixRow row)
+ _ = nothing
+tfLocalBody width row = lazyMaybe (_ : tfLocalSuffix row)
+ (size : lazyMaybe (_ : tfLocalSuffix row)
+ (cells : case cells of
+ [prefix atom rest] = append prefix (append atom
+ (append (tfPad (succ (sub size (length atom)))) rest))
+ _ = tfLocalSuffix row) (tfLocalPrefixRow row)) width
+tfLocalRender widths bodyWidth row = (let
+ suffix = tfLocalBody bodyWidth row
+ in case tfLocalHead row of
+ (name, []) = append name (append (tfPad (sub (snd widths) (length name))) suffix)
+ (name, arguments) = append name
+ (append (tfPad (succ (sub (fst widths) (length name))))
+ (append arguments (append
+ (tfPad (sub (snd widths) (add (succ (fst widths)) (length arguments)))) suffix)))
+ _ = tfOriginal row)
+tfLocalEmit rows = (let widths = tfLocalWidth rows in
+ lazyBool
+ (_ : let bodyWidth = tfLocalBodies rows in map (tfLocalRender widths bodyWidth) rows)
+ (_ : map tfOriginal rows)
+ (and? (not? (lt_? (length rows) 2))
+ (and? (tfLocalUnique rows) (not? (any? (row : not? (tfLocalFits widths row)) rows)))))
+tfLocalContinued [(_, (_, (_, ^true))) | rest] = true
+tfLocalContinued rows = false
+tfLocalGroups rows = (let rec
+ walk [] reversed = tfLocalEmit (reverse reversed)
+ walk [row | rest] reversed = lazyBool
+ (_ : walk rest (pair row reversed))
+ (_ : append (tfLocalEmit (reverse reversed)) (pair (tfOriginal row) (walk rest [])))
+ (and? (tfLocalReady row) (not? (tfLocalContinued rest)))
+ walk input reversed = tfLocalEmit (reverse reversed)
+ in walk rows [])
+tfLocal source = (let originalLines = lines source in
+ parserMatchReply (_ _ _ _ : source)
+ (tokens _ _ _ : intercalate [10] (tfLocalGroups (tfRowsWith true originalLines tokens)))
+ (lexGateSource (intercalate [10] (map tfMaskLine originalLines))))
+
formatTri source = (let
originalLines = lines source
masked = intercalate [10] (map tfMaskLine originalLines)
in parserMatchReply
(_ _ _ _ : ok source t)
- (tokens _ _ _ : ok (intercalate [10] (tfGroups (tfRows originalLines tokens))) t)
+ (tokens _ _ _ : ok (tfLocal (intercalate [10] (tfGroups (tfRows originalLines tokens)))) t)
(lexGateSource masked))
diff --git a/test/formatter_cases.json b/test/formatter_cases.json
@@ -80,9 +80,9 @@
"f x = x\n"
],
[
- "unchanged-1",
+ "adjacent-constants",
"a=1\nb =2\n",
- "a=1\nb =2\n"
+ "a =1\nb =2\n"
],
[
"unchanged-2",
@@ -158,5 +158,50 @@
"generated-8",
"f [] = t\nf [x0] = t\nf [x0 x1] = t\nf [x0 x1 x2] = t\nf [x0 x1 x2 x3] = t\nf [x0 x1 x2 x3 x4] = t\nf [x0 x1 x2 x3 x4 x5] = t\nf _ = t\n",
"f [] = t\nf [x0] = t\nf [x0 x1] = t\nf [x0 x1 x2] = t\nf [x0 x1 x2 x3] = t\nf [x0 x1 x2 x3 x4] = t\nf [x0 x1 x2 x3 x4 x5] = t\nf _ = t\n"
+ ],
+ [
+ "local-core",
+ "io action = B.pair \"tricuIO\" (B.pair 2 action)\npure value = B.pair 0 value\nbind action next = B.pair 1 (B.pair action next)\nfail diagnostic = B.pair 2 diagnostic\nscope body = B.pair 3 body\ncurrentScope = B.pair 4 t\n",
+ "io action = B.pair \"tricuIO\" (B.pair 2 action)\npure value = B.pair 0 value\nbind action next = B.pair 1 (B.pair action next)\nfail diagnostic = B.pair 2 diagnostic\nscope body = B.pair 3 body\ncurrentScope = B.pair 4 t\n"
+ ],
+ [
+ "local-heads",
+ "join ticket = B.pair 6 ticket\ndiscard ticket = B.pair 7 ticket\ncancel task reason = B.pair 8 (B.pair task reason)\ncancelled reason = B.pair 9 reason\n",
+ "join ticket = B.pair 6 ticket\ndiscard ticket = B.pair 7 ticket\ncancel task reason = B.pair 8 (B.pair task reason)\ncancelled reason = B.pair 9 reason\n"
+ ],
+ [
+ "local-barriers",
+ "a x = x\n\nlonger y = y\n-- group\nz = t\n",
+ "a x = x\n\nlonger y = y\n-- group\nz = t\n"
+ ],
+ [
+ "local-padding-bound",
+ "a x = P 1 x\nveryLongUnrelatedName y = P 2 y\n",
+ "a x = P 1 x\nveryLongUnrelatedName y = P 2 y\n"
+ ],
+ [
+ "local-layout-barrier",
+ "id x = x\nother value = do f\n value\n",
+ "id x = x\nother value = do f\n value\n"
+ ],
+ [
+ "local-patterns",
+ "one (t x) = x\ntwo _ = t\n",
+ "one (t x) = x\ntwo _ = t\n"
+ ],
+ [
+ "local-rhs-comment",
+ "a x = P 1 -- preserve\nb y = P \"long\" y\n",
+ "a x = P 1 -- preserve\nb y = P \"long\" y\n"
+ ],
+ [
+ "local-crlf",
+ "a x = x\r\nlong y = y\r\n",
+ "a x = x\r\nlong y = y\r\n"
+ ],
+ [
+ "local-continuation-barrier",
+ "f x = f\ng argument = g\n argument\nh z = z\n",
+ "f x = f\ng argument = g\n argument\nh z = z\n"
]
]
diff --git a/test/formatter_equivalence.py b/test/formatter_equivalence.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+"""Check formatting against a frozen native parser, ignoring only source spans.
+
+The oracle is independent of the formatter. Pattern structure, local scope,
+operator direction, literals and definition order remain in the comparison.
+No source files are changed; output candidates and evidence live in --directory.
+"""
+import argparse
+import hashlib
+import json
+import subprocess
+from pathlib import Path
+from bundle_dag import merge_bundles
+from fixture_sources import verify
+from source_order import Runner, field, number
+from tooling_batch_models import chars
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def items(tree):
+ while tree != b'\0':
+ yield field(tree, 0)
+ tree = field(tree, 1)
+
+
+def token(tree):
+ return [field(tree, 0).hex(), field(tree, 1, 0).hex()]
+
+
+def optional(tree, transform):
+ if tree == b'\0':
+ return None
+ assert tree[0] == 1
+ return transform(tree[1:])
+
+
+def ast(tree):
+ kind, payload = number(field(tree, 0)), field(tree, 1, 0)
+ if kind in (1, 2, 6, 7):
+ value = payload.hex()
+ elif kind in (3, 14):
+ value = [ast(field(payload, 0)), ast(field(payload, 1))]
+ elif kind == 4:
+ value = [field(payload, 0).hex(), ast(field(payload, 1))]
+ elif kind == 5:
+ value = ast(payload)
+ elif kind == 8:
+ value = [ast(x) for x in items(payload)]
+ elif kind in (9, 15):
+ value = [token(field(payload, 0)), ast(field(payload, 1, 0)), ast(field(payload, 1, 1))]
+ elif kind == 10:
+ value = [field(payload, 0).hex(), ast(field(payload, 1, 0)), ast(field(payload, 1, 1))]
+ elif kind == 11:
+ value = [ast(field(payload, 0)), [[optional(field(x, 0), token), ast(field(x, 1))]
+ for x in items(field(payload, 1))]]
+ elif kind == 12:
+ value = [[[ast(p) for p in items(field(x, 0))], ast(field(x, 1))] for x in items(payload)]
+ elif kind == 13:
+ tag, data = number(field(payload, 0)), field(payload, 1)
+ if tag in (0, 1, 4, 5):
+ part = data.hex()
+ elif tag in (2, 7):
+ part = ast(data)
+ elif tag == 3:
+ part = [ast(field(data, 0)), ast(field(data, 1))]
+ elif tag == 6:
+ part = [field(data, 0).hex(), ast(field(data, 1))]
+ else:
+ raise AssertionError(('unknown pattern', tag))
+ value = [tag, part]
+ else:
+ raise AssertionError(('unknown AST kind', kind))
+ return [kind, value]
+
+
+def program(reply):
+ if field(reply, 0) == b'\0':
+ return None
+ assert field(reply, 0) == b'\1\0'
+ value = field(reply, 1, 0)
+ return [[[token(field(d, 0)), ast(field(d, 1))] for d in items(field(value, 0))],
+ optional(field(value, 1), ast)]
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument('tricu', type=Path)
+ p.add_argument('--formatter-directory', required=True, type=Path)
+ p.add_argument('--directory', required=True, type=Path)
+ p.add_argument('--baseline', type=Path, help='Compare named repository files to saved pre-formatting sources')
+ p.add_argument('files', nargs='*', type=Path)
+ args = p.parse_args()
+ root = args.directory.resolve()
+ root.mkdir(parents=True, exist_ok=False)
+ selected = args.formatter_directory.resolve()
+ metadata = json.loads((selected / 'report.json').read_text())
+ assert metadata['complete'], 'formatter preparation must pass first'
+ assert hashlib.sha256((selected / 'formatter.arboricx').read_bytes()).hexdigest() == metadata['formatter_sha256']
+ rows = {r[2]: r for r in verify(ROOT / 'test/fixtures')}
+ fixture = ROOT / 'test/fixtures' / (rows['source_program'][0] + '.arboricx')
+ parser_bundle = root / 'parser.arboricx'
+ merge_bundles(parser_bundle, [(fixture, [('source_program.parseGateProgram', 'parse')])], prune_unreachable=True)
+ runner = Runner(args.tricu, root)
+ pin = runner.imported(parser_bundle, 'parser-import')
+
+ def parse(label, source):
+ masked = '\n'.join('--' + ' ' * (len(line) - 2) if line.startswith('!') else line
+ for line in source.split('\n'))
+ path = root / 'source.tree'
+ path.write_bytes(chars(masked))
+ return program(runner.invoke(label, pin, 'parse', ['--arg-tree-file', path]))
+
+ def format_source(source):
+ path = root / 'input.tri'
+ path.write_bytes(source.encode('utf-8'))
+ result = subprocess.run([str(args.tricu.resolve()), 'format', str(path), '--formatter',
+ metadata['formatter_manifest'], '--tool-store', str(selected / 'store'),
+ '+RTS', '-N1', '-M8192m', '-RTS'], capture_output=True, timeout=90)
+ assert result.returncode == 0, result.stderr.decode()
+ assert path.read_bytes() == source.encode('utf-8')
+ return result.stdout.decode('utf-8')
+
+ records = []
+ cases = [(label, before, after, False) for label, before, after in
+ json.loads((ROOT / 'test/formatter_cases.json').read_text())]
+ cases += [(str(path), path.read_bytes().decode('utf-8'), None, True) for path in args.files]
+ for i, (label, before, expected, required) in enumerate(cases):
+ after = format_source(before)
+ if expected is not None:
+ assert after == expected, label
+ assert format_source(after) == after, (label, 'idempotence')
+ original, formatted = parse(f'{i}-before', before), parse(f'{i}-after', after)
+ baseline_digest = None
+ if required:
+ assert original is not None, (label, 'source did not parse')
+ if args.baseline:
+ baseline = args.baseline / label
+ assert not Path(label).is_absolute() and baseline.is_file(), label
+ baseline_bytes = baseline.read_bytes()
+ baseline_digest = hashlib.sha256(baseline_bytes).hexdigest()
+ assert parse(f'{i}-baseline', baseline_bytes.decode('utf-8')) == original, (label, 'baseline AST changed')
+ assert original == formatted, (label, 'AST changed beyond locations')
+ (root / f'{i}-formatted.tri').write_bytes(after.encode('utf-8'))
+ records.append(dict(path=label, parsed=original is not None, changed=before != after,
+ baseline_sha256=baseline_digest,
+ normalized_ast_sha256=hashlib.sha256(json.dumps(original).encode()).hexdigest() if original is not None else None,
+ before_sha256=hashlib.sha256(before.encode()).hexdigest(),
+ after_sha256=hashlib.sha256(after.encode()).hexdigest(), output=f'{i}-formatted.tri'))
+ # A wrong body and a different binding scope must not pass this normalizer.
+ assert parse('control-body-a', 'f x = x\n') != parse('control-body-b', 'f x = t\n')
+ assert parse('control-scope-a', 'f x = let y = x in y\n') != parse('control-scope-b', 'f x = let y = x in x\n')
+ (root / 'report.json').write_text(json.dumps(dict(complete=True, records=records,
+ parser_sha256=hashlib.sha256(parser_bundle.read_bytes()).hexdigest(),
+ formatter_sha256=metadata['formatter_sha256']), indent=2) + '\n')
+ print(f'PASS {len(records)} formatting/idempotence checks; {sum(r["parsed"] for r in records)} span-normalized parse comparisons; body/scope negative controls')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/prepare_formatter.py b/test/prepare_formatter.py
@@ -25,6 +25,7 @@ def main():
root.mkdir(parents=True, exist_ok=False)
workspace = root / 'workspace'
workspace.mkdir()
+ (workspace / 'tricu.workspace').write_text('tricu.workspace.v1\n')
tools, store = root / 'tools', root / 'store'
report = dict(complete=False, compiler_sha256=metadata['sha256'], sources={}, commands=[], cases=[])
started = time.monotonic()
@@ -88,6 +89,8 @@ def check_formatter(root, formatter, store, run, diverging):
source.write_text(before)
assert run(['format', source, '--formatter', formatter, '--tool-store', store]) == after, label
assert source.read_bytes() == before.encode('utf-8')
+ source.write_text(after)
+ assert run(['format', source, '--formatter', formatter, '--tool-store', store]) == after, label + ' idempotence'
labels.append(label)
before, after = json.loads((ROOT / 'test/formatter_cases.json').read_text())[0][1:]
source.write_text(before)