diff --git a/.github/run_tests.py b/.github/run_tests.py new file mode 100755 index 0000000..0bf5ce3 --- /dev/null +++ b/.github/run_tests.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""CI script to test the reference implementation for a particular chapter. +Test all combinations of extra credit features and other test options. +This script's main purpose is to test the test suite itself (e.g. make sure +we didn't accidentally use Part II features in what's supposed to be a +Part I-only test). +We do exhaustive tests of combinations only on Linux; on macOS we run +all the tests (including extra-credit tests) but don't run tests of +intermediate stages or different combinations of extra credit features. +""" +import argparse +import json +import platform +import subprocess + +# usage: /run_tests.py /path/to/cc --chapter 1 +# for pre-coalescing chapter 20: ./run_tests.py /path/to/cc --chapter 1 --no-coalesce +parser = argparse.ArgumentParser() +parser.add_argument( + "cc", type=str, nargs="?", default=None, help="Path to compiler under test" +) +parser.add_argument( + "--chapter", + type=str, + help=("Chapter whose implementation we're testing"), +) + +parser.add_argument( + "--skip-extra-credit", action="store_true", help="Don't run extra credit tests" +) + +args = parser.parse_args() + +CC: str = args.cc +CHAPTER: int +NO_COALESCING: bool = False +if args.chapter == "20a": + CHAPTER = 20 + NO_COALESCING = True +else: + CHAPTER = int(args.chapter) + +if CHAPTER < 1 or CHAPTER > 20: + exit("Bad chapter number") + +# do we need rosetta? +ARCH_PREFIX = "" +if platform.machine().lower() == "arm64": + ARCH_PREFIX = "arch -x86_64 " + +# figure out what combinations of extra-credit flags to test. Include: +# --extra-credit (all extra credit features that have been implemented so far) +# none (no extra credit features) +# each combo of extra-credit features covered by any test case in this +# chapter or an earlier one +EXTRA_CRED_CHAPTERS = { + "bitwise": 3, + "compound": 5, + "increment": 5, + "goto": 6, + "switch": 8, + "nan": 13, + "union": 18, +} + + +def get_idx(flag: str) -> tuple[int, str]: + """Sort flags by chapter, use alphabetical order as tiebreaker + Just for consistency/determinism in test runs + """ + return (EXTRA_CRED_CHAPTERS[flag], flag) + + +# all extra credit chapters implemented so far +ALL_EXTRA_CREDIT: tuple = tuple( + sorted( + [f for f in EXTRA_CRED_CHAPTERS if EXTRA_CRED_CHAPTERS[f] <= CHAPTER], + key=get_idx, + ) +) + +# find set of all extra-credit flag combinations we should test, +# based on test_properties.json, and record appropriate option +# strings to enable each of them (including options passed to test_compiler +# and options passed to the compiler itself +extra_credit_combos: dict[tuple, dict[str, str]] + +if args.skip_extra_credit: + extra_credit_combos = { + # none (empty set) + (): {"test_opts": "", "compiler_opts": ""}, + } +elif platform.system() == "Darwin": + # only run full --extra-credit option + extra_credit_combos = { + # all + ALL_EXTRA_CREDIT: { + "test_opts": "--extra-credit", + "compiler_opts": " ".join("--" + f for f in ALL_EXTRA_CREDIT), + }, + } +else: + extra_credit_combos = { + # none (empty set) + (): {"test_opts": "", "compiler_opts": ""}, + # all + ALL_EXTRA_CREDIT: { + "test_opts": "--extra-credit", + "compiler_opts": " ".join("--" + f for f in ALL_EXTRA_CREDIT), + }, + } + # if the key-value pair "test/case.c": (flag1, flag2, ...) + # appears in test_properties.json, and test/case.c is in the current + # chapter or earlier, add (flag1, flag2, ...) to our set of extra credit combos + with open("./test_properties.json", encoding="utf-8") as f: + extra_cred_flags = json.load(f)["extra_credit_tests"] + for path, flags in extra_cred_flags.items(): + # all keys in this dict start with chapter_N/ + chapter_num = int(path.split("/")[0].removeprefix("chapter_")) + if chapter_num <= CHAPTER: + k = tuple(sorted(flags, key=get_idx)) + opt_string = " ".join("--" + flag for flag in k) + extra_credit_combos[k] = { + "test_opts": opt_string, + "compiler_opts": opt_string, + } + + +# test functions +def run_tests(test_script_opts: str = "", cc_opts: str = "") -> None: + """Invoke test_compiler script with a particular set of options. + Args: + test_script_opts: options to pass test_compiler + cc_opts: options to pass the compiler under test (after -- ) + """ + + if cc_opts: + cc_opt_str = f" -- {cc_opts}" + else: + cc_opt_str = "" + + cmd = f"{ARCH_PREFIX}./test_compiler {CC} --chapter {CHAPTER} {test_script_opts}{cc_opt_str}" + print(cmd, flush=True) + subprocess.run( + cmd, + shell=True, + check=True, + ) + + +def test_normal() -> None: + """Main test method for if we're testing a chapter from part I or II""" + # define the stages we're going to test + stages: list[str] = ["lex", "parse", "tacky", "validate", "codegen", "run"] + if platform.system() == "Darwin": + # don't test intermediate stages + stages = ["run"] + elif CHAPTER == 1: + # tacky and validate stages not added yet + stages.remove("tacky") + stages.remove("validate") + elif CHAPTER < 5: + # TACKY stage added, validate not + stages.remove("validate") + for _, extra_cred_opts in extra_credit_combos.items(): + test_script_opts = extra_cred_opts["test_opts"] + compiler_opts = extra_cred_opts["compiler_opts"] + for stage in stages: + stage_test_script_opts = f"{test_script_opts} --stage {stage}" + run_tests(test_script_opts=stage_test_script_opts, cc_opts=compiler_opts) + + +def test_chapter_19() -> None: + """Run tests for chapter 19. + Don't test different stages but do run both with and without + --int-only option + """ + for _, extra_cred_opts in extra_credit_combos.items(): + test_script_opts = extra_cred_opts["test_opts"] + compiler_opts = extra_cred_opts["compiler_opts"] + # run with --int-only option + run_tests( + test_script_opts=test_script_opts + " --int-only", + cc_opts=compiler_opts + " --int-only", + ) + # run without it + run_tests(test_script_opts=test_script_opts, cc_opts=compiler_opts) + + +def test_chapter_20a() -> None: + """Run tests for chapter 20 without coalescing. + Don't test different stages but do run both with and without + --int-only option. + """ + for _, extra_cred_opts in extra_credit_combos.items(): + test_script_opts = extra_cred_opts["test_opts"] + compiler_opts = extra_cred_opts["compiler_opts"] + # run with --int-only option + run_tests( + test_script_opts=test_script_opts + " --int-only --no-coalescing", + cc_opts=compiler_opts + " --int-only", + ) + # run without it + run_tests( + test_script_opts=test_script_opts + " --no-coalescing", + cc_opts=compiler_opts, + ) + + +def test_chapter_20() -> None: + """Run tests for chapter 20 with coalescing. + Don't test different stages but do run both with and without + --int-only option. + """ + for _, extra_cred_opts in extra_credit_combos.items(): + test_script_opts = extra_cred_opts["test_opts"] + compiler_opts = extra_cred_opts["compiler_opts"] + # run with --int-only option + run_tests( + test_script_opts=test_script_opts + " --int-only", + cc_opts=compiler_opts + " --int-only", + ) + # run without it + run_tests(test_script_opts=test_script_opts, cc_opts=compiler_opts) + + +if __name__ == "__main__": + if CHAPTER < 19: + test_normal() + elif CHAPTER == 19: + test_chapter_19() + elif CHAPTER == 20: + if NO_COALESCING: + test_chapter_20a() + else: + test_chapter_20() + else: + exit(f"Bad chapter number {CHAPTER}") + exit(0) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..339588e --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,97 @@ +# Build tagged versions of the reference implementation compiler +name: Build the compiler + +on: + workflow_dispatch: + inputs: + chapter: + required: true + type: string + os: + required: true + default: ubuntu-latest + + workflow_call: + inputs: + chapter: + required: true + type: string + os: + type: string + required: true + default: ubuntu-latest + branch: + type: string + required: false + default: main + +env: + EXE_DIR: _build/default/bin/ + EXE_PATH: _build/default/bin/main.exe + +jobs: + + build: + runs-on: ${{ inputs.os }} + env: + CHAPTER: ${{ inputs.chapter }} + + steps: + + # first check out at specified branch so we can find commit hash for this chapter + - uses: actions/checkout@v4 + with: + repository: nlsandler/nqcc2 + ref: ${{ inputs.branch }} + fetch-depth: 0 # need this to get commit history + + # NOTE: for chapters with extra-credit features we have two commits: + # one for regular feature, one for extra credit. The '-n 1' options limits + # us to the first (i.e. later i.e. extra credit) commit hash + - name: Get commit hash + run: | + git log --grep "chapter $CHAPTER\b" -i --format='%H' -n 1 + commit=$(git log --grep "chapter $CHAPTER\b" -i --format='%H' -n 1) + echo "commit=$commit" >> $GITHUB_ENV + + - name: Check out NQCC at chapter ${{ inputs.chapter }} + uses: actions/checkout@v4 + with: + repository: nlsandler/nqcc2 + ref: ${{ env.commit }} + + - name: Construct cache key + id: make-key + env: + runner_os: ${{ runner.os }} + run: | + commit=$(git rev-parse --short "$commit") + echo "cache-key=${runner_os}-${commit}-nqcc" >> $GITHUB_OUTPUT + + - name: Cache build result + id: cache-nqcc + uses: actions/cache@v4 + with: + path: ${{ env.EXE_PATH }} + key: ${{ steps.make-key.outputs.cache-key }} + + # skip building if we get a cache hit + - name: Set up OCaml + if: steps.cache-nqcc.outputs.cache-hit != 'true' + uses: ocaml/setup-ocaml@v3 + with: + ocaml-compiler: 5.2.x + # necessary to avoid random errors, see https://github.com/ocaml/setup-ocaml/issues/400 + dune-cache: false + + - name: Build it + if: steps.cache-nqcc.outputs.cache-hit != 'true' + run: | + opam install . --deps-only + opam exec -- dune build + + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: nqcc-${{inputs.os}}-${{ inputs.chapter }} + path: ${{ env.EXE_PATH }} diff --git a/.github/workflows/build_and_test.yaml b/.github/workflows/build_and_test.yaml new file mode 100644 index 0000000..40f19a6 --- /dev/null +++ b/.github/workflows/build_and_test.yaml @@ -0,0 +1,64 @@ +name: Build and test the compiler + +on: + workflow_call: + inputs: + os: + required: true + type: string + chapter: + required: true + type: string + branch: + type: string + required: false + default: main + tests-branch: + type: string + required: false + default: main + +jobs: + build: + uses: ./.github/workflows/build.yaml + with: + chapter: ${{ inputs.chapter }} + os: ${{ inputs.os }} + branch: ${{ inputs.branch }} + + test: + runs-on: ${{ inputs.os }} + needs: [build] + env: + CHAPTER: ${{ inputs.chapter }} + steps: + + - name: Check out tests + uses: actions/checkout@v4 + with: + repository: nlsandler/writing-a-c-compiler-tests + ref: ${{ inputs.tests-branch }} + + - name: Check out test runner script + uses: actions/checkout@v4 + with: + repository: nlsandler/nqcc2 + ref: ${{ inputs.branch }} + sparse-checkout: | + .github/run_tests.py + sparse-checkout-cone-mode: false + path: script + + - name: Download the compiler + uses: actions/download-artifact@v4 + with: + name: nqcc-${{inputs.os}}-${{ inputs.chapter }} + path: nqcc + + # make NQCC executable + - run: chmod u+x nqcc/main.exe + + # Invoke the run_tests.py script to test each intermediate stage + - name: Run the tests + run: ./script/.github/run_tests.py nqcc/main.exe --chapter "${CHAPTER}" --skip-extra-credit + diff --git a/.github/workflows/test_each_chapter.yaml b/.github/workflows/test_each_chapter.yaml new file mode 100644 index 0000000..2007bdb --- /dev/null +++ b/.github/workflows/test_each_chapter.yaml @@ -0,0 +1,42 @@ +name: Build and test each chapter + +on: + workflow_dispatch: + inputs: + tests-branch: + type: string + required: false + default: main + + + workflow_call: + inputs: + tests-branch: + type: string + required: true + # should always be the name of the branch + # that holds this version of the workflow file; + # pass as input b/c we can't easily get it directly + cc-branch: + type: string + required: true + +jobs: + + + build-and-test: + strategy: + fail-fast: true + matrix: + os: [macos-latest, ubuntu-latest] + chapter: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20a","20"] + uses: ./.github/workflows/build_and_test.yaml + with: + chapter: ${{ matrix.chapter }} + os: ${{ matrix.os }} + # use inputs.cc-branch if this is a workflow call from other repo, + # and ref-name if this is a workflow dispatch; annoyingly + # there's no easy way to just get the short ref name + # for the current workflow + branch: ${{ inputs.cc-branch || github.ref_name }} + tests-branch: ${{ inputs.tests-branch }} diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b66b92 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# NQCC2, the Not-Quite-C Compiler + +**CAUTION: FREQUENT FORCE PUSHES** + +This is the reference implementation for the upcoming book [Writing a C Compiler](https://nostarch.com/writing-c-compiler), a hands-on guide to writing your own compiler for a big chunk of C. + +Each commit corresponds to one chapter in the book, to illustrate what needs to change at each step. Because commits are structured this way, this repository sees frequent rebases and force pushes - fork with caution! + +This implementation is still a work in progress - the functionality is complete, but needs more work on the readability front. + +# Building the Compiler + +This compiler is written in OCaml. Building it requires opam, the OCaml package manager (installation instructions [here](https://opam.ocaml.org/doc/Install.html)). + +Then do: + +``` +git clone https://github.com/nlsandler/nqcc2.git +cd nqcc2 +opam install . --deps-only +dune build +``` + +This puts the executable at `_build/default/bin/main.exe`. + +# Usage Example + +Assume we have this source file at ~/hello_world.c: +```c +int puts(char *c); + +int main(void) { + puts("Hello, world!"); +} +``` + +To compile and run it: +``` +$ _build/default/bin/main.exe ~/hello_world.c +$ ~/hello_world +Hello, World! +``` + + + + + +