Skip to content

Commit

Permalink
refactor: retire ViewSnapshotCollection
Browse files Browse the repository at this point in the history
It has some issues that are tricky to solve because we are using
multithreads... Instead, we use a view snapshot in the function call
chain.

Signed-off-by: Jack Cherng <[email protected]>
  • Loading branch information
jfcherng committed Mar 5, 2024
1 parent d246add commit 84a1831
Show file tree
Hide file tree
Showing 36 changed files with 180 additions and 274 deletions.
141 changes: 61 additions & 80 deletions plugin/commands/auto_set_syntax.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
from __future__ import annotations

import re
from functools import wraps
from itertools import chain
from pathlib import Path
from typing import Any, Callable, TypeVar, cast
from typing import Any

import sublime
import sublime_plugin
Expand All @@ -27,8 +26,6 @@
stringify,
)

_T_Callable = TypeVar("_T_Callable", bound=Callable[..., Any])


class AutoSetSyntaxCommand(sublime_plugin.TextCommand):
def description(self) -> str:
Expand All @@ -38,35 +35,20 @@ def run(self, edit: sublime.Edit) -> None:
run_auto_set_syntax_on_view(self.view, ListenerEvent.COMMAND, must_plaintext=False)


def _snapshot_view(failed_ret: Any = None) -> Callable[[_T_Callable], _T_Callable]:
def decorator(func: _T_Callable) -> _T_Callable:
@wraps(func)
def wrapped(view: sublime.View, *args: Any, **kwargs: Any) -> Any:
if not ((window := view.window()) and G.is_plugin_ready(window) and view.is_valid()):
Logger.log("⏳ Calm down! View has gone or the plugin is not ready yet.")
return failed_ret

with G.view_snapshot_collection.snapshot_context(view):
return func(view, *args, **kwargs)

return cast(_T_Callable, wrapped)

return decorator


@_snapshot_view(failed_ret=False)
def run_auto_set_syntax_on_view(
view: sublime.View,
event: ListenerEvent | None = None,
*,
must_plaintext: bool = False,
) -> bool:
# multithread guard...
if not G.view_snapshot_collection.get_by_view(view):
if not ((window := view.window()) and G.is_plugin_ready(window) and view.is_valid()):
Logger.log("⏳ Calm down! View has gone or the plugin is not ready yet.")
return False

view_snapshot = ViewSnapshot.from_view(view)

if event is ListenerEvent.EXEC:
return _assign_syntax_for_exec_output(view, event)
return _assign_syntax_for_exec_output(view_snapshot, event)

# prerequsites
if not (
Expand All @@ -77,15 +59,15 @@ def run_auto_set_syntax_on_view(
return False

if event is ListenerEvent.NEW:
return _assign_syntax_for_new_view(view, event)
return _assign_syntax_for_new_view(view_snapshot, event)

if _assign_syntax_for_st_syntax_test(view, event):
if _assign_syntax_for_st_syntax_test(view_snapshot, event):
return True

if _assign_syntax_with_plugin_rules(view, syntax_rule_collection, event):
if _assign_syntax_with_plugin_rules(view_snapshot, syntax_rule_collection, event):
return True

if _assign_syntax_with_first_line(view, event):
if _assign_syntax_with_first_line(view_snapshot, event):
return True

if event in {
Expand All @@ -94,7 +76,7 @@ def run_auto_set_syntax_on_view(
ListenerEvent.LOAD,
ListenerEvent.SAVE,
ListenerEvent.UNTRANSIENTIZE,
} and _assign_syntax_with_trimmed_filename(view, event):
} and _assign_syntax_with_trimmed_filename(view_snapshot, event):
return True

if event in {
Expand All @@ -105,18 +87,19 @@ def run_auto_set_syntax_on_view(
ListenerEvent.PASTE,
ListenerEvent.SAVE,
ListenerEvent.UNTRANSIENTIZE,
} and _assign_syntax_with_magika(view, event):
} and _assign_syntax_with_magika(view_snapshot, event):
return True

if _assign_syntax_with_heuristics(view, event):
if _assign_syntax_with_heuristics(view_snapshot, event):
return True

return _sorry_cannot_help(view, event)


def _assign_syntax_for_exec_output(view: sublime.View, event: ListenerEvent | None = None) -> bool:
def _assign_syntax_for_exec_output(view_snapshot: ViewSnapshot, event: ListenerEvent | None = None) -> bool:
if (
(window := view.window())
(view := view_snapshot.valid_view)
and (window := view.window())
and (not (syntax_old := view.syntax()) or syntax_old.scope == "text.plain")
and (exec_file_syntax := get_merged_plugin_setting("exec_file_syntax", window=window))
and (syntax := find_syntax_by_syntax_like(exec_file_syntax, include_hidden=True))
Expand All @@ -129,9 +112,10 @@ def _assign_syntax_for_exec_output(view: sublime.View, event: ListenerEvent | No
return False


def _assign_syntax_for_new_view(view: sublime.View, event: ListenerEvent | None = None) -> bool:
def _assign_syntax_for_new_view(view_snapshot: ViewSnapshot, event: ListenerEvent | None = None) -> bool:
if (
(window := view.window())
(view := view_snapshot.valid_view)
and (window := view.window())
and (new_file_syntax := get_merged_plugin_setting("new_file_syntax", window=window))
and (syntax := find_syntax_by_syntax_like(new_file_syntax, include_plaintext=False))
):
Expand All @@ -143,9 +127,9 @@ def _assign_syntax_for_new_view(view: sublime.View, event: ListenerEvent | None
return False


def _assign_syntax_for_st_syntax_test(view: sublime.View, event: ListenerEvent | None = None) -> bool:
def _assign_syntax_for_st_syntax_test(view_snapshot: ViewSnapshot, event: ListenerEvent | None = None) -> bool:
if (
(view_snapshot := G.view_snapshot_collection.get_by_view(view))
(view := view_snapshot.valid_view)
and (not view_snapshot.syntax or is_plaintext_syntax(view_snapshot.syntax))
and (m := RE_ST_SYNTAX_TEST_LINE.search(view_snapshot.first_line))
and (new_syntax := m.group("syntax")).endswith(".sublime-syntax")
Expand All @@ -160,11 +144,11 @@ def _assign_syntax_for_st_syntax_test(view: sublime.View, event: ListenerEvent |


def _assign_syntax_with_plugin_rules(
view: sublime.View,
view_snapshot: ViewSnapshot,
syntax_rule_collection: SyntaxRuleCollection,
event: ListenerEvent | None = None,
) -> bool:
if syntax_rule := syntax_rule_collection.test(view, event):
if (view := view_snapshot.valid_view) and (syntax_rule := syntax_rule_collection.test(view_snapshot, event)):
assert syntax_rule.syntax # otherwise it should be dropped during optimizing
return assign_syntax_to_view(
view,
Expand All @@ -174,7 +158,7 @@ def _assign_syntax_with_plugin_rules(
return False


def _assign_syntax_with_first_line(view: sublime.View, event: ListenerEvent | None = None) -> bool:
def _assign_syntax_with_first_line(view_snapshot: ViewSnapshot, event: ListenerEvent | None = None) -> bool:
# Note that this only works for files under some circumstances.
# This is to prevent from, for example, changing a ".erb" (Rails HTML template) file into HTML syntax.
# But we want to change a file whose name is "cpp" with a Python shebang into Python syntax.
Expand Down Expand Up @@ -202,7 +186,7 @@ def _prefer_vim_modeline(view_snapshot: ViewSnapshot) -> sublime.Syntax | None:
return syntax
return None

if not (view_snapshot := G.view_snapshot_collection.get_by_view(view)):
if not (view := view_snapshot.valid_view):
return False

# It's potentially that a first line of a syntax is a prefix of another syntax's.
Expand All @@ -221,9 +205,10 @@ def _prefer_vim_modeline(view_snapshot: ViewSnapshot) -> sublime.Syntax | None:
return False


def _assign_syntax_with_trimmed_filename(view: sublime.View, event: ListenerEvent | None = None) -> bool:
def _assign_syntax_with_trimmed_filename(view_snapshot: ViewSnapshot, event: ListenerEvent | None = None) -> bool:
if not (
(filepath := view.file_name())
(view := view_snapshot.valid_view)
and (filepath := view.file_name())
and (window := view.window())
and (syntax_old := view.syntax())
and is_plaintext_syntax(syntax_old)
Expand Down Expand Up @@ -256,46 +241,12 @@ def _assign_syntax_with_trimmed_filename(view: sublime.View, event: ListenerEven
return False


def _assign_syntax_with_heuristics(view: sublime.View, event: ListenerEvent | None = None) -> bool:
if not (
(view_snapshot := G.view_snapshot_collection.get_by_view(view))
and view_snapshot.syntax
and is_plaintext_syntax(view_snapshot.syntax)
):
return False

def is_large_file(view_snapshot: ViewSnapshot) -> bool:
return view_snapshot.char_count >= 10 * 1024 # 10KB

def is_json(view_snapshot: ViewSnapshot) -> bool:
text_begin = re.sub(r"^\s+", "", view_snapshot.content[:10])
text_end = re.sub(r"\s+$", "", view_snapshot.content[-10:])

# XSSI protection prefix (https://security.stackexchange.com/q/110539)
if text_begin.startswith((")]}'\n", ")]}',\n")):
return True

return is_large_file(view_snapshot) and bool(
# map
(re.search(r'^\{"', text_begin) and re.search(r'(?:[\d"\]}]|true|false|null)\}$', text_end))
# array
or (re.search(r'^\["', text_begin) and re.search(r'(?:[\d"\]}]|true|false|null)\]$', text_end))
or (text_begin.startswith("[[") and text_end.endswith("]]"))
or (text_begin.startswith("[{") and text_end.endswith("}]"))
)

if is_json(view_snapshot) and (syntax := find_syntax_by_syntax_like("scope:source.json")):
return assign_syntax_to_view(view, syntax, details={"event": event, "reason": "heuristics"})

return False


def _assign_syntax_with_magika(view: sublime.View, event: ListenerEvent | None = None) -> bool:
def _assign_syntax_with_magika(view_snapshot: ViewSnapshot, event: ListenerEvent | None = None) -> bool:
if not (
(window := view.window())
(view := view_snapshot.valid_view)
and (window := view.window())
and (settings := get_merged_plugin_settings(window=window))
and settings.get("magika.enabled")
and (view_snapshot := G.view_snapshot_collection.get_by_view(view))
# don't apply on those have an extension
and (event == ListenerEvent.COMMAND or "." not in view_snapshot.file_name_unhidden)
# only apply on plain text syntax
Expand Down Expand Up @@ -338,6 +289,36 @@ def _assign_syntax_with_magika(view: sublime.View, event: ListenerEvent | None =
return assign_syntax_to_view(view, syntax, details={"event": event, "reason": "Magika (Deep Learning)"})


def _assign_syntax_with_heuristics(view_snapshot: ViewSnapshot, event: ListenerEvent | None = None) -> bool:
def is_large_file(view_snapshot: ViewSnapshot) -> bool:
return view_snapshot.char_count >= 10 * 1024 # 10KB

def is_json(view_snapshot: ViewSnapshot) -> bool:
text_begin = re.sub(r"^\s+", "", view_snapshot.content[:10])
text_end = re.sub(r"\s+$", "", view_snapshot.content[-10:])

# XSSI protection prefix (https://security.stackexchange.com/q/110539)
if text_begin.startswith((")]}'\n", ")]}',\n")):
return True

return is_large_file(view_snapshot) and bool(
# map
(re.search(r'^\{"', text_begin) and re.search(r'(?:[\d"\]}]|true|false|null)\}$', text_end))
# array
or (re.search(r'^\["', text_begin) and re.search(r'(?:[\d"\]}]|true|false|null)\]$', text_end))
or (text_begin.startswith("[[") and text_end.endswith("]]"))
or (text_begin.startswith("[{") and text_end.endswith("}]"))
)

if not ((view := view_snapshot.valid_view) and view_snapshot.syntax and is_plaintext_syntax(view_snapshot.syntax)):
return False

if is_json(view_snapshot) and (syntax := find_syntax_by_syntax_like("scope:source.json")):
return assign_syntax_to_view(view, syntax, details={"event": event, "reason": "heuristics"})

return False


def _sorry_cannot_help(view: sublime.View, event: ListenerEvent | None = None) -> bool:
details = {"event": event, "reason": "no matching rule"}
Logger.log(f"❌ Cannot help {stringify(view)} because {stringify(details)}", window=view.window())
Expand Down
19 changes: 4 additions & 15 deletions plugin/rules/constraint.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,8 @@
from pathlib import Path
from typing import Any, Callable, Generator, Iterable, Pattern, TypeVar, final

import sublime

from ..cache import clearable_lru_cache
from ..constants import PLUGIN_NAME, ST_PLATFORM
from ..shared import G
from ..snapshot import ViewSnapshot
from ..types import Optimizable, ST_ConstraintRule
from ..utils import (
Expand Down Expand Up @@ -54,11 +51,11 @@ def optimize(self) -> Generator[Optimizable, None, None]:
return
yield

def test(self, view: sublime.View) -> bool:
def test(self, view_snapshot: ViewSnapshot) -> bool:
assert self.constraint

try:
result = self.constraint.test(view)
result = self.constraint.test(view_snapshot)
except AlwaysTruthyException:
return True
except AlwaysFalsyException:
Expand Down Expand Up @@ -117,8 +114,8 @@ def is_droppable(self) -> bool:
return False

@abstractmethod
def test(self, view: sublime.View) -> bool:
"""Tests whether the `view` passes this constraint."""
def test(self, view_snapshot: ViewSnapshot) -> bool:
"""Tests whether the `view_snapshot` passes this constraint."""

@final
def _handled_args(self, normalizer: Callable[[T], T] | None = None) -> tuple[T, ...]:
Expand Down Expand Up @@ -161,14 +158,6 @@ def _handled_case_insensitive(kwargs: dict[str, Any]) -> bool:
"""Returns `case_insensitive` in `kwars`. Defaulted to platform's specification."""
return bool(kwargs.get("case_insensitive", ST_PLATFORM in {"windows", "osx"}))

@final
@staticmethod
def get_view_snapshot(view: sublime.View) -> ViewSnapshot:
"""Gets the cached information for the `view`."""
snapshot = G.view_snapshot_collection.get_by_view(view)
assert snapshot # our workflow guarantees this won't be None
return snapshot

@final
@staticmethod
def find_parent_with_sibling(base: str | Path, sibling: str, *, use_exists: bool = False) -> Path | None:
Expand Down
9 changes: 3 additions & 6 deletions plugin/rules/constraints/contains.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

from typing import Any, final

import sublime

from ...snapshot import ViewSnapshot
from ...utils import nth, str_finditer
from ..constraint import AbstractConstraint

Expand All @@ -19,15 +18,13 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
def is_droppable(self) -> bool:
return not (self.needles and isinstance(self.threshold, (int, float)))

def test(self, view: sublime.View) -> bool:
def test(self, view_snapshot: ViewSnapshot) -> bool:
if self.threshold <= 0:
return True

content = self.get_view_snapshot(view).content

return (
nth(
(_ for needle in self.needles for _ in str_finditer(content, needle)),
(_ for needle in self.needles for _ in str_finditer(view_snapshot.content, needle)),
self.threshold - 1,
)
is not None
Expand Down
7 changes: 3 additions & 4 deletions plugin/rules/constraints/contains_regex.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

from typing import Any, final

import sublime

from ...snapshot import ViewSnapshot
from ...utils import nth
from ..constraint import AbstractConstraint

Expand All @@ -19,13 +18,13 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
def is_droppable(self) -> bool:
return not isinstance(self.threshold, (int, float))

def test(self, view: sublime.View) -> bool:
def test(self, view_snapshot: ViewSnapshot) -> bool:
if self.threshold <= 0:
return True

return (
nth(
self.regex.finditer(self.get_view_snapshot(view).content),
self.regex.finditer(view_snapshot.content),
self.threshold - 1,
)
is not None
Expand Down
8 changes: 3 additions & 5 deletions plugin/rules/constraints/first_line_contains.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

from typing import Any, final

import sublime

from ...snapshot import ViewSnapshot
from ..constraint import AbstractConstraint


Expand All @@ -17,6 +16,5 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
def is_droppable(self) -> bool:
return not self.needles

def test(self, view: sublime.View) -> bool:
first_line = self.get_view_snapshot(view).first_line
return any((needle in first_line) for needle in self.needles)
def test(self, view_snapshot: ViewSnapshot) -> bool:
return any((needle in view_snapshot.first_line) for needle in self.needles)
Loading

0 comments on commit 84a1831

Please sign in to comment.