diff --git a/plugin/commands/auto_set_syntax_debug_information.py b/plugin/commands/auto_set_syntax_debug_information.py index bef58fb7..a8bc9a75 100644 --- a/plugin/commands/auto_set_syntax_debug_information.py +++ b/plugin/commands/auto_set_syntax_debug_information.py @@ -2,15 +2,15 @@ from typing import Any, Mapping -import sublime import sublime_plugin -from ..constants import PLUGIN_NAME, PY_VERSION, ST_CHANNEL, ST_PLATFORM_ARCH, ST_VERSION, VERSION, VIEW_KEY_IS_CREATED +from ..constants import PLUGIN_NAME, PY_VERSION, ST_CHANNEL, ST_PLATFORM_ARCH, ST_VERSION, VERSION +from ..helpers import create_new_view from ..rules.constraint import get_constraints from ..rules.match import get_matches from ..settings import get_merged_plugin_settings from ..shared import G -from ..utils import find_syntax_by_syntax_like, get_fqcn, stringify +from ..utils import get_fqcn, stringify TEMPLATE = f""" # === [{PLUGIN_NAME}] Debug Information === # @@ -51,7 +51,7 @@ class AutoSetSyntaxDebugInformationCommand(sublime_plugin.WindowCommand): def description(self) -> str: return f"{PLUGIN_NAME}: Debug Information" - def run(self, *, copy_only: bool = False) -> None: + def run(self) -> None: info: dict[str, Any] = {} info["env"] = { @@ -68,18 +68,10 @@ def run(self, *, copy_only: bool = False) -> None: content = TEMPLATE.format_map(_pythonize(info)) - if copy_only: - sublime.set_clipboard(content) - sublime.message_dialog(f"[{PLUGIN_NAME}] The result has been copied to the clipboard.") - return - - view = self.window.new_file() - view.set_name(self.description()) + view = create_new_view( + name=self.description(), + content=content, + syntax="scope:source.python", + window=self.window, + ) view.set_scratch(True) - view.run_command("append", {"characters": content}) - view.settings().update({ - VIEW_KEY_IS_CREATED: True, - }) - - if syntax_ := find_syntax_by_syntax_like("scope:source.python"): - view.assign_syntax(syntax_) diff --git a/plugin/commands/auto_set_syntax_syntax_rules_summary.py b/plugin/commands/auto_set_syntax_syntax_rules_summary.py index e4c9c361..ba1df64d 100644 --- a/plugin/commands/auto_set_syntax_syntax_rules_summary.py +++ b/plugin/commands/auto_set_syntax_syntax_rules_summary.py @@ -5,57 +5,35 @@ import sublime import sublime_plugin -from ..constants import PLUGIN_NAME, VIEW_KEY_IS_CREATED -from ..rules import SyntaxRule +from ..constants import PLUGIN_NAME +from ..helpers import create_new_view from ..shared import G -from ..utils import find_syntax_by_syntax_like, stringify - -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 # -######################## - -{{content}} -""".lstrip() +from ..types import StSyntaxRule class AutoSetSyntaxSyntaxRulesSummaryCommand(sublime_plugin.WindowCommand): def description(self) -> str: return f"{PLUGIN_NAME}: Syntax Rules Summary" - def run(self, *, copy_only: bool = False) -> None: + def run(self) -> 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) - - content = "" - for syntax_, rules in sorted(summary.items(), key=lambda x: x[0].name.casefold()): - content += f"# Syntax: {syntax_.name}\n" - for rule in rules: - content += f"{stringify(rule)}\n" - content += "\n" - content = TEMPLATE.format(content=content) - - if copy_only: - sublime.set_clipboard(content) - sublime.message_dialog(f"[{PLUGIN_NAME}] The result has been copied to the clipboard.") - return - - view = self.window.new_file() - view.set_name(self.description()) + if rule.syntax and rule.src_setting: + summary[rule.syntax].append(rule.src_setting) + + content = f"// [{PLUGIN_NAME}] Syntax Rules Summary\n\n" + for syntax, rules in sorted(summary.items(), key=lambda x: x[0].name.casefold()): + content += "/" * 80 + "\n" + f"// Syntax: {syntax.name}\n" + "/" * 80 + "\n" + content += "\n".join(st_rule.model_dump_json(indent=4) for st_rule in rules) + content += "\n\n" + + view = create_new_view( + name=self.description(), + content=content, + syntax="scope:source.json", + window=self.window, + ) view.set_scratch(True) - view.run_command("append", {"characters": content}) - view.settings().update({ - VIEW_KEY_IS_CREATED: True, - }) - - if syntax := find_syntax_by_syntax_like("scope:source.python"): - view.assign_syntax(syntax) diff --git a/plugin/helpers.py b/plugin/helpers.py index c4a58247..716492b2 100644 --- a/plugin/helpers.py +++ b/plugin/helpers.py @@ -4,8 +4,9 @@ import sublime +from .constants import VIEW_KEY_IS_CREATED from .settings import get_st_setting -from .utils import is_plaintext_syntax, is_transient_view +from .utils import find_syntax_by_syntax_like, is_plaintext_syntax, is_transient_view def is_syntaxable_view(view: sublime.View, *, must_plaintext: bool = False) -> bool: @@ -44,3 +45,26 @@ def resolve_magika_label_with_syntax_map(label: str, syntax_map: dict[str, list[ res[scope] = True # parsed return [scope for scope, is_parsed in res.items() if is_parsed] + + +def create_new_view( + *, + name: str = "Untitled", + content: str = "", + syntax: str | sublime.Syntax | None = None, + window: sublime.Window | None = None, +) -> sublime.View: + """Copies the content to a new view.""" + window = window or sublime.active_window() + + view = window.new_file() + view.set_name(name) + view.run_command("append", {"characters": content}) + view.settings().update({ + VIEW_KEY_IS_CREATED: True, + }) + + if syntax and (syntax := find_syntax_by_syntax_like(syntax)): + view.assign_syntax(syntax) + + return view diff --git a/plugin/rules/constraint.py b/plugin/rules/constraint.py index 55cff708..d5102506 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,10 @@ 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 + + src_setting: StConstraintRule | None = None + """The source setting object.""" def is_droppable(self) -> bool: return not (self.constraint and not self.constraint.is_droppable()) @@ -74,21 +78,18 @@ def test(self, view_snapshot: ViewSnapshot) -> bool: def make(cls, constraint_rule: StConstraintRule) -> Self: """Build this object with the `constraint_rule`.""" obj = cls() + obj.src_setting = constraint_rule - if args := constraint_rule.get("args"): - # make sure args is always a tuple - obj.args = tuple(args) if isinstance(args, list) else (args,) - - if kwargs := constraint_rule.get("kwargs"): - obj.kwargs = kwargs - - if (inverted := constraint_rule.get("inverted")) is not None: - obj.inverted = bool(inverted) + obj.args = tuple(constraint_rule.args) + obj.kwargs = constraint_rule.kwargs + obj.inverted = constraint_rule.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..1a913f2f 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,14 +31,15 @@ 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() kwargs: dict[str, Any] = field(default_factory=dict) rules: tuple[MatchableRule, ...] = tuple() + src_setting: StMatchRule | None = None + """The source setting object.""" + def is_droppable(self) -> bool: return not (self.rules and self.match and not self.match.is_droppable(self.rules)) @@ -62,25 +64,24 @@ def test(self, view_snapshot: ViewSnapshot) -> bool: def make(cls, match_rule: StMatchRule) -> Self: """Build this object with the `match_rule`.""" obj = cls() + obj.src_setting = match_rule - 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..ba270b81 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 + src_setting: StSyntaxRule | None = None + """The source 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.src_setting = 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]):