|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +"""Wrapper script to download and run a suitable version of ANTLR for |
| 5 | +generating or verifying the Rust bindings for a given grammar.""" |
| 6 | + |
| 7 | +import urllib.request |
| 8 | +import os |
| 9 | +import sys |
| 10 | +import hashlib |
| 11 | +import logging |
| 12 | +import tempfile |
| 13 | +import shutil |
| 14 | +import filecmp |
| 15 | +import subprocess |
| 16 | +import difflib |
| 17 | +import argparse |
| 18 | + |
| 19 | + |
| 20 | +# NOTE: the Rust bindings for ANTLR are not (yet) official, so we need to |
| 21 | +# download a forked ANTLR build. |
| 22 | +ANTLR_URL = "https://github.com/rrevenantt/antlr4rust/releases/download/antlr4-4.8-2-Rust0.3.0-beta/antlr4-4.8-2-SNAPSHOT-complete.jar" |
| 23 | +ANTLR_SHA1 = "775d24ac1ad5df1eb0ed0e802f0fb2a5aeace43c" |
| 24 | + |
| 25 | + |
| 26 | +class Failure(Exception): |
| 27 | + """Used for fatal errors.""" |
| 28 | + |
| 29 | + |
| 30 | +def fail(msg): |
| 31 | + """Logs and throws an error message.""" |
| 32 | + logging.error(msg) |
| 33 | + raise Failure(msg) |
| 34 | + |
| 35 | + |
| 36 | +def download_file(fname, url): |
| 37 | + """Downloads a file if it does not already exist.""" |
| 38 | + if not os.path.isfile(fname): |
| 39 | + logging.info(f"Downloading {fname}...") |
| 40 | + urllib.request.urlretrieve(ANTLR_URL, fname) |
| 41 | + |
| 42 | + |
| 43 | +def verify_file_hash(fname, hash_str): |
| 44 | + """Verifies the hash of a (downloaded) file.""" |
| 45 | + logging.info(f"Verifying {fname}...") |
| 46 | + with open(fname, "rb") as f: |
| 47 | + file_hash = hashlib.sha1() |
| 48 | + while chunk := f.read(8192): |
| 49 | + file_hash.update(chunk) |
| 50 | + actual = file_hash.hexdigest() |
| 51 | + if hash_str != actual: |
| 52 | + fail(f"Verification failed; hash should be {hash_str} but was {actual}") |
| 53 | + |
| 54 | + |
| 55 | +def verify_file_identical(new, old): |
| 56 | + """Verifies that two text files are identical, printing a diff if not.""" |
| 57 | + logging.info(f"Verifying {new} against {old}...") |
| 58 | + if not os.path.isfile(new): |
| 59 | + fail(f"{new} does not exist") |
| 60 | + if not os.path.isfile(old): |
| 61 | + fail(f"{old} does not exist") |
| 62 | + if not filecmp.cmp(new, old, shallow=False): |
| 63 | + with open(new, "r") as f: |
| 64 | + new_data = f.readlines() |
| 65 | + with open(old, "r") as f: |
| 66 | + old_data = f.readlines() |
| 67 | + sys.stdout.writelines(difflib.unified_diff(old_data, new_data, old, new)) |
| 68 | + fail(f"{new} is different, see diff") |
| 69 | + |
| 70 | + |
| 71 | +def run_antlr(antlr, grammar, output_dir, verify=False, java="java"): |
| 72 | + """Runns the given ANTLR JAR on the given grammar, sending outputs to |
| 73 | + output_dir. If verify is set, instead of copying the newly-generated files, |
| 74 | + this checks that there are no differences between the newly and previously |
| 75 | + generated files.""" |
| 76 | + logging.info("Running ANTLR...") |
| 77 | + |
| 78 | + # Determine the names of the generated files that we're interested in. |
| 79 | + name = os.path.basename(grammar).split(".")[0].lower() |
| 80 | + expected_files = [f"{name}lexer.rs", f"{name}parser.rs", f"{name}listener.rs"] |
| 81 | + |
| 82 | + # Run in a temporary directory, because ANTLR spams random files we didn't |
| 83 | + # ask for in its working directory. |
| 84 | + with tempfile.TemporaryDirectory() as generate_dir: |
| 85 | + shutil.copyfile(grammar, os.path.join(generate_dir, os.path.basename(grammar))) |
| 86 | + subprocess.run( |
| 87 | + [ |
| 88 | + java, |
| 89 | + "-jar", |
| 90 | + os.path.realpath(antlr), |
| 91 | + "-Dlanguage=Rust", |
| 92 | + os.path.basename(grammar), |
| 93 | + ], |
| 94 | + cwd=generate_dir, |
| 95 | + ) |
| 96 | + |
| 97 | + logging.info("Copying/verifying output files...") |
| 98 | + for expected_file in expected_files: |
| 99 | + src = os.path.join(generate_dir, expected_file) |
| 100 | + dest = os.path.join(output_dir, expected_file) |
| 101 | + if not os.path.isfile(src): |
| 102 | + fail(f"ANTLR failed to generate {expected_file}") |
| 103 | + with open(src, "r+") as f: |
| 104 | + data = f.read() |
| 105 | + data = ( |
| 106 | + "// SPDX-License-Identifier: Apache-2.0\n" |
| 107 | + "#![allow(clippy::all)]\n" |
| 108 | + "#![cfg_attr(rustfmt, rustfmt_skip)]\n" |
| 109 | + f"{data}" |
| 110 | + ) |
| 111 | + f.seek(0) |
| 112 | + f.write(data) |
| 113 | + if verify: |
| 114 | + verify_file_identical(src, dest) |
| 115 | + else: |
| 116 | + if os.path.exists(dest): |
| 117 | + os.unlink(dest) |
| 118 | + shutil.copyfile(src, dest) |
| 119 | + |
| 120 | + |
| 121 | +def main(*args): |
| 122 | + """Utility to generate Rust bindings for an ANTLR grammar.""" |
| 123 | + parser = argparse.ArgumentParser(description=main.__doc__) |
| 124 | + parser.add_argument( |
| 125 | + "--antlr", |
| 126 | + metavar="antlr.jar", |
| 127 | + default=os.path.join(os.path.dirname(os.path.realpath(__file__)), "antlr.jar"), |
| 128 | + help="alternate location for the ANTLR jar", |
| 129 | + ) |
| 130 | + parser.add_argument( |
| 131 | + "--no-download", |
| 132 | + action="store_true", |
| 133 | + help="don't attempt to download the ANTLR jar", |
| 134 | + ) |
| 135 | + parser.add_argument( |
| 136 | + "--no-verify", |
| 137 | + action="store_true", |
| 138 | + help="don't attempt to verify the hash of the ANTLR jar", |
| 139 | + ) |
| 140 | + parser.add_argument( |
| 141 | + "--java", default="java", help="path to java executable to call ANTLR with" |
| 142 | + ) |
| 143 | + parser.add_argument( |
| 144 | + "--ci-check", |
| 145 | + action="store_true", |
| 146 | + help="instead of regenerating the files, assert that the files do not need to be regenerated", |
| 147 | + ) |
| 148 | + parser.add_argument("grammar", help="the .g4 grammar file to generate") |
| 149 | + parser.add_argument( |
| 150 | + "dest_dir", default=".", nargs="?", help="where to copy the generated files to" |
| 151 | + ) |
| 152 | + args = parser.parse_args(args) |
| 153 | + |
| 154 | + logging.basicConfig(level=logging.INFO) |
| 155 | + |
| 156 | + # Acquire ANTLR jar. |
| 157 | + if args.no_download: |
| 158 | + if not os.path.isfile(args.antlr): |
| 159 | + parser.error(f"{args.antlr} does not exist and auto-download is disabled") |
| 160 | + else: |
| 161 | + download_file(args.antlr, ANTLR_URL) |
| 162 | + if not args.no_verify: |
| 163 | + verify_file_hash(args.antlr, ANTLR_SHA1) |
| 164 | + |
| 165 | + # Run ANTLR. |
| 166 | + if not os.path.isfile(args.grammar): |
| 167 | + parser.error(f"{args.grammar} does not exist") |
| 168 | + run_antlr( |
| 169 | + args.antlr, args.grammar, args.dest_dir, verify=args.ci_check, java=args.java |
| 170 | + ) |
| 171 | + |
| 172 | + |
| 173 | +if __name__ == "__main__": |
| 174 | + try: |
| 175 | + main(*sys.argv[1:]) |
| 176 | + logging.info("Done") |
| 177 | + except Failure: |
| 178 | + logging.info("Returning failure exit status") |
| 179 | + sys.exit(1) |
0 commit comments