From e7495dbc33555e700c5e8264844c2ea9545427e6 Mon Sep 17 00:00:00 2001 From: HolonProduction Date: Fri, 3 Feb 2023 16:14:26 +0100 Subject: [PATCH] Initial commit --- .gitattributes | 1 + .github/FUNDING.yml | 13 ++ .gitignore | 6 + LICENSE | 19 ++ README.md | 129 +++++++++++++ codexgd/__about__.py | 1 + codexgd/__init__.py | 3 + codexgd/__main__.py | 138 +++++++++++++ codexgd/callback.py | 32 +++ codexgd/codex.py | 182 ++++++++++++++++++ codexgd/exceptions.py | 2 + codexgd/gdscript/__init__.py | 5 + codexgd/gdscript/gdscript_codex.py | 123 ++++++++++++ codexgd/gdscript/util.py | 10 + codexgd/presets/official.yml | 21 ++ codexgd/presets/recommended.yml | 7 + codexgd/problem.py | 24 +++ codexgd/rule.py | 136 +++++++++++++ codexgd/rules/function_names.py | 53 +++++ codexgd/rules/inner_class_names.py | 47 +++++ codexgd/rules/no_invalid_chars.py | 58 ++++++ codexgd/rules/require_extends.py | 31 +++ plugins/godot/.gitattributes | 2 + plugins/godot/.gitignore | 2 + .../godot/addons/codexgd/annotation_layer.gd | 156 +++++++++++++++ plugins/godot/addons/codexgd/plugin.cfg | 11 ++ plugins/godot/addons/codexgd/plugin.gd | 80 ++++++++ plugins/godot/codex.yml | 2 + plugins/godot/icon.svg | 98 ++++++++++ plugins/godot/icon.svg.import | 37 ++++ plugins/godot/main.gd | 11 ++ plugins/godot/main.tscn | 6 + plugins/godot/project.godot | 20 ++ pylintrc | 8 + pyproject.toml | 45 +++++ tests/__init__.py | 0 tests/common.py | 15 ++ tests/env/codex.yml | 4 + tests/env/example.gd | 12 ++ tests/env/rules/always_rule.py | 10 + tests/env/rules/custom_rule.py | 18 ++ tests/env/rules/never_rule.py | 3 + tests/env/rules/rule_with_state.py | 14 ++ tests/test_codex.py | 150 +++++++++++++++ tests/test_gdscript_codex.py | 67 +++++++ tests/test_gdscript_rules.py | 144 ++++++++++++++ 46 files changed, 1956 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/FUNDING.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 codexgd/__about__.py create mode 100644 codexgd/__init__.py create mode 100644 codexgd/__main__.py create mode 100644 codexgd/callback.py create mode 100644 codexgd/codex.py create mode 100644 codexgd/exceptions.py create mode 100644 codexgd/gdscript/__init__.py create mode 100644 codexgd/gdscript/gdscript_codex.py create mode 100644 codexgd/gdscript/util.py create mode 100644 codexgd/presets/official.yml create mode 100644 codexgd/presets/recommended.yml create mode 100644 codexgd/problem.py create mode 100644 codexgd/rule.py create mode 100644 codexgd/rules/function_names.py create mode 100644 codexgd/rules/inner_class_names.py create mode 100644 codexgd/rules/no_invalid_chars.py create mode 100644 codexgd/rules/require_extends.py create mode 100644 plugins/godot/.gitattributes create mode 100644 plugins/godot/.gitignore create mode 100644 plugins/godot/addons/codexgd/annotation_layer.gd create mode 100644 plugins/godot/addons/codexgd/plugin.cfg create mode 100644 plugins/godot/addons/codexgd/plugin.gd create mode 100644 plugins/godot/codex.yml create mode 100644 plugins/godot/icon.svg create mode 100644 plugins/godot/icon.svg.import create mode 100644 plugins/godot/main.gd create mode 100644 plugins/godot/main.tscn create mode 100644 plugins/godot/project.godot create mode 100644 pylintrc create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/common.py create mode 100644 tests/env/codex.yml create mode 100644 tests/env/example.gd create mode 100644 tests/env/rules/always_rule.py create mode 100644 tests/env/rules/custom_rule.py create mode 100644 tests/env/rules/never_rule.py create mode 100644 tests/env/rules/rule_with_state.py create mode 100644 tests/test_codex.py create mode 100644 tests/test_gdscript_codex.py create mode 100644 tests/test_gdscript_rules.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..7300eca --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: HolonProduction +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0badffe --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +.vscode/ +.pytest_cache/ +dist/ + +__pycache__ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4ae262d --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2022-2023 HolonProduction + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..723462d --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# CodexGD - *Give your code a dress code*. + +[![Godot 4.x](https://img.shields.io/static/v1?label=Godot&message=4.x&color=grey&logo=godotengine&logoColor=white&labelColor=478cbf)](https://godotengine.org) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Linting: pylint](https://img.shields.io/badge/linting-pylint-yellowgreen.svg)](https://github.com/PyCQA/pylint) + +> :construction: CodexGD is currently in development. If you are looking for a way to enforce the official style guide you should have a look at [gdtoolkit](https://github.com/Scony/godot-gdscript-toolkit). I am in fact using gdtoolkit internally myself. It has its own linter that does a very good job when it comes to the official style. The main reason for me to develop this package is that I have my own GDScript style and extending gdlint is not very simple. + +CodexGD is a configurable and extendable Godot style analyzer written in python. CodexGD comes with a set of rules that you can configure to your liking but it also gives you an easy way to write own rules using python. + +## :electric_plug: Installation +In the future CodexGD will be available via PyPi and the Godot Asset Lib. Until then you will have to set it up manually. Start by cloning this repo. +To use the backend you will need a python 3.11 installation. Install the cloned repo as python module by calling the following command in the cloned repo's root folder. +```shell +> python -m pip install -e . +``` +It is recommended to install CodexGD into a [virtual environment](https://docs.python.org/3/tutorial/venv.html). When doing this the codexgd command will not be available globaly. You will also have to configure your venv when using the editor plugin (more on this later). + +To install the editor plugin copy the `plugins/godot/addons/codexgd` folder into your project's `addons` folder. You should also create a `codex.yml` file at the root of your project before enabeling it. + +## :computer: General Usage +CodexGD is built around a `codex.yml` file which you will use to configure your style. The codex file will define the rules that will be used on your code. Here is a simple example: +```yml +extends: "official" # Use the official style as base. + +rules: + no-invalid-chars: "warn" # Downgrade the severity of the no-invalid-chars rule. +``` + +The easiest way to use CodexGD is to use the provided CLI. +```shell +> python -m codexgd project_directory +``` +CodexGD will look for a `codex.yml` file in the provided directory and apply it to all `.gd` files in the directory. + +You can also use the package from your own python scripts. The API is centered around `Codex` objects which you can create by providing a `codex.yml` file. + +```python +from codexgd.gdscript import GDScriptCodex + +with open("codex.yml", "r") as file: + codex = GDScriptCodex(file) + +with open("file.gd", "r") as file: + for problem in codex.check(file): + print(problem) +``` + +## :jigsaw: Editor Plugin +> :construction: The editor plugin is in development and not yet feature complete. + +CodexGD comes with an editor integration. It will display the problems that CodexGD detected in the script editor. It relies on the CLI, therefore an installation of the python package is needed. If the python package was installed globaly the plugin should work out of the box. Otherwise you will have to select the path to the `codexgd` command in the EditorSettings `"codexgd/general/command_path"`. The file will be located in your venv folder in the `scripts` subfolder. It should be named `codexgd` with an executable file ending. + +State of the editor integration: +- [x] Display problems in the script editor. +- [ ] Support for embedded scripts. +- [ ] Handle errors during the execution. (The plugin may fail silently.) +- [ ] Display overlapping problems in a good way. +- [ ] Help with the setup of a `codex.yml` file. +- [ ] Provide a simple way to see all problems of the project. + +## :wrench: Configuration + +### :scroll: The codex file +The codex file is a yaml file that may contain the following keys. + +**`extends`** +Which style to use as base. Currently only `"official"` and `"recommended"` are supported values. You may overwrite the rule settings with the `rules` key. + +**`rules`** +A list of rules with associated options. +```yaml +rules: + no-invalid-chars: + level: "warn" # The severity of the rule. Allowed values: off, warn, error + options: + codec: "utf8" + # If no options are specified the severity may be specified directly. + require-extends: "error" +``` +There are multiple ways to specify which rule is meant. You can use the rule name as above but this is only possible if the rule was loaded before. You can use the name `no-invalid-chars` because the `official` base file loaded it. To load a rule you specify the python module name. You can use a global module specification like `codexgd.rules.no_invalid_chars`. You may also use relative module specifications. In this case codexgd will try to resolve them as relative path from your codex file. In this way you can load custom rules which are located in your project directory. +```yaml +rules: + .rules.custom_rule: "error" +``` +The code above will load a rule from `res://rules/custom_rule.py` given your codex file is located at `res://codex.yml`. +> :warning: CodexGD will not load rules from outside the codexgd package by default because an untrusted project may execute code on your machine in this way. Pass the `--load-unsafe-code` option to the CLI to confirm that you know about the risks. + +**`variables`** +A dictionary of named values which may be used in the options of rules later. This allows to unify values which are needed by multiple rules like the prefix of private methods. The name of a variable should start with `<` and end with `>`. +```yaml +variables: + : "_" +rules: + function-names: + level: "warn" + options: + private-prefix: +``` + +### :heavy_check_mark: Rules +The rules contain documentation comments explaining them. Currently implemented rules can be found at `"codexgd/rules"`. + +### :see_no_evil: Ignore comments +CodexGD alows you to disable certain rules in gdscript files by using special comments. +Ignore comments are not meant to be placed on the same line with gdscript code. + +```python +# Ignore only the next line. +# codexgd-ignore + +# Disable rules until they are enabled again. +# codexgd-disable + +# Enable rules. +# codexgd-enable +``` +All ignore comments may optionally recieve a list of rule names. If no rule names are provided all rules will be affected. +```python +# codexgd-disable: function-names, no-invalid-chars +# codexgd-enable: no-invalid-chars +``` + +Problems are ignored based on the line on which they beginn. Therefore problems which span the complete file can only be ignored by a `codexgd-disable` statement on the first line. + +It is considered good practice to state the reason for disabeling the rule behind the ignore comment in a rational and calm manner... +```python +# codexgd-ignore: no-invalid-chars Why would CodexGD not allow me to use utf8 inside of strings?! What is the developer even thinking! +``` diff --git a/codexgd/__about__.py b/codexgd/__about__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/codexgd/__about__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/codexgd/__init__.py b/codexgd/__init__.py new file mode 100644 index 0000000..33bb353 --- /dev/null +++ b/codexgd/__init__.py @@ -0,0 +1,3 @@ +from .problem import Problem +from .exceptions import CodexGDError +from .codex import Codex diff --git a/codexgd/__main__.py b/codexgd/__main__.py new file mode 100644 index 0000000..ad1efd3 --- /dev/null +++ b/codexgd/__main__.py @@ -0,0 +1,138 @@ +"""CodexGD + +A configurable and extendable Godot style analyzer. +CodexGD can also be used in your own python scripts +with an API that is more powerfull than this CLI. + +Usage: + codexgd [options] + codexgd ... [options] + +Options: + -h --help Show this screen. + -v --version Show the current version. + --encoding=codec The encoding of the files. [default: "utf-8"] + --load-unsafe-code Load untrusted code from outside the CodexGD package if the codex file says so. + --json Output json data. + -- + +Returns: + 0 The input conforms to the given codex file. + 1 The input does not conform to the give codex. + 2 During execution an exception occured. +""" + +import os.path +import sys +import glob +import json +from docopt import docopt +from codexgd import CodexGDError +from codexgd.gdscript import GDScriptCodex +from codexgd.__about__ import __version__ + + +def main(): + arguments = docopt(__doc__, version=__version__) + + if arguments[""]: + try: + with open( + os.path.join(arguments[""], "codex.yml"), + "r", + encoding=arguments["--encoding"], + ) as file: + codex = GDScriptCodex(file, arguments["--load-unsafe-code"]) + except (FileExistsError, CodexGDError) as error: + if not arguments["--json"]: + print(error) + sys.exit(2) + elif arguments[""]: + try: + with open( + arguments[""], "r", encoding=arguments["--encoding"] + ) as file: + codex = GDScriptCodex(file, arguments["--load-unsafe-code"]) + except (FileExistsError, CodexGDError) as error: + if not arguments["--json"]: + print(error) + sys.exit(2) + else: + if not arguments["--json"]: + print("No configuration was provided.") + sys.exit(2) + + file_paths = [] + file_paths += arguments[""] + + if arguments[""]: + file_paths += glob.glob( + os.path.join(arguments[""], "**", "*.gd"), recursive=True + ) + + if arguments["--json"]: + print("[", end="") + + found_problems = 0 + for file_path in file_paths: + with open(file_path, "r", encoding=arguments["--encoding"]) as file: + for problem in codex.check(file): + if arguments["--json"]: + if found_problems > 0: + print(", ", end="") + print( + json.dumps( + { + "severity": problem.rule.severity, + "info": problem.info, + "file": file_path, + "start": problem.start, + "end": problem.end, + "rule": problem.rule.name, + } + ), + end="", + ) + else: + print( + problem.rule.severity.upper(), + ": ", + problem.info, + "\t", + os.path.realpath(file_path), + " Ln ", + problem.start[0], + ":", + problem.start[1], + " (", + problem.rule.name, + ")", + sep="", + ) + found_problems += 1 + + if not arguments["--json"]: + print( + "\n" if found_problems > 0 else "", + "CodexGD found ", + str(found_problems), + " problem", + "s" if found_problems != 1 else "", + " in ", + str(len(file_paths)), + " file", + "s" if len(file_paths) > 1 else "", + ".", + " Go fix " + ("them" if found_problems > 1 else "it") + "!" + if found_problems > 0 + else " \U0001f389", + sep="", + ) + else: + print("]", end="") + + sys.exit(int(found_problems > 0)) + + +if __name__ == "__main__": + main() diff --git a/codexgd/callback.py b/codexgd/callback.py new file mode 100644 index 0000000..654c412 --- /dev/null +++ b/codexgd/callback.py @@ -0,0 +1,32 @@ +from typing import TypeVar, TypeVarTuple, Generic, Self, Type + +Values = TypeVarTuple("Values") + + +class Callback(Generic[*Values]): + """A callback allows the rules to hook into some processing step of the codex.""" + + +Identifier = TypeVar("Identifier") + + +class DynamicCallback(Callback[*Values], Generic[*Values, Identifier]): + """ + A dynamic callback allows to pass an identifier. + This allows the codex to notify only certain group of listeners. + """ + + _identifier: Identifier + + def __init__(self, identifier: Identifier) -> None: + self._identifier = identifier + + def __eq__(self, other: "DynamicCallback") -> bool: + return self._identifier == other._identifier + + def __hash__(self) -> int: + return hash((self.__class__, self._identifier)) + + @classmethod + def factory(cls: Type[Self], identifier: Identifier) -> Self: + return cls(identifier) diff --git a/codexgd/codex.py b/codexgd/codex.py new file mode 100644 index 0000000..da18e05 --- /dev/null +++ b/codexgd/codex.py @@ -0,0 +1,182 @@ +from typing import List, Iterable, TypeVarTuple, Any, Dict, Optional, Callable + +from abc import ABC + +import os +import sys +import importlib.util +from importlib.machinery import ModuleSpec +import yaml + +from .rule import Rule, Severity +from .callback import Callback +from . import rule + +from .exceptions import CodexGDError +from .problem import Problem + + +class ConfigurationError(CodexGDError): + """Errors related to loading the configuration.""" + + +Parameter = TypeVarTuple("Parameter") + + +class Codex(ABC): + """Applies rules onto given data.""" + + rules: List[Rule] + before_all = Callback[()]() + after_all = Callback[()]() + + def __init__(self, codex_file, unsafe: bool = False, generate_cache: bool = False): + self.rules = [] + self._load_file(codex_file, unsafe, generate_cache) + + def notify( + self, callback: Callback[*Parameter], *values: *Parameter + ) -> Iterable[Problem]: + for r in self.rules: + if r.severity == Severity.OFF: + continue + + if callback in r.callbacks: + for m in r.callbacks[callback]: + for p in m(*values, r.options): + yield p + + def _load_file( + self, + file, + unsafe: bool = False, + generate_cache: bool = False, + variables: Optional[Dict[str, Any]] = None, + ): + if variables is None: + variables = {} + file_name = file.name if hasattr(file, "name") else None + data = yaml.safe_load(file) + if not data: + return + + if "variables" in data.keys(): + for variable in data["variables"].keys(): + # Variables from higher files take precedence. + if not variable in variables.keys(): + variables[variable] = data["variables"][variable] + + if "extends" in data.keys(): + # Load builtin rulesets unsafe to import builtin rules. + if data["extends"] == "official": + with open( + os.path.join(os.path.dirname(__file__), "presets", "official.yml"), + "r", + encoding="utf8", + ) as extends: + self._load_file( + extends, + True, + True, + variables, + ) + elif data["extends"] == "recommended": + with open( + os.path.join( + os.path.dirname(__file__), "presets", "recommended.yml" + ), + "r", + encoding="utf8", + ) as extends: + self._load_file( + extends, + True, + True, + variables, + ) + + if "rules" in data.keys(): + + def compare(spec: Optional[ModuleSpec]) -> Callable[[Rule], bool]: + def inner(new_rule: Rule) -> bool: + return new_rule.name == rule_key or ( + os.path.normpath(os.path.realpath(spec.origin)) + == new_rule.origin + if spec and spec.origin + else False + ) + + return inner + + for rule_key in data["rules"].keys(): + if rule_key[0] == ".": + # Resolve relative rules as files. + if not file_name: + raise ConfigurationError( + "Relative rule imports cannot be used in codex files that were not loaded from the file system." + ) + spec = importlib.util.spec_from_file_location( + rule_key, + os.path.join(os.path.dirname(file_name), *rule_key.split(".")) + + ".py", + ) + else: + spec = importlib.util.find_spec(rule_key) + + loaded: List[Rule] = list(filter(compare(spec), self.rules)) + + if len(loaded) < 1: + if spec and spec.loader: + if unsafe: + old = sys.dont_write_bytecode + sys.dont_write_bytecode = not generate_cache + + rule.rule = Rule() + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + if rule.rule.origin == "": + raise ConfigurationError( + "The module '" + rule_key + "' is no valid rule." + ) + + self.rules.append(rule.rule) + rule.rule = None + sys.dont_write_bytecode = old + else: + raise ConfigurationError( + "The module '" + + rule_key + + "' will not be loaded because it could contain untrusted code. If you want to use unofficial rules you have to enable unsafe loading." + ) + else: + raise ConfigurationError( + "'" + + rule_key + + "' is not the name of a module or a loaded rule." + ) + + loaded_rule: Rule = list(filter(compare(spec), self.rules))[0] + + rule_data = data["rules"][rule_key] + if isinstance(rule_data, str): + loaded_rule.severity = Severity(data["rules"][rule_key]) + elif isinstance(rule_data, dict): + if "level" in rule_data.keys(): + loaded_rule.severity = Severity(rule_data["level"]) + if "options" in rule_data.keys(): + for option in rule_data["options"]: + if option not in loaded_rule.options: + raise ConfigurationError( + "'" + + option + + "' is not a valid option of the rule '" + + loaded_rule.name + + "'." + ) + + if rule_data["options"][option] in variables: + value = variables[rule_data["options"][option]] + else: + value = rule_data["options"][option] + loaded_rule.options[option] = value diff --git a/codexgd/exceptions.py b/codexgd/exceptions.py new file mode 100644 index 0000000..3bbd6fd --- /dev/null +++ b/codexgd/exceptions.py @@ -0,0 +1,2 @@ +class CodexGDError(Exception): + """Base class for all the exceptions of CodexGD.""" diff --git a/codexgd/gdscript/__init__.py b/codexgd/gdscript/__init__.py new file mode 100644 index 0000000..a413115 --- /dev/null +++ b/codexgd/gdscript/__init__.py @@ -0,0 +1,5 @@ +from .gdscript_codex import GDScriptCodex, ParseTree +from .util import positions_from_token, COMPLETE_FILE + +# Import convenience. +from ..problem import Problem diff --git a/codexgd/gdscript/gdscript_codex.py b/codexgd/gdscript/gdscript_codex.py new file mode 100644 index 0000000..de19638 --- /dev/null +++ b/codexgd/gdscript/gdscript_codex.py @@ -0,0 +1,123 @@ +from typing import Iterable, TextIO, List, cast, Dict, Tuple + +from io import StringIO +import re + +from lark import Tree as ParseTree, Visitor, Token +from gdtoolkit.parser import parser + +from ..codex import Codex +from ..problem import Problem +from ..callback import Callback, DynamicCallback + + +IgnoreMap = Dict[str, List[Tuple[str, int]]] + + +class ParseTreeCallback(DynamicCallback[ParseTree, str]): + """Dynamic callback for subscribing to certain parse tree elements.""" + + +class _ParseTreeVisitor(Visitor): + codex: "GDScriptCodex" + problems: List[Problem] + + def __init__(self, codex: "GDScriptCodex") -> None: + self.codex = codex + self.problems = [] + + def __default__(self, tree: ParseTree): + for i in self.codex.notify(ParseTreeCallback(tree.data), tree): + self.problems.append(i) + + +class GDScriptCodex(Codex): + """Enforce rules on GDScript code.""" + + plain_text = Callback[str]() + parse_tree = ParseTreeCallback.factory + + def check(self, file: TextIO) -> Iterable[Problem]: + code = file.read() + ignore_map = self._construct_ignore_map(code) + + yield from self._apply_ignore_map( + ignore_map, self.check_without_ignore(StringIO(code)) + ) + + def check_without_ignore(self, file: TextIO) -> Iterable[Problem]: + code = file.read() + + yield from self.notify(self.before_all) + yield from self.notify(self.plain_text, code) + + parse_tree = self._build_parse_tree(code) + + visitor = _ParseTreeVisitor(self) + visitor.visit(parse_tree) + yield from visitor.problems + + yield from self.notify(self.after_all) + + def _build_parse_tree(self, code: str) -> ParseTree: + return parser.parse(code, True) + + def _construct_ignore_map(self, code: str) -> IgnoreMap: + comments = parser.parse_comments(code) + + ignore_map: IgnoreMap = {} + + def add(rule_name: str): + """Makes sure that a key for the rule exists.""" + if rule_name not in ignore_map: + ignore_map[rule_name] = [] + + for comment in comments.children: + match = re.search( + r"codexgd-(?Pdisable|enable|ignore)(:(?P\s*[^,\s]+(\s*,\s*[^,\s]+)*))?", + cast(Token, comment), + ) + if match: + rules = match.group("rules") + if rules: + for rule in rules.split(","): + rule_name = rule.strip() + add(rule_name) + ignore_map[rule_name].append( + (match.group("type"), cast(Token, comment).line) + ) + else: + for rule in self.rules: + add(rule.name) + ignore_map[rule.name].append( + (match.group("type"), cast(Token, comment).line) + ) + + last_line = len(code.split("\n")) + for i in ignore_map.items(): + i[1].append(("enable", last_line)) + + return ignore_map + + def _apply_ignore_map( + self, ignore_map: IgnoreMap, problems: Iterable[Problem] + ) -> Iterable[Problem]: + def resolve_start(start): + return start[0] if start[0] > 0 else 1 + + for problem in problems: + if problem.rule.name not in ignore_map: + yield problem + continue + enabled = True + for step in ignore_map[problem.rule.name]: + if step[0] == "ignore": + if step[1] != resolve_start(problem.start) - 1: + yield problem + break + if step[1] <= resolve_start(problem.start): + enabled = True if step[0] == "enable" else False + if step[1] >= resolve_start(problem.start): + if enabled: + yield problem + break diff --git a/codexgd/gdscript/util.py b/codexgd/gdscript/util.py new file mode 100644 index 0000000..69f72b9 --- /dev/null +++ b/codexgd/gdscript/util.py @@ -0,0 +1,10 @@ +from typing import Tuple +from lark import Token +from ..problem import Position + + +COMPLETE_FILE = ((-1, -1), (-1, -1)) + + +def positions_from_token(token: Token) -> Tuple[Position, Position]: + return (token.line, token.column), (token.end_line, token.end_column) diff --git a/codexgd/presets/official.yml b/codexgd/presets/official.yml new file mode 100644 index 0000000..7f7ab40 --- /dev/null +++ b/codexgd/presets/official.yml @@ -0,0 +1,21 @@ +# Codex based on the official GDScript style guide at https://docs.godotengine.org/en/latest/tutorials/scripting/gdscript/gdscript_styleguide.html. + +variables: + : "_" + +rules: + codexgd.rules.no_invalid_chars: + level: "error" + options: + codec: "ascii" + codexgd.rules.function_names: + level: "warn" + options: + private-prefix: + connected-pascal-case: true + regex: null + codexgd.rules.inner_class_names: + level: "warn" + options: + private-prefix: + regex: null \ No newline at end of file diff --git a/codexgd/presets/recommended.yml b/codexgd/presets/recommended.yml new file mode 100644 index 0000000..fde385d --- /dev/null +++ b/codexgd/presets/recommended.yml @@ -0,0 +1,7 @@ +extends: "official" + +variables: + : "__" # To better distinguish private and virtual stuff. + +rules: + codexgd.rules.require_extends: "warn" # Godot will inherit from `RefCounted` anyway so why not write it down. diff --git a/codexgd/problem.py b/codexgd/problem.py new file mode 100644 index 0000000..6f7a679 --- /dev/null +++ b/codexgd/problem.py @@ -0,0 +1,24 @@ +from typing import TYPE_CHECKING, Tuple + +from dataclasses import dataclass + +if TYPE_CHECKING: + from .rule import Rule + + +# line, column +Position = Tuple[int, int] + + +@dataclass +class Problem: + """ + In start a -1 means the very first. So if line is -1 the problem starts in the first line. + If the column is -1 it will start at the first char as well. + In end a -1 means the very last so the problem would end in the last line or at the last char. + """ + + start: Position + end: Position + rule: "Rule" + info: str diff --git a/codexgd/rule.py b/codexgd/rule.py new file mode 100644 index 0000000..dcdfef4 --- /dev/null +++ b/codexgd/rule.py @@ -0,0 +1,136 @@ +from typing import ( + Dict, + TypeVarTuple, + Protocol, + Callable, + Iterable, + Union, + List, + Any, + cast, + Tuple, + Optional, +) +from enum import StrEnum, unique, auto + +import inspect +import os + +from .problem import Problem +from .exceptions import CodexGDError +from .callback import Callback + + +class RuleRegistrationError(CodexGDError): + """Errors that happen while a rule is loaded.""" + + +@unique +class Severity(StrEnum): + """The way a certain rule is treated when reporting problems.""" + + OFF = auto() + WARN = auto() + ERROR = auto() + + +Options = Dict[str, Union[str, int, float, bool, None]] + +Parameter = TypeVarTuple("Parameter") + + +class CallbackDict(Protocol): + """Protocoll for a dictionary with entries that use generics.""" + + def __getitem__( + self, item: Callback[*Parameter] + ) -> List[Callable[[*Parameter, Options], Iterable[Problem]]]: + ... + + def __setitem__( + self, + item: Callback[*Parameter], + value: List[Callable[[*Parameter, Options], Iterable[Problem]]], + ): + ... + + def values(self) -> List[Callable[[*Tuple[Any, ...], Options], Iterable[Problem]]]: + ... + + def __contains__(self, item: Any) -> bool: + ... + + +class Rule: + """Representation of a loaded rule.""" + + name: str + info: str + severity: Severity + options: Options + callbacks: CallbackDict + origin: str + + def __str__(self) -> str: + return ( + 'Rule("' + + self.name + + '", "' + + self.info + + '", ' + + str(self.severity) + + ", " + + str(self.options) + + ")" + ) + + def __init__(self): + self.severity = Severity.OFF + self.callbacks = cast(CallbackDict, {}) + self.origin = "" + + def __call__(self, name: str, info: str, options: Optional[Options] = None) -> None: + if options is None: + options = {} + + if "name" in self.__dict__ or "info" in self.__dict__: + raise RuleRegistrationError("You may only define one rule per module.") + + self.name = name + self.info = info + self.options = options + + self.origin = os.path.normpath(os.path.realpath(inspect.stack()[1][1])) + + def doc(self, docstring: str, options: Optional[Options] = None) -> None: + """Utility function that extracts name and info from a docstring.""" + if options is None: + options = {} + + lines = list(filter(lambda x: x != "", docstring.splitlines())) + name = lines[0].strip() + info = lines[1].strip() + + self(name, info, options) + # Correct the origin because __call__ is now called from this file. + self.origin = os.path.normpath(os.path.realpath(inspect.stack()[1][1])) + + def check( + self, callback: Callback[*Parameter] + ) -> Callable[ + [Callable[[*Parameter, Options], Iterable[Problem]]], + Callable[[*Parameter, Options], Iterable[Problem]], + ]: + def decorator( + callable_to_connect: Callable[[*Parameter, Options], Iterable[Problem]] + ) -> Callable[[*Parameter, Options], Iterable[Problem]]: + if callback not in self.callbacks: + self.callbacks[callback] = [] + if callable_to_connect not in self.callbacks[callback]: + self.callbacks[callback].append(callable_to_connect) + return callable_to_connect + + return decorator + + +rule: Rule diff --git a/codexgd/rules/function_names.py b/codexgd/rules/function_names.py new file mode 100644 index 0000000..67550eb --- /dev/null +++ b/codexgd/rules/function_names.py @@ -0,0 +1,53 @@ +"""function-names + +Function names should use snake case which may have a prefix to indicate private functions. + +Good: +```gdscript +func _private_method() +func public_method() +func 2d_stuff() +``` +Bad: +``` +func PascalCase() +``` + +Options: +**Use the corresponding variable! (Except you know what you are doing.)** +private-prefix = "_" The prefix for private functions. Supports regex. + +connected-pascal-case = True Whether to allow PascalCase for connected functions. (`on_BodyEntered_kinematic_body`) +regex = None Provide a custom regex for function names. When this is set all other options will be ignored. +""" +from typing import Iterable, cast + +import re + +from lark import Token + +from codexgd.rule import rule, Options +from codexgd.gdscript import GDScriptCodex, Problem, ParseTree, positions_from_token + + +rule.doc(__doc__, {"private-prefix": "_", "connected-pascal-case": True, "regex": None}) + + +@rule.check(GDScriptCodex.parse_tree("func_header")) +def parse_tree_element(tree: ParseTree, options: Options) -> Iterable[Problem]: + if not options["regex"]: + + regex = r"^(%s|_)?(%s[a-z1-9]+)(_[a-z1-9]+)*$" % ( + options["private-prefix"], + r"(on_[A-Z][a-zA-Z1-9]+)|" if options["connected-pascal-case"] else r"", + ) + else: + regex = str(options["regex"]) + + name = cast(Token, tree.children[0]) + if not re.match(regex, name): + yield Problem( + *positions_from_token(name), + rule, + "The function name '" + name + "' is not formated correctly." + ) diff --git a/codexgd/rules/inner_class_names.py b/codexgd/rules/inner_class_names.py new file mode 100644 index 0000000..7449c91 --- /dev/null +++ b/codexgd/rules/inner_class_names.py @@ -0,0 +1,47 @@ +"""inner-class-names + +Inner class names should use PascalCase which may have a prefix to indicate private classes. + +Good: +```gdscript +class PascalCase +class _PrivateClass +``` +Bad: +``` +class snake_case +``` + +Options: +**Use the corresponding variable! (Except you know what you are doing.)** +private-prefix = "_" The prefix for private classes. Supports regex. + +regex = None Provide a custom regex for class names. When this is set all other options will be ignored. +""" +from typing import Iterable, cast + +import re + +from lark import Token + +from codexgd.gdscript import GDScriptCodex, Problem, ParseTree, positions_from_token +from codexgd.rule import rule, Options + + +rule.doc(__doc__, {"private-prefix": "_", "regex": None}) + + +@rule.check(GDScriptCodex.parse_tree("class_def")) +def parse_tree_element(tree: ParseTree, options: Options) -> Iterable[Problem]: + if not options["regex"]: + regex = r"^(%s)?([A-Z]+[a-z1-9]*)+$" % options["private-prefix"] + else: + regex = str(options["regex"]) + + name = cast(Token, tree.children[0]) + if not re.match(regex, name): + yield Problem( + *positions_from_token(name), + rule, + "The inner class name '" + name + "' is not formated correctly." + ) diff --git a/codexgd/rules/no_invalid_chars.py b/codexgd/rules/no_invalid_chars.py new file mode 100644 index 0000000..0b14d3e --- /dev/null +++ b/codexgd/rules/no_invalid_chars.py @@ -0,0 +1,58 @@ +"""no-invalid-chars + +Do not use characters that cannot be encoded with a certain codec. + +Good: +```gdscript +func hello_world(): + pass +``` +Bad: +``` +func hello_wörld(): + pass +``` + +Options: +- codec = "ASCII" +Only chars which can be encoded with this codec will be accepted. Accepts any valid python codec name. +""" + +from typing import Iterable + +from codexgd.rule import rule, Options +from codexgd.gdscript import GDScriptCodex, Problem + + +rule.doc(__doc__, {"codec": "ascii"}) + + +@rule.check(GDScriptCodex.plain_text) +def plain_text(text: str, options: Options) -> Iterable[Problem]: + res = [] + + line_index = 1 + column = 1 + + for line in text.splitlines(): + for char in line: + try: + char.encode(str(options["codec"])) + except UnicodeEncodeError: + res.append( + Problem( + (line_index, column), + (line_index, column + 1), + rule, + "Using the not " + + str(options["codec"]) + + " encodable char '" + + char + + "'.", + ) + ) + column += 1 + line_index += 1 + column = 1 + + return res diff --git a/codexgd/rules/require_extends.py b/codexgd/rules/require_extends.py new file mode 100644 index 0000000..e35a3b1 --- /dev/null +++ b/codexgd/rules/require_extends.py @@ -0,0 +1,31 @@ +"""require-extends + +Each file has to use the `extends` statement. +""" + +from codexgd.gdscript import GDScriptCodex, Problem, ParseTree, COMPLETE_FILE +from codexgd.rule import rule, Options + + +rule.doc(__doc__, {}) + +# pylint: disable-next=invalid-name +found_extends_statement = False + + +@rule.check(GDScriptCodex.parse_tree("extends_stmt")) +def parse_tree_extends(_tree: ParseTree, _options: Options): + # pylint: disable-next=invalid-name + global found_extends_statement + found_extends_statement = True + return [] + + +@rule.check(GDScriptCodex.after_all) +def after_all(_options: Options): + if not found_extends_statement: + yield Problem( + *COMPLETE_FILE, + rule, + "The file does not clearly declare its inheritance using the `extends` statement.", + ) diff --git a/plugins/godot/.gitattributes b/plugins/godot/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/plugins/godot/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/plugins/godot/.gitignore b/plugins/godot/.gitignore new file mode 100644 index 0000000..4709183 --- /dev/null +++ b/plugins/godot/.gitignore @@ -0,0 +1,2 @@ +# Godot 4+ specific ignores +.godot/ diff --git a/plugins/godot/addons/codexgd/annotation_layer.gd b/plugins/godot/addons/codexgd/annotation_layer.gd new file mode 100644 index 0000000..dd20187 --- /dev/null +++ b/plugins/godot/addons/codexgd/annotation_layer.gd @@ -0,0 +1,156 @@ +@tool +extends Node + + +## Displays annotations over the opened script editor. + + +class AnnotationData extends RefCounted: + var source_script: Script + var color: Color + var start: Vector2i + var end: Vector2i + var hint: String + + + func _init(p_source_script: Script, p_color: Color, p_start: Vector2i, p_end: Vector2i, p_hint: String = ""): + source_script = p_source_script + color = p_color + start = p_start + end = p_end + hint = p_hint + + +var annotations: Array[AnnotationData] = [] +var __script_editor: ScriptEditor +var __current_script_annotations: Array[AnnotationData] = [] +var __annotation_rects: Array[ColorRect] = [] +var __current_base_editor: TextEdit + + +func _ready() -> void: + __script_editor = get_parent() as ScriptEditor + __script_editor.editor_script_changed.connect(__on_script_changed) + __on_script_changed(__script_editor.get_current_script()) + + + +func _exit_tree() -> void: + for rect in __annotation_rects: + rect.queue_free() + + +func clear_annotations() -> void: + annotations.clear() + if __script_editor: + __on_script_changed(__script_editor.get_current_script()) + + +func add_annotation(data: AnnotationData) -> void: + annotations.append(data) + if __script_editor: + __on_script_changed(__script_editor.get_current_script()) + + +func remove_annotation(data: AnnotationData) -> void: + annotations.erase(data) + if __script_editor: + __on_script_changed(__script_editor.get_current_script()) + + +# HACK: Typing on p_script makes problems. +func __on_script_changed(p_script = null) -> void: + if is_instance_valid(__current_base_editor) and __current_base_editor.gui_input.is_connected(__on_base_editor_gui_input): + __current_base_editor.get_h_scroll_bar().value_changed.disconnect(__on_should_draw) + __current_base_editor.get_v_scroll_bar().value_changed.disconnect(__on_should_draw) + __current_base_editor.gui_input.disconnect(__on_base_editor_gui_input) + var current_editor = __script_editor.get_current_editor() + if is_instance_valid(current_editor): + __current_base_editor = current_editor.get_base_editor() + __current_base_editor.get_h_scroll_bar().value_changed.connect(__on_should_draw) + __current_base_editor.get_v_scroll_bar().value_changed.connect(__on_should_draw) + __current_base_editor.gui_input.connect(__on_base_editor_gui_input) + + __current_script_annotations = [] + for annotation in annotations: + if p_script and annotation.source_script == p_script: + __current_script_annotations.append(annotation) + __on_should_draw() + + +func __on_base_editor_gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.is_pressed() and not event.is_echo(): + __on_should_draw() + if event is InputEventShortcut: + __on_should_draw() + + +func __on_should_draw(_value1: int = 0, _value2: int = 0) -> void: + await get_tree().create_timer(0.0).timeout + for rect in __annotation_rects: + rect.hide() + rect.queue_free() + __annotation_rects = [] + + for annotation in __current_script_annotations: + # The values are transfered with 1 as starting line and column. + # In code a start at 0 is more convenient. + var end = annotation.end - Vector2i.ONE + var pos = annotation.start - Vector2i.ONE + + # Resolve -1 in file position as start/end of file/line. + if pos.x < 0: + pos.x = 0 + if pos.y < 0: + pos.y = 0 + + if end.x < 0: + end.x = __current_base_editor.get_line_count() + if end.y < 0: + end.y = len(__current_base_editor.get_line(end.x)) + + var rect = null + + while true: + if end.x < pos.x or (end.x == pos.x and end.y <= pos.y): + if rect: + __draw_annotation(rect, annotation) + rect = null + break + + if len(__current_base_editor.get_line(pos.x)) == 0: + var r = __current_base_editor.get_rect_at_line_column(pos.x, 0) + if r.position.abs() == r.position: + # Get width of a space char. + r.size.x += __current_base_editor.get_theme_font(&"font").get_char_size(32, __current_base_editor.get_theme_font_size(&"font_size")).x + __draw_annotation(r, annotation) + elif rect != null: + var n_rect = __current_base_editor.get_rect_at_line_column(pos.x, pos.y + 1) + if n_rect.position.abs() == n_rect.position: + rect.size.x += n_rect.size.x + else: + __draw_annotation(rect, annotation) + rect = null + else: + rect = __current_base_editor.get_rect_at_line_column(pos.x, pos.y + 1) + if rect.position.abs() != rect.position: + rect = null + + pos.y += 1 + if len(__current_base_editor.get_line(pos.x)) <= pos.y: + if rect: + __draw_annotation(rect, annotation) + rect = null + pos.y = 0 + pos.x += 1 + + +func __draw_annotation(rect, annotation): + var color_rect = ColorRect.new() + color_rect.position = rect.position + color_rect.tooltip_text = annotation.hint + color_rect.size = rect.size + color_rect.mouse_filter = Control.MOUSE_FILTER_PASS + color_rect.color = Color(annotation.color, 0.4) + __current_base_editor.add_child(color_rect) + __annotation_rects.append(color_rect) diff --git a/plugins/godot/addons/codexgd/plugin.cfg b/plugins/godot/addons/codexgd/plugin.cfg new file mode 100644 index 0000000..669f661 --- /dev/null +++ b/plugins/godot/addons/codexgd/plugin.cfg @@ -0,0 +1,11 @@ +[plugin] + +name="CodexGD" +description="Editor integration for the CodexGD style analyzer. +known limitations: +- overlapping problems are broken +- results only show after saving +- no support for builtin scripts" +author="HolonProduction" +version="1.0" +script="plugin.gd" diff --git a/plugins/godot/addons/codexgd/plugin.gd b/plugins/godot/addons/codexgd/plugin.gd new file mode 100644 index 0000000..bd96e49 --- /dev/null +++ b/plugins/godot/addons/codexgd/plugin.gd @@ -0,0 +1,80 @@ +@tool +extends EditorPlugin + + +const __AnnotationLayer := preload("res://addons/codexgd/annotation_layer.gd") + +var annotation_layer: __AnnotationLayer +var color_map: Dictionary + +var thread: Thread = null + +func _enter_tree() -> void: + setup_editor_settings() + + color_map = { + "warn": get_editor_interface().get_base_control().get_theme_color(&"warning_color", &"Editor"), + "error": get_editor_interface().get_base_control().get_theme_color(&"error_color", &"Editor"), + } + annotation_layer = __AnnotationLayer.new() + get_editor_interface().get_script_editor().add_child(annotation_layer) + + get_editor_interface().get_resource_filesystem().filesystem_changed.connect(__on_filesystem_changed, CONNECT_DEFERRED) + __on_filesystem_changed() + + +func _exit_tree() -> void: + annotation_layer.queue_free() + + thread.wait_to_finish() + + +func setup_editor_settings(): + var settings = get_editor_interface().get_editor_settings() + + if not settings.has_setting("codexgd/general/command_path"): + settings.set_setting("codexgd/general/command_path", "codexgd") + settings.set_initial_value("codexgd/general/command_path", "codexgd", false) + settings.add_property_info({ + "name": "codexgd/general/command_path", + "type": TYPE_STRING, + "hint": PROPERTY_HINT_GLOBAL_FILE, + }) + + if not settings.has_setting("codexgd/general/load_unsafe_rules"): + settings.set_setting("codexgd/general/load_unsafe_rules", false) + settings.set_initial_value("codexgd/general/load_unsafe_rules", false, false) + settings.add_property_info({ + "name": "codexgd/general/load_unsave_rules", + "type": TYPE_BOOL, + }) + + +func scan(): + var res := [] + var settings = get_editor_interface().get_editor_settings() + var options = [ProjectSettings.globalize_path("res://"), "--json"] + if settings.get_setting("codexgd/general/load_unsafe_rules"): + options.append("--load-unsafe-code") + OS.execute(settings.get_setting("codexgd/general/command_path"), options, res) + annotation_layer.clear_annotations() + for i in JSON.parse_string(res[0]): + var script = load(i["file"]) + var color = color_map[i["severity"]] + var start = Vector2i(i["start"][0], i["start"][1]) + var end = Vector2i(i["end"][0], i["end"][1]) + var annotation = __AnnotationLayer.AnnotationData.new(script, color, start, end, i["info"]) + annotation_layer.add_annotation(annotation) + + +func __on_filesystem_changed(): + if thread and not thread.is_started(): + print(thread) + + if thread and not thread.is_alive(): + thread.wait_to_finish() + thread = null + + if not thread: + thread = Thread.new() + thread.start(scan) diff --git a/plugins/godot/codex.yml b/plugins/godot/codex.yml new file mode 100644 index 0000000..7d3bb9c --- /dev/null +++ b/plugins/godot/codex.yml @@ -0,0 +1,2 @@ +extends: "recommended" + diff --git a/plugins/godot/icon.svg b/plugins/godot/icon.svg new file mode 100644 index 0000000..6c5d360 --- /dev/null +++ b/plugins/godot/icon.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/plugins/godot/icon.svg.import b/plugins/godot/icon.svg.import new file mode 100644 index 0000000..9a61604 --- /dev/null +++ b/plugins/godot/icon.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d2es1h0hm5fm0" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/plugins/godot/main.gd b/plugins/godot/main.gd new file mode 100644 index 0000000..fae2c76 --- /dev/null +++ b/plugins/godot/main.gd @@ -0,0 +1,11 @@ +extends Node + + +# Called when the node enters the scene tree for the first time. +func _ready() -> void: + pass # Replace with function body. + + +# Called every frame. 'delta' is the elapsed time since the previous frame. +func _process(delta: float) -> void: + pass diff --git a/plugins/godot/main.tscn b/plugins/godot/main.tscn new file mode 100644 index 0000000..e28707d --- /dev/null +++ b/plugins/godot/main.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://26kobs2its7g"] + +[ext_resource type="Script" path="res://main.gd" id="1_vs1lp"] + +[node name="Main" type="Node"] +script = ExtResource("1_vs1lp") diff --git a/plugins/godot/project.godot b/plugins/godot/project.godot new file mode 100644 index 0000000..0135927 --- /dev/null +++ b/plugins/godot/project.godot @@ -0,0 +1,20 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="CodexGD" +run/main_scene="res://main.tscn" +config/features=PackedStringArray("4.0", "Forward Plus") +config/icon="res://icon.svg" + +[editor_plugins] + +enabled=PackedStringArray("res://addons/codexgd/plugin.cfg") diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..94be1a5 --- /dev/null +++ b/pylintrc @@ -0,0 +1,8 @@ +[MASTER] +ignore-patterns=test_.*?py # Pytest and pylint are no friends. + +[MESSAGES CONTROL] +disable = + missing-function-docstring, + missing-module-docstring, + global-statement, # Usefull for keeping state inside rules. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e324a26 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "codexgd" +description = "A configurable and extendable Godot style analyzer." +readme = "README.md" +requires-python = ">=3.11" +license = {file = "LICENSE"} +authors = [{ name="HolonProduction", email="holonproduction@gmail.com" }] +keywords = ["lint", "godot", "gdscript", "style", "analyzer"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Topic :: Software Development :: Quality Assurance", +] +dynamic = ["version"] +dependencies = [ + "gdtoolkit@git+https://github.com/Scony/godot-gdscript-toolkit.git", + "pyyaml", + "lark", +] + +[project.urls] +"Homepage" = "https://github.com/holonproduction/codexgd" +"Bug Tracker" = "https://github.com/holonproduction/codexgd/issues" + +[project.scripts] +codexgd = "codexgd.__main__:main" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.version] +path = "codexgd/__about__.py" + +[tool.hatch.build] +packages = ["codexgd"] + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/common.py b/tests/common.py new file mode 100644 index 0000000..015dc2c --- /dev/null +++ b/tests/common.py @@ -0,0 +1,15 @@ +import os.path +from io import StringIO +import pytest + + +@pytest.fixture +def config_file(): + """Creates a config file stream whose location points into the example folder.""" + + def create(yaml: str = ""): + file = StringIO(yaml) + file.name = os.path.join(os.path.dirname(__file__), "env", "config.yaml") + return file + + return create diff --git a/tests/env/codex.yml b/tests/env/codex.yml new file mode 100644 index 0000000..6a1a36e --- /dev/null +++ b/tests/env/codex.yml @@ -0,0 +1,4 @@ +extends: "recommended" + +rules: + .rules.always_rule: "error" diff --git a/tests/env/example.gd b/tests/env/example.gd new file mode 100644 index 0000000..85028cc --- /dev/null +++ b/tests/env/example.gd @@ -0,0 +1,12 @@ +# codexgd-disable: require-extends + +func _invaliD_this(): + pass + +func _inaliD_the_second(): + pass + +class __AValidClass: + var a = 2 + var s = "Hello World" +# Hello diff --git a/tests/env/rules/always_rule.py b/tests/env/rules/always_rule.py new file mode 100644 index 0000000..a304b38 --- /dev/null +++ b/tests/env/rules/always_rule.py @@ -0,0 +1,10 @@ +from codexgd.rule import rule +from codexgd import Codex, Problem +from codexgd.gdscript import COMPLETE_FILE + + +rule("always-rule", "Will always add exactly one problem.") + +@rule.check(Codex.before_all) +def before_all(options): + yield Problem(*COMPLETE_FILE, rule, "This is a test problem. It will always apear and you can't do a thing about it \U0001f608") diff --git a/tests/env/rules/custom_rule.py b/tests/env/rules/custom_rule.py new file mode 100644 index 0000000..e326121 --- /dev/null +++ b/tests/env/rules/custom_rule.py @@ -0,0 +1,18 @@ +from codexgd.rule import rule, Problem +from codexgd.gdscript import GDScriptCodex, COMPLETE_FILE + +rule("custom-rule", "Test custom rule loading.") + +found_class = False + +@rule.check(GDScriptCodex.parse_tree("class_def")) +def parse_tree_class_def(tree, options): + global found_class + found_class = not found_class + return [] + +@rule.check(GDScriptCodex.after_all) +def after_all(options): + global found_class + if not found_class: + yield Problem(*COMPLETE_FILE, rule, "The file does not contain any classes.") \ No newline at end of file diff --git a/tests/env/rules/never_rule.py b/tests/env/rules/never_rule.py new file mode 100644 index 0000000..c526ff4 --- /dev/null +++ b/tests/env/rules/never_rule.py @@ -0,0 +1,3 @@ +from codexgd.rule import rule + +rule("never-rule", "Just a dummy for testing.") \ No newline at end of file diff --git a/tests/env/rules/rule_with_state.py b/tests/env/rules/rule_with_state.py new file mode 100644 index 0000000..914fbc4 --- /dev/null +++ b/tests/env/rules/rule_with_state.py @@ -0,0 +1,14 @@ +from codexgd.rule import rule, Problem +from codexgd import Codex +from codexgd.gdscript import COMPLETE_FILE + +rule("rule-with-state", "Rule with state.") + +state = False + +@rule.check(Codex.before_all) +def parse_tree_class_def(options): + global state + if state: + yield Problem(*COMPLETE_FILE, rule, "State leaks accross different rule instances.") + state = True diff --git a/tests/test_codex.py b/tests/test_codex.py new file mode 100644 index 0000000..375627d --- /dev/null +++ b/tests/test_codex.py @@ -0,0 +1,150 @@ +from io import StringIO + +import pytest + +from codexgd import Codex +from codexgd.gdscript import GDScriptCodex +from codexgd.codex import ConfigurationError +from .common import config_file + + +@pytest.fixture(params=[GDScriptCodex, Codex]) +def Implementation(request): + return request.param + + +def test_empty_codex(config_file, Implementation): + codex = Implementation(config_file("""""")) + + +def test_unsafe_loading(config_file, Implementation): + config = """ +rules: + codexgd.rules.no_invalid_chars: "error" +""" + try: + codex = Implementation(config_file(config)) + assert False, "An rule was loaded without unsafe loading enabled." + except: + pass + + codex = Implementation(config_file(config), True) + + +def test_load_module_rules(config_file, Implementation): + config = """ +rules: + codexgd.rules.no_invalid_chars: "error" +""" + codex = Implementation(config_file(config), True) + assert len(codex.rules) == 1 + assert codex.rules[0].name == "no-invalid-chars" + + codex = Implementation(StringIO(config), True) + assert len(codex.rules) == 1 + assert codex.rules[0].name == "no-invalid-chars" + + +def test_load_relative_rules(config_file, Implementation): + config = """ +rules: + .rules.custom_rule: "error" +""" + codex = Implementation(config_file(config), True) + assert len(codex.rules) == 1 + assert codex.rules[0].name == "custom-rule" + + try: + codex = Implementation(StringIO(config), True) + assert ( + False + ), "Codex loaded from StringIO should raise an error if relative rules are used." + except ConfigurationError: + pass + + +@pytest.mark.parametrize( + "config_str, expected", + [ + ( + """rules: + .rules.never_rule: "off" """, + "off", + ), + ( + """rules: + .rules.never_rule: "warn" """, + "warn", + ), + ( + """rules: + .rules.never_rule: "error" """, + "error", + ), + ( + """rules: + .rules.never_rule: + level: "off" """, + "off", + ), + ( + """rules: + .rules.never_rule: + level: "warn" """, + "warn", + ), + ( + """rules: + .rules.never_rule: + level: "error" """, + "error", + ), + ], +) +def test_load_severity(config_str, expected, config_file, Implementation): + codex = Implementation(config_file(config_str), True) + assert len(codex.rules) == 1 + assert codex.rules[0].severity == expected + + +def test_load_invalid_option(Implementation): + config = """ +rules: + codexgd.rules.no_invalid_chars: + options: + invalid-option: true +""" + try: + codex = Implementation(config, True) + assert ( + True + ), "A codex object should not accept options which a rule does not define." + except ConfigurationError: + pass + + +def test_notify(config_file, Implementation): + config = """ +rules: + .rules.always_rule: "error" +""" + codex = Implementation(config_file(config), True) + assert len(codex.rules) == 1 + + problems = list(codex.notify(Implementation.before_all)) + assert len(problems) == 1 + + +def test_multiple_codexes(config_file, Implementation): + config = """ +rules: + .rules.rule_with_state: "error" +""" + codex1 = Implementation(config_file(config), True) + codex2 = Implementation(config_file(config), True) + assert codex1.rules[0] is not codex2.rules[0] + + problems = list(codex1.notify(Implementation.before_all)) + list( + codex2.notify(Implementation.before_all) + ) + assert len(problems) == 0 diff --git a/tests/test_gdscript_codex.py b/tests/test_gdscript_codex.py new file mode 100644 index 0000000..049d8d9 --- /dev/null +++ b/tests/test_gdscript_codex.py @@ -0,0 +1,67 @@ +from .common import config_file +from codexgd.gdscript import GDScriptCodex +from io import StringIO +import pytest + + +@pytest.mark.parametrize( + "code, counts", + [ + ("""""", (1, 1)), + ( + """# codexgd-disable +var a""", + (0, 1), + ), + ( + """# codexgd-disable +func Invalid(): + pass +# codexgd-enable +func Invalid2(): + pass +""", + (1, 3), + ), + ( + """# codexgd-enable +var a""", + (1, 1), + ), + ( + """# codexgd-disable: function-names + +func Invalid(): + pass""", + (1, 2), + ), + ( + """# codexgd-disable +# codexgd-enable: function-names +func Invalid(): + pass""", + (1, 2), + ), + ( + """# codexgd-ignore +func Invalid(): + pass""", + (1, 2), + ), + ( + """# codexgd-ignore: function-names +func Invalid(): + pass""", + (1, 2), + ), + ], +) +def test_ignore_comments(code: str, counts, config_file): + config = """rules: + .rules.always_rule: "error" + codexgd.rules.function_names: "error" +""" + codex = GDScriptCodex(config_file(config), True) + p_with = list(codex.check(StringIO(code))) + p_without = list(codex.check_without_ignore(StringIO(code))) + assert (len(p_with), len(p_without)) == counts diff --git a/tests/test_gdscript_rules.py b/tests/test_gdscript_rules.py new file mode 100644 index 0000000..d3c4c07 --- /dev/null +++ b/tests/test_gdscript_rules.py @@ -0,0 +1,144 @@ +from codexgd.gdscript import GDScriptCodex +from .common import config_file +from io import StringIO +import pytest + + +@pytest.mark.parametrize( + "config_str, code, n", + [ + # require-extends + ("""rules: {codexgd.rules.require_extends: "error"}""", """""", 1), + ( + """rules: {codexgd.rules.require_extends: "error"}""", + """func test(): + pass +""", + 1, + ), + ( + """rules: {codexgd.rules.require_extends: "error"}""", + """extends Object""", + 0, + ), + ( + """rules: {codexgd.rules.require_extends: "error"}""", + """extends "res://script.gd" +func test(): + pass +""", + 0, + ), + # no-invalid-chars + ("""rules: {codexgd.rules.no_invalid_chars: "error"}""", """""", 0), + ( + """rules: {codexgd.rules.no_invalid_chars: "error"}""", + """class T: pass +# valid \n""", + 0, + ), + ( + """rules: {codexgd.rules.no_invalid_chars: {level: "error", options: {codec: "ascii"}}}""", + """class T: pass +# not valid ä \n""", + 1, + ), + ( + """rules: {codexgd.rules.no_invalid_chars: {level: "error", options: {codec: "utf8"}}}""", + """class T: pass +# valid ö \n""", + 0, + ), + # function-names + ("""rules: {codexgd.rules.function_names: "error"}""", """""", 0), + ( + """rules: {codexgd.rules.function_names: {level: "error", options: {private-prefix: "_"}}}""", + """ +func _virtual(): pass +func _private(): pass +func valid_with_nr2(): pass +""", + 0, + ), + ( + """rules: {codexgd.rules.function_names: {level: "error", options: {private-prefix: "_"}}}""", + """ +func __private(): pass +func wrongName(): pass +func WrongAsWell(): pass +""", + 3, + ), + ( + """rules: {codexgd.rules.function_names: {level: "error", options: {private-prefix: "__"}}}""", + """ +func _virtual(): pass +func __private(): pass +""", + 0, + ), + ( + """rules: {codexgd.rules.function_names: {level: "error", options: {connected-pascal-case: true}}}""", + """ +func on_SignalName(): pass +func _on_SignalName_private(): pass +""", + 0, + ), + ( + """rules: {codexgd.rules.function_names: {level: "error", options: {connected-pascal-case: false}}}""", + """ +func on_SignalName(): pass +func _on_SignalName_private(): pass +""", + 2, + ), + # inner-class-names + ("""rules: {codexgd.rules.inner_class_names: "error"}""", """""", 0), + ( + """rules: {codexgd.rules.inner_class_names: "error"}""", + """ +class _PrivateClass: pass +class PublicClass: pass +""", + 0, + ), + ( + """rules: {codexgd.rules.inner_class_names: "error"}""", + """ +class invalid_class: pass +class Mixed_Class: pass +class __InvalidPrefix: pass +""", + 3, + ), + ( + """rules: + codexgd.rules.inner_class_names: + level: "error" + options: + private-prefix: "__" +""", + """ +class __PrivateClass: pass +""", + 0, + ), + ( + """rules: + codexgd.rules.inner_class_names: + level: "error" + options: + private-prefix: "__" +""", + """ +class _PrivateClass: pass +""", + 1, + ), + ], +) +def test_rule(config_str, code, n, config_file): + codex = GDScriptCodex(config_file(config_str), True) + problems = list(codex.check(StringIO(code))) + assert len(problems) == n