diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1e7502..e7161f2 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 542e252..dc94639 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,19 @@ -# Prefer a pinned image which is used in other Dockerfiles. -# alpine:3.23.6@sha256:85fe1e81d6758c208f3e1eed4338a1997e19d4be002d4dd32d3100c9a8c010a0 -# alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 -# debian:bookworm-slim@sha256:f9c6a2fd2ddbc23e336b6257a5245e31f996953ef06cd13a59fa0a1df2d5c252 -# ubuntu:26.04@sha256:f144425ff09be612d6d9ad965196e9cdc23dae1f42110a8a11a3e9a8198759f7 -# ubuntu:24.04@sha256:534baea6a22c03a63003dbc8dbe78fe34bc0d7e595d9a9dc9834884ff530eb55 FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 -# install packages required to run the tests -RUN apk add --no-cache coreutils +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-representer/Dockerfile WORKDIR /opt/analyzer -COPY . . -ENTRYPOINT ["/opt/analyzer/bin/run.sh"] + +COPY gdlintrc gdlintrc +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 b7b4914..decba1e 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,9 @@ The Docker image to automatically run tests on GDScript solutions submitted to [Exercism]. -## Getting started +## Implementation -Build the analyzer, conforming to the [analyzer interface specification](https://github.com/exercism/docs/blob/main/building/tooling/analyzers/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 - -- 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 +The analyzer wraps the `gdlint` logic from `gdtoolkit` and outputs the results matching the Exercism interface. ## Run the analyzer diff --git a/bin/run-tests.sh b/bin/run-tests.sh index d4bbf19..efdf59b 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 new file mode 100755 index 0000000..cc04396 --- /dev/null +++ b/bin/run.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Generate a representation of a GDScript solution.""" + +import argparse +import dataclasses +import json +import pathlib +import yaml + +import gdtoolkit.linter + + +def gdlintrc() -> dict: + """Load the gdlint config.""" + path = pathlib.Path("gdlintrc") + if path.exists(): + return yaml.load(pathlib.Path("gdlintrc").read_text(), Loader=yaml.Loader) + return gdtoolkit.linter.DEFAULT_CONFIG + + +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", []) + if files: + return [input_path / file for file in files] + return [(input_path / slug.replace("-", "_")).with_suffix(".gd")] + + +def format_comments(path: pathlib.Path, problems: list[gdtoolkit.linter.problem.Problem]) -> list[dict[str, str]]: + return [ + {"comment": "gdscript.gdlint.finding", "params": dataclasses.asdict(problem) | {"file": path.name}} + for problem in problems + ] + + +def write_output(output_path: pathlib.Path, comments: list[dict], tags: list[str]) -> None: + """Write the representer files.""" + if not output_path.exists(): + output_path.mkdir(parents=True, exist_ok=True) + + (output_path / "tags.json").write_text(json.dumps({"tags": tags}, indent=2) + "\n") + (output_path / "analysis.json").write_text(json.dumps({"comments": comments}, indent=2, sort_keys=True) + "\n") + + +def arg_parser() -> argparse.ArgumentParser: + """Return an ArgumentParser.""" + parser = argparse.ArgumentParser() + parser.add_argument("slug") + parser.add_argument("input_dir") + parser.add_argument("output_dir") + return parser + + +def main() -> None: + """Generate a representation.""" + args = arg_parser().parse_args() + slug = args.slug + input_path = pathlib.Path(args.input_dir) + output_path = pathlib.Path(args.output_dir) + + print(f"== Analyzing {args.slug!r} ==") + comments = [] + config = gdlintrc() + for solution_file in solution_files(slug, input_path): + comments.extend(format_comments(solution_file, gdtoolkit.linter.lint_code(solution_file.read_text()))) + + tags = [] + + write_output(output_path, comments, tags) + print(f"== Done {args.slug!r} ==") + + +if __name__ == "__main__": + main() diff --git a/bin/run.sh b/bin/run.sh index dd5f056..51bb0c0 100755 --- a/bin/run.sh +++ b/bin/run.sh @@ -15,28 +15,4 @@ # Example: # ./bin/run.sh two-fer path/to/solution/folder/ path/to/output/directory/ -# If any required arguments is missing, print the usage and exit -if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]; then - echo "usage: ./bin/run.sh exercise-slug path/to/solution/folder/ path/to/output/directory/" - exit 1 -fi - -slug="$1" -solution_dir=$(realpath "${2%/}") -output_dir=$(realpath "${3%/}") -analysis_file="${output_dir}/analysis.json" -tags_file="${output_dir}/tags.json" - -# Create the output directory if it doesn't exist -mkdir -p "${output_dir}" - -echo "${slug}: analyzing..." - -# Analyze the solution -# TODO: replace the below command with your own command(s) -# to analyze the solution and output the analysis.json and -# tags.json files -echo '{"comments": []}' > "${analysis_file}" -echo '{"tags": []}' > "${tags_file}" - -echo "${slug}: done" +python3 "${0%.*}.py" "$@" diff --git a/gdlintrc b/gdlintrc new file mode 100644 index 0000000..5f41e41 --- /dev/null +++ b/gdlintrc @@ -0,0 +1,47 @@ +class-definitions-order: +- tools +- classnames +- extends +- docstrings +- signals +- enums +- consts +- staticvars +- exports +- pubvars +- prvvars +- onreadypubvars +- onreadyprvvars +- others +class-load-variable-name: (([A-Z][a-z0-9]*)+|_?[a-z][a-z0-9]*(_[a-z0-9]+)*) +class-name: ([A-Z][a-z0-9]*)+ +class-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)* +comparison-with-itself: null +constant-name: _?[A-Z][A-Z0-9]*(_[A-Z0-9]+)* +disable: [] +duplicated-load: null +enum-element-name: '[A-Z][A-Z0-9]*(_[A-Z0-9]+)*' +enum-name: ([A-Z][a-z0-9]*)+ +excluded_directories: !!set + .git: null +expression-not-assigned: null +function-argument-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)* +function-arguments-number: 10 +function-name: (_on_([A-Z][a-z0-9]*)+(_[a-z0-9]+)*|_?[a-z][a-z0-9]*(_[a-z0-9]+)*) +function-preload-variable-name: ([A-Z][a-z0-9]*)+ +function-variable-name: '[a-z][a-z0-9]*(_[a-z0-9]+)*' +load-constant-name: (([A-Z][a-z0-9]*)+|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*) +loop-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)* +max-file-lines: 1000 +max-line-length: 120 +max-public-methods: 100 +max-returns: 8 +mixed-tabs-and-spaces: null +no-elif-return: null +no-else-return: null +signal-name: '[a-z][a-z0-9]*(_[a-z0-9]+)*' +sub-class-name: _?([A-Z][a-z0-9]*)+ +tab-characters: 1 +trailing-whitespace: null +unnecessary-pass: null +unused-argument: null diff --git a/tests/else-return/else_return.gd b/tests/else-return/else_return.gd new file mode 100644 index 0000000..b71cb1a --- /dev/null +++ b/tests/else-return/else_return.gd @@ -0,0 +1,7 @@ +func foo(a): + if a == 1: + return "One" + elif a == 2: + return "Two" + else: + return "Three" diff --git a/tests/else-return/expected_analysis.json b/tests/else-return/expected_analysis.json new file mode 100644 index 0000000..21597bb --- /dev/null +++ b/tests/else-return/expected_analysis.json @@ -0,0 +1,24 @@ +{ + "comments": [ + { + "comment": "gdscript.gdlint.finding", + "params": { + "column": 2, + "description": "Unnecessary \"elif\" after \"return\"", + "file": "else_return.gd", + "line": 4, + "name": "no-elif-return" + } + }, + { + "comment": "gdscript.gdlint.finding", + "params": { + "column": 2, + "description": "Unnecessary \"else\" after \"return\"", + "file": "else_return.gd", + "line": 6, + "name": "no-else-return" + } + } + ] +} diff --git a/tests/else-return/expected_tags.json b/tests/else-return/expected_tags.json new file mode 100644 index 0000000..8468624 --- /dev/null +++ b/tests/else-return/expected_tags.json @@ -0,0 +1,3 @@ +{ + "tags": [] +} diff --git a/tests/naming/expected_analysis.json b/tests/naming/expected_analysis.json new file mode 100644 index 0000000..07a4dcd --- /dev/null +++ b/tests/naming/expected_analysis.json @@ -0,0 +1,34 @@ +{ + "comments": [ + { + "comment": "gdscript.gdlint.finding", + "params": { + "column": 6, + "description": "Function name \"Foo\" is not valid", + "file": "naming.gd", + "line": 3, + "name": "function-name" + } + }, + { + "comment": "gdscript.gdlint.finding", + "params": { + "column": 6, + "description": "Function-scope variable name \"Name\" is not valid", + "file": "naming.gd", + "line": 4, + "name": "function-variable-name" + } + }, + { + "comment": "gdscript.gdlint.finding", + "params": { + "column": 7, + "description": "Constant name \"Five\" is not valid", + "file": "naming.gd", + "line": 1, + "name": "constant-name" + } + } + ] +} diff --git a/tests/naming/expected_tags.json b/tests/naming/expected_tags.json new file mode 100644 index 0000000..8468624 --- /dev/null +++ b/tests/naming/expected_tags.json @@ -0,0 +1,3 @@ +{ + "tags": [] +} diff --git a/tests/naming/naming.gd b/tests/naming/naming.gd new file mode 100644 index 0000000..8a1a755 --- /dev/null +++ b/tests/naming/naming.gd @@ -0,0 +1,4 @@ +const Five = 5 + +func Foo(): + var Name = "bob" diff --git a/tests/no-comments/expected_analysis.json b/tests/no-comments/expected_analysis.json index ec7c5c2..a0d0268 100644 --- a/tests/no-comments/expected_analysis.json +++ b/tests/no-comments/expected_analysis.json @@ -1 +1,3 @@ -{"comments": []} +{ + "comments": [] +} diff --git a/tests/no-comments/expected_tags.json b/tests/no-comments/expected_tags.json index c3f6273..8468624 100644 --- a/tests/no-comments/expected_tags.json +++ b/tests/no-comments/expected_tags.json @@ -1 +1,3 @@ -{"tags": []} +{ + "tags": [] +} diff --git a/tests/no-comments/no_comments.gd b/tests/no-comments/no_comments.gd new file mode 100644 index 0000000..0d59bd4 --- /dev/null +++ b/tests/no-comments/no_comments.gd @@ -0,0 +1,2 @@ +func foo(): + return 1 diff --git a/tests/trailing-whitespace/expected_analysis.json b/tests/trailing-whitespace/expected_analysis.json new file mode 100644 index 0000000..313b52b --- /dev/null +++ b/tests/trailing-whitespace/expected_analysis.json @@ -0,0 +1,14 @@ +{ + "comments": [ + { + "comment": "gdscript.gdlint.finding", + "params": { + "column": 0, + "description": "Trailing whitespace(s)", + "file": "trailing_whitespace.gd", + "line": 2, + "name": "trailing-whitespace" + } + } + ] +} diff --git a/tests/trailing-whitespace/expected_tags.json b/tests/trailing-whitespace/expected_tags.json new file mode 100644 index 0000000..8468624 --- /dev/null +++ b/tests/trailing-whitespace/expected_tags.json @@ -0,0 +1,3 @@ +{ + "tags": [] +} diff --git a/tests/trailing-whitespace/trailing_whitespace.gd b/tests/trailing-whitespace/trailing_whitespace.gd new file mode 100644 index 0000000..b498fd8 --- /dev/null +++ b/tests/trailing-whitespace/trailing_whitespace.gd @@ -0,0 +1,2 @@ +func foo(): + return 1 diff --git a/tests/unused-arg/expected_analysis.json b/tests/unused-arg/expected_analysis.json new file mode 100644 index 0000000..8dd9552 --- /dev/null +++ b/tests/unused-arg/expected_analysis.json @@ -0,0 +1,14 @@ +{ + "comments": [ + { + "comment": "gdscript.gdlint.finding", + "params": { + "column": 10, + "description": "unused function argument 'bob'", + "file": "unused_arg.gd", + "line": 1, + "name": "unused-argument" + } + } + ] +} diff --git a/tests/unused-arg/expected_tags.json b/tests/unused-arg/expected_tags.json new file mode 100644 index 0000000..8468624 --- /dev/null +++ b/tests/unused-arg/expected_tags.json @@ -0,0 +1,3 @@ +{ + "tags": [] +} diff --git a/tests/unused-arg/unused_arg.gd b/tests/unused-arg/unused_arg.gd new file mode 100644 index 0000000..81b1d30 --- /dev/null +++ b/tests/unused-arg/unused_arg.gd @@ -0,0 +1,2 @@ +func foo(bob): + return 1