From 769787b219209c731b005d222ac749edd7146f5f Mon Sep 17 00:00:00 2001 From: Jack Cherng Date: Mon, 4 Nov 2024 02:10:05 +0800 Subject: [PATCH] refactor: use pydantic to validate settings Signed-off-by: Jack Cherng --- .../auto_set_syntax_syntax_rules_summary.py | 24 ++++----- plugin/rules/constraint.py | 19 +++---- plugin/rules/match.py | 23 ++++---- plugin/rules/syntax.py | 21 +++----- plugin/settings.py | 11 ++-- plugin/types.py | 54 ++++++++++++------- 6 files changed, 77 insertions(+), 75 deletions(-) diff --git a/plugin/commands/auto_set_syntax_syntax_rules_summary.py b/plugin/commands/auto_set_syntax_syntax_rules_summary.py index e4c9c361..f667c78f 100644 --- a/plugin/commands/auto_set_syntax_syntax_rules_summary.py +++ b/plugin/commands/auto_set_syntax_syntax_rules_summary.py @@ -6,18 +6,12 @@ import sublime_plugin from ..constants import PLUGIN_NAME, VIEW_KEY_IS_CREATED -from ..rules import SyntaxRule from ..shared import G -from ..utils import find_syntax_by_syntax_like, stringify +from ..types import StSyntaxRule +from ..utils import find_syntax_by_syntax_like TEMPLATE = f""" -# === [{PLUGIN_NAME}] Syntax Rules Summary === # -# You may use the following website to beautify this debug information. -# @link https://play.ruff.rs/?secondary=Format - -######################## -# Syntax Rules Summary # -######################## +// [{PLUGIN_NAME}] Syntax Rules Summary {{content}} """.lstrip() @@ -31,16 +25,16 @@ def run(self, *, copy_only: bool = False) -> None: if not (rule_collection := G.syntax_rule_collections.get(self.window)): return - summary: defaultdict[sublime.Syntax, list[SyntaxRule]] = defaultdict(list) + summary: defaultdict[sublime.Syntax, list[StSyntaxRule]] = defaultdict(list) for rule in rule_collection.rules: - if rule.syntax: - summary[rule.syntax].append(rule) + if rule.syntax and rule.st_syntax_rule: + summary[rule.syntax].append(rule.st_syntax_rule) content = "" for syntax_, rules in sorted(summary.items(), key=lambda x: x[0].name.casefold()): - content += f"# Syntax: {syntax_.name}\n" + content += "/" * 80 + "\n" + f"// Syntax: {syntax_.name}\n" + "/" * 80 + "\n" for rule in rules: - content += f"{stringify(rule)}\n" + content += rule.model_dump_json() + "\n" content += "\n" content = TEMPLATE.format(content=content) @@ -57,5 +51,5 @@ def run(self, *, copy_only: bool = False) -> None: VIEW_KEY_IS_CREATED: True, }) - if syntax := find_syntax_by_syntax_like("scope:source.python"): + if syntax := find_syntax_by_syntax_like("scope:source.json"): view.assign_syntax(syntax) diff --git a/plugin/rules/constraint.py b/plugin/rules/constraint.py index 55cff708..afa70e47 100644 --- a/plugin/rules/constraint.py +++ b/plugin/rules/constraint.py @@ -12,6 +12,7 @@ from ..cache import clearable_lru_cache from ..constants import PLUGIN_NAME, ST_PLATFORM +from ..logger import Logger from ..snapshot import ViewSnapshot from ..types import Optimizable, StConstraintRule from ..utils import ( @@ -46,7 +47,7 @@ class ConstraintRule(Optimizable): constraint_name: str = "" args: tuple[Any, ...] = tuple() kwargs: dict[str, Any] = field(default_factory=dict) - inverted: bool = False # whether the test result should be inverted + inverted: bool = False def is_droppable(self) -> bool: return not (self.constraint and not self.constraint.is_droppable()) @@ -75,20 +76,16 @@ def make(cls, constraint_rule: StConstraintRule) -> Self: """Build this object with the `constraint_rule`.""" obj = cls() - if args := constraint_rule.get("args"): - # make sure args is always a tuple - obj.args = tuple(args) if isinstance(args, list) else (args,) + obj.args = tuple(constraint_rule.args) + obj.kwargs = constraint_rule.kwargs + obj.inverted = constraint_rule.inverted - if kwargs := constraint_rule.get("kwargs"): - obj.kwargs = kwargs - - if (inverted := constraint_rule.get("inverted")) is not None: - obj.inverted = bool(inverted) - - if constraint := constraint_rule.get("constraint"): + if constraint := constraint_rule.constraint: obj.constraint_name = constraint if constraint_class := find_constraint(constraint): obj.constraint = constraint_class(*obj.args, **obj.kwargs) + else: + Logger.log(f"Unsupported constraint rule: {constraint}") return obj diff --git a/plugin/rules/match.py b/plugin/rules/match.py index f68befe6..5b434ab9 100644 --- a/plugin/rules/match.py +++ b/plugin/rules/match.py @@ -9,8 +9,9 @@ from typing_extensions import Self from ..cache import clearable_lru_cache +from ..logger import Logger from ..snapshot import ViewSnapshot -from ..types import Optimizable, StMatchRule +from ..types import Optimizable, StConstraintRule, StMatchRule from ..utils import camel_to_snake, list_all_subclasses, remove_suffix from .constraint import ConstraintRule @@ -30,8 +31,6 @@ def list_matches() -> Generator[type[AbstractMatch], None, None]: @dataclass class MatchRule(Optimizable): - DEFAULT_MATCH_NAME = "any" - match: AbstractMatch | None = None match_name: str = "" args: tuple[Any, ...] = tuple() @@ -63,24 +62,22 @@ def make(cls, match_rule: StMatchRule) -> Self: """Build this object with the `match_rule`.""" obj = cls() - if args := match_rule.get("args"): - # make sure args is always a tuple - obj.args = tuple(args) if isinstance(args, list) else (args,) - - if kwargs := match_rule.get("kwargs"): - obj.kwargs = kwargs + obj.args = tuple(match_rule.args) + obj.kwargs = match_rule.kwargs - match = match_rule.get("match", cls.DEFAULT_MATCH_NAME) + match = match_rule.match if match_class := find_match(match): obj.match_name = match obj.match = match_class(*obj.args, **obj.kwargs) + else: + Logger.log(f"Unsupported match rule: {match}") rules_compiled: list[MatchableRule] = [] - for rule in match_rule.get("rules", []): + for rule in match_rule.rules: rule_class: type[MatchableRule] | None = None - if "constraint" in rule: + if isinstance(rule, StConstraintRule): rule_class = ConstraintRule - elif "rules" in rule: # nested MatchRule + elif isinstance(rule, StMatchRule): rule_class = MatchRule if rule_class and (rule_compiled := rule_class.make(rule)): # type: ignore rules_compiled.append(rule_compiled) diff --git a/plugin/rules/syntax.py b/plugin/rules/syntax.py index f395751c..c85871c6 100644 --- a/plugin/rules/syntax.py +++ b/plugin/rules/syntax.py @@ -24,6 +24,9 @@ class SyntaxRule(Optimizable): """`None` = no restriction, empty = no event = never triggered.""" root_rule: MatchRule | None = None + st_syntax_rule: StSyntaxRule | None = None + """The original syntax rule setting object.""" + def is_droppable(self) -> bool: return not (self.syntax and (self.on_events is None or self.on_events) and self.root_rule) @@ -56,24 +59,16 @@ def test(self, view_snapshot: ViewSnapshot, event: ListenerEvent | None = None) def make(cls, syntax_rule: StSyntaxRule) -> Self: """Build this object with the `syntax_rule`.""" obj = cls() + obj.st_syntax_rule = syntax_rule - if comment := syntax_rule.get("comment"): - obj.comment = str(comment) - - syntaxes = syntax_rule.get("syntaxes", []) - if isinstance(syntaxes, str): - syntaxes = [syntaxes] - obj.syntaxes_name = tuple(syntaxes) - if target_syntax := find_syntax_by_syntax_likes(syntaxes): + obj.syntaxes_name = tuple(syntax_rule.syntaxes) + if target_syntax := find_syntax_by_syntax_likes(syntax_rule.syntaxes): obj.syntax = target_syntax # note that an empty string selector should match any scope - if (selector := syntax_rule.get("selector")) is not None: - obj.selector = selector + obj.selector = syntax_rule.selector - if (on_events := syntax_rule.get("on_events")) is not None: - if isinstance(on_events, str): - on_events = [on_events] + if (on_events := syntax_rule.on_events) is not None: obj.on_events = set(drop_falsy(map(ListenerEvent.from_value, on_events))) if match_rule_compiled := MatchRule.make(syntax_rule): diff --git a/plugin/settings.py b/plugin/settings.py index 78da692e..c07474e8 100644 --- a/plugin/settings.py +++ b/plugin/settings.py @@ -2,11 +2,12 @@ from collections import ChainMap from itertools import chain -from typing import Any, Callable, Mapping, MutableMapping +from typing import Any, Callable, List, Mapping, MutableMapping import sublime import sublime_plugin from more_itertools import unique_everseen +from pydantic import TypeAdapter from .types import StSyntaxRule from .utils import drop_falsy @@ -34,7 +35,7 @@ def get_st_settings() -> sublime.Settings: def pref_syntax_rules(*, window: sublime.Window | None = None) -> list[StSyntaxRule]: - return get_merged_plugin_setting("syntax_rules", [], window=window) + return TypeAdapter(List[StSyntaxRule]).validate_python(get_merged_plugin_setting("syntax_rules", [], window=window)) def pref_trim_suffixes(*, window: sublime.Window | None = None) -> tuple[str]: @@ -194,7 +195,7 @@ def _on_settings_change(cls, windows: list[sublime.Window] | None = None, run_ca @classmethod def _update_plugin_settings(cls) -> None: assert cls._plugin_settings_object - cls._plugin_settings = {"_comment": "plugin_settings"} + cls._plugin_settings = {"__comment": "plugin_settings"} cls._plugin_settings.update(cls._plugin_settings_object.to_dict()) if cls._settings_normalizer: cls._settings_normalizer(cls._plugin_settings) @@ -202,7 +203,7 @@ def _update_plugin_settings(cls) -> None: @classmethod def _update_project_plugin_settings(cls, window: sublime.Window) -> None: window_id = window.id() - cls._project_plugin_settings[window_id] = {"_comment": "project_settings"} + cls._project_plugin_settings[window_id] = {"__comment": "project_settings"} cls._project_plugin_settings[window_id].update( (window.project_data() or {}).get("settings", {}).get(cls.plugin_name, {}) ) @@ -218,7 +219,7 @@ def _update_merged_plugin_settings(cls, window: sublime.Window) -> None: cls._plugin_settings, ) - produced = {"_comment": "produced_settings"} + produced = {"__comment": "produced_settings"} if cls._settings_producer: produced.update(cls._settings_producer(merged)) diff --git a/plugin/types.py b/plugin/types.py index 9078e6b7..e23eb0b3 100644 --- a/plugin/types.py +++ b/plugin/types.py @@ -5,9 +5,10 @@ from collections import UserDict as BuiltinUserDict from collections.abc import Generator, Hashable, Iterator, KeysView from enum import Enum -from typing import Any, Generic, TypedDict, TypeVar, Union, overload +from typing import Any, Generic, TypeVar, Union, overload import sublime +from pydantic import BaseModel, Field, field_validator from typing_extensions import Self SyntaxLike = Union[str, sublime.Syntax] @@ -95,31 +96,48 @@ def optimize(self) -> Generator[Any, None, None]: """Does optimizations and returns a generator for dropped objects.""" -class StConstraintRule(TypedDict): - """Typed dict for corresponding ST settings.""" +class StConstraintRule(BaseModel): + """Model for a "constraint rule" in settings.""" constraint: str - args: list[Any] | Any | None - kwargs: dict[str, Any] | None - inverted: bool + """The name of the "constraint".""" + args: list[Any] = Field(default_factory=list) + """Positional arguments for the "constraint".""" + kwargs: dict[str, Any] = Field(default_factory=dict) + """Keyword arguments for the "constraint".""" + inverted: bool = False + """Whether the test result should be inverted.""" -class StMatchRule(TypedDict): - """Typed dict for corresponding ST settings.""" +class StMatchRule(BaseModel): + """Model for a "match rule" in settings.""" - match: str - args: list[Any] | Any | None - kwargs: dict[str, Any] | None - rules: list[StMatchRule | StConstraintRule] + match: str = "any" + """The name of the "match".""" + args: list[Any] = Field(default_factory=list) + """Positional arguments for the "match".""" + kwargs: dict[str, Any] = Field(default_factory=dict) + """Keyword arguments for the "match".""" + rules: list[StConstraintRule | StMatchRule] = Field(default_factory=list) + """Rules to match against.""" class StSyntaxRule(StMatchRule): - """Typed dict for corresponding ST settings.""" - - comment: str - selector: str - syntaxes: str | list[str] - on_events: str | list[str] | None + """Model for a "syntax rule" in settings.""" + + comment: str = "" + """A comment for the rule.""" + selector: str = "text.plain" + """To constrain the syntax scope of the current view. An empty string matches any scope.""" + syntaxes: list[str] = Field(default_factory=list) + """Syntaxes to be used. The first available one will be used.""" + on_events: list[str] | None = None + """Events to listen to, or `None` for all events.""" + + @field_validator("syntaxes", "on_events", mode="before") + @classmethod + def str_to_list_str(cls, v: Any) -> list[str]: + return [v] if isinstance(v, str) else v class WindowKeyedDict(UserDict[WindowIdAble, _T]):