tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

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:
Mdocs/language.md | 27++++++++++++++++-----------
Mdocs/tri-tools.md | 20++++++++++++++++++--
Mlib/tools/format-tri.tri | 170++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mtest/formatter_cases.json | 49+++++++++++++++++++++++++++++++++++++++++++++++--
Atest/formatter_equivalence.py | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/prepare_formatter.py | 3+++
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)