-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add command to convert between property names and their signal handlers
This command comes with GitHub actions workflow and an extensive test suite.
- Loading branch information
Showing
8 changed files
with
388 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
name: Commands Tests | ||
|
||
on: | ||
push: | ||
paths: | ||
- '.github/workflows/commands.yml' | ||
- '**.py' | ||
pull_request: | ||
paths: | ||
- '.github/workflows/commands.yml' | ||
- '**.py' | ||
|
||
jobs: | ||
run-tests: | ||
strategy: | ||
fail-fast: false | ||
runs-on: ubuntu-latest | ||
steps: | ||
- uses: actions/checkout@v2 | ||
- uses: SublimeText/UnitTesting/actions/setup@v1 | ||
with: | ||
sublime-text-version: 4 | ||
- uses: SublimeText/UnitTesting/actions/run-tests@v1 | ||
with: | ||
coverage: true | ||
- uses: codecov/codecov-action@v3 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
3.8 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
[ | ||
{ | ||
"caption": "QML: Convert Between Property And Signal Handler", | ||
"command": "convert_between_property_and_signal_handler" | ||
} | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,188 @@ | ||
# SPDX-FileCopyrightText: 2023 ivan tkachenko <[email protected]> | ||
# SPDX-License-Identifier: MIT | ||
|
||
from dataclasses import dataclass | ||
from typing import Callable, List, Optional, Tuple | ||
import sublime | ||
from sublime import Region | ||
import sublime_plugin | ||
import re | ||
|
||
# Apparently, sublime module claims to have Point member as a type alias to int, but in fact it doesn't | ||
Point = int | ||
|
||
Parser = Callable[[sublime.View, Point], Optional[Region]] | ||
|
||
# Based on QML.sublime-syntax, except that | ||
# Python regex lacks [:groups:] support | ||
IDENTIFIER = re.compile(r"\b_*([a-zA-Z])\w*\b") | ||
IDENTIFIER_HANDLER = re.compile(rf"\b(?:on)(_*([A-Z])\w*?)(Changed)?\b") | ||
|
||
|
||
class Atoms: | ||
""" | ||
Atoms parsers. | ||
""" | ||
|
||
@staticmethod | ||
def dot(view: sublime.View, point: Point) -> Optional[Region]: | ||
region = Region(point, point + 1) | ||
|
||
if view.substr(region) == ".": | ||
return region | ||
|
||
return None | ||
|
||
|
||
@staticmethod | ||
def identifier(view: sublime.View, point: Point) -> Optional[Region]: | ||
region = view.word(point) | ||
if region is None: | ||
return None | ||
|
||
word = view.substr(region) | ||
if IDENTIFIER.fullmatch(word) is None: | ||
return None | ||
|
||
return region | ||
|
||
|
||
class Combinators: | ||
""" | ||
Simple parser combinators. | ||
""" | ||
|
||
@staticmethod | ||
def last_delimited(parser: Parser, separator: Parser) -> Parser: | ||
def parse(view: sublime.View, point: Point) -> Optional[Region]: | ||
last = result = parser(view, point) | ||
|
||
if result is not None: | ||
point = result.end() | ||
|
||
while True: | ||
result = separator(view, point) | ||
if result is None: | ||
return last | ||
|
||
point = result.end() | ||
|
||
result = parser(view, point) | ||
|
||
if result is None: | ||
return last | ||
|
||
last = result | ||
point = result.end() | ||
|
||
return parse | ||
|
||
|
||
last_identifier = Combinators.last_delimited(Atoms.identifier, Atoms.dot) | ||
""" | ||
Get last identifier in a dot-separated series, like | ||
- "margins" in `anchors.margins`, | ||
- "onCompleted" in `Component.onCompleted`, | ||
- or "visible" in `QQC2.ToolTip.visible`. | ||
""" | ||
|
||
|
||
def substring(string: str, span: Tuple[int, int]) -> str: | ||
start, end = span | ||
return string[start:end] | ||
|
||
|
||
def string_splice(string: str, span: Tuple[int, int], new: str) -> str: | ||
start, end = span | ||
return string[:start] + new + string[end:] | ||
|
||
|
||
def string_splice_apply(string: str, span: Tuple[int, int], fn: Callable[[str], str]) -> str: | ||
new = fn(substring(string, span)) | ||
return string_splice(string, span, new) | ||
|
||
|
||
def generate_replacement_for(string: str) -> Optional[str]: | ||
match_handler = IDENTIFIER_HANDLER.fullmatch(string) | ||
match_identifier = IDENTIFIER.fullmatch(string) | ||
|
||
if match_handler is not None: | ||
property_name = string_splice_apply(string, match_handler.span(2), str.lower) | ||
property_name = substring(property_name, match_handler.span(1)) | ||
return property_name | ||
|
||
elif match_identifier is not None: | ||
property_uppercased = string_splice_apply(string, match_identifier.span(1), str.upper) | ||
signal_handler_name = f"on{property_uppercased}Changed" | ||
return signal_handler_name | ||
|
||
return None | ||
|
||
|
||
def common_prefix(first: str, second: str) -> str: | ||
i = 0 | ||
while i < len(first) and i < len(second) and first[i] == second[i]: | ||
i += 1 | ||
return first[:i] | ||
|
||
|
||
@dataclass | ||
class Replacement: | ||
original_selection_region: Region | ||
source_region: Region | ||
replacement_string: str | ||
|
||
|
||
class ConvertBetweenPropertyAndSignalHandler(sublime_plugin.TextCommand): | ||
def run(self, edit: sublime.Edit): | ||
signal_handlers: List[Replacement] = [] | ||
properties: List[Replacement] = [] | ||
|
||
for original_selection_region in self.view.sel(): | ||
point = original_selection_region.b | ||
scope = self.view.scope_name(point) | ||
|
||
if "source.qml" not in scope and "text.plain" not in scope: | ||
continue | ||
|
||
identifier_region = last_identifier(self.view, original_selection_region.b) | ||
if identifier_region is None: | ||
continue | ||
|
||
target = [] | ||
|
||
if IDENTIFIER_HANDLER.fullmatch(self.view.substr(identifier_region)): | ||
target = signal_handlers | ||
elif IDENTIFIER.fullmatch(self.view.substr(identifier_region)): | ||
target = properties | ||
else: | ||
continue | ||
|
||
identifier_string = self.view.substr(identifier_region) | ||
replacement_string = generate_replacement_for(identifier_string) | ||
if replacement_string is None: | ||
continue | ||
|
||
# Insert ": " if needed, and set selection to the point after the whitespace | ||
extra_string = ": " | ||
lookahead = self.view.substr(Region(identifier_region.end(), identifier_region.end() + len(extra_string))) | ||
extra_preexisting = common_prefix(lookahead, extra_string) | ||
|
||
source_region = Region(identifier_region.a, identifier_region.b + len(extra_preexisting)) | ||
replacement_string += extra_string | ||
|
||
target.append(Replacement(original_selection_region, source_region, replacement_string)) | ||
|
||
# If there are no properties, change all signal handlers to | ||
# properties, otherwise change all properties to signal handlers. | ||
regions = signal_handlers if len(properties) == 0 else properties | ||
|
||
# Regions are maintained by selection in sorted order, so it should be | ||
# reversed to apply changes down below before those which come | ||
# earlier, so they don't shift regions downward. | ||
for replacement in reversed(regions): | ||
target_selection_region = Region(replacement.source_region.begin() + len(replacement.replacement_string)) | ||
|
||
self.view.sel().subtract(replacement.original_selection_region) | ||
self.view.replace(edit, replacement.source_region, replacement.replacement_string) | ||
self.view.sel().add(target_selection_region) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,139 @@ | ||
# SPDX-FileCopyrightText: 2023 ivan tkachenko <[email protected]> | ||
# SPDX-License-Identifier: MIT | ||
|
||
import re | ||
import sys | ||
from typing import Iterable, List, Optional, Union | ||
|
||
import sublime | ||
from sublime import Region | ||
|
||
from unittest import TestCase | ||
from unittesting import DeferrableTestCase | ||
|
||
import QML.QMLCommands as qml | ||
|
||
|
||
class TestConvertBetweenPropertyAndSignalHandler(DeferrableTestCase): | ||
def setUp(self): | ||
# make sure we have a window to work with | ||
s = sublime.load_settings("Preferences.sublime-settings") | ||
s.set("close_windows_when_empty", False) | ||
self.view = sublime.active_window().new_file() | ||
self.view.assign_syntax("scope:source.qml") | ||
|
||
def tearDown(self): | ||
self.view.set_scratch(True) | ||
self.view.window().focus_view(self.view) # pyright: ignore [reportOptionalMemberAccess] | ||
self.view.window().run_command("close_file") # pyright: ignore [reportOptionalMemberAccess] | ||
|
||
def setText(self, string: str): | ||
self.view.run_command("select_all") | ||
self.view.run_command("left_delete") | ||
self.view.run_command("insert", {"characters": string}) | ||
|
||
def getRow(self, row: int): | ||
return self.view.substr(self.view.line(self.view.text_point(row, 0))) | ||
|
||
def runBasicParserTest(self, parser: qml.Parser, string: str, point: qml.Point, expected: Optional[Region]): | ||
self.setText(string) | ||
actual = parser(self.view, point) | ||
self.assertEqual(actual, expected) | ||
|
||
def test_atoms(self): | ||
self.runBasicParserTest(qml.Atoms.dot, ".", 0, Region(0, 1)) | ||
self.runBasicParserTest(qml.Atoms.dot, ".", 1, None) | ||
self.runBasicParserTest(qml.Atoms.dot, "abc.", 0, None) | ||
self.runBasicParserTest(qml.Atoms.dot, "abc.", 3, Region(3, 4)) | ||
self.runBasicParserTest(qml.Atoms.dot, "abc.def", 4, None) | ||
|
||
self.runBasicParserTest(qml.Atoms.identifier, "abc", 0, Region(0, 3)) | ||
self.runBasicParserTest(qml.Atoms.identifier, "abc", 3, Region(0, 3)) | ||
self.runBasicParserTest(qml.Atoms.identifier, "abc.", 3, Region(0, 3)) | ||
self.runBasicParserTest(qml.Atoms.identifier, "abc.", 4, None) | ||
self.runBasicParserTest(qml.Atoms.identifier, "abc123", 0, Region(0, 6)) | ||
self.runBasicParserTest(qml.Atoms.identifier, "abc123:", 0, Region(0, 6)) | ||
self.runBasicParserTest(qml.Atoms.identifier, "abc123:", 5, Region(0, 6)) | ||
|
||
def test_locate_last_identifier(self): | ||
self.runBasicParserTest(qml.last_identifier, "ABC.Def.xyz", 5, Region(8, 11)) | ||
self.runBasicParserTest(qml.last_identifier, "ABC.Def.xyz: 42", 5, Region(8, 11)) | ||
|
||
def runCommandTest(self, string: str, regions: Iterable[Union[Region, qml.Point]], expected_string: str, expected_regions: List[Region]): | ||
yield 0 # turn this function into generator, convenient for testing | ||
|
||
self.setText(string) | ||
self.view.sel().clear() | ||
self.view.sel().add_all(regions) | ||
self.view.run_command("convert_between_property_and_signal_handler") | ||
self.view.run_command("select_all") | ||
text = self.view.substr(self.view.sel()[0]) | ||
self.view.run_command("soft_undo") | ||
|
||
self.assertEqual(text, expected_string) | ||
self.assertEqual(list(self.view.sel()), expected_regions) | ||
|
||
def test_convert_command(self): | ||
yield from self.runCommandTest("abc.def", [0], "abc.onDefChanged: ", [Region(18)]) | ||
yield from self.runCommandTest("abc.def\nxyz: 123", [0, 8], "abc.onDefChanged: \nonXyzChanged: 123", [Region(18), Region(33)]) | ||
yield from self.runCommandTest("abc.onDefChanged", [0], "abc.def: ", [Region(9)]) | ||
yield from self.runCommandTest("abc.onDefChanged\nonXyzChanged: 123", [0, 17], "abc.def: \nxyz: 123", [Region(9), Region(15)]) | ||
yield from self.runCommandTest("_pressed:", [0], "on_PressedChanged: ", [Region(19)]) | ||
|
||
|
||
class TestStringUnits(TestCase): | ||
def test_substring(self): | ||
string = "abc123def" | ||
m = re.search("123", string) | ||
self.assertIsNotNone(m) | ||
span = m.span() # pyright: ignore [reportOptionalMemberAccess] | ||
self.assertEqual("123", qml.substring(string, span)) | ||
|
||
def test_string_splice(self): | ||
string = "abc123def" | ||
m = re.search("123", string) | ||
self.assertIsNotNone(m) | ||
span = m.span() # pyright: ignore [reportOptionalMemberAccess] | ||
qml.string_splice(string, span, "xyz") | ||
self.assertEqual("abcxyzdef", qml.string_splice(string, span, "xyz")) | ||
|
||
|
||
class TestFunctions(TestCase): | ||
def test_replace_property(self): | ||
replacement = qml.generate_replacement_for("hoverEnabled") | ||
self.assertEqual(replacement, "onHoverEnabledChanged") | ||
|
||
replacement = qml.generate_replacement_for("ABC") | ||
self.assertEqual(replacement, "onABCChanged") | ||
|
||
def test_replace_property_with_underscores(self): | ||
replacement = qml.generate_replacement_for("_abc") | ||
self.assertEqual(replacement, "on_AbcChanged") | ||
|
||
replacement = qml.generate_replacement_for("__internal") | ||
self.assertEqual(replacement, "on__InternalChanged") | ||
|
||
def test_replace_broken_property(self): | ||
replacement = qml.generate_replacement_for("_12") | ||
self.assertIsNone(replacement) | ||
|
||
def test_replace_signal_handler(self): | ||
replacement = qml.generate_replacement_for("onHoverEnabledChanged") | ||
self.assertEqual(replacement, "hoverEnabled") | ||
|
||
replacement = qml.generate_replacement_for("onToggled") | ||
self.assertEqual(replacement, "toggled") | ||
|
||
def test_replace_signal_handler_with_underscores(self): | ||
replacement = qml.generate_replacement_for("on__InternalChanged") | ||
self.assertEqual(replacement, "__internal") | ||
|
||
replacement = qml.generate_replacement_for("on__Pressed") | ||
self.assertEqual(replacement, "__pressed") | ||
|
||
def test_replace_unknown(self): | ||
replacement = qml.generate_replacement_for("123") | ||
self.assertIsNone(replacement) | ||
|
||
replacement = qml.generate_replacement_for("#$%") | ||
self.assertIsNone(replacement) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
{ | ||
"1.0.0": "messages/1.0.0.txt", | ||
"1.2.0": "messages/1.2.0.txt", | ||
"1.6.0": "messages/1.6.0.txt" | ||
"1.6.0": "messages/1.6.0.txt", | ||
"1.7.0": "messages/1.7.0.txt" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
QML for Sublime Text 1.6.0 has been released! | ||
|
||
A brand new command allows converting between property names and their change | ||
signal handlers! Try now, put a cursor on the words below, open command | ||
palette, and find "QML: Convert Between Property And Signal Handler" entry: | ||
|
||
position: | ||
<-> | ||
onPositionChanged: | ||
|
||
It comes without any shortcut by default, so you might want to edit your keymap: | ||
|
||
{ | ||
"command": "convert_between_property_and_signal_handler", | ||
"keys": ["ctrl+alt+shift+s"], | ||
"context": [ | ||
{ | ||
"key": "selector", | ||
"operator": "equal", | ||
"operand": "source.qml" | ||
} | ||
] | ||
} |
Oops, something went wrong.