diff --git a/.vscode/settings.json b/.vscode/settings.json index 808b27ff..3edac3cf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,7 +14,20 @@ "AXBY", "AYBX", "Backdrilling", + "cftp", + "chgt", + "clbd", + "clbn", + "cmfr", + "cmnp", + "CMNP", + "cmnt", "CMPN", + "cpgd", + "cpgn", + "crot", + "csup", + "cval", "Cxxx", "DONUTVAR", "dpmm", diff --git a/src/pygerber/console/gerber.py b/src/pygerber/console/gerber.py index f2da25ff..d00ba7b6 100644 --- a/src/pygerber/console/gerber.py +++ b/src/pygerber/console/gerber.py @@ -4,7 +4,7 @@ import json from pathlib import Path -from typing import Any, Callable, Literal, Optional +from typing import Any, Callable, Iterable, Literal, Optional import click @@ -514,3 +514,58 @@ def _project(files: str, output: str, dpmm: int) -> None: Layers are merged from first to last, thus last layer will be on top. """ raise NotImplementedError + + +@gerber.command("lint") +@click.argument("files", nargs=-1) +@click.option( + "-r", + "--rules", + type=str, + multiple=True, + help=( + "Linting rules to be applied. Option can be used multiple times and accepts " + "comma separated list of rules, eg. `-r DEP001,DEP002,DEP003 -r DEP004`. " + "If not specified, all available rules will be applied." + ), +) +def lint(files: str, rules: list[str]) -> None: + """Lint Gerber files with specified rules.""" + if len(files) == 0: + msg = "At least one file must be specified." + raise click.UsageError(msg) + + def _parse_rules(rules: list[str]) -> Iterable[str]: + for rule in rules: + yield from rule.split(",") + + from pygerber.gerber.linter import RULE_REGISTRY, Linter + from pygerber.gerber.parser import parse + + if len(rules) != 0: + rule_objects = [RULE_REGISTRY[rule_id]() for rule_id in _parse_rules(rules)] + else: + rule_objects = [r() for r in RULE_REGISTRY.values()] + + linter = Linter(rule_objects) + + for file in files: + path = Path(file).expanduser().resolve() + + ast = parse(path.read_text()) + violations = linter.lint(ast) + + for violation in violations: + click.echo( + f"{path.as_posix()}:{violation.line}:{violation.column} " + f"{violation.rule_id}: {violation.title}" + ) + + +@gerber.command("list-lint-rules") +def list_lint_rules() -> None: + """List available linting rules.""" + from pygerber.gerber.linter.rules import RULE_REGISTRY + + for rule_id in RULE_REGISTRY: + click.echo(rule_id) diff --git a/src/pygerber/gerber/linter/__init__.py b/src/pygerber/gerber/linter/__init__.py index d8d0cd3c..83f12509 100644 --- a/src/pygerber/gerber/linter/__init__.py +++ b/src/pygerber/gerber/linter/__init__.py @@ -1 +1,18 @@ """Code diagnostic logic.""" + +from __future__ import annotations + +from pygerber.gerber.linter.event_ast_visitor import EventAstVisitor +from pygerber.gerber.linter.linter import Linter +from pygerber.gerber.linter.rule_violation import RuleViolation +from pygerber.gerber.linter.rules import DEP001, RULE_REGISTRY, Rule, StaticRule + +__all__ = [ + "DEP001", + "RULE_REGISTRY", + "EventAstVisitor", + "Linter", + "Rule", + "RuleViolation", + "StaticRule", +] diff --git a/src/pygerber/gerber/linter/rule_violation.py b/src/pygerber/gerber/linter/rule_violation.py index 181f11e1..7ac1d942 100644 --- a/src/pygerber/gerber/linter/rule_violation.py +++ b/src/pygerber/gerber/linter/rule_violation.py @@ -13,3 +13,5 @@ class RuleViolation(BaseModel): description: str start_offset: int end_offset: int + line: int + column: int diff --git a/src/pygerber/gerber/linter/rules/DEP001.py b/src/pygerber/gerber/linter/rules/DEP001.py index 37afd901..cc6113e9 100644 --- a/src/pygerber/gerber/linter/rules/DEP001.py +++ b/src/pygerber/gerber/linter/rules/DEP001.py @@ -12,7 +12,7 @@ class DEP001(StaticRule): """Rule DEP001 class implements a specific linting rule.""" rule_id = "DEP001" - message = "Use of deprecated G54 code." + title = "Use of deprecated G54 code." description = ( "This historic code optionally precedes an aperture " "selection Dnn command. It has no effect. " diff --git a/src/pygerber/gerber/linter/rules/DEP002.py b/src/pygerber/gerber/linter/rules/DEP002.py new file mode 100644 index 00000000..c38770d2 --- /dev/null +++ b/src/pygerber/gerber/linter/rules/DEP002.py @@ -0,0 +1,20 @@ +"""`DEP002` module contains linter rule DEP002 implementation.""" # noqa: N999 + +from __future__ import annotations + +from pygerber.gerber.ast.nodes import G55 +from pygerber.gerber.linter.rules.rule import register_rule +from pygerber.gerber.linter.rules.static_rule import StaticRule + + +@register_rule +class DEP002(StaticRule): + """Rule DEP002 class implements a specific linting rule.""" + + rule_id = "DEP002" + title = "Use of deprecated G55 code." + description = ( + "This historic code optionally precedes D03 code. It has no effect. " + "Deprecated in 2012." + ) + trigger_nodes = (G55,) diff --git a/src/pygerber/gerber/linter/rules/__init__.py b/src/pygerber/gerber/linter/rules/__init__.py index 2cd68cf5..cf7cda19 100644 --- a/src/pygerber/gerber/linter/rules/__init__.py +++ b/src/pygerber/gerber/linter/rules/__init__.py @@ -1 +1,10 @@ """`rules` package contains all the linting rules for Gerber files.""" + +from __future__ import annotations + +from pygerber.gerber.linter.rules.DEP001 import DEP001 +from pygerber.gerber.linter.rules.DEP002 import DEP002 +from pygerber.gerber.linter.rules.rule import RULE_REGISTRY, Rule +from pygerber.gerber.linter.rules.static_rule import StaticRule + +__all__ = ["DEP001", "DEP002", "RULE_REGISTRY", "Rule", "StaticRule"] diff --git a/src/pygerber/gerber/linter/rules/rule.py b/src/pygerber/gerber/linter/rules/rule.py index 9b3f84a2..aa0aa68e 100644 --- a/src/pygerber/gerber/linter/rules/rule.py +++ b/src/pygerber/gerber/linter/rules/rule.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from typing import ClassVar, Optional, TypeVar -from pygerber.gerber.ast.nodes import Node +from pygerber.gerber.ast.nodes import Node, SourceInfo from pygerber.gerber.linter.event_ast_visitor import EventAstVisitor from pygerber.gerber.linter.rule_violation import RuleViolation from pygerber.gerber.linter.violation_collector import ViolationCollector @@ -38,15 +38,21 @@ def bind_rule_to_violation_collector(self, collector: ViolationCollector) -> Non """Bind the rule to the violation collector.""" self.collector = collector - def report_violation(self, start_offset: int, end_offset: int) -> None: + def report_violation(self, source_info: Optional[SourceInfo]) -> None: """Report a violation.""" if self.collector is not None: violation = RuleViolation( rule_id=self.rule_id, title=self.get_violation_title(), description=self.get_violation_description(), - start_offset=start_offset, - end_offset=end_offset, + start_offset=(source_info.location if source_info is not None else 0), + end_offset=( + source_info.location + source_info.length + if source_info is not None + else 0 + ), + line=(source_info.line if source_info is not None else 0), + column=(source_info.column if source_info is not None else 0), ) self.collector.add_violation(violation) diff --git a/src/pygerber/gerber/linter/rules/static_rule.py b/src/pygerber/gerber/linter/rules/static_rule.py index 552f20d4..d9b85c75 100644 --- a/src/pygerber/gerber/linter/rules/static_rule.py +++ b/src/pygerber/gerber/linter/rules/static_rule.py @@ -12,7 +12,7 @@ class StaticRule(Rule): """ title: str - message: str + description: str trigger_nodes: tuple[type[Node]] def get_violation_title(self) -> str: @@ -21,7 +21,7 @@ def get_violation_title(self) -> str: def get_violation_description(self) -> str: """Return a description of the rule violation.""" - return self.message + return self.description def get_trigger_nodes(self) -> list[type[Node]]: """Return a list of node names that trigger the rule.""" @@ -29,13 +29,4 @@ def get_trigger_nodes(self) -> list[type[Node]]: def node_callback(self, node: Node) -> None: """Check the node for violations.""" - self.report_violation( - start_offset=( - node.source_info.location if node.source_info is not None else 0 - ), - end_offset=( - node.source_info.location + node.source_info.length - if node.source_info is not None - else 0 - ), - ) + self.report_violation(node.source_info) diff --git a/test/unit/test_gerber/test_linter/__init__.py b/test/unit/test_gerber/test_linter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unit/test_gerber/test_linter/test_rules.py b/test/unit/test_gerber/test_linter/test_rules.py new file mode 100644 index 00000000..2fa3f0a5 --- /dev/null +++ b/test/unit/test_gerber/test_linter/test_rules.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from pygerber.gerber.ast.nodes import G54, G55, File +from pygerber.gerber.linter.linter import Linter +from pygerber.gerber.linter.rules import DEP001, DEP002 + + +def test_DEP001() -> None: + rule = DEP001() + linter = Linter([rule]) + + ast = File(nodes=[G54()]) + + violations = linter.lint(ast) + + assert len(list(violations)) == 1 + + +def test_DEP002() -> None: + rule = DEP002() + linter = Linter([rule]) + + ast = File(nodes=[G55()]) + + violations = linter.lint(ast) + + assert len(list(violations)) == 1