From 7b070fd3ed3a38006a5a1e377e5a8837f35637c5 Mon Sep 17 00:00:00 2001 From: Isaac Good Date: Sat, 24 Oct 2026 17:10:59 -0700 Subject: [PATCH] Prepare the representer for launch * Set up the Docker image * Update README.md * Add docstrings to the representer Python tool --- .github/workflows/ci.yml | 2 +- Dockerfile | 16 +++++++++++++--- README.md | 34 ++++++++++++++-------------------- bin/run-tests.sh | 2 +- bin/run.py | 23 ++++++++++++++++++++--- 5 files changed, 49 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25adb0f..43c1229 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ on: jobs: build: name: Tests - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 diff --git a/Dockerfile b/Dockerfile index 975693c..7f3a21b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,18 @@ FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 -# TODO: install packages required to run the representer -RUN apk add --no-cache bash jq +RUN apk add --no-cache python3 py3-pip +RUN pip install --user --break-system-packages 'gdtoolkit == 4.5.0' +# gdtoolkit pins an older lark release. Force a newer version. +RUN pip install --user --break-system-packages --no-deps 'lark == 1.3.1' + +# Load and cache the gdscipt grammar +RUN python3 -c 'import gdtoolkit.parser; gdtoolkit.parser.parser.parse("")' + +# Everything prior to this line should be synced with gdscript-analyzer/Dockerfile WORKDIR /opt/representer -COPY . . + +COPY bin/run.sh bin/run.sh +COPY bin/run.py bin/run.py + ENTRYPOINT ["/opt/representer/bin/run.sh"] diff --git a/README.md b/README.md index b13e7fa..879758f 100644 --- a/README.md +++ b/README.md @@ -2,28 +2,22 @@ The Docker image to automatically create a representation for GDScript solutions submitted to [Exercism]. -## Getting started +## Implementation Details -Build the representer, conforming to the [Representer interface specification](https://github.com/exercism/docs/blob/main/building/tooling/representers/interface.md). -Update the files to match your track's needs. At the very least, you'll need to update `bin/run.sh`, `Dockerfile` and the test solutions in the `tests` directory. -The existing test solutions are suggestions for you could be testing, but remember to add your track-specific files too +The representer is written in Python. +It relies on `gdtoolkit 4.5.0` and `lark 1.3.1`. +It represents the solution using Lark's `pretty()` output. -- Tip: look for `TODO:` comments to point you towards code that need updating -- Tip: look for `OPTIONAL:` comments to point you towards code that _could_ be useful +### Normalizations -### Default Implementation - -The default implementation should work as follows: - -- The `representation.txt` contains the concatenated solution files - - Solution files are separated by an empty line - - Solution files are identified via the the `.files.solution[]` property in the `.meta/config.json` file -- The `mapping.json` contains an empty JSON object (`{}`) - -#### Normalizations - -- Blank files in the solution files are removed in the `representation.txt` -- Line-based trailing whitespace in the solution files is removed in the `representation.txt` +* **Comments:** The generated AST does not include comments so comments are removed. +* **Whitespace:** The generated AST does not include input whitespace so whitespace is normalized. +* **Docstrings:** Module docstrings are dropped from the output. +* **String expressions:** Function docstrings are treated as any "string expression". + String expressions (expressions that contain only a string) are dropped. +* **Variable names:** Function variables are replaced with placeholders. + This does not cover module variables or class variables. +* **Function names:** User defined functions have their names replaced with placeholders. ## Run the representer @@ -81,4 +75,4 @@ Bear in mind though that the performance on Exercism's production servers is oft [representers]: https://github.com/exercism/docs/tree/main/building/tooling/representers [golden]: https://ro-che.info/articles/2017-12-04-golden-tests -[exercism]: https://exercism.io +[exercism]: https://exercism.org diff --git a/bin/run-tests.sh b/bin/run-tests.sh index 946499c..2f2a2ed 100755 --- a/bin/run-tests.sh +++ b/bin/run-tests.sh @@ -24,7 +24,7 @@ for test_dir in tests/*; do expected_file="expected_${file}" echo "${test_dir_name}: comparing ${file} to ${expected_file}" - if ! diff "${test_dir_path}/${file}" "${test_dir_path}/${expected_file}"; then + if ! diff "${test_dir_path}/${expected_file}" "${test_dir_path}/${file}"; then exit_code=1 fi done diff --git a/bin/run.py b/bin/run.py index ce81697..3f9a253 100755 --- a/bin/run.py +++ b/bin/run.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +"""Generate a representation of a GDScript solution.""" import argparse import json @@ -8,7 +9,9 @@ import gdtoolkit.parser from lark import Token, Tree + def load_config(input_path: pathlib.Path) -> dict | None: + """Return the contents of .meta/config.json if it exists, or None.""" config_path = input_path / ".meta/config.json" if not config_path.exists(): return None @@ -16,6 +19,7 @@ def load_config(input_path: pathlib.Path) -> dict | None: def solution_files(slug: str, input_path: pathlib.Path) -> list[pathlib.Path]: + """Return the solution files, based on the config.json if available, or the slug.""" config = load_config(input_path) if config: files = config.get("files", {}).get("solution", []) @@ -25,6 +29,7 @@ def solution_files(slug: str, input_path: pathlib.Path) -> list[pathlib.Path]: def load_ast(slug: str, input_path: pathlib.Path) -> list[Tree]: + """Return the AST Trees for each solution file.""" return [ gdtoolkit.parser.parser.parse(file.read_text()) for file in solution_files(slug, input_path) @@ -32,7 +37,10 @@ def load_ast(slug: str, input_path: pathlib.Path) -> list[Tree]: def drop_docstr(trees: list[Tree]) -> list[Tree]: - def skip(t): + """Remove module docstrings and string-statements from the AST.""" + + def skip(t: Tree | Token) -> bool: + """Return if a node should be skipped.""" if isinstance(t, Tree) and t.data == "docstr_stmt": return True if ( @@ -48,7 +56,8 @@ def skip(t): return True return False - def translate(t): + def translate(t: Tree | Token) -> Tree | Token: + """Return a node, removing docstrings.""" if isinstance(t, Token): return t elif isinstance(t, Tree): @@ -60,6 +69,7 @@ def translate(t): def replace_func_names(trees: list[Tree]) -> tuple[list[Tree], dict[str, str]]: + """Replace custom function names with placeholders.""" varmap = {} for tree in trees: func_names = [ @@ -85,6 +95,10 @@ def translate(t): def replace_var_names(trees: list[Tree]) -> tuple[list[Tree], dict[str, str]]: + """Replace function variables with placeholders. + + This does not handle constants and module level variables. + """ varmap = {} for tree in trees: func_vars = [ @@ -112,8 +126,9 @@ def translate(t): def write_output(output_path: pathlib.Path, trees: list[Tree], varmap: dict[str, str]) -> None: + """Write the representer files.""" if not output_path.exists(): - output_path.mkdir(parents=Tree, exist_ok=True) + output_path.mkdir(parents=True, exist_ok=True) (output_path / "representation.json").write_text(json.dumps({"version": 1}, indent=2) + "\n") (output_path / "mapping.json").write_text(json.dumps(varmap, indent=2, sort_keys=True) + "\n") @@ -122,6 +137,7 @@ def write_output(output_path: pathlib.Path, trees: list[Tree], varmap: dict[str, def arg_parser() -> argparse.ArgumentParser: + """Return an ArgumentParser.""" parser = argparse.ArgumentParser() parser.add_argument("slug") parser.add_argument("input_dir") @@ -130,6 +146,7 @@ def arg_parser() -> argparse.ArgumentParser: def main() -> None: + """Generate a representation.""" args = arg_parser().parse_args() input_path = pathlib.Path(args.input_dir) output_path = pathlib.Path(args.output_dir)