tricu

A lazy functional language based on Tree Calculus
Log | Files | Refs | README | LICENSE

formatter_equivalence.py (7325B)


      1 #!/usr/bin/env python3
      2 """Check formatting against a frozen native parser, ignoring only source spans.
      3 
      4 The oracle is independent of the formatter. Pattern structure, local scope,
      5 operator direction, literals and definition order remain in the comparison.
      6 No source files are changed; output candidates and evidence live in --directory.
      7 """
      8 import argparse
      9 import hashlib
     10 import json
     11 import subprocess
     12 from pathlib import Path
     13 from bundle_dag import merge_bundles
     14 from fixture_sources import verify
     15 from source_order import Runner, field, number
     16 from tooling_batch_models import chars
     17 
     18 ROOT = Path(__file__).resolve().parent.parent
     19 
     20 
     21 def items(tree):
     22     while tree != b'\0':
     23         yield field(tree, 0)
     24         tree = field(tree, 1)
     25 
     26 
     27 def token(tree):
     28     return [field(tree, 0).hex(), field(tree, 1, 0).hex()]
     29 
     30 
     31 def optional(tree, transform):
     32     if tree == b'\0':
     33         return None
     34     assert tree[0] == 1
     35     return transform(tree[1:])
     36 
     37 
     38 def ast(tree):
     39     kind, payload = number(field(tree, 0)), field(tree, 1, 0)
     40     if kind in (1, 2, 6, 7):
     41         value = payload.hex()
     42     elif kind in (3, 14):
     43         value = [ast(field(payload, 0)), ast(field(payload, 1))]
     44     elif kind == 4:
     45         value = [field(payload, 0).hex(), ast(field(payload, 1))]
     46     elif kind == 5:
     47         value = ast(payload)
     48     elif kind == 8:
     49         value = [ast(x) for x in items(payload)]
     50     elif kind in (9, 15):
     51         value = [token(field(payload, 0)), ast(field(payload, 1, 0)), ast(field(payload, 1, 1))]
     52     elif kind == 10:
     53         value = [field(payload, 0).hex(), ast(field(payload, 1, 0)), ast(field(payload, 1, 1))]
     54     elif kind == 11:
     55         value = [ast(field(payload, 0)), [[optional(field(x, 0), token), ast(field(x, 1))]
     56                                           for x in items(field(payload, 1))]]
     57     elif kind == 12:
     58         value = [[[ast(p) for p in items(field(x, 0))], ast(field(x, 1))] for x in items(payload)]
     59     elif kind == 13:
     60         tag, data = number(field(payload, 0)), field(payload, 1)
     61         if tag in (0, 1, 4, 5):
     62             part = data.hex()
     63         elif tag in (2, 7):
     64             part = ast(data)
     65         elif tag == 3:
     66             part = [ast(field(data, 0)), ast(field(data, 1))]
     67         elif tag == 6:
     68             part = [field(data, 0).hex(), ast(field(data, 1))]
     69         else:
     70             raise AssertionError(('unknown pattern', tag))
     71         value = [tag, part]
     72     else:
     73         raise AssertionError(('unknown AST kind', kind))
     74     return [kind, value]
     75 
     76 
     77 def program(reply):
     78     if field(reply, 0) == b'\0':
     79         return None
     80     assert field(reply, 0) == b'\1\0'
     81     value = field(reply, 1, 0)
     82     return [[[token(field(d, 0)), ast(field(d, 1))] for d in items(field(value, 0))],
     83             optional(field(value, 1), ast)]
     84 
     85 
     86 def main():
     87     p = argparse.ArgumentParser(description=__doc__)
     88     p.add_argument('tricu', type=Path)
     89     p.add_argument('--formatter-directory', required=True, type=Path)
     90     p.add_argument('--directory', required=True, type=Path)
     91     p.add_argument('--baseline', type=Path, help='Compare named repository files to saved pre-formatting sources')
     92     p.add_argument('files', nargs='*', type=Path)
     93     args = p.parse_args()
     94     root = args.directory.resolve()
     95     root.mkdir(parents=True, exist_ok=False)
     96     selected = args.formatter_directory.resolve()
     97     metadata = json.loads((selected / 'report.json').read_text())
     98     assert metadata['complete'], 'formatter preparation must pass first'
     99     assert hashlib.sha256((selected / 'formatter.arboricx').read_bytes()).hexdigest() == metadata['formatter_sha256']
    100     rows = {r[2]: r for r in verify(ROOT / 'test/fixtures')}
    101     fixture = ROOT / 'test/fixtures' / (rows['source_program'][0] + '.arboricx')
    102     parser_bundle = root / 'parser.arboricx'
    103     merge_bundles(parser_bundle, [(fixture, [('source_program.parseGateProgram', 'parse')])], prune_unreachable=True)
    104     runner = Runner(args.tricu, root)
    105     pin = runner.imported(parser_bundle, 'parser-import')
    106 
    107     def parse(label, source):
    108         masked = '\n'.join('--' + ' ' * (len(line) - 2) if line.startswith('!') else line
    109                            for line in source.split('\n'))
    110         path = root / 'source.tree'
    111         path.write_bytes(chars(masked))
    112         return program(runner.invoke(label, pin, 'parse', ['--arg-tree-file', path]))
    113 
    114     def format_source(source):
    115         path = root / 'input.tri'
    116         path.write_bytes(source.encode('utf-8'))
    117         result = subprocess.run([str(args.tricu.resolve()), 'format', str(path), '--formatter',
    118             metadata['formatter_manifest'], '--tool-store', str(selected / 'store'),
    119             '+RTS', '-N1', '-M8192m', '-RTS'], capture_output=True, timeout=90)
    120         assert result.returncode == 0, result.stderr.decode()
    121         assert path.read_bytes() == source.encode('utf-8')
    122         return result.stdout.decode('utf-8')
    123 
    124     records = []
    125     cases = [(label, before, after, False) for label, before, after in
    126              json.loads((ROOT / 'test/formatter_cases.json').read_text())]
    127     cases += [(str(path), path.read_bytes().decode('utf-8'), None, True) for path in args.files]
    128     for i, (label, before, expected, required) in enumerate(cases):
    129         after = format_source(before)
    130         if expected is not None:
    131             assert after == expected, label
    132         assert format_source(after) == after, (label, 'idempotence')
    133         original, formatted = parse(f'{i}-before', before), parse(f'{i}-after', after)
    134         baseline_digest = None
    135         if required:
    136             assert original is not None, (label, 'source did not parse')
    137             if args.baseline:
    138                 baseline = args.baseline / label
    139                 assert not Path(label).is_absolute() and baseline.is_file(), label
    140                 baseline_bytes = baseline.read_bytes()
    141                 baseline_digest = hashlib.sha256(baseline_bytes).hexdigest()
    142                 assert parse(f'{i}-baseline', baseline_bytes.decode('utf-8')) == original, (label, 'baseline AST changed')
    143         assert original == formatted, (label, 'AST changed beyond locations')
    144         (root / f'{i}-formatted.tri').write_bytes(after.encode('utf-8'))
    145         records.append(dict(path=label, parsed=original is not None, changed=before != after,
    146             baseline_sha256=baseline_digest,
    147             normalized_ast_sha256=hashlib.sha256(json.dumps(original).encode()).hexdigest() if original is not None else None,
    148             before_sha256=hashlib.sha256(before.encode()).hexdigest(),
    149             after_sha256=hashlib.sha256(after.encode()).hexdigest(), output=f'{i}-formatted.tri'))
    150     # A wrong body and a different binding scope must not pass this normalizer.
    151     assert parse('control-body-a', 'f x = x\n') != parse('control-body-b', 'f x = t\n')
    152     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')
    153     (root / 'report.json').write_text(json.dumps(dict(complete=True, records=records,
    154         parser_sha256=hashlib.sha256(parser_bundle.read_bytes()).hexdigest(),
    155         formatter_sha256=metadata['formatter_sha256']), indent=2) + '\n')
    156     print(f'PASS {len(records)} formatting/idempotence checks; {sum(r["parsed"] for r in records)} span-normalized parse comparisons; body/scope negative controls')
    157 
    158 
    159 if __name__ == '__main__':
    160     main()