syntax highlighting on changed lines, keeping the red/green background

This commit is contained in:
Vaclav Tvrdik
2026-09-29 10:29:56 +02:00
parent af8c1dd6df
commit 54c69484d5
8 changed files with 637 additions and 113 deletions

View File

@@ -1,7 +1,25 @@
import os
import sys
import pytest
# tests import the package directly; sublime/sublime_plugin come from tests/stubs
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(HERE))
sys.path.insert(0, os.path.join(HERE, "stubs"))
@pytest.fixture(autouse=True)
def scheme_override_sandbox(tmp_path, monkeypatch):
"""Keep the colour-scheme override palette.py writes inside the test's own tmp_path
Both caches are per session and the stub hands every view the same id, so they would otherwise
leak between tests and make every assertion about the override depend on test order.
"""
import sublime
from vdiff import palette, state
palette.reset()
monkeypatch.setattr(state, "STATES", {})
monkeypatch.setattr(sublime, "packages_path", lambda: str(tmp_path))

View File

@@ -1,4 +1,5 @@
""" Minimal sublime API stub so the plugin modules import and header/regions logic runs offline """
import re
OP_EQUAL = 0
OP_NOT_EQUAL = 1
@@ -109,6 +110,29 @@ class Window:
return self.input_view
# a bundled scheme's styles: the diff.* and region.* scopes defined, the plugin's vdiff.* ones not.
# Nothing here simulates sublime merging in a written override, so a test that wants the plugin's
# scopes to resolve has to say so by adding them.
DEFAULT_FG = "#ffffff"
DEFAULT_STYLES = {
"diff.deleted": {"foreground": DEFAULT_FG, "background": "#4a2b2b"},
"diff.deleted.char": {"foreground": DEFAULT_FG, "background": "#6a3535"},
"diff.inserted": {"foreground": DEFAULT_FG, "background": "#2b4a3a"},
"diff.inserted.char": {"foreground": DEFAULT_FG, "background": "#356a4a"},
"region.yellowish": {"foreground": "#e6db74", "background": "#4a4a2b"},
"markup.deleted": {"foreground": "#d64646"},
"markup.inserted": {"foreground": "#46d646"},
# a few of palette.PALETTE_PROBES, so the palette probe finds a working set of colours
"source": {"foreground": DEFAULT_FG},
"comment": {"foreground": "#7c8595"},
"string": {"foreground": "#99c794"},
"keyword": {"foreground": "#c594c5"},
"constant.numeric": {"foreground": "#f99157"},
"entity.name.function": {"foreground": "#6699cc"},
"punctuation.definition": {"foreground": "#5fb3b3"},
}
class View:
"""Text buffer with just the calls render.py makes"""
@@ -125,6 +149,10 @@ class View:
self.commands = []
self.popup = None
self.popup_navigate = None
# what style_for_scope answers; a test reshapes it to model another colour scheme
self.styles = {scope: dict(style) for scope, style in DEFAULT_STYLES.items()}
# whether sublime has merged the override vdiff writes; it only does so on a restart
self.override_loaded = False
def id(self):
return 1
@@ -211,8 +239,25 @@ class View:
def em_width(self):
return 8.0
def extract_tokens_with_scopes(self, region):
"""One token per run of word characters and one per run of anything else, scoped by its shape
Enough shape for the colouring tests: real sublime returns the syntax's tokens, and all the
painter needs is that adjacent tokens differ and each maps to a scope with a colour.
"""
tokens = []
text = self.text[region.a:region.b]
for match in re.finditer(r"\w+|\s+|[^\w\s]+", text):
scope = "keyword" if match.group().isidentifier() else "punctuation.definition"
tokens.append((Region(region.a + match.start(), region.a + match.end()), scope))
return tokens
def style_for_scope(self, scope):
return {"foreground": "#ffffff", "background": "#333333"}
# a fresh dict per call, and no background for an unknown scope, like the real API
if self.override_loaded and scope.startswith("vdiff.") and scope not in self.styles:
# sublime has merged the override, so every rule vdiff wrote in it resolves
return {"foreground": DEFAULT_FG, "background": "#333333"}
return dict(self.styles.get(scope, {"foreground": DEFAULT_FG}))
def is_valid(self):
return True

86
tests/test_palette.py Normal file
View File

@@ -0,0 +1,86 @@
import json
import sublime # the stub, via conftest
from vdiff import palette
def scheme_file(tmp_path):
"""The override of whichever scheme the stub reports as active"""
return tmp_path / "User" / palette.override_name(sublime.View())
def override(tmp_path):
"""The rules palette.py wrote into that override"""
path = scheme_file(tmp_path)
return json.loads(path.read_text())["rules"] if path.exists() else None
def by_scope(tmp_path):
return {rule["scope"]: rule for rule in override(tmp_path)}
def test_a_scheme_with_diff_colours_needs_no_tint_rules(tmp_path):
view = sublime.View()
palette.ensure(view)
# the tints are the scheme's own scopes, so only the generated colour rules are ours to write
assert not [r for r in override(tmp_path) if r["scope"].startswith("diff.")]
def test_a_scheme_without_diff_colours_gets_our_tints(tmp_path):
view = sublime.View()
# all or nothing per pair: taking only the half a scheme has would put our hue next to its own
del view.styles["diff.deleted.char"]
del view.styles["diff.inserted"]
del view.styles["diff.inserted.char"]
palette.ensure(view)
rules = by_scope(tmp_path)
assert rules[palette.SCOPE_INS]["background"] == palette.FALLBACK_TINTS[palette.SCOPE_INS]
assert rules[palette.SCOPE_DEL_CHAR]["background"] == palette.FALLBACK_TINTS[palette.SCOPE_DEL_CHAR]
# the one colour it does define is left to the scheme
assert palette.SCOPE_DEL not in rules
def test_every_palette_colour_gets_a_rule_per_background_kind(tmp_path):
view = sublime.View()
palette.ensure(view)
rules = by_scope(tmp_path)
colours = {view.styles[scope]["foreground"] for scope in ("comment", "string", "keyword")}
for colour in colours:
scope = palette.scope_for("ins", colour)
assert scope is not None
# both properties, which is the whole point: the tint alone would flatten the text
assert rules[scope] == {"scope": scope, "background": view.styles["diff.inserted"]["background"],
"foreground": colour}
for kind in palette.KINDS:
assert palette.scope_for(kind, colour) in rules
def test_a_colour_the_scheme_does_not_use_has_no_scope():
palette.ensure(sublime.View())
assert palette.scope_for("ins", "#123456") is None
def test_the_override_is_written_once_per_scheme(tmp_path):
view = sublime.View()
palette.ensure(view)
scheme_file(tmp_path).write_text(json.dumps({"rules": []}))
# sublime reloads the whole scheme every time we touch the file, so nothing must rewrite it
palette.ensure(view)
assert override(tmp_path) == []
def test_a_hand_edited_override_is_never_clobbered(tmp_path):
(tmp_path / "User").mkdir()
path = tmp_path / "User" / "Mariana.sublime-color-scheme"
# comments are legal in a .sublime-color-scheme but decode_value here is plain json
path.write_text('{\n // mine\n "rules": []\n}')
palette.ensure(sublime.View())
assert "// mine" in path.read_text()

View File

@@ -4,7 +4,7 @@ import re
import sublime # the stub, via conftest
from vdiff import commands, git, listeners, parse, render, state # noqa: F401
from vdiff import commands, git, listeners, palette, parse, render, state # noqa: F401
DIFF = """--- a/x.py
+++ b/x.py
@@ -34,8 +34,18 @@ def header(view):
return view.phantoms[render.PHANTOM_KEY][0].content
def with_loaded_override(view):
"""Model a view whose scheme has picked up the override palette.py writes
The stub deliberately does not simulate that merge, just as sublime does not until it restarts,
so a test wanting the generated colour scopes to resolve has to say so.
"""
view.override_loaded = True
return view
def test_render_text_regions_and_header():
view = sublime.View()
view = with_loaded_override(sublime.View())
render.init_view(view)
st = make_state()
render.render(view, st)
@@ -316,9 +326,99 @@ def test_flash_survives_a_re_render(monkeypatch):
assert render.BELL_KEY in view.regions
def test_bell_scope_falls_back_when_the_scheme_lacks_it(monkeypatch):
view = sublime.View()
def test_bell_scope_falls_back_when_the_scheme_lacks_it():
view = with_loaded_override(sublime.View())
assert render._bell_scope(view) == render.SCOPE_BELL
monkeypatch.setattr(view, "style_for_scope", lambda scope: {"foreground": "#fff"})
# an invisible bell is a silent failure, so fall back to a scope the plugin guarantees
assert render._bell_scope(view) == render.SCOPE_INS
del view.styles["region.yellowish"]
# an invisible bell is a silent failure, so fall back to a scope that is resolving
assert render._bell_scope(view) == palette.SCOPE_INS
# ... and when the plugin's own scopes are not resolving either, to the scheme's
view.override_loaded = False
assert render._bell_scope(view) == "diff.inserted"
def token_regions(view, kind, colour):
"""The regions painted for one (background kind, colour) pair of the token layer"""
scope = palette.scope_for(kind, colour)
entry = view.regions.get(scope and scope.replace("vdiff.", "vdiff.tok.", 1))
return entry[0] if entry else []
def test_changed_lines_are_repainted_in_the_files_own_colours():
view = with_loaded_override(sublime.View())
render.init_view(view)
render.render(view, make_state())
keyword = view.styles["keyword"]["foreground"]
punctuation = view.styles["punctuation.definition"]["foreground"]
# " return 10" is an inserted line: its words carry the keyword colour, its spaces and digits
# are their own runs. Without this layer every one of them would be the tint's flat foreground.
inserted = token_regions(view, "ins", keyword)
assert sublime.Region(view.text_point(4, 4), view.text_point(4, 10)) in inserted
# and a deleted line is painted from the deleted palette, not the inserted one
assert sublime.Region(view.text_point(2, 4), view.text_point(2, 10)) in token_regions(view, "del", keyword)
assert token_regions(view, "del", punctuation)
# the plain tint still covers the whole line underneath, so gaps and line ends stay coloured
assert view.regions[render.KEY_INS][1] == palette.SCOPE_INS
def test_token_runs_merge_but_stop_at_a_word_diff_range():
view = with_loaded_override(sublime.View())
render.init_view(view)
render.render(view, make_state("--- a/y\n+++ b/y\n@@ -1 +1 @@\n-foo = bar\n+foo = baz\n"))
keyword = view.styles["keyword"]["foreground"]
# only the changed character sits in the stronger tint; the rest of the word keeps the line tint
assert token_regions(view, "ins.char", keyword) == [sublime.Region(view.text_point(2, 8), view.text_point(2, 9))]
plain = token_regions(view, "ins", keyword)
assert sublime.Region(view.text_point(2, 6), view.text_point(2, 8)) in plain
assert sublime.Region(view.text_point(2, 0), view.text_point(2, 3)) in plain
def test_token_layer_waits_for_the_scopes_to_resolve():
view = sublime.View()
render.init_view(view)
render.render(view, make_state())
# sublime has not loaded the override yet, so the changed lines keep the scheme's flat tint and
# not one token key is painted: an unknown scope would draw its text in sublime's marker green
assert view.regions[render.KEY_DEL][1] == "diff.deleted"
assert view.regions[render.KEY_INS][1] == "diff.inserted"
assert not [key for key in view.regions if key.startswith("vdiff.tok.")]
def test_token_layer_is_skipped_for_a_diff_too_big_to_read(monkeypatch):
monkeypatch.setattr(render, "TOKEN_COLOUR_MAX_CHARS", 4)
view = with_loaded_override(sublime.View())
render.init_view(view)
render.render(view, make_state())
assert not [key for key in view.regions if key.startswith("vdiff.tok.")]
def test_token_regions_are_dropped_when_a_re_render_no_longer_needs_them():
view = with_loaded_override(sublime.View())
render.init_view(view)
render.render(view, make_state())
assert [key for key in view.regions if key.startswith("vdiff.tok.")]
render.render(view, make_state("--- a/y\n+++ b/y\n@@ -1,2 +1,2 @@\n a\n-b\n+c\n"))
keyword = view.styles["keyword"]["foreground"]
# the new diff paints far fewer runs; the keys the old one used must not linger
assert token_regions(view, "ins", keyword) == [sublime.Region(view.text_point(3, 0), view.text_point(3, 1))]
def test_restart_notice_when_the_override_cannot_take_effect_yet(monkeypatch):
said = []
monkeypatch.setattr(sublime, "status_message", said.append)
view = sublime.View()
render.init_view(view)
render.render(view, make_state())
assert said == ["vDiff: " + render.NEEDS_RESTART]
# said once per scheme, not once per render
render.render(view, make_state())
assert said == ["vDiff: " + render.NEEDS_RESTART]