Skip to content

Commit

Permalink
Add command to convert between property names and their signal handlers
Browse files Browse the repository at this point in the history
This command comes with GitHub actions workflow and an extensive test
suite.
  • Loading branch information
ratijas committed Nov 20, 2023
1 parent 4537802 commit bf5fb56
Show file tree
Hide file tree
Showing 8 changed files with 388 additions and 1 deletion.
26 changes: 26 additions & 0 deletions .github/workflows/commands.yml
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
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.8
6 changes: 6 additions & 0 deletions QML.sublime-commands
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"
}
]
188 changes: 188 additions & 0 deletions QMLCommands.py
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)
139 changes: 139 additions & 0 deletions Support/tests/test_commands.py
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)
3 changes: 2 additions & 1 deletion messages.json
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"
}
23 changes: 23 additions & 0 deletions messages/1.7.0.txt
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"
}
]
}
Loading

0 comments on commit bf5fb56

Please sign in to comment.