diff --git a/.gitattributes b/.gitattributes index a1bc7ea..431f9ce 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ /.git* export-ignore /Support/tests export-ignore +/unittesting.json export-ignore diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml new file mode 100644 index 0000000..614a5a3 --- /dev/null +++ b/.github/workflows/commands.yml @@ -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 diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..cc1923a --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.8 diff --git a/QML.sublime-commands b/QML.sublime-commands new file mode 100644 index 0000000..447e629 --- /dev/null +++ b/QML.sublime-commands @@ -0,0 +1,6 @@ +[ + { + "caption": "QML: Convert Between Property And Signal Handler", + "command": "convert_between_property_and_signal_handler" + } +] diff --git a/QMLCommands.py b/QMLCommands.py new file mode 100644 index 0000000..b704741 --- /dev/null +++ b/QMLCommands.py @@ -0,0 +1,189 @@ +# SPDX-FileCopyrightText: 2023 ivan tkachenko +# 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 + shortest = min(len(first), len(second)) + while i < shortest 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) diff --git a/README.md b/README.md index ded02df..120e1d0 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ This package provides the following features: - Complete syntax highlighting for QML and qmldir files. - GoTo indexing for `id`s, property declarations and inline components. - Snippets for commonly used properties and types. + - Command to turn property names into their `onPropertyChanged` signal handlers and vice-versa. If you are a KDE developer, check out [kdesrc-build plugin for Sublime Text](https://github.com/ratijas/kdesrc-build-sublime) as well! diff --git a/Support/tests/test_commands.py b/Support/tests/test_commands.py new file mode 100644 index 0000000..86537e8 --- /dev/null +++ b/Support/tests/test_commands.py @@ -0,0 +1,139 @@ +# SPDX-FileCopyrightText: 2023 ivan tkachenko +# 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) diff --git a/messages.json b/messages.json index b704fb2..807ac66 100644 --- a/messages.json +++ b/messages.json @@ -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" } diff --git a/messages/1.7.0.txt b/messages/1.7.0.txt new file mode 100644 index 0000000..c1b46b0 --- /dev/null +++ b/messages/1.7.0.txt @@ -0,0 +1,23 @@ +QML for Sublime Text 1.7.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" + } + ] + } diff --git a/unittesting.json b/unittesting.json new file mode 100644 index 0000000..6d52bb2 --- /dev/null +++ b/unittesting.json @@ -0,0 +1,3 @@ +{ + "tests_dir": "Support/tests" +}