Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
34 changes: 14 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion bin/run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 20 additions & 3 deletions bin/run.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env python3
"""Generate a representation of a GDScript solution."""

import argparse
import json
Expand All @@ -8,14 +9,17 @@
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
return json.loads((input_path / ".meta/config.json").read_text())


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", [])
Expand All @@ -25,14 +29,18 @@ 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)
]


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 (
Expand All @@ -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):
Expand All @@ -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 = [
Expand All @@ -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 = [
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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)
Expand Down
Loading