diff --git a/changelog/2025-01-addedCollectionItemScopeProvider.md b/changelog/2025-01-addedCollectionItemScopeProvider.md new file mode 100644 index 0000000000..37d26d2390 --- /dev/null +++ b/changelog/2025-01-addedCollectionItemScopeProvider.md @@ -0,0 +1,6 @@ +--- +tags: [enhancement] +pullRequest: 2683 +--- + +At long last, collection items have been migrated to our next generation scope framework! This means, within a list of items, you can now use relative navigation (`previous item`), absolute navigation via ordinals (`fifth item`), multiple selection (`two items`, optionally preceded with `previous` or `next`), and lastly, requesting multiple items to be individually selected via `every` (`every two items`)! diff --git a/cursorless-everywhere-talon/cursorless_everywhere_talon.py b/cursorless-everywhere-talon/cursorless_everywhere_talon.py index 6af329a51d..2b913248e1 100644 --- a/cursorless-everywhere-talon/cursorless_everywhere_talon.py +++ b/cursorless-everywhere-talon/cursorless_everywhere_talon.py @@ -3,7 +3,12 @@ from talon import Context, Module, actions -from .cursorless_everywhere_types import EditorEdit, EditorState, SelectionOffsets +from .cursorless_everywhere_types import ( + EditorEdit, + EditorState, + RangeOffsets, + SelectionOffsets, +) mod = Module() @@ -14,6 +19,8 @@ tag: user.cursorless_everywhere_talon """ +ctx.tags = ["user.cursorless"] + @ctx.action_class("user") class UserActions: @@ -55,6 +62,14 @@ def cursorless_everywhere_edit_text( ): """Edit focused element text""" + def cursorless_everywhere_flash_ranges( + ranges: list[RangeOffsets], # pyright: ignore [reportGeneralTypeIssues] + ): + """Flash ranges in focused element""" + actions.skip() + + # Private actions + def private_cursorless_talonjs_run_and_wait( command_id: str, # pyright: ignore [reportGeneralTypeIssues] arg1: Any = None, @@ -69,5 +84,5 @@ def private_cursorless_talonjs_run_no_wait( ): """Executes a Cursorless command, but does not wait for it to finish, nor return the response""" - def private_cursorless_talonjs_get_response_json() -> str: + def private_cursorless_talonjs_get_response_json() -> str: # pyright: ignore [reportReturnType] """Returns the response from the last Cursorless command""" diff --git a/cursorless-everywhere-talon/cursorless_everywhere_talon_browser.py b/cursorless-everywhere-talon/cursorless_everywhere_talon_browser.py new file mode 100644 index 0000000000..826b52df35 --- /dev/null +++ b/cursorless-everywhere-talon/cursorless_everywhere_talon_browser.py @@ -0,0 +1,99 @@ +from talon import Context, Module, actions + +from .cursorless_everywhere_types import ( + EditorEdit, + EditorState, + RangeOffsets, + SelectionOffsets, +) + +mod = Module() + +mod.tag( + "cursorless_everywhere_talon_browser", + desc="Enable RPC to browser extension when using cursorless everywhere in Talon", +) + +ctx = Context() +ctx.matches = r""" +tag: user.cursorless_everywhere_talon_browser +""" + +RPC_COMMAND = "talonCommand" + + +@ctx.action_class("user") +class Actions: + def cursorless_everywhere_get_editor_state() -> EditorState: + command = { + "id": "getEditorState", + } + res = rpc_get(command) + if use_fallback(res): + return actions.next() + return res + + def cursorless_everywhere_set_selections( + selections: list[SelectionOffsets], # pyright: ignore [reportGeneralTypeIssues] + ): + command = { + "id": "setSelections", + "selections": [ + js_object_to_python_dict(s, ["anchor", "active"]) + for s in js_array_to_python_list(selections) + ], + } + res = rpc_get(command) + if use_fallback(res): + actions.next(selections) + + def cursorless_everywhere_edit_text( + edit: EditorEdit, # pyright: ignore [reportGeneralTypeIssues] + ): + command = { + "id": "editText", + "text": edit["text"], + "changes": [ + js_object_to_python_dict(c, ["text", "rangeOffset", "rangeLength"]) + for c in js_array_to_python_list(edit["changes"]) + ], + } + res = rpc_get(command) + if use_fallback(res): + actions.next(edit) + + def cursorless_everywhere_flash_ranges( + ranges: list[RangeOffsets], # pyright: ignore [reportGeneralTypeIssues] + ): + command = { + "id": "flashRanges", + "ranges": [ + js_object_to_python_dict(r, ["start", "end"]) + for r in js_array_to_python_list(ranges) + ], + } + res = rpc_get(command) + if use_fallback(res): + actions.next(ranges) + + +def rpc_get(command: dict): + return actions.user.run_rpc_command_get(RPC_COMMAND, command) + + +def use_fallback(result: dict) -> bool: + return result.get("fallback", False) + + +def js_array_to_python_list(array) -> list: + result = [] + for i in range(array.length): + result.append(array[i]) + return result + + +def js_object_to_python_dict(object, keys: list[str]) -> dict: + result = {} + for key in keys: + result[key] = object[key] + return result diff --git a/cursorless-everywhere-talon/cursorless_everywhere_types.py b/cursorless-everywhere-talon/cursorless_everywhere_types.py index df1247fe3f..44a41af5c9 100644 --- a/cursorless-everywhere-talon/cursorless_everywhere_types.py +++ b/cursorless-everywhere-talon/cursorless_everywhere_types.py @@ -6,6 +6,11 @@ class SelectionOffsets(TypedDict): active: int +class RangeOffsets(TypedDict): + start: int + end: int + + class EditorState(TypedDict): text: str selections: list[SelectionOffsets] diff --git a/cursorless-talon/.github/PULL_REQUEST_TEMPLATE.md b/cursorless-talon/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..dba63f4524 --- /dev/null +++ b/cursorless-talon/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1 @@ +Please file pull requests to the cursorless-talon subdirectory in the https://github.com/cursorless-dev/cursorless repo diff --git a/cursorless-talon/src/apps/vscode_settings.py b/cursorless-talon/src/apps/vscode_settings.py index 9caa61c4ce..1700db7e48 100644 --- a/cursorless-talon/src/apps/vscode_settings.py +++ b/cursorless-talon/src/apps/vscode_settings.py @@ -1,5 +1,4 @@ import os -import traceback from pathlib import Path from typing import Any @@ -61,7 +60,6 @@ def vscode_get_setting_with_fallback( return actions.user.vscode_get_setting(key, default_value), False except Exception: print(fallback_message) - traceback.print_exc() return fallback_value, True diff --git a/cursorless-talon/src/check_community_repo.py b/cursorless-talon/src/check_community_repo.py new file mode 100644 index 0000000000..ef776de3ae --- /dev/null +++ b/cursorless-talon/src/check_community_repo.py @@ -0,0 +1,37 @@ +from talon import app, registry + +required_captures = [ + "number_small", + "user.any_alphanumeric_key", + "user.formatters", + "user.ordinals_small", +] + +required_actions = [ + "user.homophones_get", + "user.reformat_text", +] + + +def on_ready(): + missing_captures = [ + capture for capture in required_captures if capture not in registry.captures + ] + missing_actions = [ + action for action in required_actions if action not in registry.actions + ] + errors = [] + if missing_captures: + errors.append(f"Missing captures: {', '.join(missing_captures)}") + if missing_actions: + errors.append(f"Missing actions: {', '.join(missing_actions)}") + if errors: + print("Cursorless community requirements:") + print("\n".join(errors)) + app.notify( + "Cursorless: Please install the community repository", + body="https://github.com/talonhub/community", + ) + + +app.register("ready", on_ready) diff --git a/cursorless-talon/src/cursorless.talon b/cursorless-talon/src/cursorless.talon index 68047b9f03..11c2fa2f9a 100644 --- a/cursorless-talon/src/cursorless.talon +++ b/cursorless-talon/src/cursorless.talon @@ -45,5 +45,5 @@ tutorial (previous | last): user.private_cursorless_tutorial_previous() tutorial restart: user.private_cursorless_tutorial_restart() tutorial resume: user.private_cursorless_tutorial_resume() tutorial (list | close): user.private_cursorless_tutorial_list() -tutorial : - user.private_cursorless_tutorial_start_by_number(private_cursorless_number_small) +tutorial : + user.private_cursorless_tutorial_start_by_number(number_small) diff --git a/cursorless-talon/src/get_grapheme_spoken_form_entries.py b/cursorless-talon/src/get_grapheme_spoken_form_entries.py index 42ff196b00..c423df6f1f 100644 --- a/cursorless-talon/src/get_grapheme_spoken_form_entries.py +++ b/cursorless-talon/src/get_grapheme_spoken_form_entries.py @@ -2,7 +2,6 @@ import typing from collections import defaultdict from typing import Iterator, Mapping -from uu import Error from talon import app, registry, scope @@ -55,7 +54,7 @@ def generate_lists_from_capture(capture_name) -> Iterator[str]: try: # NB: [-1] because the last capture is the active one rule = registry.captures[capture_name][-1].rule.rule - except Error: + except Exception: app.notify("Error constructing spoken forms for graphemes") print(f"Error getting rule for capture {capture_name}") return @@ -86,7 +85,7 @@ def get_id_to_talon_list(list_name: str) -> dict[str, str]: try: # NB: [-1] because the last list is the active one return typing.cast(dict[str, str], registry.lists[list_name][-1]).copy() - except Error: + except Exception: app.notify(f"Error getting list {list_name}") return {} diff --git a/cursorless-talon/src/marks/decorated_mark.py b/cursorless-talon/src/marks/decorated_mark.py index 3592d4f6cc..df219477f2 100644 --- a/cursorless-talon/src/marks/decorated_mark.py +++ b/cursorless-talon/src/marks/decorated_mark.py @@ -153,7 +153,12 @@ def setup_hat_styles_csv(hat_colors: dict[str, str], hat_shapes: dict[str, str]) def init_hats(hat_colors: dict[str, str], hat_shapes: dict[str, str]): setup_hat_styles_csv(hat_colors, hat_shapes) - vscode_settings_path: Path = actions.user.vscode_settings_path().resolve() + vscode_settings_path: Path | None = None + + try: + vscode_settings_path = actions.user.vscode_settings_path().resolve() + except Exception as ex: + print(ex) def on_watch(path, flags): global fast_reload_job, slow_reload_job @@ -166,10 +171,12 @@ def on_watch(path, flags): "10s", lambda: setup_hat_styles_csv(hat_colors, hat_shapes) ) - fs.watch(str(vscode_settings_path), on_watch) + if vscode_settings_path is not None: + fs.watch(vscode_settings_path, on_watch) def unsubscribe(): - fs.unwatch(str(vscode_settings_path), on_watch) + if vscode_settings_path is not None: + fs.unwatch(vscode_settings_path, on_watch) if unsubscribe_hat_styles is not None: unsubscribe_hat_styles() diff --git a/cursorless-talon/src/marks/lines_number.py b/cursorless-talon/src/marks/lines_number.py index a1ff66fe62..63a550bd96 100644 --- a/cursorless-talon/src/marks/lines_number.py +++ b/cursorless-talon/src/marks/lines_number.py @@ -31,13 +31,13 @@ class CustomizableTerm: @mod.capture( rule=( - "{user.cursorless_line_direction} " - "[ ]" + "{user.cursorless_line_direction} " + "[ ]" ) ) def cursorless_line_number(m) -> LineNumber: direction = directions_map[m.cursorless_line_direction] - numbers: list[int] = m.private_cursorless_number_small_list + numbers: list[int] = m.number_small_list anchor = create_line_number_mark(direction.type, direction.formatter(numbers[0])) if len(numbers) > 1: active = create_line_number_mark( diff --git a/cursorless-talon/src/modifiers/ordinal_scope.py b/cursorless-talon/src/modifiers/ordinal_scope.py index db617c59eb..daa89684d8 100644 --- a/cursorless-talon/src/modifiers/ordinal_scope.py +++ b/cursorless-talon/src/modifiers/ordinal_scope.py @@ -47,7 +47,7 @@ def cursorless_ordinal_range(m) -> dict[str, Any]: rule=( "[{user.cursorless_every_scope_modifier}] " "({user.cursorless_first_modifier} | {user.cursorless_last_modifier}) " - " " + " " ), ) def cursorless_first_last(m) -> dict[str, Any]: @@ -57,13 +57,13 @@ def cursorless_first_last(m) -> dict[str, Any]: return create_ordinal_scope_modifier( m.cursorless_scope_type_plural, 0, - m.private_cursorless_number_small, + m.number_small, is_every, ) return create_ordinal_scope_modifier( m.cursorless_scope_type_plural, - -m.private_cursorless_number_small, - m.private_cursorless_number_small, + -m.number_small, + m.number_small, is_every, ) diff --git a/cursorless-talon/src/modifiers/relative_scope.py b/cursorless-talon/src/modifiers/relative_scope.py index 1fd60ac693..c8ac86bd6b 100644 --- a/cursorless-talon/src/modifiers/relative_scope.py +++ b/cursorless-talon/src/modifiers/relative_scope.py @@ -31,28 +31,28 @@ def cursorless_relative_scope_singular(m) -> dict[str, Any]: @mod.capture( - rule="[{user.cursorless_every_scope_modifier}] " + rule="[{user.cursorless_every_scope_modifier}] " ) def cursorless_relative_scope_plural(m) -> dict[str, Any]: """Relative previous/next plural scope. `next three funks`""" return create_relative_scope_modifier( m.cursorless_scope_type_plural, 1, - m.private_cursorless_number_small, + m.number_small, m.cursorless_relative_direction, hasattr(m, "cursorless_every_scope_modifier"), ) @mod.capture( - rule="[{user.cursorless_every_scope_modifier}] [{user.cursorless_forward_backward_modifier}]" + rule="[{user.cursorless_every_scope_modifier}] [{user.cursorless_forward_backward_modifier}]" ) def cursorless_relative_scope_count(m) -> dict[str, Any]: """Relative count scope. `three funks`""" return create_relative_scope_modifier( m.cursorless_scope_type_plural, 0, - m.private_cursorless_number_small, + m.number_small, getattr(m, "cursorless_forward_backward_modifier", "forward"), hasattr(m, "cursorless_every_scope_modifier"), ) diff --git a/cursorless-talon/src/number_small.py b/cursorless-talon/src/number_small.py index fd800ae232..e043682c2f 100644 --- a/cursorless-talon/src/number_small.py +++ b/cursorless-talon/src/number_small.py @@ -1,46 +1,20 @@ """ -This file allows us to use a custom `number_small` capture. See #1021 for more -info. +DEPRECATED @ 2024-12-21 +This file allows us to use a custom `number_small` capture. See #1021 for more info. """ -from talon import Context, Module +from talon import Module, app, registry mod = Module() -mod.tag( - "cursorless_custom_number_small", - "This tag causes Cursorless to use the global capture", -) -ctx = Context() -ctx.matches = """ -not tag: user.cursorless_custom_number_small -""" - - -@mod.capture(rule="") -def private_cursorless_number_small(m) -> int: - return m.number_small - - -digit_list = "zero one two three four five six seven eight nine".split() -teens = "ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen".split() -tens = "twenty thirty forty fifty sixty seventy eighty ninety".split() +mod.tag("cursorless_custom_number_small", "DEPRECATED!") -number_small_list = [*digit_list, *teens] -for ten in tens: - number_small_list.append(ten) - number_small_list.extend(f"{ten} {digit}" for digit in digit_list[1:]) -number_small_map = {n: i for i, n in enumerate(number_small_list)} -mod.list("private_cursorless_number_small", desc="List of small numbers") -# FIXME: Remove type ignore once Talon supports list types -# See https://github.com/talonvoice/talon/issues/654 -ctx.lists["self.private_cursorless_number_small"] = number_small_map.keys() # pyright: ignore [reportArgumentType] +def on_ready(): + if "user.cursorless_custom_number_small" in registry.tags: + print( + "WARNING tag: 'user.cursorless_custom_number_small' is deprecated and should not be used anymore, as Cursorless now uses community number_small" + ) -@ctx.capture( - "user.private_cursorless_number_small", - rule="{user.private_cursorless_number_small}", -) -def override_private_cursorless_number_small(m) -> int: - return number_small_map[m.private_cursorless_number_small] +app.register("ready", on_ready) diff --git a/cursorless-talon/src/spoken_forms.json b/cursorless-talon/src/spoken_forms.json index 8f1034afe3..249b75abcf 100644 --- a/cursorless-talon/src/spoken_forms.json +++ b/cursorless-talon/src/spoken_forms.json @@ -2,9 +2,12 @@ "NOTE FOR USERS": "Please don't edit this json file; see https://www.cursorless.org/docs/user/customization", "actions.csv": { "simple_action": { + "append post": "addSelectionAfter", + "append pre": "addSelectionBefore", + "append": "addSelection", "bottom": "scrollToBottom", - "break": "breakLine", "break point": "toggleLineBreakpoint", + "break": "breakLine", "carve": "cutToClipboard", "center": "scrollToCenter", "change": "clearAndSetSelection", @@ -22,8 +25,8 @@ "extract": "extractVariable", "float": "insertEmptyLineAfter", "fold": "foldRegion", - "follow": "followLink", "follow split": "followLinkAside", + "follow": "followLink", "give": "deselect", "highlight": "highlight", "hover": "showHover", @@ -39,8 +42,8 @@ "reference": "showReferences", "rename": "rename", "reverse": "reverseTargets", - "scout": "findInDocument", "scout all": "findInWorkspace", + "scout": "findInDocument", "shuffle": "randomizeTargets", "snippet make": "generateSnippet", "sort": "sortTargets", diff --git a/cursorless-talon/src/spoken_forms.py b/cursorless-talon/src/spoken_forms.py index 1b89cd96c5..5d66e1ffc1 100644 --- a/cursorless-talon/src/spoken_forms.py +++ b/cursorless-talon/src/spoken_forms.py @@ -160,6 +160,7 @@ def handle_new_values(csv_name: str, values: list[SpokenFormEntry]): "private.switchStatementSubject", "textFragment", "disqualifyDelimiter", + "pairDelimiter", ], default_list_name="scope_type", ), diff --git a/data/fixtures/recorded/actions/appendPostWhale.yml b/data/fixtures/recorded/actions/appendPostWhale.yml new file mode 100644 index 0000000000..dd266c1a93 --- /dev/null +++ b/data/fixtures/recorded/actions/appendPostWhale.yml @@ -0,0 +1,33 @@ +languageId: plaintext +command: + version: 7 + spokenForm: append post whale + action: + name: addSelectionAfter + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: w} + usePrePhraseSnapshot: true +initialState: + documentContents: hello world + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: + default.w: + start: {line: 0, character: 6} + end: {line: 0, character: 11} +finalState: + documentContents: hello world + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + - anchor: {line: 0, character: 11} + active: {line: 0, character: 11} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 6} + end: {line: 0, character: 11} + isReversed: false + hasExplicitRange: false diff --git a/data/fixtures/recorded/actions/appendPreWhale.yml b/data/fixtures/recorded/actions/appendPreWhale.yml new file mode 100644 index 0000000000..765c092161 --- /dev/null +++ b/data/fixtures/recorded/actions/appendPreWhale.yml @@ -0,0 +1,33 @@ +languageId: plaintext +command: + version: 7 + spokenForm: append pre whale + action: + name: addSelectionBefore + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: w} + usePrePhraseSnapshot: true +initialState: + documentContents: hello world + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: + default.w: + start: {line: 0, character: 6} + end: {line: 0, character: 11} +finalState: + documentContents: hello world + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + - anchor: {line: 0, character: 6} + active: {line: 0, character: 6} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 6} + end: {line: 0, character: 11} + isReversed: false + hasExplicitRange: false diff --git a/data/fixtures/recorded/actions/appendWhale.yml b/data/fixtures/recorded/actions/appendWhale.yml new file mode 100644 index 0000000000..9acc5078e4 --- /dev/null +++ b/data/fixtures/recorded/actions/appendWhale.yml @@ -0,0 +1,33 @@ +languageId: plaintext +command: + version: 7 + spokenForm: append whale + action: + name: addSelection + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: w} + usePrePhraseSnapshot: true +initialState: + documentContents: hello world + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: + default.w: + start: {line: 0, character: 6} + end: {line: 0, character: 11} +finalState: + documentContents: hello world + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + - anchor: {line: 0, character: 6} + active: {line: 0, character: 11} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 6} + end: {line: 0, character: 11} + isReversed: false + hasExplicitRange: false diff --git a/data/fixtures/recorded/itemTextual/chuckItem4.yml b/data/fixtures/recorded/itemTextual/chuckItem4.yml deleted file mode 100644 index fbbe2a17cf..0000000000 --- a/data/fixtures/recorded/itemTextual/chuckItem4.yml +++ /dev/null @@ -1,31 +0,0 @@ -languageId: plaintext -command: - version: 6 - spokenForm: chuck item - action: - name: remove - target: - type: primitive - modifiers: - - type: containingScope - scopeType: {type: collectionItem} - usePrePhraseSnapshot: true -initialState: - documentContents: |- - [ - foo, - bar, - baz, - ] - selections: - - anchor: {line: 2, character: 8} - active: {line: 2, character: 7} - marks: {} -finalState: - documentContents: |- - [ - foo, - ] - selections: - - anchor: {line: 1, character: 7} - active: {line: 1, character: 7} diff --git a/data/fixtures/recorded/itemTextual/clearEveryItemToken.yml b/data/fixtures/recorded/itemTextual/clearEveryItemToken.yml deleted file mode 100644 index c706e90534..0000000000 --- a/data/fixtures/recorded/itemTextual/clearEveryItemToken.yml +++ /dev/null @@ -1,25 +0,0 @@ -languageId: plaintext -command: - version: 6 - spokenForm: change every item token - action: - name: clearAndSetSelection - target: - type: primitive - modifiers: - - type: everyScope - scopeType: {type: collectionItem} - - type: containingScope - scopeType: {type: token} - usePrePhraseSnapshot: true -initialState: - documentContents: aaa bbb, ccc - selections: - - anchor: {line: 0, character: 0} - active: {line: 0, character: 0} - marks: {} -finalState: - documentContents: ", ccc" - selections: - - anchor: {line: 0, character: 0} - active: {line: 0, character: 0} diff --git a/data/fixtures/recorded/itemTextual/clearItem10.yml b/data/fixtures/recorded/itemTextual/clearItem10.yml deleted file mode 100644 index 7bd7c2b453..0000000000 --- a/data/fixtures/recorded/itemTextual/clearItem10.yml +++ /dev/null @@ -1,23 +0,0 @@ -languageId: typescript -command: - version: 6 - spokenForm: change item - action: - name: clearAndSetSelection - target: - type: primitive - modifiers: - - type: containingScope - scopeType: {type: collectionItem} - usePrePhraseSnapshot: false -initialState: - documentContents: foo(hello, world) - selections: - - anchor: {line: 0, character: 10} - active: {line: 0, character: 10} - marks: {} -finalState: - documentContents: foo(hello, ) - selections: - - anchor: {line: 0, character: 11} - active: {line: 0, character: 11} diff --git a/data/fixtures/recorded/itemTextual/clearItem11.yml b/data/fixtures/recorded/itemTextual/clearItem11.yml deleted file mode 100644 index 52ad8ebe0d..0000000000 --- a/data/fixtures/recorded/itemTextual/clearItem11.yml +++ /dev/null @@ -1,23 +0,0 @@ -languageId: typescript -command: - version: 6 - spokenForm: change item - action: - name: clearAndSetSelection - target: - type: primitive - modifiers: - - type: containingScope - scopeType: {type: collectionItem} - usePrePhraseSnapshot: false -initialState: - documentContents: foo(hello, world) - selections: - - anchor: {line: 0, character: 7} - active: {line: 0, character: 13} - marks: {} -finalState: - documentContents: foo() - selections: - - anchor: {line: 0, character: 4} - active: {line: 0, character: 4} diff --git a/data/fixtures/recorded/itemTextual/clearItem13.yml b/data/fixtures/recorded/itemTextual/clearItem13.yml deleted file mode 100644 index 692134407a..0000000000 --- a/data/fixtures/recorded/itemTextual/clearItem13.yml +++ /dev/null @@ -1,23 +0,0 @@ -languageId: plaintext -command: - version: 6 - spokenForm: change item - action: - name: clearAndSetSelection - target: - type: primitive - modifiers: - - type: containingScope - scopeType: {type: collectionItem} - usePrePhraseSnapshot: true -initialState: - documentContents: "[1, [2, 3]];" - selections: - - anchor: {line: 0, character: 4} - active: {line: 0, character: 5} - marks: {} -finalState: - documentContents: "[1, ];" - selections: - - anchor: {line: 0, character: 4} - active: {line: 0, character: 4} diff --git a/data/fixtures/recorded/itemTextual/clearItem14.yml b/data/fixtures/recorded/itemTextual/clearItem14.yml deleted file mode 100644 index fc438d0268..0000000000 --- a/data/fixtures/recorded/itemTextual/clearItem14.yml +++ /dev/null @@ -1,23 +0,0 @@ -languageId: plaintext -command: - version: 6 - spokenForm: change item - action: - name: clearAndSetSelection - target: - type: primitive - modifiers: - - type: containingScope - scopeType: {type: collectionItem} - usePrePhraseSnapshot: true -initialState: - documentContents: "[1, [2, 3]];" - selections: - - anchor: {line: 0, character: 10} - active: {line: 0, character: 9} - marks: {} -finalState: - documentContents: "[1, ];" - selections: - - anchor: {line: 0, character: 4} - active: {line: 0, character: 4} diff --git a/data/fixtures/recorded/itemTextual/clearItemDrip.yml b/data/fixtures/recorded/itemTextual/clearItemDrip.yml deleted file mode 100644 index 545936cc75..0000000000 --- a/data/fixtures/recorded/itemTextual/clearItemDrip.yml +++ /dev/null @@ -1,27 +0,0 @@ -languageId: typescript -command: - version: 6 - spokenForm: change item comma - action: - name: clearAndSetSelection - target: - type: primitive - modifiers: - - type: containingScope - scopeType: {type: collectionItem} - mark: {type: decoratedSymbol, symbolColor: default, character: ','} - usePrePhraseSnapshot: true -initialState: - documentContents: foo(hello, world) - selections: - - anchor: {line: 0, character: 13} - active: {line: 0, character: 13} - marks: - default.,: - start: {line: 0, character: 9} - end: {line: 0, character: 10} -finalState: - documentContents: foo() - selections: - - anchor: {line: 0, character: 4} - active: {line: 0, character: 4} diff --git a/data/fixtures/recorded/languages/clojure/chuckItemZip.yml b/data/fixtures/recorded/languages/clojure/chuckItemZip.yml index 3972d6f0ea..6e5c8a77d5 100644 --- a/data/fixtures/recorded/languages/clojure/chuckItemZip.yml +++ b/data/fixtures/recorded/languages/clojure/chuckItemZip.yml @@ -29,9 +29,7 @@ finalState: documentContents: |- { :foo "bar", - ;; hello - , } selections: - - anchor: {line: 4, character: 1} - active: {line: 4, character: 1} + - anchor: {line: 2, character: 1} + active: {line: 2, character: 1} diff --git a/data/fixtures/recorded/languages/clojure/clearEveryItem.yml b/data/fixtures/recorded/languages/clojure/clearEveryItem.yml index b4202900fd..99f7622d33 100644 --- a/data/fixtures/recorded/languages/clojure/clearEveryItem.yml +++ b/data/fixtures/recorded/languages/clojure/clearEveryItem.yml @@ -27,11 +27,10 @@ finalState: { , - ;; hello , } selections: - anchor: {line: 2, character: 4} active: {line: 2, character: 4} - - anchor: {line: 4, character: 4} - active: {line: 4, character: 4} + - anchor: {line: 3, character: 4} + active: {line: 3, character: 4} diff --git a/data/fixtures/recorded/languages/clojure/clearItem.yml b/data/fixtures/recorded/languages/clojure/clearItem.yml index c03ec79ef5..b956e1fa9e 100644 --- a/data/fixtures/recorded/languages/clojure/clearItem.yml +++ b/data/fixtures/recorded/languages/clojure/clearItem.yml @@ -26,8 +26,11 @@ initialState: finalState: documentContents: |- { - + :bongo { + :foo "bar", + , + } } selections: - - anchor: {line: 1, character: 4} - active: {line: 1, character: 4} + - anchor: {line: 3, character: 8} + active: {line: 3, character: 8} diff --git a/data/fixtures/recorded/languages/clojure/clearItemFine.yml b/data/fixtures/recorded/languages/clojure/clearItemFine.yml index df2f19e0e8..56bfdc6554 100644 --- a/data/fixtures/recorded/languages/clojure/clearItemFine.yml +++ b/data/fixtures/recorded/languages/clojure/clearItemFine.yml @@ -21,7 +21,7 @@ initialState: start: {line: 0, character: 2} end: {line: 0, character: 5} finalState: - documentContents: "{ :baz \"whatever\"}" + documentContents: "{}" selections: - anchor: {line: 0, character: 1} active: {line: 0, character: 1} diff --git a/data/fixtures/recorded/languages/rust/changeItemOne.yml b/data/fixtures/recorded/languages/rust/changeItemOne.yml index 34b4d26b62..556b73e8b8 100644 --- a/data/fixtures/recorded/languages/rust/changeItemOne.yml +++ b/data/fixtures/recorded/languages/rust/changeItemOne.yml @@ -23,7 +23,7 @@ initialState: end: {line: 0, character: 21} finalState: documentContents: | - let x = [None, ]; + let x = [None, Some()]; selections: - - anchor: {line: 0, character: 15} - active: {line: 0, character: 15} + - anchor: {line: 0, character: 20} + active: {line: 0, character: 20} diff --git a/data/fixtures/recorded/languages/typescript/changeInside.yml b/data/fixtures/recorded/languages/typescript/changeInside.yml new file mode 100644 index 0000000000..453a1b5dd8 --- /dev/null +++ b/data/fixtures/recorded/languages/typescript/changeInside.yml @@ -0,0 +1,22 @@ +languageId: typescript +command: + version: 7 + spokenForm: change inside + action: + name: clearAndSetSelection + target: + type: primitive + modifiers: + - {type: interiorOnly} + usePrePhraseSnapshot: false +initialState: + documentContents: Promise<() => number>; + selections: + - anchor: {line: 0, character: 14} + active: {line: 0, character: 14} + marks: {} +finalState: + documentContents: Promise<>; + selections: + - anchor: {line: 0, character: 8} + active: {line: 0, character: 8} diff --git a/data/fixtures/recorded/languages/typescript/takeItem4.yml b/data/fixtures/recorded/languages/typescript/takeItem4.yml deleted file mode 100644 index 675da100a1..0000000000 --- a/data/fixtures/recorded/languages/typescript/takeItem4.yml +++ /dev/null @@ -1,27 +0,0 @@ -languageId: typescript -command: - version: 6 - spokenForm: take item - action: - name: setSelection - target: - type: primitive - modifiers: - - type: containingScope - scopeType: {type: collectionItem} - usePrePhraseSnapshot: false -initialState: - documentContents: | - - const value = { a: 1, b: 2, c: 3 }; - selections: - - anchor: {line: 1, character: 21} - active: {line: 1, character: 21} - marks: {} -finalState: - documentContents: | - - const value = { a: 1, b: 2, c: 3 }; - selections: - - anchor: {line: 1, character: 22} - active: {line: 1, character: 26} diff --git a/data/fixtures/recorded/languages/typescript/takeItemComma.yml b/data/fixtures/recorded/languages/typescript/takeItemComma.yml deleted file mode 100644 index 8cadeffc69..0000000000 --- a/data/fixtures/recorded/languages/typescript/takeItemComma.yml +++ /dev/null @@ -1,31 +0,0 @@ -languageId: typescript -command: - version: 6 - spokenForm: take item comma - action: - name: setSelection - target: - type: primitive - modifiers: - - type: containingScope - scopeType: {type: collectionItem} - mark: {type: decoratedSymbol, symbolColor: default, character: ','} - usePrePhraseSnapshot: false -initialState: - documentContents: | - - const value = { a: 1, b: 2, c: 3 }; - selections: - - anchor: {line: 0, character: 0} - active: {line: 0, character: 0} - marks: - default.,: - start: {line: 1, character: 20} - end: {line: 1, character: 21} -finalState: - documentContents: | - - const value = { a: 1, b: 2, c: 3 }; - selections: - - anchor: {line: 1, character: 16} - active: {line: 1, character: 26} diff --git a/data/fixtures/recorded/surroundingPair/parseTree/python/changeInside.yml b/data/fixtures/recorded/surroundingPair/parseTree/python/changeInside.yml new file mode 100644 index 0000000000..62718ad5b6 --- /dev/null +++ b/data/fixtures/recorded/surroundingPair/parseTree/python/changeInside.yml @@ -0,0 +1,22 @@ +languageId: python +command: + version: 7 + spokenForm: change inside + action: + name: clearAndSetSelection + target: + type: primitive + modifiers: + - {type: interiorOnly} + usePrePhraseSnapshot: true +initialState: + documentContents: r'command server' + selections: + - anchor: {line: 0, character: 2} + active: {line: 0, character: 2} + marks: {} +finalState: + documentContents: r'' + selections: + - anchor: {line: 0, character: 2} + active: {line: 0, character: 2} diff --git a/data/fixtures/recorded/surroundingPair/parseTree/python/changePair2.yml b/data/fixtures/recorded/surroundingPair/parseTree/python/changePair2.yml index 99faeae3d1..a7c42ed0a5 100644 --- a/data/fixtures/recorded/surroundingPair/parseTree/python/changePair2.yml +++ b/data/fixtures/recorded/surroundingPair/parseTree/python/changePair2.yml @@ -11,7 +11,7 @@ command: scopeType: {type: surroundingPair, delimiter: any} usePrePhraseSnapshot: true initialState: - documentContents: "\" r\"" + documentContents: "' r'" selections: - anchor: {line: 0, character: 0} active: {line: 0, character: 0} diff --git a/data/fixtures/recorded/itemTextual/clearItem6.yml b/data/fixtures/recorded/surroundingPair/parseTree/python/changePair3.yml similarity index 58% rename from data/fixtures/recorded/itemTextual/clearItem6.yml rename to data/fixtures/recorded/surroundingPair/parseTree/python/changePair3.yml index fd0471d915..1969b7a28c 100644 --- a/data/fixtures/recorded/itemTextual/clearItem6.yml +++ b/data/fixtures/recorded/surroundingPair/parseTree/python/changePair3.yml @@ -1,20 +1,20 @@ -languageId: plaintext +languageId: python command: - version: 6 - spokenForm: change item + version: 7 + spokenForm: change pair action: name: clearAndSetSelection target: type: primitive modifiers: - type: containingScope - scopeType: {type: collectionItem} + scopeType: {type: surroundingPair, delimiter: any} usePrePhraseSnapshot: true initialState: - documentContents: aaa aaa + documentContents: r'command server' selections: - - anchor: {line: 0, character: 0} - active: {line: 0, character: 0} + - anchor: {line: 0, character: 2} + active: {line: 0, character: 2} marks: {} finalState: documentContents: "" diff --git a/data/fixtures/recorded/surroundingPair/textual/changePair2.yml b/data/fixtures/recorded/surroundingPair/textual/changePair2.yml new file mode 100644 index 0000000000..efe2c1e421 --- /dev/null +++ b/data/fixtures/recorded/surroundingPair/textual/changePair2.yml @@ -0,0 +1,23 @@ +languageId: typescript +command: + version: 7 + spokenForm: change pair + action: + name: clearAndSetSelection + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: surroundingPair, delimiter: any} + usePrePhraseSnapshot: true +initialState: + documentContents: "[1, ']', 2]" + selections: + - anchor: {line: 0, character: 1} + active: {line: 0, character: 1} + marks: {} +finalState: + documentContents: "" + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} diff --git a/data/fixtures/recorded/surroundingPair/textual/changePair3.yml b/data/fixtures/recorded/surroundingPair/textual/changePair3.yml new file mode 100644 index 0000000000..6615e2530c --- /dev/null +++ b/data/fixtures/recorded/surroundingPair/textual/changePair3.yml @@ -0,0 +1,23 @@ +languageId: typescript +command: + version: 7 + spokenForm: change pair + action: + name: clearAndSetSelection + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: surroundingPair, delimiter: any} + usePrePhraseSnapshot: true +initialState: + documentContents: "`[1, ${']'}, 3]`" + selections: + - anchor: {line: 0, character: 1} + active: {line: 0, character: 1} + marks: {} +finalState: + documentContents: "``" + selections: + - anchor: {line: 0, character: 1} + active: {line: 0, character: 1} diff --git a/data/fixtures/scopes/java/collectionItem.unenclosed.iteration.scope b/data/fixtures/scopes/java/collectionItem.unenclosed.iteration.scope new file mode 100644 index 0000000000..323bff9b47 --- /dev/null +++ b/data/fixtures/scopes/java/collectionItem.unenclosed.iteration.scope @@ -0,0 +1,33 @@ +public class MyClass { + String foo, bar; +} +--- + +[#1 Range] = 1:4-1:20 + >----------------< +1| String foo, bar; + +[#1 Domain] = 0:22-2:0 + > +0| public class MyClass { +1| String foo, bar; +2| } + < + + +[#2 Range] = 1:4-1:20 + >----------------< +1| String foo, bar; + +[#2 Domain] = 1:0-1:20 + >--------------------< +1| String foo, bar; + + +[#3 Range] = 1:11-1:19 + >--------< +1| String foo, bar; + +[#3 Domain] = 1:4-1:20 + >----------------< +1| String foo, bar; diff --git a/data/fixtures/scopes/java/collectionItem.unenclosed.iteration2.scope b/data/fixtures/scopes/java/collectionItem.unenclosed.iteration2.scope new file mode 100644 index 0000000000..2920e986f2 --- /dev/null +++ b/data/fixtures/scopes/java/collectionItem.unenclosed.iteration2.scope @@ -0,0 +1,58 @@ +public class MyClass { + public void myFunk() { + String foo, bar; + } +} +--- + +[#1 Range] = 1:4-3:5 + >---------------------- +1| public void myFunk() { +2| String foo, bar; +3| } + -----< + +[#1 Domain] = 0:22-4:0 + > +0| public class MyClass { +1| public void myFunk() { +2| String foo, bar; +3| } +4| } + < + + +[#2 Range] = +[#2 Domain] = 1:23-1:23 + >< +1| public void myFunk() { + + +[#3 Range] = 2:8-2:24 + >----------------< +2| String foo, bar; + +[#3 Domain] = 1:26-3:4 + > +1| public void myFunk() { +2| String foo, bar; +3| } + ----< + + +[#4 Range] = 2:8-2:24 + >----------------< +2| String foo, bar; + +[#4 Domain] = 2:0-2:24 + >------------------------< +2| String foo, bar; + + +[#5 Range] = 2:15-2:23 + >--------< +2| String foo, bar; + +[#5 Domain] = 2:8-2:24 + >----------------< +2| String foo, bar; diff --git a/data/fixtures/scopes/java/collectionItem.unenclosed.scope b/data/fixtures/scopes/java/collectionItem.unenclosed.scope new file mode 100644 index 0000000000..999c5c0da9 --- /dev/null +++ b/data/fixtures/scopes/java/collectionItem.unenclosed.scope @@ -0,0 +1,67 @@ +public class MyClass { + String foo, bar; +} +--- + +[#1 Content] = +[#1 Domain] = 1:4-1:14 + >----------< +1| String foo, bar; + +[#1 Removal] = 1:4-1:16 + >------------< +1| String foo, bar; + +[#1 Trailing delimiter] = 1:14-1:16 + >--< +1| String foo, bar; + +[#1 Insertion delimiter] = ",\n" + + +[#2 Content] = +[#2 Domain] = 1:11-1:14 + >---< +1| String foo, bar; + +[#2 Removal] = 1:11-1:16 + >-----< +1| String foo, bar; + +[#2 Trailing delimiter] = 1:14-1:16 + >--< +1| String foo, bar; + +[#2 Insertion delimiter] = ", " + + +[#3 Content] = +[#3 Domain] = 1:16-1:19 + >---< +1| String foo, bar; + +[#3 Removal] = 1:14-1:19 + >-----< +1| String foo, bar; + +[#3 Leading delimiter] = 1:14-1:16 + >--< +1| String foo, bar; + +[#3 Insertion delimiter] = ", " + + +[#4 Content] = +[#4 Domain] = 1:16-1:20 + >----< +1| String foo, bar; + +[#4 Removal] = 1:14-1:20 + >------< +1| String foo, bar; + +[#4 Leading delimiter] = 1:14-1:16 + >--< +1| String foo, bar; + +[#4 Insertion delimiter] = ",\n" diff --git a/data/fixtures/scopes/java/collectionItem.unenclosed2.scope b/data/fixtures/scopes/java/collectionItem.unenclosed2.scope new file mode 100644 index 0000000000..ccc7682a58 --- /dev/null +++ b/data/fixtures/scopes/java/collectionItem.unenclosed2.scope @@ -0,0 +1,91 @@ +public class MyClass { + public void myFunk() { + String foo, bar; + } +} +--- + +[#1 Content] = +[#1 Domain] = 1:4-3:5 + >---------------------- +1| public void myFunk() { +2| String foo, bar; +3| } + -----< + +[#1 Removal] = 1:0-3:5 + >-------------------------- +1| public void myFunk() { +2| String foo, bar; +3| } + -----< + +[#1 Leading delimiter] = 1:0-1:4 + >----< +1| public void myFunk() { + +[#1 Insertion delimiter] = ",\n" + + +[#2 Content] = +[#2 Domain] = 2:8-2:18 + >----------< +2| String foo, bar; + +[#2 Removal] = 2:8-2:20 + >------------< +2| String foo, bar; + +[#2 Trailing delimiter] = 2:18-2:20 + >--< +2| String foo, bar; + +[#2 Insertion delimiter] = ",\n" + + +[#3 Content] = +[#3 Domain] = 2:15-2:18 + >---< +2| String foo, bar; + +[#3 Removal] = 2:15-2:20 + >-----< +2| String foo, bar; + +[#3 Trailing delimiter] = 2:18-2:20 + >--< +2| String foo, bar; + +[#3 Insertion delimiter] = ", " + + +[#4 Content] = +[#4 Domain] = 2:20-2:23 + >---< +2| String foo, bar; + +[#4 Removal] = 2:18-2:23 + >-----< +2| String foo, bar; + +[#4 Leading delimiter] = 2:18-2:20 + >--< +2| String foo, bar; + +[#4 Insertion delimiter] = ", " + + +[#5 Content] = +[#5 Domain] = 2:20-2:24 + >----< +2| String foo, bar; + +[#5 Removal] = 2:18-2:24 + >------< +2| String foo, bar; + +[#5 Leading delimiter] = 2:18-2:20 + >--< +2| String foo, bar; + +[#5 Insertion delimiter] = ",\n" diff --git a/data/fixtures/scopes/javascript.core/collectionItem.unenclosed.scope b/data/fixtures/scopes/javascript.core/collectionItem.unenclosed.scope index 703a4b32bb..12cf5428b2 100644 --- a/data/fixtures/scopes/javascript.core/collectionItem.unenclosed.scope +++ b/data/fixtures/scopes/javascript.core/collectionItem.unenclosed.scope @@ -2,12 +2,12 @@ let foo, bar; --- [#1 Content] = -[#1 Domain] = 0:4-0:7 - >---< +[#1 Domain] = 0:0-0:7 + >-------< 0| let foo, bar; -[#1 Removal] = 0:4-0:9 - >-----< +[#1 Removal] = 0:0-0:9 + >---------< 0| let foo, bar; [#1 Trailing delimiter] = 0:7-0:9 @@ -18,16 +18,48 @@ let foo, bar; [#2 Content] = -[#2 Domain] = 0:9-0:12 +[#2 Domain] = 0:4-0:7 + >---< +0| let foo, bar; + +[#2 Removal] = 0:4-0:9 + >-----< +0| let foo, bar; + +[#2 Trailing delimiter] = 0:7-0:9 + >--< +0| let foo, bar; + +[#2 Insertion delimiter] = ", " + + +[#3 Content] = +[#3 Domain] = 0:9-0:12 >---< 0| let foo, bar; -[#2 Removal] = 0:7-0:12 +[#3 Removal] = 0:7-0:12 >-----< 0| let foo, bar; -[#2 Leading delimiter] = 0:7-0:9 +[#3 Leading delimiter] = 0:7-0:9 >--< 0| let foo, bar; -[#2 Insertion delimiter] = ", " +[#3 Insertion delimiter] = ", " + + +[#4 Content] = +[#4 Domain] = 0:9-0:13 + >----< +0| let foo, bar; + +[#4 Removal] = 0:7-0:13 + >------< +0| let foo, bar; + +[#4 Leading delimiter] = 0:7-0:9 + >--< +0| let foo, bar; + +[#4 Insertion delimiter] = ", " diff --git a/data/fixtures/scopes/python/collectionItem.unenclosed.iteration3.scope b/data/fixtures/scopes/python/collectionItem.unenclosed.iteration3.scope index d24c8b8d1a..badf04bc7e 100644 --- a/data/fixtures/scopes/python/collectionItem.unenclosed.iteration3.scope +++ b/data/fixtures/scopes/python/collectionItem.unenclosed.iteration3.scope @@ -2,10 +2,25 @@ def foo(): global bar, baz --- -[Range] = 1:11-1:19 +[#1 Range] = +[#1 Domain] = 0:8-0:8 + >< +0| def foo(): + + +[#2 Range] = 1:4-1:19 + >---------------< +1| global bar, baz + +[#2 Domain] = 1:0-1:19 + >-------------------< +1| global bar, baz + + +[#3 Range] = 1:11-1:19 >--------< 1| global bar, baz -[Domain] = 1:4-1:19 +[#3 Domain] = 1:4-1:19 >---------------< 1| global bar, baz diff --git a/data/fixtures/scopes/python/collectionItem.unenclosed.iteration4.scope b/data/fixtures/scopes/python/collectionItem.unenclosed.iteration4.scope index 79ad18b890..c5748186a1 100644 --- a/data/fixtures/scopes/python/collectionItem.unenclosed.iteration4.scope +++ b/data/fixtures/scopes/python/collectionItem.unenclosed.iteration4.scope @@ -2,7 +2,13 @@ for key, value in map.items(): pass --- -[Range] = -[Domain] = 0:4-0:14 +[#1 Range] = +[#1 Domain] = 0:4-0:14 >----------< 0| for key, value in map.items(): + + +[#2 Range] = +[#2 Domain] = 0:28-0:28 + >< +0| for key, value in map.items(): diff --git a/data/fixtures/scopes/python/collectionItem.unenclosed2.scope b/data/fixtures/scopes/python/collectionItem.unenclosed2.scope index fa8b5b2e11..ee8ba44d8e 100644 --- a/data/fixtures/scopes/python/collectionItem.unenclosed2.scope +++ b/data/fixtures/scopes/python/collectionItem.unenclosed2.scope @@ -2,12 +2,12 @@ import foo, bar --- [#1 Content] = -[#1 Domain] = 0:7-0:10 - >---< +[#1 Domain] = 0:0-0:10 + >----------< 0| import foo, bar -[#1 Removal] = 0:7-0:12 - >-----< +[#1 Removal] = 0:0-0:12 + >------------< 0| import foo, bar [#1 Trailing delimiter] = 0:10-0:12 @@ -18,16 +18,32 @@ import foo, bar [#2 Content] = -[#2 Domain] = 0:12-0:15 +[#2 Domain] = 0:7-0:10 + >---< +0| import foo, bar + +[#2 Removal] = 0:7-0:12 + >-----< +0| import foo, bar + +[#2 Trailing delimiter] = 0:10-0:12 + >--< +0| import foo, bar + +[#2 Insertion delimiter] = ", " + + +[#3 Content] = +[#3 Domain] = 0:12-0:15 >---< 0| import foo, bar -[#2 Removal] = 0:10-0:15 +[#3 Removal] = 0:10-0:15 >-----< 0| import foo, bar -[#2 Leading delimiter] = 0:10-0:12 +[#3 Leading delimiter] = 0:10-0:12 >--< 0| import foo, bar -[#2 Insertion delimiter] = ", " +[#3 Insertion delimiter] = ", " diff --git a/data/fixtures/scopes/python/collectionItem.unenclosed5.scope b/data/fixtures/scopes/python/collectionItem.unenclosed5.scope index 0ebe039ba3..f595441373 100644 --- a/data/fixtures/scopes/python/collectionItem.unenclosed5.scope +++ b/data/fixtures/scopes/python/collectionItem.unenclosed5.scope @@ -2,36 +2,52 @@ from foo import bar, baz --- [#1 Content] = -[#1 Domain] = 0:16-0:19 +[#1 Domain] = 0:0-0:19 + >-------------------< +0| from foo import bar, baz + +[#1 Removal] = 0:0-0:21 + >---------------------< +0| from foo import bar, baz + +[#1 Trailing delimiter] = 0:19-0:21 + >--< +0| from foo import bar, baz + +[#1 Insertion delimiter] = ", " + + +[#2 Content] = +[#2 Domain] = 0:16-0:19 >---< 0| from foo import bar, baz -[#1 Removal] = 0:16-0:21 +[#2 Removal] = 0:16-0:21 >-----< 0| from foo import bar, baz -[#1 Leading delimiter] = 0:15-0:16 +[#2 Leading delimiter] = 0:15-0:16 >-< 0| from foo import bar, baz -[#1 Trailing delimiter] = 0:19-0:21 +[#2 Trailing delimiter] = 0:19-0:21 >--< 0| from foo import bar, baz -[#1 Insertion delimiter] = ", " +[#2 Insertion delimiter] = ", " -[#2 Content] = -[#2 Domain] = 0:21-0:24 +[#3 Content] = +[#3 Domain] = 0:21-0:24 >---< 0| from foo import bar, baz -[#2 Removal] = 0:19-0:24 +[#3 Removal] = 0:19-0:24 >-----< 0| from foo import bar, baz -[#2 Leading delimiter] = 0:19-0:21 +[#3 Leading delimiter] = 0:19-0:21 >--< 0| from foo import bar, baz -[#2 Insertion delimiter] = ", " +[#3 Insertion delimiter] = ", " diff --git a/data/fixtures/scopes/python/collectionItem.unenclosed6.scope b/data/fixtures/scopes/python/collectionItem.unenclosed6.scope index 4b5eda3fd4..b4aa4158f1 100644 --- a/data/fixtures/scopes/python/collectionItem.unenclosed6.scope +++ b/data/fixtures/scopes/python/collectionItem.unenclosed6.scope @@ -3,12 +3,12 @@ def foo(): --- [#1 Content] = -[#1 Domain] = 1:11-1:14 - >---< +[#1 Domain] = 1:4-1:14 + >----------< 1| global bar, baz -[#1 Removal] = 1:11-1:16 - >-----< +[#1 Removal] = 1:4-1:16 + >------------< 1| global bar, baz [#1 Trailing delimiter] = 1:14-1:16 @@ -19,16 +19,32 @@ def foo(): [#2 Content] = -[#2 Domain] = 1:16-1:19 +[#2 Domain] = 1:11-1:14 + >---< +1| global bar, baz + +[#2 Removal] = 1:11-1:16 + >-----< +1| global bar, baz + +[#2 Trailing delimiter] = 1:14-1:16 + >--< +1| global bar, baz + +[#2 Insertion delimiter] = ", " + + +[#3 Content] = +[#3 Domain] = 1:16-1:19 >---< 1| global bar, baz -[#2 Removal] = 1:14-1:19 +[#3 Removal] = 1:14-1:19 >-----< 1| global bar, baz -[#2 Leading delimiter] = 1:14-1:16 +[#3 Leading delimiter] = 1:14-1:16 >--< 1| global bar, baz -[#2 Insertion delimiter] = ", " +[#3 Insertion delimiter] = ", " diff --git a/data/fixtures/scopes/python/collectionItem.unenclosed7.scope b/data/fixtures/scopes/python/collectionItem.unenclosed7.scope index 9f30c7a870..30844ef51c 100644 --- a/data/fixtures/scopes/python/collectionItem.unenclosed7.scope +++ b/data/fixtures/scopes/python/collectionItem.unenclosed7.scope @@ -3,12 +3,12 @@ for key, value in map.items(): --- [#1 Content] = -[#1 Domain] = 0:4-0:7 - >---< +[#1 Domain] = 0:0-0:7 + >-------< 0| for key, value in map.items(): -[#1 Removal] = 0:4-0:9 - >-----< +[#1 Removal] = 0:0-0:9 + >---------< 0| for key, value in map.items(): [#1 Trailing delimiter] = 0:7-0:9 @@ -19,16 +19,48 @@ for key, value in map.items(): [#2 Content] = -[#2 Domain] = 0:9-0:14 +[#2 Domain] = 0:4-0:7 + >---< +0| for key, value in map.items(): + +[#2 Removal] = 0:4-0:9 + >-----< +0| for key, value in map.items(): + +[#2 Trailing delimiter] = 0:7-0:9 + >--< +0| for key, value in map.items(): + +[#2 Insertion delimiter] = ", " + + +[#3 Content] = +[#3 Domain] = 0:9-0:14 >-----< 0| for key, value in map.items(): -[#2 Removal] = 0:7-0:14 +[#3 Removal] = 0:7-0:14 >-------< 0| for key, value in map.items(): -[#2 Leading delimiter] = 0:7-0:9 +[#3 Leading delimiter] = 0:7-0:9 >--< 0| for key, value in map.items(): -[#2 Insertion delimiter] = ", " +[#3 Insertion delimiter] = ", " + + +[#4 Content] = +[#4 Domain] = 0:9-0:30 + >---------------------< +0| for key, value in map.items(): + +[#4 Removal] = 0:7-0:30 + >-----------------------< +0| for key, value in map.items(): + +[#4 Leading delimiter] = 0:7-0:9 + >--< +0| for key, value in map.items(): + +[#4 Insertion delimiter] = ", " diff --git a/data/fixtures/scopes/python/pairDelimiter.scope b/data/fixtures/scopes/python/pairDelimiter.scope new file mode 100644 index 0000000000..ff8c087dfa --- /dev/null +++ b/data/fixtures/scopes/python/pairDelimiter.scope @@ -0,0 +1,72 @@ +"server" +'server' +"""server""" +'''server''' +r" r" +r' r' +r""" r""" +r''' r''' +--- + +[#1 Content] = +[#1 Domain] = 4:0-4:2 + >--< +4| r" r" + +[#1 Removal] = 4:0-4:3 + >---< +4| r" r" + +[#1 Trailing delimiter] = 4:2-4:3 + >-< +4| r" r" + +[#1 Insertion delimiter] = " " + + +[#2 Content] = +[#2 Domain] = 5:0-5:2 + >--< +5| r' r' + +[#2 Removal] = 5:0-5:3 + >---< +5| r' r' + +[#2 Trailing delimiter] = 5:2-5:3 + >-< +5| r' r' + +[#2 Insertion delimiter] = " " + + +[#3 Content] = +[#3 Domain] = 6:0-6:4 + >----< +6| r""" r""" + +[#3 Removal] = 6:0-6:5 + >-----< +6| r""" r""" + +[#3 Trailing delimiter] = 6:4-6:5 + >-< +6| r""" r""" + +[#3 Insertion delimiter] = " " + + +[#4 Content] = +[#4 Domain] = 7:0-7:4 + >----< +7| r''' r''' + +[#4 Removal] = 7:0-7:5 + >-----< +7| r''' r''' + +[#4 Trailing delimiter] = 7:4-7:5 + >-< +7| r''' r''' + +[#4 Insertion delimiter] = " " diff --git a/data/fixtures/scopes/textual/collectionItem.textual.iteration.scope b/data/fixtures/scopes/textual/collectionItem.textual.iteration.scope new file mode 100644 index 0000000000..9cced361b5 --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual.iteration.scope @@ -0,0 +1,7 @@ +(1, 2, 3) +--- + +[Range] = +[Domain] = 0:1-0:8 + >-------< +0| (1, 2, 3) diff --git a/data/fixtures/scopes/textual/collectionItem.textual.iteration2.scope b/data/fixtures/scopes/textual/collectionItem.textual.iteration2.scope new file mode 100644 index 0000000000..09af92177c --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual.iteration2.scope @@ -0,0 +1,7 @@ +[1, 2, 3] +--- + +[Range] = +[Domain] = 0:1-0:8 + >-------< +0| [1, 2, 3] diff --git a/data/fixtures/scopes/textual/collectionItem.textual.iteration3.scope b/data/fixtures/scopes/textual/collectionItem.textual.iteration3.scope new file mode 100644 index 0000000000..06f24d63a9 --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual.iteration3.scope @@ -0,0 +1,7 @@ +{1, 2, 3} +--- + +[Range] = +[Domain] = 0:1-0:8 + >-------< +0| {1, 2, 3} diff --git a/data/fixtures/scopes/textual/collectionItem.textual.iteration4.scope b/data/fixtures/scopes/textual/collectionItem.textual.iteration4.scope new file mode 100644 index 0000000000..81c9d30a35 --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual.iteration4.scope @@ -0,0 +1,7 @@ +<1, 2, 3> +--- + +[Range] = +[Domain] = 0:1-0:8 + >-------< +0| <1, 2, 3> diff --git a/data/fixtures/scopes/textual/collectionItem.textual.iteration5.scope b/data/fixtures/scopes/textual/collectionItem.textual.iteration5.scope new file mode 100644 index 0000000000..166cce77cd --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual.iteration5.scope @@ -0,0 +1,10 @@ +( 1, 2, 3 ) +--- + +[Range] = 0:2-0:9 + >-------< +0| ( 1, 2, 3 ) + +[Domain] = 0:1-0:10 + >---------< +0| ( 1, 2, 3 ) diff --git a/data/fixtures/scopes/textual/collectionItem.textual.iteration6.scope b/data/fixtures/scopes/textual/collectionItem.textual.iteration6.scope new file mode 100644 index 0000000000..996188a6e7 --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual.iteration6.scope @@ -0,0 +1,49 @@ +[ + 1, + 2, + 3, +] +--- + +[#1 Range] = 1:4-3:6 + >-- +1| 1, +2| 2, +3| 3, + ------< + +[#1 Domain] = 0:1-4:0 + > +0| [ +1| 1, +2| 2, +3| 3, +4| ] + < + + +[#2 Range] = 1:4-1:6 + >--< +1| 1, + +[#2 Domain] = 1:0-1:6 + >------< +1| 1, + + +[#3 Range] = 2:4-2:6 + >--< +2| 2, + +[#3 Domain] = 2:0-2:6 + >------< +2| 2, + + +[#4 Range] = 3:4-3:6 + >--< +3| 3, + +[#4 Domain] = 3:0-3:6 + >------< +3| 3, diff --git a/data/fixtures/scopes/textual/collectionItem.textual.iteration7.scope b/data/fixtures/scopes/textual/collectionItem.textual.iteration7.scope new file mode 100644 index 0000000000..5f081d0268 --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual.iteration7.scope @@ -0,0 +1,49 @@ +{ + a: 1, + b: 2, + c: 3, +} +--- + +[#1 Range] = 1:4-3:9 + >----- +1| a: 1, +2| b: 2, +3| c: 3, + ---------< + +[#1 Domain] = 0:1-4:0 + > +0| { +1| a: 1, +2| b: 2, +3| c: 3, +4| } + < + + +[#2 Range] = 1:4-1:9 + >-----< +1| a: 1, + +[#2 Domain] = 1:0-1:9 + >---------< +1| a: 1, + + +[#3 Range] = 2:4-2:9 + >-----< +2| b: 2, + +[#3 Domain] = 2:0-2:9 + >---------< +2| b: 2, + + +[#4 Range] = 3:4-3:9 + >-----< +3| c: 3, + +[#4 Domain] = 3:0-3:9 + >---------< +3| c: 3, diff --git a/data/fixtures/scopes/textual/collectionItem.textual.scope b/data/fixtures/scopes/textual/collectionItem.textual.scope new file mode 100644 index 0000000000..b6fb913f1e --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual.scope @@ -0,0 +1,53 @@ +(1, 2, 3) +--- + +[#1 Content] = +[#1 Domain] = 0:1-0:2 + >-< +0| (1, 2, 3) + +[#1 Removal] = 0:1-0:4 + >---< +0| (1, 2, 3) + +[#1 Trailing delimiter] = 0:2-0:4 + >--< +0| (1, 2, 3) + +[#1 Insertion delimiter] = ", " + + +[#2 Content] = +[#2 Domain] = 0:4-0:5 + >-< +0| (1, 2, 3) + +[#2 Removal] = 0:4-0:7 + >---< +0| (1, 2, 3) + +[#2 Leading delimiter] = 0:2-0:4 + >--< +0| (1, 2, 3) + +[#2 Trailing delimiter] = 0:5-0:7 + >--< +0| (1, 2, 3) + +[#2 Insertion delimiter] = ", " + + +[#3 Content] = +[#3 Domain] = 0:7-0:8 + >-< +0| (1, 2, 3) + +[#3 Removal] = 0:5-0:8 + >---< +0| (1, 2, 3) + +[#3 Leading delimiter] = 0:5-0:7 + >--< +0| (1, 2, 3) + +[#3 Insertion delimiter] = ", " diff --git a/data/fixtures/scopes/textual/collectionItem.textual10.scope b/data/fixtures/scopes/textual/collectionItem.textual10.scope new file mode 100644 index 0000000000..ecdbbf9473 --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual10.scope @@ -0,0 +1,65 @@ +aaa, ( bbb, ccc ) +--- + +[#1 Content] = +[#1 Domain] = 0:0-0:3 + >---< +0| aaa, ( bbb, ccc ) + +[#1 Removal] = 0:0-0:5 + >-----< +0| aaa, ( bbb, ccc ) + +[#1 Trailing delimiter] = 0:3-0:5 + >--< +0| aaa, ( bbb, ccc ) + +[#1 Insertion delimiter] = ", " + + +[#2 Content] = +[#2 Domain] = 0:5-0:17 + >------------< +0| aaa, ( bbb, ccc ) + +[#2 Removal] = 0:3-0:17 + >--------------< +0| aaa, ( bbb, ccc ) + +[#2 Leading delimiter] = 0:3-0:5 + >--< +0| aaa, ( bbb, ccc ) + +[#2 Insertion delimiter] = ", " + + +[#3 Content] = +[#3 Domain] = 0:7-0:10 + >---< +0| aaa, ( bbb, ccc ) + +[#3 Removal] = 0:7-0:12 + >-----< +0| aaa, ( bbb, ccc ) + +[#3 Trailing delimiter] = 0:10-0:12 + >--< +0| aaa, ( bbb, ccc ) + +[#3 Insertion delimiter] = ", " + + +[#4 Content] = +[#4 Domain] = 0:12-0:15 + >---< +0| aaa, ( bbb, ccc ) + +[#4 Removal] = 0:10-0:15 + >-----< +0| aaa, ( bbb, ccc ) + +[#4 Leading delimiter] = 0:10-0:12 + >--< +0| aaa, ( bbb, ccc ) + +[#4 Insertion delimiter] = ", " diff --git a/data/fixtures/scopes/textual/collectionItem.textual11.scope b/data/fixtures/scopes/textual/collectionItem.textual11.scope new file mode 100644 index 0000000000..cc98d05466 --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual11.scope @@ -0,0 +1,53 @@ +[ + 1, + + 2, +] +--- + +[#1 Content] = +[#1 Domain] = 1:4-1:5 + >-< +1| 1, + +[#1 Removal] = 1:4-3:4 + >-- +1| 1, +2| +3| 2, + ----< + +[#1 Trailing delimiter] = 1:5-3:4 + >- +1| 1, +2| +3| 2, + ----< + +[#1 Insertion delimiter] = ",\n" + + +[#2 Content] = +[#2 Domain] = 3:4-3:5 + >-< +3| 2, + +[#2 Removal] = 1:5-3:5 + >- +1| 1, +2| +3| 2, + -----< + +[#2 Leading delimiter] = 1:5-3:4 + >- +1| 1, +2| +3| 2, + ----< + +[#2 Trailing delimiter] = 3:5-3:6 + >-< +3| 2, + +[#2 Insertion delimiter] = ",\n" diff --git a/data/fixtures/scopes/textual/collectionItem.textual12.scope b/data/fixtures/scopes/textual/collectionItem.textual12.scope new file mode 100644 index 0000000000..441a9eecad --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual12.scope @@ -0,0 +1,21 @@ +[ + + 1 + +] +--- + +[Content] = +[Domain] = 2:4-2:5 + >-< +2| 1 + +[Removal] = 2:0-2:5 + >-----< +2| 1 + +[Leading delimiter] = 2:0-2:4 + >----< +2| 1 + +[Insertion delimiter] = ",\n" diff --git a/data/fixtures/scopes/textual/collectionItem.textual13.scope b/data/fixtures/scopes/textual/collectionItem.textual13.scope new file mode 100644 index 0000000000..075adf3633 --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual13.scope @@ -0,0 +1,10 @@ +(aaa) +--- + +[Content] = +[Removal] = +[Domain] = 0:1-0:4 + >---< +0| (aaa) + +[Insertion delimiter] = ", " diff --git a/data/fixtures/scopes/textual/collectionItem.textual14.scope b/data/fixtures/scopes/textual/collectionItem.textual14.scope new file mode 100644 index 0000000000..2e3a109339 --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual14.scope @@ -0,0 +1,21 @@ +( aaa ) +--- + +[Content] = +[Domain] = 0:2-0:5 + >---< +0| ( aaa ) + +[Removal] = 0:2-0:6 + >----< +0| ( aaa ) + +[Leading delimiter] = 0:1-0:2 + >-< +0| ( aaa ) + +[Trailing delimiter] = 0:5-0:6 + >-< +0| ( aaa ) + +[Insertion delimiter] = ", " diff --git a/data/fixtures/scopes/textual/collectionItem.textual2.scope b/data/fixtures/scopes/textual/collectionItem.textual2.scope new file mode 100644 index 0000000000..885304a300 --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual2.scope @@ -0,0 +1,53 @@ +[1, 2, 3] +--- + +[#1 Content] = +[#1 Domain] = 0:1-0:2 + >-< +0| [1, 2, 3] + +[#1 Removal] = 0:1-0:4 + >---< +0| [1, 2, 3] + +[#1 Trailing delimiter] = 0:2-0:4 + >--< +0| [1, 2, 3] + +[#1 Insertion delimiter] = ", " + + +[#2 Content] = +[#2 Domain] = 0:4-0:5 + >-< +0| [1, 2, 3] + +[#2 Removal] = 0:4-0:7 + >---< +0| [1, 2, 3] + +[#2 Leading delimiter] = 0:2-0:4 + >--< +0| [1, 2, 3] + +[#2 Trailing delimiter] = 0:5-0:7 + >--< +0| [1, 2, 3] + +[#2 Insertion delimiter] = ", " + + +[#3 Content] = +[#3 Domain] = 0:7-0:8 + >-< +0| [1, 2, 3] + +[#3 Removal] = 0:5-0:8 + >---< +0| [1, 2, 3] + +[#3 Leading delimiter] = 0:5-0:7 + >--< +0| [1, 2, 3] + +[#3 Insertion delimiter] = ", " diff --git a/data/fixtures/scopes/textual/collectionItem.textual3.scope b/data/fixtures/scopes/textual/collectionItem.textual3.scope new file mode 100644 index 0000000000..5a5fb483bd --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual3.scope @@ -0,0 +1,53 @@ +{1, 2, 3} +--- + +[#1 Content] = +[#1 Domain] = 0:1-0:2 + >-< +0| {1, 2, 3} + +[#1 Removal] = 0:1-0:4 + >---< +0| {1, 2, 3} + +[#1 Trailing delimiter] = 0:2-0:4 + >--< +0| {1, 2, 3} + +[#1 Insertion delimiter] = ", " + + +[#2 Content] = +[#2 Domain] = 0:4-0:5 + >-< +0| {1, 2, 3} + +[#2 Removal] = 0:4-0:7 + >---< +0| {1, 2, 3} + +[#2 Leading delimiter] = 0:2-0:4 + >--< +0| {1, 2, 3} + +[#2 Trailing delimiter] = 0:5-0:7 + >--< +0| {1, 2, 3} + +[#2 Insertion delimiter] = ", " + + +[#3 Content] = +[#3 Domain] = 0:7-0:8 + >-< +0| {1, 2, 3} + +[#3 Removal] = 0:5-0:8 + >---< +0| {1, 2, 3} + +[#3 Leading delimiter] = 0:5-0:7 + >--< +0| {1, 2, 3} + +[#3 Insertion delimiter] = ", " diff --git a/data/fixtures/scopes/textual/collectionItem.textual4.scope b/data/fixtures/scopes/textual/collectionItem.textual4.scope new file mode 100644 index 0000000000..6387887fbc --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual4.scope @@ -0,0 +1,53 @@ +<1, 2, 3> +--- + +[#1 Content] = +[#1 Domain] = 0:1-0:2 + >-< +0| <1, 2, 3> + +[#1 Removal] = 0:1-0:4 + >---< +0| <1, 2, 3> + +[#1 Trailing delimiter] = 0:2-0:4 + >--< +0| <1, 2, 3> + +[#1 Insertion delimiter] = ", " + + +[#2 Content] = +[#2 Domain] = 0:4-0:5 + >-< +0| <1, 2, 3> + +[#2 Removal] = 0:4-0:7 + >---< +0| <1, 2, 3> + +[#2 Leading delimiter] = 0:2-0:4 + >--< +0| <1, 2, 3> + +[#2 Trailing delimiter] = 0:5-0:7 + >--< +0| <1, 2, 3> + +[#2 Insertion delimiter] = ", " + + +[#3 Content] = +[#3 Domain] = 0:7-0:8 + >-< +0| <1, 2, 3> + +[#3 Removal] = 0:5-0:8 + >---< +0| <1, 2, 3> + +[#3 Leading delimiter] = 0:5-0:7 + >--< +0| <1, 2, 3> + +[#3 Insertion delimiter] = ", " diff --git a/data/fixtures/scopes/textual/collectionItem.textual5.scope b/data/fixtures/scopes/textual/collectionItem.textual5.scope new file mode 100644 index 0000000000..9730f65458 --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual5.scope @@ -0,0 +1,53 @@ +( 1, 2, 3 ) +--- + +[#1 Content] = +[#1 Domain] = 0:2-0:3 + >-< +0| ( 1, 2, 3 ) + +[#1 Removal] = 0:2-0:5 + >---< +0| ( 1, 2, 3 ) + +[#1 Trailing delimiter] = 0:3-0:5 + >--< +0| ( 1, 2, 3 ) + +[#1 Insertion delimiter] = ", " + + +[#2 Content] = +[#2 Domain] = 0:5-0:6 + >-< +0| ( 1, 2, 3 ) + +[#2 Removal] = 0:5-0:8 + >---< +0| ( 1, 2, 3 ) + +[#2 Leading delimiter] = 0:3-0:5 + >--< +0| ( 1, 2, 3 ) + +[#2 Trailing delimiter] = 0:6-0:8 + >--< +0| ( 1, 2, 3 ) + +[#2 Insertion delimiter] = ", " + + +[#3 Content] = +[#3 Domain] = 0:8-0:9 + >-< +0| ( 1, 2, 3 ) + +[#3 Removal] = 0:6-0:9 + >---< +0| ( 1, 2, 3 ) + +[#3 Leading delimiter] = 0:6-0:8 + >--< +0| ( 1, 2, 3 ) + +[#3 Insertion delimiter] = ", " diff --git a/data/fixtures/scopes/textual/collectionItem.textual6.scope b/data/fixtures/scopes/textual/collectionItem.textual6.scope new file mode 100644 index 0000000000..b1ffcc88ca --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual6.scope @@ -0,0 +1,75 @@ +[ + 1, + 2, + 3, +] +--- + +[#1 Content] = +[#1 Domain] = 1:4-1:5 + >-< +1| 1, + +[#1 Removal] = 1:4-2:4 + >-- +1| 1, +2| 2, + ----< + +[#1 Trailing delimiter] = 1:5-2:4 + >- +1| 1, +2| 2, + ----< + +[#1 Insertion delimiter] = ",\n" + + +[#2 Content] = +[#2 Domain] = 2:4-2:5 + >-< +2| 2, + +[#2 Removal] = 2:4-3:4 + >-- +2| 2, +3| 3, + ----< + +[#2 Leading delimiter] = 1:5-2:4 + >- +1| 1, +2| 2, + ----< + +[#2 Trailing delimiter] = 2:5-3:4 + >- +2| 2, +3| 3, + ----< + +[#2 Insertion delimiter] = ",\n" + + +[#3 Content] = +[#3 Domain] = 3:4-3:5 + >-< +3| 3, + +[#3 Removal] = 2:5-3:5 + >- +2| 2, +3| 3, + -----< + +[#3 Leading delimiter] = 2:5-3:4 + >- +2| 2, +3| 3, + ----< + +[#3 Trailing delimiter] = 3:5-3:6 + >-< +3| 3, + +[#3 Insertion delimiter] = ",\n" diff --git a/data/fixtures/scopes/textual/collectionItem.textual7.scope b/data/fixtures/scopes/textual/collectionItem.textual7.scope new file mode 100644 index 0000000000..453ca9a258 --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual7.scope @@ -0,0 +1,75 @@ +{ + a: 1, + b: 2, + c: 3, +} +--- + +[#1 Content] = +[#1 Domain] = 1:4-1:8 + >----< +1| a: 1, + +[#1 Removal] = 1:4-2:4 + >----- +1| a: 1, +2| b: 2, + ----< + +[#1 Trailing delimiter] = 1:8-2:4 + >- +1| a: 1, +2| b: 2, + ----< + +[#1 Insertion delimiter] = ",\n" + + +[#2 Content] = +[#2 Domain] = 2:4-2:8 + >----< +2| b: 2, + +[#2 Removal] = 2:4-3:4 + >----- +2| b: 2, +3| c: 3, + ----< + +[#2 Leading delimiter] = 1:8-2:4 + >- +1| a: 1, +2| b: 2, + ----< + +[#2 Trailing delimiter] = 2:8-3:4 + >- +2| b: 2, +3| c: 3, + ----< + +[#2 Insertion delimiter] = ",\n" + + +[#3 Content] = +[#3 Domain] = 3:4-3:8 + >----< +3| c: 3, + +[#3 Removal] = 2:8-3:8 + >- +2| b: 2, +3| c: 3, + --------< + +[#3 Leading delimiter] = 2:8-3:4 + >- +2| b: 2, +3| c: 3, + ----< + +[#3 Trailing delimiter] = 3:8-3:9 + >-< +3| c: 3, + +[#3 Insertion delimiter] = ",\n" diff --git a/data/fixtures/scopes/textual/collectionItem.textual8.scope b/data/fixtures/scopes/textual/collectionItem.textual8.scope new file mode 100644 index 0000000000..2b2cb01fd3 --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual8.scope @@ -0,0 +1,33 @@ +[1, "2, 3"] +--- + +[#1 Content] = +[#1 Domain] = 0:1-0:2 + >-< +0| [1, "2, 3"] + +[#1 Removal] = 0:1-0:4 + >---< +0| [1, "2, 3"] + +[#1 Trailing delimiter] = 0:2-0:4 + >--< +0| [1, "2, 3"] + +[#1 Insertion delimiter] = ", " + + +[#2 Content] = +[#2 Domain] = 0:4-0:10 + >------< +0| [1, "2, 3"] + +[#2 Removal] = 0:2-0:10 + >--------< +0| [1, "2, 3"] + +[#2 Leading delimiter] = 0:2-0:4 + >--< +0| [1, "2, 3"] + +[#2 Insertion delimiter] = ", " diff --git a/data/fixtures/scopes/textual/collectionItem.textual9.scope b/data/fixtures/scopes/textual/collectionItem.textual9.scope new file mode 100644 index 0000000000..cfef536609 --- /dev/null +++ b/data/fixtures/scopes/textual/collectionItem.textual9.scope @@ -0,0 +1,33 @@ +aaa, bbb +--- + +[#1 Content] = +[#1 Domain] = 0:0-0:3 + >---< +0| aaa, bbb + +[#1 Removal] = 0:0-0:5 + >-----< +0| aaa, bbb + +[#1 Trailing delimiter] = 0:3-0:5 + >--< +0| aaa, bbb + +[#1 Insertion delimiter] = ", " + + +[#2 Content] = +[#2 Domain] = 0:5-0:8 + >---< +0| aaa, bbb + +[#2 Removal] = 0:3-0:8 + >-----< +0| aaa, bbb + +[#2 Leading delimiter] = 0:3-0:5 + >--< +0| aaa, bbb + +[#2 Insertion delimiter] = ", " diff --git a/data/fixtures/scopes/typescript.core/disqualifyDelimiter.scope b/data/fixtures/scopes/typescript.core/disqualifyDelimiter.scope new file mode 100644 index 0000000000..8d72ab1b8d --- /dev/null +++ b/data/fixtures/scopes/typescript.core/disqualifyDelimiter.scope @@ -0,0 +1,5 @@ +Promise<() => number> +--- +[Content] = 0:11-0:13 + >--< +0| Promise<() => number> diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 3c54d20a40..3721c82a07 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -76,6 +76,7 @@ export * from "./types/Selection"; export * from "./types/snippet.types"; export * from "./types/SpokenForm"; export * from "./types/SpokenFormType"; +export * from "./types/StringRecord"; export * from "./types/TalonSpokenForms"; export * from "./types/TestCaseFixture"; export * from "./types/TestHelpers"; diff --git a/packages/common/src/scopeSupportFacets/java.ts b/packages/common/src/scopeSupportFacets/java.ts index f6b27eeb72..6f9bda944a 100644 --- a/packages/common/src/scopeSupportFacets/java.ts +++ b/packages/common/src/scopeSupportFacets/java.ts @@ -21,14 +21,17 @@ export const javaScopeSupport: LanguageScopeSupportFacetMap = { "argument.actual": supported, "argument.actual.iteration": supported, - element: notApplicable, - tags: notApplicable, - attribute: notApplicable, - "key.attribute": notApplicable, - "value.attribute": notApplicable, + "collectionItem.unenclosed": supported, + "collectionItem.unenclosed.iteration": supported, "branch.if": supported, "branch.if.iteration": supported, "branch.try": supported, "branch.try.iteration": supported, + + element: notApplicable, + tags: notApplicable, + attribute: notApplicable, + "key.attribute": notApplicable, + "value.attribute": notApplicable, }; diff --git a/packages/common/src/scopeSupportFacets/python.ts b/packages/common/src/scopeSupportFacets/python.ts index 7fdac4b435..05f5bd400b 100644 --- a/packages/common/src/scopeSupportFacets/python.ts +++ b/packages/common/src/scopeSupportFacets/python.ts @@ -14,6 +14,7 @@ export const pythonScopeSupport: LanguageScopeSupportFacetMap = { namedFunction: supported, anonymousFunction: supported, disqualifyDelimiter: supported, + pairDelimiter: supported, "argument.actual": supported, "argument.actual.iteration": supported, diff --git a/packages/common/src/scopeSupportFacets/scopeSupportFacetInfos.ts b/packages/common/src/scopeSupportFacets/scopeSupportFacetInfos.ts index 8ac41be384..8102ca4d8e 100644 --- a/packages/common/src/scopeSupportFacets/scopeSupportFacetInfos.ts +++ b/packages/common/src/scopeSupportFacets/scopeSupportFacetInfos.ts @@ -298,6 +298,11 @@ export const scopeSupportFacetInfos: Record< "Used to disqualify a token from being treated as a surrounding pair delimiter. This will usually be operators containing `>` or `<`, eg `<`, `<=`, `->`, etc", scopeType: "disqualifyDelimiter", }, + pairDelimiter: { + description: + "A pair delimiter, eg parentheses, brackets, braces, quotes, etc", + scopeType: "pairDelimiter", + }, "branch.if": { description: "An if/elif/else branch", diff --git a/packages/common/src/scopeSupportFacets/scopeSupportFacets.types.ts b/packages/common/src/scopeSupportFacets/scopeSupportFacets.types.ts index 638a1bea20..7983aa9384 100644 --- a/packages/common/src/scopeSupportFacets/scopeSupportFacets.types.ts +++ b/packages/common/src/scopeSupportFacets/scopeSupportFacets.types.ts @@ -80,6 +80,7 @@ export const scopeSupportFacets = [ "textFragment.string.multiLine", "disqualifyDelimiter", + "pairDelimiter", "branch.if", "branch.if.iteration", @@ -170,7 +171,6 @@ export const scopeSupportFacets = [ // FIXME: Still in legacy // selector // unit - // collectionItem ] as const; export interface ScopeSupportFacetInfo { @@ -204,7 +204,9 @@ export type TextualScopeSupportFacet = | "boundedNonWhitespaceSequence.iteration" | "url" | "surroundingPair" - | "surroundingPair.iteration"; + | "surroundingPair.iteration" + | "collectionItem.textual" + | "collectionItem.textual.iteration"; export type LanguageScopeSupportFacetMap = Partial< Record diff --git a/packages/common/src/scopeSupportFacets/textualScopeSupportFacetInfos.ts b/packages/common/src/scopeSupportFacets/textualScopeSupportFacetInfos.ts index eb933a8876..86c34b5abf 100644 --- a/packages/common/src/scopeSupportFacets/textualScopeSupportFacetInfos.ts +++ b/packages/common/src/scopeSupportFacets/textualScopeSupportFacetInfos.ts @@ -83,4 +83,17 @@ export const textualScopeSupportFacetInfos: Record< }, isIteration: true, }, + "collectionItem.textual": { + description: "A text based collection item", + scopeType: { + type: "collectionItem", + }, + }, + "collectionItem.textual.iteration": { + description: "Iteration scope for text based collection items", + scopeType: { + type: "collectionItem", + }, + isIteration: true, + }, }; diff --git a/packages/common/src/scopeSupportFacets/typescript.ts b/packages/common/src/scopeSupportFacets/typescript.ts index a5e8521164..ee1a64e127 100644 --- a/packages/common/src/scopeSupportFacets/typescript.ts +++ b/packages/common/src/scopeSupportFacets/typescript.ts @@ -24,4 +24,6 @@ export const typescriptScopeSupport: LanguageScopeSupportFacetMap = { "value.field": supported, "value.typeAlias": supported, + + disqualifyDelimiter: supported, }; diff --git a/packages/common/src/types/command/ActionDescriptor.ts b/packages/common/src/types/command/ActionDescriptor.ts index 200918604d..c064f28339 100644 --- a/packages/common/src/types/command/ActionDescriptor.ts +++ b/packages/common/src/types/command/ActionDescriptor.ts @@ -8,6 +8,9 @@ import type { DestinationDescriptor } from "./DestinationDescriptor.types"; * A simple action takes only a single target and no other arguments. */ export const simpleActionNames = [ + "addSelection", + "addSelectionAfter", + "addSelectionBefore", "breakLine", "clearAndSetSelection", "copyToClipboard", @@ -52,9 +55,9 @@ export const simpleActionNames = [ "toggleLineBreakpoint", "toggleLineComment", "unfoldRegion", + "private.getTargets", "private.setKeyboardTarget", "private.showParseTree", - "private.getTargets", ] as const; const complexActionNames = [ diff --git a/packages/common/src/types/command/PartialTargetDescriptor.types.ts b/packages/common/src/types/command/PartialTargetDescriptor.types.ts index a382bc9dc2..e09d94d230 100644 --- a/packages/common/src/types/command/PartialTargetDescriptor.types.ts +++ b/packages/common/src/types/command/PartialTargetDescriptor.types.ts @@ -205,6 +205,7 @@ export const simpleScopeTypeTypes = [ // Private scope types "textFragment", "disqualifyDelimiter", + "pairDelimiter", ] as const; export function isSimpleScopeType( diff --git a/packages/common/src/util/regex.ts b/packages/common/src/util/regex.ts index 8e61cb26a1..449bd544c9 100644 --- a/packages/common/src/util/regex.ts +++ b/packages/common/src/util/regex.ts @@ -30,16 +30,20 @@ function makeCache(func: (arg: T) => U) { export const rightAnchored = makeCache(_rightAnchored); export const leftAnchored = makeCache(_leftAnchored); +export function matchAllIterator(text: string, regex: RegExp) { + // Reset the regex to start at the beginning of string, in case the regex has + // been used before. + // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec#finding_successive_matches + regex.lastIndex = 0; + return text.matchAll(regex); +} + export function matchAll( text: string, regex: RegExp, mapfn: (v: RegExpMatchArray, k: number) => T, ) { - // Reset the regex to start at the beginning of string, in case the regex has - // been used before. - // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec#finding_successive_matches - regex.lastIndex = 0; - return Array.from(text.matchAll(regex), mapfn); + return Array.from(matchAllIterator(text, regex), mapfn); } export function testRegex(regex: RegExp, text: string): boolean { diff --git a/packages/cursorless-engine/src/CommandHistory.ts b/packages/cursorless-engine/src/CommandHistory.ts index b68f256b38..65a148539c 100644 --- a/packages/cursorless-engine/src/CommandHistory.ts +++ b/packages/cursorless-engine/src/CommandHistory.ts @@ -130,6 +130,9 @@ function sanitizeActionInPlace(action: ActionDescriptor): void { delete action.options?.commandArgs; break; + case "addSelection": + case "addSelectionAfter": + case "addSelectionBefore": case "breakLine": case "clearAndSetSelection": case "copyToClipboard": diff --git a/packages/cursorless-engine/src/actions/Actions.ts b/packages/cursorless-engine/src/actions/Actions.ts index 18258bf4bb..3bdd24e28c 100644 --- a/packages/cursorless-engine/src/actions/Actions.ts +++ b/packages/cursorless-engine/src/actions/Actions.ts @@ -18,6 +18,7 @@ import GenerateSnippet from "./GenerateSnippet"; import GetTargets from "./GetTargets"; import GetText from "./GetText"; import Highlight from "./Highlight"; +import { IndentLine, OutdentLine } from "./IndentLine"; import { CopyContentAfter as InsertCopyAfter, CopyContentBefore as InsertCopyBefore, @@ -35,13 +36,15 @@ import Replace from "./Replace"; import Rewrap from "./Rewrap"; import { ScrollToBottom, ScrollToCenter, ScrollToTop } from "./Scroll"; import { + AddSelection, + AddSelectionAfter, + AddSelectionBefore, SetSelection, SetSelectionAfter, SetSelectionBefore, } from "./SetSelection"; import { SetSpecialTarget } from "./SetSpecialTarget"; import ShowParseTree from "./ShowParseTree"; -import { IndentLine, OutdentLine } from "./IndentLine"; import { ExtractVariable, Fold, @@ -73,6 +76,9 @@ export class Actions implements ActionRecord { private modifierStageFactory: ModifierStageFactory, ) {} + addSelection = new AddSelection(); + addSelectionBefore = new AddSelectionBefore(); + addSelectionAfter = new AddSelectionAfter(); callAsFunction = new Call(this); clearAndSetSelection = new Clear(this); copyToClipboard = new CopyToClipboard(this, this.rangeUpdater); diff --git a/packages/cursorless-engine/src/actions/ExecuteCommand.ts b/packages/cursorless-engine/src/actions/ExecuteCommand.ts index e6c8a41e36..403cf160d1 100644 --- a/packages/cursorless-engine/src/actions/ExecuteCommand.ts +++ b/packages/cursorless-engine/src/actions/ExecuteCommand.ts @@ -14,6 +14,7 @@ import type { ActionReturnValue } from "./actions.types"; */ export default class ExecuteCommand { private callbackAction: CallbackAction; + constructor(rangeUpdater: RangeUpdater) { this.callbackAction = new CallbackAction(rangeUpdater); this.run = this.run.bind(this); diff --git a/packages/cursorless-engine/src/actions/SetSelection.ts b/packages/cursorless-engine/src/actions/SetSelection.ts index 2bab32a858..db46311631 100644 --- a/packages/cursorless-engine/src/actions/SetSelection.ts +++ b/packages/cursorless-engine/src/actions/SetSelection.ts @@ -4,19 +4,23 @@ import type { Target } from "../typings/target.types"; import { ensureSingleEditor } from "../util/targetUtils"; import type { SimpleAction, ActionReturnValue } from "./actions.types"; -export class SetSelection implements SimpleAction { - constructor() { +abstract class SetSelectionBase implements SimpleAction { + constructor( + private selectionMode: "set" | "add", + private rangeMode: "content" | "before" | "after", + ) { this.run = this.run.bind(this); } - protected getSelection(target: Target) { - return target.contentSelection; - } - async run(targets: Target[]): Promise { const editor = ensureSingleEditor(targets); + const targetSelections = this.getSelections(targets); + + const selections = + this.selectionMode === "add" + ? editor.selections.concat(targetSelections) + : targetSelections; - const selections = targets.map(this.getSelection); await ide() .getEditableTextEditor(editor) .setSelections(selections, { focusEditor: true }); @@ -25,16 +29,57 @@ export class SetSelection implements SimpleAction { thatTargets: targets, }; } + + private getSelections(targets: Target[]): Selection[] { + switch (this.rangeMode) { + case "content": + return targets.map((target) => target.contentSelection); + case "before": + return targets.map( + (target) => + new Selection(target.contentRange.start, target.contentRange.start), + ); + case "after": + return targets.map( + (target) => + new Selection(target.contentRange.end, target.contentRange.end), + ); + } + } +} + +export class SetSelection extends SetSelectionBase { + constructor() { + super("set", "content"); + } +} + +export class SetSelectionBefore extends SetSelectionBase { + constructor() { + super("set", "before"); + } +} + +export class SetSelectionAfter extends SetSelectionBase { + constructor() { + super("set", "after"); + } } -export class SetSelectionBefore extends SetSelection { - protected getSelection(target: Target) { - return new Selection(target.contentRange.start, target.contentRange.start); +export class AddSelection extends SetSelectionBase { + constructor() { + super("add", "content"); + } +} + +export class AddSelectionBefore extends SetSelectionBase { + constructor() { + super("add", "before"); } } -export class SetSelectionAfter extends SetSelection { - protected getSelection(target: Target) { - return new Selection(target.contentRange.end, target.contentRange.end); +export class AddSelectionAfter extends SetSelectionBase { + constructor() { + super("add", "after"); } } diff --git a/packages/cursorless-engine/src/core/commandRunner/CommandRunnerImpl.ts b/packages/cursorless-engine/src/core/commandRunner/CommandRunnerImpl.ts index c1230aaf33..77bfe82bc3 100644 --- a/packages/cursorless-engine/src/core/commandRunner/CommandRunnerImpl.ts +++ b/packages/cursorless-engine/src/core/commandRunner/CommandRunnerImpl.ts @@ -211,6 +211,13 @@ export class CommandRunnerImpl implements CommandRunner { default: { const action = this.actions[actionDescriptor.name]; + + // Ensure we don't miss any new actions. Needed because we don't have input validation. + // FIXME: remove once we have schema validation (#983) + if (action == null) { + throw new Error(`Unknown action: ${actionDescriptor.name}`); + } + this.finalStages = action.getFinalStages?.() ?? []; this.noAutomaticTokenExpansion = action.noAutomaticTokenExpansion ?? false; diff --git a/packages/cursorless-engine/src/languages/LanguageDefinition.ts b/packages/cursorless-engine/src/languages/LanguageDefinition.ts index 25f8ac29fc..93e93fb6d0 100644 --- a/packages/cursorless-engine/src/languages/LanguageDefinition.ts +++ b/packages/cursorless-engine/src/languages/LanguageDefinition.ts @@ -74,7 +74,7 @@ export class LanguageDefinition { * legacy pathways */ getScopeHandler(scopeType: ScopeType) { - if (!this.query.captureNames.includes(scopeType.type)) { + if (!this.query.hasCapture(scopeType.type)) { return undefined; } @@ -82,21 +82,28 @@ export class LanguageDefinition { } /** - * This is a low-level function that just returns a list of captures of the given - * capture name in the document. We use this in our surrounding pair code. + * This is a low-level function that just returns a map of all captures in the + * document. We use this in our surrounding pair code. * * @param document The document to search * @param captureName The name of a capture to search for - * @returns A list of captures of the given capture name in the document + * @returns A map of captures in the document */ - getCaptures( - document: TextDocument, - captureName: SimpleScopeTypeType, - ): QueryCapture[] { - return this.query - .matches(document) - .map((match) => match.captures.find(({ name }) => name === captureName)) - .filter((capture) => capture != null); + getCapturesMap(document: TextDocument) { + const matches = this.query.matches(document); + const result: Partial> = {}; + + for (const match of matches) { + for (const capture of match.captures) { + const name = capture.name as SimpleScopeTypeType; + if (result[name] == null) { + result[name] = []; + } + result[name]!.push(capture); + } + } + + return result; } } diff --git a/packages/cursorless-engine/src/languages/LanguageDefinitions.ts b/packages/cursorless-engine/src/languages/LanguageDefinitions.ts index 53e7045692..1d5a5f4ca0 100644 --- a/packages/cursorless-engine/src/languages/LanguageDefinitions.ts +++ b/packages/cursorless-engine/src/languages/LanguageDefinitions.ts @@ -10,6 +10,7 @@ import { import { toString } from "lodash-es"; import type { SyntaxNode } from "web-tree-sitter"; import { LanguageDefinition } from "./LanguageDefinition"; +import { treeSitterQueryCache } from "./TreeSitterQuery/treeSitterQueryCache"; /** * Sentinel value to indicate that a language doesn't have @@ -71,7 +72,13 @@ export class LanguageDefinitionsImpl private treeSitter: TreeSitter, private treeSitterQueryProvider: RawTreeSitterQueryProvider, ) { + const isTesting = ide.runMode === "test"; + ide.onDidOpenTextDocument((document) => { + // During testing we open untitled documents that all have the same uri and version which breaks our cache + if (isTesting) { + treeSitterQueryCache.clear(); + } void this.loadLanguage(document.languageId); }); ide.onDidChangeVisibleTextEditors((editors) => { @@ -139,6 +146,7 @@ export class LanguageDefinitionsImpl private async reloadLanguageDefinitions(): Promise { this.languageDefinitions.clear(); await this.loadAllLanguages(); + treeSitterQueryCache.clear(); this.notifier.notifyListeners(); } diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts index 9db14b000d..60d3763a02 100644 --- a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts @@ -1,32 +1,38 @@ import type { Position, TextDocument } from "@cursorless/common"; -import { showError, type TreeSitter } from "@cursorless/common"; -import { groupBy, uniq } from "lodash-es"; -import type { Point, Query } from "web-tree-sitter"; +import { type TreeSitter } from "@cursorless/common"; +import type * as treeSitter from "web-tree-sitter"; import { ide } from "../../singletons/ide.singleton"; import { getNodeRange } from "../../util/nodeSelectors"; import type { + MutableQueryCapture, MutableQueryMatch, - QueryCapture, QueryMatch, } from "./QueryCapture"; import { checkCaptureStartEnd } from "./checkCaptureStartEnd"; import { isContainedInErrorNode } from "./isContainedInErrorNode"; -import { parsePredicates } from "./parsePredicates"; -import { predicateToString } from "./predicateToString"; -import { rewriteStartOfEndOf } from "./rewriteStartOfEndOf"; +import { normalizeCaptureName } from "./normalizeCaptureName"; +import { parsePredicatesWithErrorHandling } from "./parsePredicatesWithErrorHandling"; +import { positionToPoint } from "./positionToPoint"; +import { + getStartOfEndOfRange, + rewriteStartOfEndOf, +} from "./rewriteStartOfEndOf"; +import { treeSitterQueryCache } from "./treeSitterQueryCache"; /** * Wrapper around a tree-sitter query that provides a more convenient API, and * defines our own custom predicate operators */ export class TreeSitterQuery { + private shouldCheckCaptures: boolean; + private constructor( private treeSitter: TreeSitter, /** * The raw tree-sitter query as parsed by tree-sitter from the query file */ - private query: Query, + private query: treeSitter.Query, /** * The predicates for each pattern in the query. Each element of the outer @@ -34,111 +40,151 @@ export class TreeSitterQuery { * corresponds to a predicate for that pattern. */ private patternPredicates: ((match: MutableQueryMatch) => boolean)[][], - ) {} - - static create(languageId: string, treeSitter: TreeSitter, query: Query) { - const { errors, predicates } = parsePredicates(query.predicates); - - if (errors.length > 0) { - for (const error of errors) { - const context = [ - `language ${languageId}`, - `pattern ${error.patternIdx}`, - `predicate \`${predicateToString( - query.predicates[error.patternIdx][error.predicateIdx], - )}\``, - ].join(", "); - - void showError( - ide().messages, - "TreeSitterQuery.parsePredicates", - `Error parsing predicate for ${context}: ${error.error}`, - ); - } + ) { + this.shouldCheckCaptures = ide().runMode !== "production"; + } - // We show errors to the user, but we don't want to crash the extension - // unless we're in test mode - if (ide().runMode === "test") { - throw new Error("Invalid predicates"); - } - } + static create( + languageId: string, + treeSitter: TreeSitter, + query: treeSitter.Query, + ) { + const predicates = parsePredicatesWithErrorHandling(languageId, query); return new TreeSitterQuery(treeSitter, query, predicates); } + hasCapture(name: string): boolean { + return this.query.captureNames.some( + (n) => normalizeCaptureName(n) === name, + ); + } + matches( document: TextDocument, start?: Position, end?: Position, ): QueryMatch[] { - return this.query - .matches(this.treeSitter.getTree(document).rootNode, { - startPosition: start == null ? undefined : positionToPoint(start), - endPosition: end == null ? undefined : positionToPoint(end), - }) - .map( - ({ pattern, captures }): MutableQueryMatch => ({ - patternIdx: pattern, - captures: captures.map(({ name, node }) => ({ - name, - node, - document, - range: getNodeRange(node), - insertionDelimiter: undefined, - allowMultiple: false, - hasError: () => isContainedInErrorNode(node), - })), - }), - ) - .filter((match) => - this.patternPredicates[match.patternIdx].every((predicate) => - predicate(match), - ), - ) - .map((match): QueryMatch => { - // Merge the ranges of all captures with the same name into a single - // range and return one capture with that name. We consider captures - // with names `@foo`, `@foo.start`, and `@foo.end` to have the same - // name, for which we'd return a capture with name `foo`. - const captures: QueryCapture[] = Object.entries( - groupBy(match.captures, ({ name }) => normalizeCaptureName(name)), - ).map(([name, captures]) => { - captures = rewriteStartOfEndOf(captures); - const capturesAreValid = checkCaptureStartEnd( - captures, - ide().messages, - ); - - if (!capturesAreValid && ide().runMode === "test") { - throw new Error("Invalid captures"); - } - - return { - name, - range: captures - .map(({ range }) => range) - .reduce((accumulator, range) => range.union(accumulator)), - allowMultiple: captures.some((capture) => capture.allowMultiple), - insertionDelimiter: captures.find( - (capture) => capture.insertionDelimiter != null, - )?.insertionDelimiter, - hasError: () => captures.some((capture) => capture.hasError()), - }; - }); - - return { ...match, captures }; - }); + if (!treeSitterQueryCache.isValid(document, start, end)) { + const matches = this.getAllMatches(document, start, end); + treeSitterQueryCache.update(document, start, end, matches); + } + return treeSitterQueryCache.get(); } - get captureNames() { - return uniq(this.query.captureNames.map(normalizeCaptureName)); + private getAllMatches( + document: TextDocument, + start?: Position, + end?: Position, + ): QueryMatch[] { + const matches = this.getTreeMatches(document, start, end); + const results: QueryMatch[] = []; + + for (const match of matches) { + const mutableMatch = this.createMutableQueryMatch(document, match); + + if (!this.runPredicates(mutableMatch)) { + continue; + } + + results.push(this.createQueryMatch(mutableMatch)); + } + + return results; } -} -function normalizeCaptureName(name: string): string { - return name.replace(/(\.(start|end))?(\.(startOf|endOf))?$/, ""); -} + private getTreeMatches( + document: TextDocument, + start?: Position, + end?: Position, + ) { + const { rootNode } = this.treeSitter.getTree(document); + return this.query.matches(rootNode, { + startPosition: start != null ? positionToPoint(start) : undefined, + endPosition: end != null ? positionToPoint(end) : undefined, + }); + } + + private createMutableQueryMatch( + document: TextDocument, + match: treeSitter.QueryMatch, + ): MutableQueryMatch { + return { + patternIdx: match.pattern, + captures: match.captures.map(({ name, node }) => ({ + name, + node, + document, + range: getNodeRange(node), + insertionDelimiter: undefined, + allowMultiple: false, + hasError: () => isContainedInErrorNode(node), + })), + }; + } + + private runPredicates(match: MutableQueryMatch): boolean { + for (const predicate of this.patternPredicates[match.patternIdx]) { + if (!predicate(match)) { + return false; + } + } + return true; + } + + private createQueryMatch(match: MutableQueryMatch): QueryMatch { + const result: MutableQueryCapture[] = []; + const map = new Map< + string, + { acc: MutableQueryCapture; captures: MutableQueryCapture[] } + >(); + + // Merge the ranges of all captures with the same name into a single + // range and return one capture with that name. We consider captures + // with names `@foo`, `@foo.start`, and `@foo.end` to have the same + // name, for which we'd return a capture with name `foo`. -function positionToPoint(start: Position): Point { - return { row: start.line, column: start.character }; + for (const capture of match.captures) { + const name = normalizeCaptureName(capture.name); + const range = getStartOfEndOfRange(capture); + const existing = map.get(name); + + if (existing == null) { + const captures = [capture]; + const acc = { + ...capture, + name, + range, + hasError: () => captures.some((c) => c.hasError()), + }; + result.push(acc); + map.set(name, { acc, captures }); + } else { + existing.acc.range = existing.acc.range.union(range); + existing.acc.allowMultiple = + existing.acc.allowMultiple || capture.allowMultiple; + existing.acc.insertionDelimiter = + existing.acc.insertionDelimiter ?? capture.insertionDelimiter; + existing.captures.push(capture); + } + } + + if (this.shouldCheckCaptures) { + this.checkCaptures(Array.from(map.values())); + } + + return { captures: result }; + } + + private checkCaptures(matches: { captures: MutableQueryCapture[] }[]) { + for (const match of matches) { + const capturesAreValid = checkCaptureStartEnd( + rewriteStartOfEndOf(match.captures), + ide().messages, + ); + if (!capturesAreValid && ide().runMode === "test") { + throw new Error("Invalid captures"); + } + } + } } diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/normalizeCaptureName.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/normalizeCaptureName.ts new file mode 100644 index 0000000000..5322ff1556 --- /dev/null +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/normalizeCaptureName.ts @@ -0,0 +1,3 @@ +export function normalizeCaptureName(name: string): string { + return name.replace(/(\.(start|end))?(\.(startOf|endOf))?$/, ""); +} diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/parsePredicatesWithErrorHandling.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/parsePredicatesWithErrorHandling.ts new file mode 100644 index 0000000000..6798939249 --- /dev/null +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/parsePredicatesWithErrorHandling.ts @@ -0,0 +1,38 @@ +import { showError } from "@cursorless/common"; +import type { Query } from "web-tree-sitter"; +import { ide } from "../../singletons/ide.singleton"; +import { parsePredicates } from "./parsePredicates"; +import { predicateToString } from "./predicateToString"; + +export function parsePredicatesWithErrorHandling( + languageId: string, + query: Query, +) { + const { errors, predicates } = parsePredicates(query.predicates); + + if (errors.length > 0) { + for (const error of errors) { + const context = [ + `language ${languageId}`, + `pattern ${error.patternIdx}`, + `predicate \`${predicateToString( + query.predicates[error.patternIdx][error.predicateIdx], + )}\``, + ].join(", "); + + void showError( + ide().messages, + "TreeSitterQuery.parsePredicates", + `Error parsing predicate for ${context}: ${error.error}`, + ); + } + + // We show errors to the user, but we don't want to crash the extension + // unless we're in test mode + if (ide().runMode === "test") { + throw new Error("Invalid predicates"); + } + } + + return predicates; +} diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/positionToPoint.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/positionToPoint.ts new file mode 100644 index 0000000000..af5650a309 --- /dev/null +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/positionToPoint.ts @@ -0,0 +1,6 @@ +import type { Position } from "@cursorless/common"; +import type { Point } from "web-tree-sitter"; + +export function positionToPoint(start: Position): Point { + return { row: start.line, column: start.character }; +} diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/queryPredicateOperators.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/queryPredicateOperators.ts index 143edece3b..1ba2bca944 100644 --- a/packages/cursorless-engine/src/languages/TreeSitterQuery/queryPredicateOperators.ts +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/queryPredicateOperators.ts @@ -62,6 +62,21 @@ class HasMultipleChildrenOfType extends QueryPredicateOperator { + name = "match?" as const; + schema = z.tuple([q.node, q.string]); + + run(nodeInfo: MutableQueryCapture, pattern: string) { + const { document, range } = nodeInfo; + const regex = new RegExp(pattern, "ds"); + const text = document.getText(range); + return regex.test(text); + } +} + class ChildRange extends QueryPredicateOperator { name = "child-range!" as const; schema = z.union([ @@ -277,4 +292,5 @@ export const queryPredicateOperators = [ new InsertionDelimiter(), new SingleOrMultilineDelimiter(), new HasMultipleChildrenOfType(), + new Match(), ]; diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/rewriteStartOfEndOf.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/rewriteStartOfEndOf.ts index 463ba9a15c..76fc022411 100644 --- a/packages/cursorless-engine/src/languages/TreeSitterQuery/rewriteStartOfEndOf.ts +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/rewriteStartOfEndOf.ts @@ -1,3 +1,4 @@ +import type { Range } from "@cursorless/common"; import type { MutableQueryCapture } from "./QueryCapture"; /** @@ -11,22 +12,29 @@ import type { MutableQueryCapture } from "./QueryCapture"; export function rewriteStartOfEndOf( captures: MutableQueryCapture[], ): MutableQueryCapture[] { - return captures.map((capture) => { - // Remove trailing .startOf and .endOf, adjusting ranges. - if (capture.name.endsWith(".startOf")) { - return { - ...capture, - name: capture.name.replace(/\.startOf$/, ""), - range: capture.range.start.toEmptyRange(), - }; - } - if (capture.name.endsWith(".endOf")) { - return { - ...capture, - name: capture.name.replace(/\.endOf$/, ""), - range: capture.range.end.toEmptyRange(), - }; - } - return capture; - }); + return captures.map((capture) => ({ + ...capture, + range: getStartOfEndOfRange(capture), + name: getStartOfEndOfName(capture), + })); +} + +export function getStartOfEndOfRange(capture: MutableQueryCapture): Range { + if (capture.name.endsWith(".startOf")) { + return capture.range.start.toEmptyRange(); + } + if (capture.name.endsWith(".endOf")) { + return capture.range.end.toEmptyRange(); + } + return capture.range; +} + +function getStartOfEndOfName(capture: MutableQueryCapture): string { + if (capture.name.endsWith(".startOf")) { + return capture.name.slice(0, -8); + } + if (capture.name.endsWith(".endOf")) { + return capture.name.slice(0, -6); + } + return capture.name; } diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/treeSitterQueryCache.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/treeSitterQueryCache.ts new file mode 100644 index 0000000000..07124e5e38 --- /dev/null +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/treeSitterQueryCache.ts @@ -0,0 +1,61 @@ +import type { Position, TextDocument } from "@cursorless/common"; +import type { QueryMatch } from "./QueryCapture"; + +export class Cache { + private documentVersion: number = -1; + private documentUri: string = ""; + private documentLanguageId: string = ""; + private startPosition: Position | undefined; + private endPosition: Position | undefined; + private matches: QueryMatch[] = []; + + clear() { + this.documentUri = ""; + this.documentVersion = -1; + this.documentLanguageId = ""; + this.startPosition = undefined; + this.endPosition = undefined; + this.matches = []; + } + + isValid( + document: TextDocument, + startPosition: Position | undefined, + endPosition: Position | undefined, + ) { + return ( + this.documentVersion === document.version && + this.documentUri === document.uri.toString() && + this.documentLanguageId === document.languageId && + positionsEqual(this.startPosition, startPosition) && + positionsEqual(this.endPosition, endPosition) + ); + } + + update( + document: TextDocument, + startPosition: Position | undefined, + endPosition: Position | undefined, + matches: QueryMatch[], + ) { + this.documentVersion = document.version; + this.documentUri = document.uri.toString(); + this.documentLanguageId = document.languageId; + this.startPosition = startPosition; + this.endPosition = endPosition; + this.matches = matches; + } + + get(): QueryMatch[] { + return this.matches; + } +} + +function positionsEqual(a: Position | undefined, b: Position | undefined) { + if (a == null || b == null) { + return a === b; + } + return a.isEqual(b); +} + +export const treeSitterQueryCache = new Cache(); diff --git a/packages/cursorless-engine/src/languages/clojure.ts b/packages/cursorless-engine/src/languages/clojure.ts index ed87759de9..4ecb9ef947 100644 --- a/packages/cursorless-engine/src/languages/clojure.ts +++ b/packages/cursorless-engine/src/languages/clojure.ts @@ -1,3 +1,7 @@ +import type { SimpleScopeTypeType } from "@cursorless/common"; +import type { SyntaxNode } from "web-tree-sitter"; +import type { NodeFinder, NodeMatcherAlternative } from "../typings/Types"; +import { patternFinder } from "../util/nodeFinders"; import { cascadingMatcher, chainedMatcher, @@ -5,13 +9,7 @@ import { matcher, patternMatcher, } from "../util/nodeMatchers"; -import type { NodeMatcherAlternative, NodeFinder } from "../typings/Types"; -import type { SimpleScopeTypeType } from "@cursorless/common"; -import type { SyntaxNode } from "web-tree-sitter"; -import { delimitedSelector } from "../util/nodeSelectors"; -import { identity } from "lodash-es"; import { getChildNodesForFieldName } from "../util/treeSitterUtils"; -import { patternFinder } from "../util/nodeFinders"; /** * Picks a node by rounding down and using the given parity. This function is @@ -73,13 +71,6 @@ function indexNodeFinder( }; } -function itemFinder() { - return indexNodeFinder( - (node) => node, - (nodeIndex: number) => nodeIndex, - ); -} - /** * Return the "value" node children of a given node. These are the items in a list * @param node The node whose children to get @@ -134,21 +125,6 @@ const nodeMatchers: Partial< Record > = { collectionKey: matcher(mapParityNodeFinder(0)), - collectionItem: cascadingMatcher( - // Treat each key value pair as a single item if we're in a map - matcher( - mapParityNodeFinder(0), - delimitedSelector( - (node) => node.type === "{" || node.type === "}", - ", ", - identity, - mapParityNodeFinder(1) as (node: SyntaxNode) => SyntaxNode, - ), - ), - - // Otherwise just treat every item within a list as an item - matcher(itemFinder()), - ), value: matcher(mapParityNodeFinder(1)), // FIXME: Handle formal parameters diff --git a/packages/cursorless-engine/src/languages/latex.ts b/packages/cursorless-engine/src/languages/latex.ts index 46bb4e0c57..22f5001cca 100644 --- a/packages/cursorless-engine/src/languages/latex.ts +++ b/packages/cursorless-engine/src/languages/latex.ts @@ -1,5 +1,5 @@ import type { SimpleScopeTypeType, TextEditor } from "@cursorless/common"; -import { Range, Selection } from "@cursorless/common"; +import { Selection } from "@cursorless/common"; import type { SyntaxNode } from "web-tree-sitter"; import type { NodeMatcherAlternative, @@ -120,36 +120,6 @@ function extendToNamedSiblingIfExists( }; } -function extractItemContent( - editor: TextEditor, - node: SyntaxNode, -): SelectionWithContext { - let contentStartIndex = node.startIndex; - - const label = node.childForFieldName("label"); - if (label == null) { - const command = node.childForFieldName("command"); - if (command != null) { - contentStartIndex = command.endIndex + 1; - } - } else { - contentStartIndex = label.endIndex + 1; - } - - return { - selection: new Selection( - editor.document.positionAt(contentStartIndex), - editor.document.positionAt(node.endIndex), - ), - context: { - leadingDelimiterRange: new Range( - editor.document.positionAt(node.startIndex), - editor.document.positionAt(contentStartIndex - 1), - ), - }, - }; -} - const nodeMatchers: Partial< Record > = { @@ -174,8 +144,6 @@ const nodeMatchers: Partial< matcher(patternFinder(...sectioningText), unwrapGroupParens), patternMatcher("begin[name][text]", "end[name][text]"), ), - - collectionItem: matcher(patternFinder("enum_item"), extractItemContent), }; export default createPatternMatchers(nodeMatchers); diff --git a/packages/cursorless-engine/src/languages/ruby.ts b/packages/cursorless-engine/src/languages/ruby.ts index ba186c3188..62ed5a1a92 100644 --- a/packages/cursorless-engine/src/languages/ruby.ts +++ b/packages/cursorless-engine/src/languages/ruby.ts @@ -113,9 +113,6 @@ const EXPRESSION_STATEMENT_PARENT_TYPES = [ "then", ]; -const mapTypes = ["hash"]; -const listTypes = ["array", "string_array", "symbol_array"]; - const assignmentOperators = [ "=", "+=", @@ -187,6 +184,5 @@ const nodeMatchers: Partial< ], assignmentOperators.concat(mapKeyValueSeparators), ), - collectionItem: argumentMatcher(...mapTypes, ...listTypes), }; export const patternMatchers = createPatternMatchers(nodeMatchers); diff --git a/packages/cursorless-engine/src/languages/rust.ts b/packages/cursorless-engine/src/languages/rust.ts index 21f1d5186a..f7bf5eda7f 100644 --- a/packages/cursorless-engine/src/languages/rust.ts +++ b/packages/cursorless-engine/src/languages/rust.ts @@ -159,11 +159,6 @@ const nodeMatchers: Partial< ), leadingMatcher(["*.match_pattern![condition]"], ["if"]), ), - collectionItem: argumentMatcher( - "array_expression", - "tuple_expression", - "tuple_type", - ), type: cascadingMatcher( leadingMatcher( [ diff --git a/packages/cursorless-engine/src/processTargets/MarkStageFactoryImpl.ts b/packages/cursorless-engine/src/processTargets/MarkStageFactoryImpl.ts index 97b884dea0..b88b030393 100644 --- a/packages/cursorless-engine/src/processTargets/MarkStageFactoryImpl.ts +++ b/packages/cursorless-engine/src/processTargets/MarkStageFactoryImpl.ts @@ -47,6 +47,13 @@ export class MarkStageFactoryImpl implements MarkStageFactory { return new TargetMarkStage(this.targetPipelineRunner, mark); case "explicit": return new ExplicitMarkStage(mark); + default: { + // Ensure we don't miss any new marks. Needed because we don't have input validation. + // FIXME: remove once we have schema validation (#983) + const _exhaustiveCheck: never = mark; + const { type } = mark; + throw new Error(`Unknown mark: ${type}`); + } } } } diff --git a/packages/cursorless-engine/src/processTargets/ModifierStageFactoryImpl.ts b/packages/cursorless-engine/src/processTargets/ModifierStageFactoryImpl.ts index 5f7b6ec009..17e040c88f 100644 --- a/packages/cursorless-engine/src/processTargets/ModifierStageFactoryImpl.ts +++ b/packages/cursorless-engine/src/processTargets/ModifierStageFactoryImpl.ts @@ -21,7 +21,6 @@ import { ExcludeInteriorStage, InteriorOnlyStage, } from "./modifiers/InteriorStage"; -import { ItemStage } from "./modifiers/ItemStage"; import { LeadingStage, TrailingStage } from "./modifiers/LeadingTrailingStages"; import { OrdinalScopeStage } from "./modifiers/OrdinalScopeStage"; import { EndOfStage, StartOfStage } from "./modifiers/PositionStage"; @@ -109,6 +108,13 @@ export class ModifierStageFactoryImpl implements ModifierStageFactory { throw Error( `Unexpected modifier '${modifier.type}'; it should have been removed during inference`, ); + default: { + // Ensure we don't miss any new modifiers. Needed because we don't have input validation. + // FIXME: remove once we have schema validation (#983) + const _exhaustiveCheck: never = modifier; + const { type } = modifier; + throw new Error(`Unknown modifier: ${type}`); + } } } @@ -131,8 +137,6 @@ export class ModifierStageFactoryImpl implements ModifierStageFactory { switch (modifier.scopeType.type) { case "notebookCell": return new NotebookCellStage(modifier); - case "collectionItem": - return new ItemStage(this.languageDefinitions, this, modifier); default: // Default to containing syntax scope using tree sitter return new LegacyContainingSyntaxScopeStage( diff --git a/packages/cursorless-engine/src/processTargets/modifiers/ContainingScopeStage.ts b/packages/cursorless-engine/src/processTargets/modifiers/ContainingScopeStage.ts index 6b22f1c783..bf74445f54 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/ContainingScopeStage.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/ContainingScopeStage.ts @@ -34,7 +34,7 @@ export class ContainingScopeStage implements ModifierStage { run(target: Target): Target[] { const { scopeType, ancestorIndex = 0 } = this.modifier; - const scopeHandler = this.scopeHandlerFactory.create( + const scopeHandler = this.scopeHandlerFactory.maybeCreate( scopeType, target.editor.document.languageId, ); @@ -50,29 +50,6 @@ export class ContainingScopeStage implements ModifierStage { scopeHandler, ancestorIndex, ); - if (scopeType.type === "collectionItem") { - // For `collectionItem`, combine with generic implementation - try { - const legacyScopes = this.modifierStageFactory - .getLegacyScopeStage(this.modifier) - .run(target); - if (containingScopes == null) { - return legacyScopes; - } - if (containingScopes.length === 1 && legacyScopes.length === 1) { - const containingRange = containingScopes[0].contentRange; - const legacyRange = legacyScopes[0].contentRange; - if ( - containingRange.contains(legacyRange) && - !containingRange.isRangeEqual(legacyRange) - ) { - return legacyScopes; - } - } - } catch (_ex) { - // Do nothing - } - } if (containingScopes == null) { throw new NoContainingScopeError(this.modifier.scopeType.type); diff --git a/packages/cursorless-engine/src/processTargets/modifiers/EveryScopeStage.ts b/packages/cursorless-engine/src/processTargets/modifiers/EveryScopeStage.ts index 8d8f00df85..1c294e3867 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/EveryScopeStage.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/EveryScopeStage.ts @@ -39,7 +39,7 @@ export class EveryScopeStage implements ModifierStage { const { scopeType } = this.modifier; const { editor, isReversed } = target; - const scopeHandler = this.scopeHandlerFactory.create( + const scopeHandler = this.scopeHandlerFactory.maybeCreate( scopeType, editor.document.languageId, ); @@ -90,13 +90,6 @@ export class EveryScopeStage implements ModifierStage { } if (scopes.length === 0) { - if (scopeType.type === "collectionItem") { - // For `collectionItem`, fall back to generic implementation - return this.modifierStageFactory - .getLegacyScopeStage(this.modifier) - .run(target); - } - throw new NoContainingScopeError(scopeType.type); } @@ -108,7 +101,7 @@ export class EveryScopeStage implements ModifierStage { scopeHandlerFactory: ScopeHandlerFactory, target: Target, ): Range[] { - const iterationScopeHandler = scopeHandlerFactory.create( + const iterationScopeHandler = scopeHandlerFactory.maybeCreate( scopeHandler.iterationScopeType, target.editor.document.languageId, ); diff --git a/packages/cursorless-engine/src/processTargets/modifiers/ItemStage/ItemStage.ts b/packages/cursorless-engine/src/processTargets/modifiers/ItemStage/ItemStage.ts deleted file mode 100644 index 0efea9f7c2..0000000000 --- a/packages/cursorless-engine/src/processTargets/modifiers/ItemStage/ItemStage.ts +++ /dev/null @@ -1,214 +0,0 @@ -import type { - ContainingScopeModifier, - EveryScopeModifier, - SimpleScopeTypeType, - TextEditor, -} from "@cursorless/common"; -import { NoContainingScopeError, Range } from "@cursorless/common"; -import type { LanguageDefinitions } from "../../../languages/LanguageDefinitions"; -import type { Target } from "../../../typings/target.types"; -import { getRangeLength } from "../../../util/rangeUtils"; -import type { ModifierStageFactory } from "../../ModifierStageFactory"; -import type { ModifierStage } from "../../PipelineStages.types"; -import { ScopeTypeTarget } from "../../targets"; -import type { SimpleContainingScopeModifier } from "../scopeTypeStages/LegacyContainingSyntaxScopeStage"; -import { LegacyContainingSyntaxScopeStage } from "../scopeTypeStages/LegacyContainingSyntaxScopeStage"; -import { getIterationScope } from "./getIterationScope"; -import { tokenizeRange } from "./tokenizeRange"; - -export class ItemStage implements ModifierStage { - constructor( - private languageDefinitions: LanguageDefinitions, - private modifierStageFactory: ModifierStageFactory, - private modifier: ContainingScopeModifier | EveryScopeModifier, - ) {} - - run(target: Target): Target[] { - // First try the language specific implementation of item - try { - return new LegacyContainingSyntaxScopeStage( - this.languageDefinitions, - this.modifier as SimpleContainingScopeModifier, - ).run(target); - } catch (_error) { - // do nothing - } - - // Then try the textual implementation - if (this.modifier.type === "everyScope") { - return this.getEveryTarget(this.modifierStageFactory, target); - } - return [this.getSingleTarget(this.modifierStageFactory, target)]; - } - - private getEveryTarget( - modifierStageFactory: ModifierStageFactory, - target: Target, - ) { - const itemInfos = getItemInfosForIterationScope( - modifierStageFactory, - target, - ); - - // If target has explicit range filter to items in that range. Otherwise expand to all items in iteration scope. - const filteredItemInfos = target.hasExplicitRange - ? filterItemInfos(target, itemInfos) - : itemInfos; - - if (filteredItemInfos.length === 0) { - throw new NoContainingScopeError(this.modifier.scopeType.type); - } - - return filteredItemInfos.map((itemInfo) => - this.itemInfoToTarget(target, itemInfo), - ); - } - - private getSingleTarget( - modifierStageFactory: ModifierStageFactory, - target: Target, - ) { - const itemInfos = getItemInfosForIterationScope( - modifierStageFactory, - target, - ); - - const filteredItemInfos = filterItemInfos(target, itemInfos); - - if (filteredItemInfos.length === 0) { - throw new NoContainingScopeError(this.modifier.scopeType.type); - } - - const first = filteredItemInfos[0]; - const last = filteredItemInfos[filteredItemInfos.length - 1]; - - const itemInfo: ItemInfo = { - contentRange: first.contentRange.union(last.contentRange), - domain: first.domain.union(last.domain), - leadingDelimiterRange: first.leadingDelimiterRange, - trailingDelimiterRange: last.trailingDelimiterRange, - }; - - // We have both leading and trailing delimiter ranges - // The leading one is longer/more specific so prefer to use that for removal. - const removalRange = - itemInfo.leadingDelimiterRange != null && - itemInfo.trailingDelimiterRange != null && - getRangeLength(target.editor, itemInfo.leadingDelimiterRange) > - getRangeLength(target.editor, itemInfo.trailingDelimiterRange) - ? itemInfo.contentRange.union(itemInfo.leadingDelimiterRange) - : undefined; - - return this.itemInfoToTarget(target, itemInfo, removalRange); - } - - private itemInfoToTarget( - target: Target, - itemInfo: ItemInfo, - removalRange?: Range, - ) { - const insertionDelimiter = getInsertionDelimiter( - itemInfo.leadingDelimiterRange, - itemInfo.trailingDelimiterRange, - ); - return new ScopeTypeTarget({ - scopeTypeType: this.modifier.scopeType.type as SimpleScopeTypeType, - editor: target.editor, - isReversed: target.isReversed, - contentRange: itemInfo.contentRange, - insertionDelimiter, - leadingDelimiterRange: itemInfo.leadingDelimiterRange, - trailingDelimiterRange: itemInfo.trailingDelimiterRange, - removalRange, - }); - } -} - -function getInsertionDelimiter( - leadingDelimiterRange?: Range, - trailingDelimiterRange?: Range, -): string { - return (leadingDelimiterRange != null && - !leadingDelimiterRange.isSingleLine) || - (trailingDelimiterRange != null && !trailingDelimiterRange.isSingleLine) - ? ",\n" - : ", "; -} - -/** Filter item infos by content range and domain intersection */ -function filterItemInfos(target: Target, itemInfos: ItemInfo[]): ItemInfo[] { - return itemInfos.filter( - (itemInfo) => itemInfo.domain.intersection(target.contentRange) != null, - ); -} - -function getItemInfosForIterationScope( - modifierStageFactory: ModifierStageFactory, - target: Target, -) { - const { range, boundary } = getIterationScope(modifierStageFactory, target); - return getItemsInRange(target.editor, range, boundary); -} - -function getItemsInRange( - editor: TextEditor, - interior: Range, - boundary?: [Range, Range], -): ItemInfo[] { - const tokens = tokenizeRange(editor, interior, boundary); - const itemInfos: ItemInfo[] = []; - - tokens.forEach((token, i) => { - if (token.type === "separator" || token.type === "boundary") { - return; - } - - const leadingDelimiterRange = (() => { - if (tokens[i - 2]?.type === "item") { - return new Range(tokens[i - 2].range.end, token.range.start); - } - if (tokens[i - 1]?.type === "separator") { - return new Range(tokens[i - 1].range.start, token.range.start); - } - return undefined; - })(); - - const trailingDelimiterRange = (() => { - if (tokens[i + 2]?.type === "item") { - return new Range(token.range.end, tokens[i + 2].range.start); - } - if (tokens[i + 1]?.type === "separator") { - return new Range(token.range.end, tokens[i + 1].range.end); - } - return undefined; - })(); - - // Leading boundary and separator are excluded - const domainStart = - tokens[i - 1]?.type === "boundary" || tokens[i - 1]?.type === "separator" - ? tokens[i - 1].range.end - : token.range.start; - - // Trailing boundary and separator are excluded - const domainEnd = - tokens[i + 1]?.type === "boundary" || tokens[i + 1]?.type === "separator" - ? tokens[i + 1].range.start - : token.range.end; - - itemInfos.push({ - contentRange: token.range, - leadingDelimiterRange, - trailingDelimiterRange, - domain: new Range(domainStart, domainEnd), - }); - }); - - return itemInfos; -} - -interface ItemInfo { - contentRange: Range; - leadingDelimiterRange?: Range; - trailingDelimiterRange?: Range; - domain: Range; -} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/ItemStage/getIterationScope.ts b/packages/cursorless-engine/src/processTargets/modifiers/ItemStage/getIterationScope.ts deleted file mode 100644 index 61fb0b3166..0000000000 --- a/packages/cursorless-engine/src/processTargets/modifiers/ItemStage/getIterationScope.ts +++ /dev/null @@ -1,194 +0,0 @@ -import type { TextEditor, TextLine } from "@cursorless/common"; -import { Range, type SurroundingPairScopeType } from "@cursorless/common"; -import type { Target } from "../../../typings/target.types"; -import type { ModifierStageFactory } from "../../ModifierStageFactory"; -import { PlainTarget } from "../../targets"; -import { fitRangeToLineContent } from "../scopeHandlers"; - -/** - * Get the iteration scope range for item scope. - * Try to find non-string surrounding scope with a fallback to line content. - * @param context The stage process context - * @param target The stage target - * @returns The stage iteration scope and optional surrounding pair boundaries - */ -export function getIterationScope( - modifierStageFactory: ModifierStageFactory, - target: Target, -): { range: Range; boundary?: [Range, Range] } { - let surroundingTarget = getBoundarySurroundingPair( - modifierStageFactory, - target, - ); - - // Iteration is necessary in case of in valid surrounding targets (nested strings, content range adjacent to delimiter) - while (surroundingTarget != null) { - if ( - useInteriorOfSurroundingTarget( - modifierStageFactory, - target, - surroundingTarget, - ) - ) { - return { - range: surroundingTarget.getInterior()![0].contentRange, - boundary: getBoundary(surroundingTarget), - }; - } - - surroundingTarget = getParentSurroundingPair( - modifierStageFactory, - target.editor, - surroundingTarget, - ); - } - - // We have not found a surrounding pair. Use the line. - return { - range: fitRangeToLineContent(target.editor, target.contentRange), - }; -} - -function useInteriorOfSurroundingTarget( - modifierStageFactory: ModifierStageFactory, - target: Target, - surroundingTarget: Target, -): boolean { - const { contentRange } = target; - - if (contentRange.isEmpty) { - const [left, right] = getBoundary(surroundingTarget); - const pos = contentRange.start; - // Content range is outside adjacent to pair - if (pos.isEqual(left.start) || pos.isEqual(right.end)) { - return false; - } - const line = target.editor.document.lineAt(pos); - // Content range is just inside of opening/left delimiter - if ( - pos.isEqual(left.end) && - characterIsWhitespaceOrMissing(line, pos.character) - ) { - return false; - } - // Content range is just inside of closing/right delimiter - if ( - pos.isEqual(right.start) && - characterIsWhitespaceOrMissing(line, pos.character - 1) - ) { - return false; - } - } else { - // Content range is equal to surrounding range - if (contentRange.isRangeEqual(surroundingTarget.contentRange)) { - return false; - } - - // Content range is equal to one of the boundaries of the surrounding range - const [left, right] = getBoundary(surroundingTarget); - if (contentRange.isRangeEqual(left) || contentRange.isRangeEqual(right)) { - return false; - } - } - - // We don't look for items inside strings. - // A non-string surrounding pair that is inside a surrounding string is fine. - const surroundingStringTarget = getStringSurroundingPair( - modifierStageFactory, - surroundingTarget, - ); - if ( - surroundingStringTarget != null && - surroundingTarget.contentRange.start.isBeforeOrEqual( - surroundingStringTarget.contentRange.start, - ) - ) { - return false; - } - - return true; -} - -function getBoundary(surroundingTarget: Target): [Range, Range] { - return surroundingTarget.getBoundary()!.map((t) => t.contentRange) as [ - Range, - Range, - ]; -} - -function characterIsWhitespaceOrMissing( - line: TextLine, - index: number, -): boolean { - return ( - index < line.range.start.character || - index >= line.range.end.character || - line.text[index].trim() === "" - ); -} - -function getParentSurroundingPair( - modifierStageFactory: ModifierStageFactory, - editor: TextEditor, - target: Target, -) { - const startOffset = editor.document.offsetAt(target.contentRange.start); - // Can't have a parent; already at start of document - if (startOffset === 0) { - return undefined; - } - // Step out of this pair and see if we have a parent - const position = editor.document.positionAt(startOffset - 1); - return getBoundarySurroundingPair( - modifierStageFactory, - new PlainTarget({ - editor, - contentRange: new Range(position, position), - isReversed: false, - }), - ); -} - -function getBoundarySurroundingPair( - modifierStageFactory: ModifierStageFactory, - target: Target, -): Target | undefined { - return getSurroundingPair(modifierStageFactory, target, { - type: "surroundingPair", - delimiter: "collectionBoundary", - requireStrongContainment: true, - }); -} - -function getStringSurroundingPair( - modifierStageFactory: ModifierStageFactory, - target: Target, -): Target | undefined { - return getSurroundingPair(modifierStageFactory, target, { - type: "surroundingPair", - delimiter: "string", - requireStrongContainment: true, - }); -} - -function getSurroundingPair( - modifierStageFactory: ModifierStageFactory, - target: Target, - scopeType: SurroundingPairScopeType, -): Target | undefined { - const pairStage = modifierStageFactory.create({ - type: "containingScope", - scopeType, - }); - const targets = (() => { - try { - return pairStage.run(target); - } catch (_error) { - return []; - } - })(); - if (targets.length > 1) { - throw Error("Expected only one surrounding pair target"); - } - return targets[0]; -} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/ItemStage/index.ts b/packages/cursorless-engine/src/processTargets/modifiers/ItemStage/index.ts deleted file mode 100644 index 492aa91ffb..0000000000 --- a/packages/cursorless-engine/src/processTargets/modifiers/ItemStage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./ItemStage"; diff --git a/packages/cursorless-engine/src/processTargets/modifiers/ItemStage/tokenizeRange.ts b/packages/cursorless-engine/src/processTargets/modifiers/ItemStage/tokenizeRange.ts deleted file mode 100644 index 43269862b5..0000000000 --- a/packages/cursorless-engine/src/processTargets/modifiers/ItemStage/tokenizeRange.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { TextEditor } from "@cursorless/common"; -import { Range } from "@cursorless/common"; - -/** - * Given the iteration scope, returns a list of "tokens" within that collection - * In this context, we define a "token" to be either an item in the collection, - * a delimiter or a separator. For example, if {@link interior} is a range - * containing `foo(hello), bar, whatever`, and {@link boundary} consists of - * two ranges containing `(` and `)`, then we'd return the following: - * - * ```json - * [ - * { range: "(", type: "boundary" }, - * { range: "foo(hello)", type: "item" }, - * { range: ",", type: "separator" }, - * { range: "bar", type: "item" }, - * { range: ",", type: "separator" }, - * { range: "whatever", type: "item" }, - * { range: ")", type: "boundary" }, - * ] - * ``` - * - * Where each `range` isn't actually a string, but a range whose text is the - * given string. - * @param editor The editor containing the range - * @param interior The range to look for tokens within - * @param boundary Optional boundaries for collections. [], {} - * @returns List of tokens - */ -export function tokenizeRange( - editor: TextEditor, - interior: Range, - boundary?: [Range, Range], -): Token[] { - const { document } = editor; - const text = document.getText(interior); - /** - * The interior range tokenized into delimited regions, including the delimiters themselves. For example: - * `"foo(hello), bar, whatever"` => - * `["foo", "(", "hello", ")", ",", " bar", ",", " whatever"]` - */ - const lexemes = text - // NB: Both the delimiters and the text between them are included because we - // use a capture group in this split regex - .split(/([,(){}<>[\]"'`])|(? lexeme != null && lexeme.length > 0); - const joinedLexemes = joinLexemesBySkippingMatchingPairs(lexemes); - const tokens: Token[] = []; - let offset = document.offsetAt(interior.start); - - joinedLexemes.forEach((lexeme) => { - // Whitespace found. Just skip - if (lexeme.trim().length === 0) { - offset += lexeme.length; - return; - } - - // Separator delimiter found. - if (lexeme === separator) { - tokens.push({ - type: "separator", - range: new Range( - document.positionAt(offset), - document.positionAt(offset + lexeme.length), - ), - }); - } - - // Text/item content found - else { - const offsetStart = offset + (lexeme.length - lexeme.trimStart().length); - tokens.push({ - type: "item", - range: new Range( - document.positionAt(offsetStart), - document.positionAt(offsetStart + lexeme.trim().length), - ), - }); - } - - offset += lexeme.length; - }); - - if (boundary != null) { - return [ - { type: "boundary", range: boundary[0] }, - ...tokens, - { type: "boundary", range: boundary[1] }, - ]; - } - - return tokens; -} - -/** - * Takes a list of lexemes and joins them into a list of alternating items and separators, skipping matching pairs (), {}, etc - * @param lexemes List of lexemes to operate on - * @returns List of merged lexemes. Note that its length will be less than or equal to {@link lexemes} - */ -export function joinLexemesBySkippingMatchingPairs(lexemes: string[]) { - const result: string[] = []; - /** - * The number of left delimiters minus right delimiters we've seen. If the - * balance is 0, we're at the top level of the collection, so separators are - * relevant. Otherwise we ignore separators because they're nested - */ - let delimiterBalance = 0; - /** The most recent opening delimiter we've seen */ - let openingDelimiter: string | null = null; - /** The closing delimiter we're currently looking for */ - let closingDelimiter: string | null = null; - /** - * The index in {@link lexemes} of the first lexeme in the current token we're - * merging. - */ - let startIndex: number = -1; - - lexemes.forEach((lexeme, index) => { - if (delimiterBalance > 0) { - // We are waiting for a closing delimiter - if (lexeme === closingDelimiter) { - // Closing delimiter found - --delimiterBalance; - } - // Additional opening delimiter found - else if (lexeme === openingDelimiter) { - ++delimiterBalance; - } - } - - // Starting delimiter found - // Make sure that there is a matching closing delimiter - else if ( - leftToRightMap[lexeme] != null && - lexemes.indexOf(leftToRightMap[lexeme], index + 1) > -1 - ) { - openingDelimiter = lexeme; - closingDelimiter = leftToRightMap[lexeme]; - delimiterBalance = 1; - if (startIndex < 0) { - // This is the first lexeme to be joined - startIndex = index; - } - } - - // This is the first lexeme to be joined - else if (startIndex < 0) { - startIndex = index; - } - - const isSeparator = lexeme === separator && delimiterBalance === 0; - - if (isSeparator || index === lexemes.length - 1) { - // This is the last lexeme to be joined - const endIndex = isSeparator ? index : index + 1; - result.push(lexemes.slice(startIndex, endIndex).join("")); - startIndex = -1; - if (isSeparator) { - // Add the separator itself - result.push(lexeme); - } - } - }); - - return result; -} - -const separator = ","; - -// Mapping between opening and closing delimiters -/* eslint-disable @typescript-eslint/naming-convention */ -const leftToRightMap: { [key: string]: string } = { - "(": ")", - "{": "}", - "<": ">", - "[": "]", - '"': '"', - "'": "'", - "`": "`", -}; -/* eslint-enable @typescript-eslint/naming-convention */ - -interface Token { - range: Range; - type: "item" | "separator" | "boundary"; -} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/PreferredScopeStage.ts b/packages/cursorless-engine/src/processTargets/modifiers/PreferredScopeStage.ts index b5f7d1d6a0..03e9fb7ca3 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/PreferredScopeStage.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/PreferredScopeStage.ts @@ -46,10 +46,6 @@ export class PreferredScopeStage implements ModifierStage { target.editor.document.languageId, ); - if (scopeHandler == null) { - throw Error(`Couldn't create scope handler for: ${scopeType.type}`); - } - const closestTargets = getClosestScopeTargets(target, scopeHandler); if (closestTargets == null) { diff --git a/packages/cursorless-engine/src/processTargets/modifiers/RelativeScopeStage.ts b/packages/cursorless-engine/src/processTargets/modifiers/RelativeScopeStage.ts index c43c62a006..3d91160e12 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/RelativeScopeStage.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/RelativeScopeStage.ts @@ -31,7 +31,7 @@ export class RelativeScopeStage implements ModifierStage { ) {} run(target: Target): Target[] { - const scopeHandler = this.scopeHandlerFactory.create( + const scopeHandler = this.scopeHandlerFactory.maybeCreate( this.modifier.scopeType, target.editor.document.languageId, ); diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/BaseScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/BaseScopeHandler.ts index 33c9614e9a..32daa91003 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/BaseScopeHandler.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/BaseScopeHandler.ts @@ -1,9 +1,13 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-imports -import type { Direction, ScopeType } from "@cursorless/common"; -import type { Position, TextEditor } from "@cursorless/common"; +import type { + Direction, + Position, + ScopeType, + TextEditor, +} from "@cursorless/common"; import type { TargetScope } from "./scope.types"; import type { - CustomScopeType, + ComplexScopeType, ScopeHandler, ScopeIteratorRequirements, } from "./scopeHandler.types"; @@ -22,7 +26,7 @@ const DEFAULT_REQUIREMENTS: Omit = */ export abstract class BaseScopeHandler implements ScopeHandler { public abstract readonly scopeType: ScopeType | undefined; - public abstract readonly iterationScopeType: ScopeType | CustomScopeType; + public abstract readonly iterationScopeType: ScopeType | ComplexScopeType; public readonly includeAdjacentInEvery: boolean = false; diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/BoundedScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/BoundedScopeHandler.ts index 4eacef014b..19d11caa7e 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/BoundedScopeHandler.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/BoundedScopeHandler.ts @@ -35,21 +35,24 @@ abstract class BoundedBaseScopeHandler extends BaseScopeHandler { this.targetScopeHandler = this.scopeHandlerFactory.create( this.targetScopeType, this.languageId, - )!; + ); this.surroundingPairInteriorScopeHandler = this.scopeHandlerFactory.create( { type: "surroundingPairInterior", delimiter: "any", }, this.languageId, - )!; + ); } get iterationScopeType(): ScopeType { - if (this.targetScopeHandler.iterationScopeType.type === "custom") { - throw Error( - "Iteration scope type can't be custom for BoundedBaseScopeHandler", - ); + switch (this.targetScopeHandler.iterationScopeType.type) { + case "custom": + case "fallback": + case "conditional": + throw Error( + `Iteration scope type can't be '${this.targetScopeHandler.iterationScopeType.type}' for BoundedBaseScopeHandler`, + ); } return { type: "oneOf", diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/CollectionItemScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/CollectionItemScopeHandler.ts new file mode 100644 index 0000000000..8c67ba3690 --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/CollectionItemScopeHandler.ts @@ -0,0 +1,72 @@ +import type { + Direction, + Position, + ScopeType, + TextEditor, +} from "@cursorless/common"; +import type { LanguageDefinitions } from "../../../../languages/LanguageDefinitions"; +import { BaseScopeHandler } from "../BaseScopeHandler"; +import { OneOfScopeHandler } from "../OneOfScopeHandler"; +import type { TargetScope } from "../scope.types"; +import type { + ComplexScopeType, + ScopeHandler, + ScopeIteratorRequirements, +} from "../scopeHandler.types"; +import type { ScopeHandlerFactory } from "../ScopeHandlerFactory"; +import { CollectionItemTextualScopeHandler } from "./CollectionItemTextualScopeHandler"; + +export class CollectionItemScopeHandler extends BaseScopeHandler { + public scopeType: ScopeType = { type: "collectionItem" }; + protected isHierarchical = true; + private scopeHandler: ScopeHandler; + + get iterationScopeType(): ScopeType | ComplexScopeType { + return this.scopeHandler.iterationScopeType; + } + + constructor( + scopeHandlerFactory: ScopeHandlerFactory, + languageDefinitions: LanguageDefinitions, + languageId: string, + ) { + super(); + + this.scopeHandler = (() => { + const textualScopeHandler = new CollectionItemTextualScopeHandler( + scopeHandlerFactory, + languageId, + ); + + const languageScopeHandler = languageDefinitions + .get(languageId) + ?.getScopeHandler(this.scopeType); + + if (languageScopeHandler == null) { + return textualScopeHandler; + } + + return OneOfScopeHandler.createFromScopeHandlers( + scopeHandlerFactory, + { + type: "oneOf", + scopeTypes: [ + languageScopeHandler.scopeType, + textualScopeHandler.scopeType, + ], + }, + [languageScopeHandler, textualScopeHandler], + languageId, + ); + })(); + } + + generateScopeCandidates( + editor: TextEditor, + position: Position, + direction: Direction, + hints: ScopeIteratorRequirements, + ): Iterable { + return this.scopeHandler.generateScopes(editor, position, direction, hints); + } +} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/CollectionItemTextualScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/CollectionItemTextualScopeHandler.ts new file mode 100644 index 0000000000..fb3f251e6b --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/CollectionItemTextualScopeHandler.ts @@ -0,0 +1,195 @@ +import { + type Direction, + type Position, + Range, + type ScopeType, + type TextEditor, +} from "@cursorless/common"; +import { shrinkRangeToFitContent } from "../../../../util/selectionUtils"; +import { BaseScopeHandler } from "../BaseScopeHandler"; +import { compareTargetScopes } from "../compareTargetScopes"; +import type { TargetScope } from "../scope.types"; +import type { + ComplexScopeType, + ScopeIteratorRequirements, +} from "../scopeHandler.types"; +import type { ScopeHandlerFactory } from "../ScopeHandlerFactory"; +import { OneWayNestedRangeFinder } from "../util/OneWayNestedRangeFinder"; +import { OneWayRangeFinder } from "../util/OneWayRangeFinder"; +import { collectionItemTextualIterationScopeHandler } from "./collectionItemTextualIterationScopeHandler"; +import { createTargetScope } from "./createTargetScope"; +import { getInteriorRanges } from "./getInteriorRanges"; +import { getSeparatorOccurrences } from "./getSeparatorOccurrences"; + +export class CollectionItemTextualScopeHandler extends BaseScopeHandler { + public scopeType: ScopeType = { type: "collectionItem" }; + protected isHierarchical = true; + + get iterationScopeType(): ComplexScopeType { + return collectionItemTextualIterationScopeHandler; + } + + constructor( + private scopeHandlerFactory: ScopeHandlerFactory, + private languageId: string, + ) { + super(); + } + + *generateScopeCandidates( + editor: TextEditor, + position: Position, + direction: Direction, + hints: ScopeIteratorRequirements, + ): Iterable { + const isEveryScope = hints.containment == null && hints.skipAncestorScopes; + const separatorRanges = getSeparatorOccurrences(editor.document); + const interiorRanges = getInteriorRanges( + this.scopeHandlerFactory, + this.languageId, + editor, + "collectionBoundary", + ); + const interiorRangeFinder = new OneWayNestedRangeFinder(interiorRanges); + const stringRanges = getInteriorRanges( + this.scopeHandlerFactory, + this.languageId, + editor, + "string", + ); + const stringRangeFinder = new OneWayRangeFinder(stringRanges); + const scopes: TargetScope[] = []; + const usedInteriors = new Set(); + const iterationStatesStack: IterationState[] = []; + + for (const separator of separatorRanges) { + // Separators in a string are not considered + if (stringRangeFinder.contains(separator)) { + continue; + } + + const currentIterationState = + iterationStatesStack[iterationStatesStack.length - 1]; + + // Get range for smallest containing interior + const containingInteriorRange = + interiorRangeFinder.getSmallestContaining(separator)?.range; + + // The contain range is either the interior or the line containing the separator + const containingIterationRange = + containingInteriorRange ?? + editor.document.lineAt(separator.start.line).range; + + if (currentIterationState != null) { + // The current containing iteration range is the same as the previous one. Just append delimiter. + if ( + currentIterationState.iterationRange.isRangeEqual( + containingIterationRange, + ) + ) { + currentIterationState.delimiters.push(separator); + continue; + } + + // The current containing range does not intersect previous one. Add scopes and remove state. + if (!currentIterationState.iterationRange.contains(separator)) { + this.addScopes(scopes, currentIterationState); + // Remove already added state + iterationStatesStack.pop(); + } + } + + // The current containing range is the same as the previous one. Just append delimiter. + if (iterationStatesStack.length > 0) { + const lastState = iterationStatesStack[iterationStatesStack.length - 1]; + if (lastState.iterationRange.isRangeEqual(containingIterationRange)) { + lastState.delimiters.push(separator); + continue; + } + } + + // New containing range. Add it to the list. + if (containingInteriorRange != null) { + usedInteriors.add(containingInteriorRange); + } + + iterationStatesStack.push({ + editor, + isEveryScope, + iterationRange: containingIterationRange, + delimiters: [separator], + }); + } + + for (const state of iterationStatesStack) { + this.addScopes(scopes, state); + } + + // Add interior ranges without a delimiter in them. eg: `[foo]` + for (const interior of interiorRanges) { + if (!usedInteriors.has(interior.range)) { + const range = shrinkRangeToFitContent(editor, interior.range); + if (!range.isEmpty) { + scopes.push( + createTargetScope(isEveryScope, editor, interior.range, range), + ); + } + } + } + + scopes.sort((a, b) => compareTargetScopes(direction, position, a, b)); + + yield* scopes; + } + + private addScopes(scopes: TargetScope[], state: IterationState) { + const { editor, iterationRange, isEveryScope, delimiters } = state; + + if (delimiters.length === 0) { + return; + } + + const itemRanges: Range[] = []; + + for (let i = 0; i < delimiters.length; ++i) { + const current = delimiters[i]; + + const previous = delimiters[i - 1]?.end ?? iterationRange.start; + itemRanges.push(new Range(previous, current.start)); + } + + const lastDelimiter = delimiters[delimiters.length - 1]; + itemRanges.push(new Range(lastDelimiter.end, iterationRange.end)); + + const trimmedRanges = itemRanges.map((range) => + shrinkRangeToFitContent(editor, range), + ); + + for (let i = 0; i < trimmedRanges.length; ++i) { + // Handle trailing delimiter + if ( + i === trimmedRanges.length - 1 && + editor.document.getText(trimmedRanges[i]).trim() === "" + ) { + continue; + } + scopes.push( + createTargetScope( + isEveryScope, + editor, + iterationRange, + trimmedRanges[i], + trimmedRanges[i - 1], + trimmedRanges[i + 1], + ), + ); + } + } +} + +interface IterationState { + editor: TextEditor; + iterationRange: Range; + isEveryScope: boolean; + delimiters: Range[]; +} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/collectionItemTextualIterationScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/collectionItemTextualIterationScopeHandler.ts new file mode 100644 index 0000000000..6c08500cb5 --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/collectionItemTextualIterationScopeHandler.ts @@ -0,0 +1,24 @@ +import { testRegex } from "@cursorless/common"; +import type { TargetScope } from "../scope.types"; +import type { ComplexScopeType } from "../scopeHandler.types"; +import { separatorRegex } from "./getSeparatorOccurrences"; + +export const collectionItemTextualIterationScopeHandler: ComplexScopeType = { + type: "fallback", + scopeTypes: [ + { + type: "surroundingPairInterior", + delimiter: "collectionBoundary", + }, + { + type: "conditional", + scopeType: { + type: "line", + }, + predicate: (scope: TargetScope) => { + const text = scope.editor.document.getText(scope.domain); + return testRegex(separatorRegex, text); + }, + }, + ], +}; diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/createTargetScope.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/createTargetScope.ts new file mode 100644 index 0000000000..770440a833 --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/createTargetScope.ts @@ -0,0 +1,55 @@ +import { type TextEditor, Range } from "@cursorless/common"; +import { getRangeLength } from "../../../../util/rangeUtils"; +import { ScopeTypeTarget } from "../../../targets"; +import type { TargetScope } from "../scope.types"; + +export function createTargetScope( + isEveryScope: boolean, + editor: TextEditor, + iterationRange: Range, + contentRange: Range, + previousRange?: Range, + nextRange?: Range, +): TargetScope { + const leadingDelimiterRange = + previousRange != null + ? new Range(previousRange.end, contentRange.start) + : undefined; + const trailingDelimiterRange = + nextRange != null + ? new Range(contentRange.end, nextRange.start) + : undefined; + + // We have both leading and trailing delimiter ranges + // If the leading one is longer/more specific, prefer to use that for removal; + // otherwise use undefined to fallback to the default behavior (often trailing) + const removalRange = + !isEveryScope && + leadingDelimiterRange != null && + trailingDelimiterRange != null && + getRangeLength(editor, leadingDelimiterRange) > + getRangeLength(editor, trailingDelimiterRange) + ? contentRange.union(leadingDelimiterRange) + : undefined; + + const insertionDelimiter = iterationRange.isSingleLine ? ", " : ",\n"; + + return { + editor, + domain: contentRange, + getTargets(isReversed) { + return [ + new ScopeTypeTarget({ + scopeTypeType: "collectionItem", + editor, + isReversed, + contentRange, + insertionDelimiter, + leadingDelimiterRange, + trailingDelimiterRange, + removalRange, + }), + ]; + }, + }; +} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/getInteriorRanges.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/getInteriorRanges.ts new file mode 100644 index 0000000000..fbd7a76324 --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/getInteriorRanges.ts @@ -0,0 +1,31 @@ +import { + type Range, + type SurroundingPairName, + type TextEditor, + Position, +} from "@cursorless/common"; +import type { ScopeHandlerFactory } from "../ScopeHandlerFactory"; + +export function getInteriorRanges( + scopeHandlerFactory: ScopeHandlerFactory, + languageId: string, + editor: TextEditor, + delimiter: SurroundingPairName, +): { range: Range }[] { + const scopeHandler = scopeHandlerFactory.create( + { + type: "surroundingPairInterior", + delimiter, + }, + languageId, + ); + + return Array.from( + scopeHandler.generateScopes(editor, new Position(0, 0), "forward", { + containment: undefined, + skipAncestorScopes: false, + includeDescendantScopes: true, + }), + (scope) => ({ range: scope.domain }), + ); +} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/getSeparatorOccurrences.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/getSeparatorOccurrences.ts new file mode 100644 index 0000000000..5d78f70d59 --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/getSeparatorOccurrences.ts @@ -0,0 +1,16 @@ +import { matchAll, Range, type TextDocument } from "@cursorless/common"; + +const separator = ","; + +export const separatorRegex = new RegExp(separator, "g"); + +export function getSeparatorOccurrences(document: TextDocument): Range[] { + const text = document.getText(); + + return matchAll(text, separatorRegex, (match): Range => { + return new Range( + document.positionAt(match.index!), + document.positionAt(match.index! + match[0].length), + ); + }); +} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ConditionalScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ConditionalScopeHandler.ts new file mode 100644 index 0000000000..139f749626 --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ConditionalScopeHandler.ts @@ -0,0 +1,51 @@ +import { + NoContainingScopeError, + type Direction, + type Position, + type ScopeType, + type TextEditor, +} from "@cursorless/common"; +import { ifilter } from "itertools"; +import { BaseScopeHandler } from "./BaseScopeHandler"; +import type { TargetScope } from "./scope.types"; +import type { + ConditionalScopeType, + ScopeIteratorRequirements, +} from "./scopeHandler.types"; +import type { ScopeHandlerFactory } from "./ScopeHandlerFactory"; + +export class ConditionalScopeHandler extends BaseScopeHandler { + public scopeType = undefined; + protected isHierarchical = true; + + constructor( + public scopeHandlerFactory: ScopeHandlerFactory, + private conditionalScopeType: ConditionalScopeType, + private languageId: string, + ) { + super(); + } + + get iterationScopeType(): ScopeType { + throw new NoContainingScopeError( + "Iteration scope for ConditionalScopeHandler", + ); + } + + generateScopeCandidates( + editor: TextEditor, + position: Position, + direction: Direction, + hints: ScopeIteratorRequirements, + ): Iterable { + const scopeHandler = this.scopeHandlerFactory.create( + this.conditionalScopeType.scopeType, + this.languageId, + ); + + return ifilter( + scopeHandler.generateScopes(editor, position, direction, hints), + this.conditionalScopeType.predicate, + ); + } +} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/FallbackScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/FallbackScopeHandler.ts new file mode 100644 index 0000000000..869d4993b3 --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/FallbackScopeHandler.ts @@ -0,0 +1,50 @@ +import { + NoContainingScopeError, + type Direction, + type Position, + type ScopeType, + type TextEditor, +} from "@cursorless/common"; +import { BaseScopeHandler } from "./BaseScopeHandler"; +import type { ScopeHandlerFactory } from "./ScopeHandlerFactory"; +import type { TargetScope } from "./scope.types"; +import type { + FallbackScopeType, + ScopeHandler, + ScopeIteratorRequirements, +} from "./scopeHandler.types"; + +export class FallbackScopeHandler extends BaseScopeHandler { + public scopeType = undefined; + protected isHierarchical = true; + + get iterationScopeType(): ScopeType { + throw new NoContainingScopeError( + "Iteration scope for FallbackScopeHandler", + ); + } + + constructor( + public scopeHandlerFactory: ScopeHandlerFactory, + private fallbackScopeType: FallbackScopeType, + private languageId: string, + ) { + super(); + } + + *generateScopeCandidates( + editor: TextEditor, + position: Position, + direction: Direction, + hints: ScopeIteratorRequirements, + ): Iterable { + const scopeHandlers: ScopeHandler[] = this.fallbackScopeType.scopeTypes.map( + (scopeType) => + this.scopeHandlerFactory.create(scopeType, this.languageId), + ); + + for (const scopeHandler of scopeHandlers) { + yield* scopeHandler.generateScopes(editor, position, direction, hints); + } + } +} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/IteratorInfo.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/IteratorInfo.ts index 582c45375f..deafe0ad4a 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/IteratorInfo.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/IteratorInfo.ts @@ -5,6 +5,7 @@ interface IteratorInfo { iterator: Iterator; value: T; + index: number; } /** @@ -19,7 +20,7 @@ interface IteratorInfo { export function getInitialIteratorInfos( iterators: Iterator[], ): IteratorInfo[] { - return iterators.flatMap((iterator) => { + return iterators.flatMap((iterator, i) => { const { value, done } = iterator.next(); return done ? [] @@ -27,6 +28,7 @@ export function getInitialIteratorInfos( { iterator, value, + index: i, }, ]; }); @@ -47,10 +49,10 @@ export function advanceIteratorsUntil( criterion: (arg: T) => boolean, ): IteratorInfo[] { return iteratorInfos.flatMap((iteratorInfo) => { - const { iterator } = iteratorInfo; + const { iterator, index } = iteratorInfo; let { value } = iteratorInfo; - let done: boolean | undefined = false; + while (!done && !criterion(value)) { ({ value, done } = iterator.next()); } @@ -59,6 +61,6 @@ export function advanceIteratorsUntil( return []; } - return [{ iterator, value }]; + return [{ iterator, value, index }]; }); } diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/NestedScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/NestedScopeHandler.ts index f4a4f18780..95af8c8675 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/NestedScopeHandler.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/NestedScopeHandler.ts @@ -63,7 +63,7 @@ export abstract class NestedScopeHandler extends BaseScopeHandler { this._searchScopeHandler = this.scopeHandlerFactory.create( this.searchScopeType, this.languageId, - )!; + ); } return this._searchScopeHandler; diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/OneOfScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/OneOfScopeHandler.ts index d8ad2cdb52..c4febc6165 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/OneOfScopeHandler.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/OneOfScopeHandler.ts @@ -17,6 +17,8 @@ import type { export class OneOfScopeHandler extends BaseScopeHandler { protected isHierarchical = true; + private iterationScopeHandler: OneOfScopeHandler | undefined; + private lastYieldedIndex: number | undefined; static create( scopeHandlerFactory: ScopeHandlerFactory, @@ -24,43 +26,58 @@ export class OneOfScopeHandler extends BaseScopeHandler { languageId: string, ): ScopeHandler { const scopeHandlers: ScopeHandler[] = scopeType.scopeTypes.map( - (scopeType) => { - const handler = scopeHandlerFactory.create(scopeType, languageId); - if (handler == null) { - throw new Error(`No available scope handler for '${scopeType.type}'`); - } - return handler; - }, + (scopeType) => scopeHandlerFactory.create(scopeType, languageId), ); - const iterationScopeType = (): CustomScopeType => ({ - type: "custom", - scopeHandler: new OneOfScopeHandler( + return this.createFromScopeHandlers( + scopeHandlerFactory, + scopeType, + scopeHandlers, + languageId, + ); + } + + static createFromScopeHandlers( + scopeHandlerFactory: ScopeHandlerFactory, + scopeType: OneOfScopeType, + scopeHandlers: ScopeHandler[], + languageId: string, + ): ScopeHandler { + const getIterationScopeHandler = () => + new OneOfScopeHandler( undefined, - scopeHandlers.map( - (scopeHandler) => - scopeHandlerFactory.create( - scopeHandler.iterationScopeType, - languageId, - )!, + scopeHandlers.map((scopeHandler) => + scopeHandlerFactory.create( + scopeHandler.iterationScopeType, + languageId, + ), ), () => { throw new Error("Not implemented"); }, - ), - }); + ); - return new OneOfScopeHandler(scopeType, scopeHandlers, iterationScopeType); + return new OneOfScopeHandler( + scopeType, + scopeHandlers, + getIterationScopeHandler, + ); } get iterationScopeType(): CustomScopeType { - return this.getIterationScopeType(); + if (this.iterationScopeHandler == null) { + this.iterationScopeHandler = this.getIterationScopeHandler(); + } + return { + type: "custom", + scopeHandler: this.iterationScopeHandler, + }; } private constructor( public readonly scopeType: OneOfScopeType | undefined, private scopeHandlers: ScopeHandler[], - private getIterationScopeType: () => CustomScopeType, + private getIterationScopeHandler: () => OneOfScopeHandler, ) { super(); } @@ -71,6 +88,14 @@ export class OneOfScopeHandler extends BaseScopeHandler { direction: Direction, hints: ScopeIteratorRequirements, ): Iterable { + // If we have used the iteration scope handler, we only want to yield from its handler. + if (this.iterationScopeHandler?.lastYieldedIndex != null) { + const handlerIndex = this.iterationScopeHandler.lastYieldedIndex; + const handler = this.scopeHandlers[handlerIndex]; + yield* handler.generateScopes(editor, position, direction, hints); + return; + } + const iterators = this.scopeHandlers.map((scopeHandler) => scopeHandler .generateScopes(editor, position, direction, hints) @@ -85,7 +110,9 @@ export class OneOfScopeHandler extends BaseScopeHandler { ); // Pick minimum scope according to canonical scope ordering - const currentScope = iteratorInfos[0].value; + const iteratorInfo = iteratorInfos[0]; + const currentScope = iteratorInfo.value; + this.lastYieldedIndex = iteratorInfo.index; yield currentScope; diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ScopeHandlerFactory.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ScopeHandlerFactory.ts index 7fe8f9d18a..0ae0823266 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ScopeHandlerFactory.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ScopeHandlerFactory.ts @@ -1,9 +1,14 @@ import type { ScopeType } from "@cursorless/common"; -import type { CustomScopeType, ScopeHandler } from "./scopeHandler.types"; +import type { ComplexScopeType, ScopeHandler } from "./scopeHandler.types"; export interface ScopeHandlerFactory { - create( - scopeType: ScopeType | CustomScopeType, + maybeCreate( + scopeType: ScopeType | ComplexScopeType, languageId: string, ): ScopeHandler | undefined; + + create( + scopeType: ScopeType | ComplexScopeType, + languageId: string, + ): ScopeHandler; } diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ScopeHandlerFactoryImpl.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ScopeHandlerFactoryImpl.ts index 0d4b4056d7..c8501df9ca 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ScopeHandlerFactoryImpl.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/ScopeHandlerFactoryImpl.ts @@ -5,7 +5,10 @@ import { BoundedParagraphScopeHandler, } from "./BoundedScopeHandler"; import { CharacterScopeHandler } from "./CharacterScopeHandler"; +import { CollectionItemScopeHandler } from "./CollectionItemScopeHandler/CollectionItemScopeHandler"; +import { ConditionalScopeHandler } from "./ConditionalScopeHandler"; import { DocumentScopeHandler } from "./DocumentScopeHandler"; +import { FallbackScopeHandler } from "./FallbackScopeHandler"; import { IdentifierScopeHandler } from "./IdentifierScopeHandler"; import { LineScopeHandler } from "./LineScopeHandler"; import { OneOfScopeHandler } from "./OneOfScopeHandler"; @@ -24,7 +27,7 @@ import { } from "./SurroundingPairScopeHandler"; import { TokenScopeHandler } from "./TokenScopeHandler"; import { WordScopeHandler } from "./WordScopeHandler/WordScopeHandler"; -import type { CustomScopeType, ScopeHandler } from "./scopeHandler.types"; +import type { ComplexScopeType, ScopeHandler } from "./scopeHandler.types"; /** * Returns a scope handler for the given scope type and language id, or @@ -45,11 +48,12 @@ import type { CustomScopeType, ScopeHandler } from "./scopeHandler.types"; */ export class ScopeHandlerFactoryImpl implements ScopeHandlerFactory { constructor(private languageDefinitions: LanguageDefinitions) { + this.maybeCreate = this.maybeCreate.bind(this); this.create = this.create.bind(this); } - create( - scopeType: ScopeType | CustomScopeType, + maybeCreate( + scopeType: ScopeType | ComplexScopeType, languageId: string, ): ScopeHandler | undefined { switch (scopeType.type) { @@ -71,8 +75,6 @@ export class ScopeHandlerFactoryImpl implements ScopeHandlerFactory { return new BoundedParagraphScopeHandler(this, scopeType, languageId); case "document": return new DocumentScopeHandler(scopeType, languageId); - case "oneOf": - return OneOfScopeHandler.create(this, scopeType, languageId); case "nonWhitespaceSequence": return new NonWhitespaceSequenceScopeHandler( this, @@ -91,6 +93,12 @@ export class ScopeHandlerFactoryImpl implements ScopeHandlerFactory { return new CustomRegexScopeHandler(this, scopeType, languageId); case "glyph": return new GlyphScopeHandler(this, scopeType, languageId); + case "collectionItem": + return new CollectionItemScopeHandler( + this, + this.languageDefinitions, + languageId, + ); case "surroundingPair": return new SurroundingPairScopeHandler( this.languageDefinitions, @@ -105,6 +113,12 @@ export class ScopeHandlerFactoryImpl implements ScopeHandlerFactory { ); case "custom": return scopeType.scopeHandler; + case "oneOf": + return OneOfScopeHandler.create(this, scopeType, languageId); + case "fallback": + return new FallbackScopeHandler(this, scopeType, languageId); + case "conditional": + return new ConditionalScopeHandler(this, scopeType, languageId); case "instance": // Handle instance pseudoscope with its own special modifier throw Error("Unexpected scope type 'instance'"); @@ -114,4 +128,15 @@ export class ScopeHandlerFactoryImpl implements ScopeHandlerFactory { ?.getScopeHandler(scopeType); } } + + create( + scopeType: ScopeType | ComplexScopeType, + languageId: string, + ): ScopeHandler { + const handler = this.maybeCreate(scopeType, languageId); + if (handler == null) { + throw new Error(`Couldn't create scope handler for '${scopeType.type}'`); + } + return handler; + } } diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/SurroundingPairInteriorScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/SurroundingPairInteriorScopeHandler.ts index f6be0ac0df..feb91a40a5 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/SurroundingPairInteriorScopeHandler.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/SurroundingPairInteriorScopeHandler.ts @@ -24,7 +24,7 @@ export class SurroundingPairInteriorScopeHandler extends BaseScopeHandler { requireStrongContainment: true, }, this.languageId, - )!; + ); } get iterationScopeType() { diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/delimiterMaps.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/delimiterMaps.ts index 3b55057c5a..d82149625f 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/delimiterMaps.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/delimiterMaps.ts @@ -14,17 +14,6 @@ interface Options { * salient example is strings. */ isSingleLine?: boolean; - - /** - * This field can be used to force us to treat the side of the delimiter as - * unknown. We usually infer this from the fact that the opening and closing - * delimiters are the same, but in some cases they are different, but the side - * is actually still unknown. In particular, this is the case for Python - * string prefixes, where if we see the prefix it doesn't necessarily mean - * that it's an opening delimiter. For example, in `" r"`, note that the `r` - * is just part of the string, not a prefix of the opening delimiter. - */ - isUnknownSide?: boolean; } type DelimiterMap = Record< @@ -52,38 +41,6 @@ const delimiterToText: DelimiterMap = Object.freeze({ squareBrackets: ["[", "]"], }); -// https://docs.python.org/3/reference/lexical_analysis.html#string-and-bytes-literals -const pythonPrefixes = [ - // Base case without a prefix - "", - // string prefixes - "r", - "u", - "R", - "U", - "f", - "F", - "fr", - "Fr", - "fR", - "FR", - "rf", - "rF", - "Rf", - "RF", - // byte prefixes - "b", - "B", - "br", - "Br", - "bR", - "BR", - "rb", - "rB", - "Rb", - "RB", -]; - // FIXME: Probably remove these as part of // https://github.com/cursorless-dev/cursorless/issues/1812#issuecomment-1691493746 const delimiterToTextOverrides: Record> = { @@ -102,26 +59,8 @@ const delimiterToTextOverrides: Record> = { }, python: { - singleQuotes: [ - pythonPrefixes.map((prefix) => `${prefix}'`), - "'", - { isSingleLine: true, isUnknownSide: true }, - ], - doubleQuotes: [ - pythonPrefixes.map((prefix) => `${prefix}"`), - '"', - { isSingleLine: true, isUnknownSide: true }, - ], - tripleSingleQuotes: [ - pythonPrefixes.map((prefix) => `${prefix}'''`), - "'''", - { isUnknownSide: true }, - ], - tripleDoubleQuotes: [ - pythonPrefixes.map((prefix) => `${prefix}"""`), - '"""', - { isUnknownSide: true }, - ], + tripleSingleQuotes: ["'''", "'''"], + tripleDoubleQuotes: ['"""', '"""'], }, ruby: { diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getDelimiterOccurrences.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getDelimiterOccurrences.ts index 18f42e3845..80e8a4a1af 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getDelimiterOccurrences.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getDelimiterOccurrences.ts @@ -1,5 +1,8 @@ -import { matchAll, Range, type TextDocument } from "@cursorless/common"; +import { matchAllIterator, Range, type TextDocument } from "@cursorless/common"; import type { LanguageDefinition } from "../../../../languages/LanguageDefinition"; +import type { QueryCapture } from "../../../../languages/TreeSitterQuery/QueryCapture"; +import { OneWayNestedRangeFinder } from "../util/OneWayNestedRangeFinder"; +import { OneWayRangeFinder } from "../util/OneWayRangeFinder"; import { getDelimiterRegex } from "./getDelimiterRegex"; import type { DelimiterOccurrence, IndividualDelimiter } from "./types"; @@ -20,12 +23,16 @@ export function getDelimiterOccurrences( return []; } - const delimiterRegex = getDelimiterRegex(individualDelimiters); - - const disqualifyDelimiters = - languageDefinition?.getCaptures(document, "disqualifyDelimiter") ?? []; - const textFragments = - languageDefinition?.getCaptures(document, "textFragment") ?? []; + const capturesMap = languageDefinition?.getCapturesMap(document) ?? {}; + const disqualifyDelimiters = new OneWayRangeFinder( + getSortedCaptures(capturesMap.disqualifyDelimiter), + ); + const pairDelimiters = new OneWayRangeFinder( + getSortedCaptures(capturesMap.pairDelimiter), + ); + const textFragments = new OneWayNestedRangeFinder( + getSortedCaptures(capturesMap.textFragment), + ); const delimiterTextToDelimiterInfoMap = Object.fromEntries( individualDelimiters.map((individualDelimiter) => [ @@ -34,28 +41,48 @@ export function getDelimiterOccurrences( ]), ); - const text = document.getText(); + const regexMatches = matchAllIterator( + document.getText(), + getDelimiterRegex(individualDelimiters), + ); + + const results: DelimiterOccurrence[] = []; - return matchAll(text, delimiterRegex, (match): DelimiterOccurrence => { + for (const match of regexMatches) { const text = match[0]; - const range = new Range( + const matchRange = new Range( document.positionAt(match.index!), document.positionAt(match.index! + text.length), ); - const isDisqualified = disqualifyDelimiters.some( - (c) => c.range.contains(range) && !c.hasError(), + const disqualifiedDelimiter = ifNoErrors( + disqualifyDelimiters.getContaining(matchRange), ); - const textFragmentRange = textFragments.find((c) => - c.range.contains(range), - )?.range; + if (disqualifiedDelimiter != null) { + continue; + } - return { + results.push({ delimiterInfo: delimiterTextToDelimiterInfoMap[text], - isDisqualified, - textFragmentRange, - range, - }; - }); + textFragmentRange: textFragments.getSmallestContaining(matchRange)?.range, + range: + ifNoErrors(pairDelimiters.getContaining(matchRange))?.range ?? + matchRange, + }); + } + + return results; +} + +function ifNoErrors(capture?: QueryCapture): QueryCapture | undefined { + return capture != null && !capture.hasError() ? capture : undefined; +} + +function getSortedCaptures(items?: QueryCapture[]): QueryCapture[] { + if (items == null) { + return []; + } + items.sort((a, b) => a.range.start.compareTo(b.range.start)); + return items; } diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getIndividualDelimiters.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getIndividualDelimiters.ts index c75118fff7..4e51ce59ce 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getIndividualDelimiters.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getIndividualDelimiters.ts @@ -21,7 +21,7 @@ import type { IndividualDelimiter } from "./types"; export function getIndividualDelimiters( delimiter: SurroundingPairName, languageId: string, -) { +): IndividualDelimiter[] { const delimiters = complexDelimiterMap[ delimiter as ComplexSurroundingPairName ] ?? [delimiter]; @@ -36,7 +36,7 @@ function getSimpleIndividualDelimiters( return delimiters.flatMap((delimiterName) => { const [leftDelimiter, rightDelimiter, options] = delimiterToText[delimiterName]; - const { isSingleLine = false, isUnknownSide = false } = options ?? {}; + const { isSingleLine = false } = options ?? {}; // Allow for the fact that a delimiter might have multiple ways to indicate // its opening / closing @@ -54,9 +54,6 @@ function getSimpleIndividualDelimiters( const isRight = rightDelimiters.includes(text); const side = (() => { - if (isUnknownSide) { - return "unknown"; - } if (isLeft && !isRight) { return "left"; } diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getSurroundingPairOccurrences.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getSurroundingPairOccurrences.ts index b1a573877c..027476ab7a 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getSurroundingPairOccurrences.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getSurroundingPairOccurrences.ts @@ -25,15 +25,10 @@ export function getSurroundingPairOccurrences( for (const occurrence of delimiterOccurrences) { const { delimiterInfo: { delimiterName, side, isSingleLine }, - isDisqualified, textFragmentRange, range, } = occurrence; - if (isDisqualified) { - continue; - } - let openingDelimiters = openingDelimiterOccurrences.get(delimiterName); if (isSingleLine) { diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/types.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/types.ts index 923f0e250a..253b8debdd 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/types.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/types.ts @@ -48,18 +48,11 @@ export interface DelimiterOccurrence { */ range: Range; - /** - * If `true`, this delimiter is disqualified from being considered as a - * surrounding pair delimiter, because it has been tagged as such based on a - * parse tree query. - */ - isDisqualified: boolean; - /** * If the delimiter is part of a text fragment, eg a string or comment, this * will be the range of the text fragment. */ - textFragmentRange?: Range; + textFragmentRange: Range | undefined; } /** diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/BaseTreeSitterScopeHandler.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/BaseTreeSitterScopeHandler.ts index af4128487d..7c659d3c85 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/BaseTreeSitterScopeHandler.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/BaseTreeSitterScopeHandler.ts @@ -3,12 +3,13 @@ import { showError } from "@cursorless/common"; import { uniqWith } from "lodash-es"; import type { TreeSitterQuery } from "../../../../languages/TreeSitterQuery"; import type { QueryMatch } from "../../../../languages/TreeSitterQuery/QueryCapture"; +import { ide } from "../../../../singletons/ide.singleton"; import { BaseScopeHandler } from "../BaseScopeHandler"; import { compareTargetScopes } from "../compareTargetScopes"; import type { TargetScope } from "../scope.types"; import type { ScopeIteratorRequirements } from "../scopeHandler.types"; +import { getQuerySearchRange } from "./getQuerySearchRange"; import { mergeAdjacentBy } from "./mergeAdjacentBy"; -import { ide } from "../../../../singletons/ide.singleton"; /** Base scope handler to use for both tree-sitter scopes and their iteration scopes */ export abstract class BaseTreeSitterScopeHandler extends BaseScopeHandler { @@ -20,18 +21,20 @@ export abstract class BaseTreeSitterScopeHandler extends BaseScopeHandler { editor: TextEditor, position: Position, direction: Direction, - _hints: ScopeIteratorRequirements, + hints: ScopeIteratorRequirements, ): Iterable { const { document } = editor; - // Due to a tree-sitter bug, we generate all scopes from the entire file - // instead of using `_hints` to restrict the search range to scopes we care - // about. The actual scopes yielded to the client are filtered by - // `BaseScopeHandler` anyway, so there's no impact on correctness, just - // performance. We'd like to roll this back; see #1769. + /** Narrow the range within which tree-sitter searches, for performance */ + const { start, end } = getQuerySearchRange( + document, + position, + direction, + hints, + ); const scopes = this.query - .matches(document) + .matches(document, start, end) .map((match) => this.matchToScope(editor, match)) .filter((scope): scope is ExtendedTargetScope => scope != null) .sort((a, b) => compareTargetScopes(direction, position, a, b)); diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/getQuerySearchRange.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/getQuerySearchRange.ts new file mode 100644 index 0000000000..60b64cc632 --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/getQuerySearchRange.ts @@ -0,0 +1,89 @@ +import type { Direction, Position, TextDocument } from "@cursorless/common"; +import type { + ContainmentPolicy, + ScopeIteratorRequirements, +} from "../scopeHandler.types"; + +/** + * Constructs a range to pass to {@link Query.matches} to find scopes. Note + * that {@link Query.matches} will only return scopes that have non-empty + * intersection with this range. Also note that the base + * {@link BaseScopeHandler.generateScopes} will filter out any extra scopes + * that we yield, so we don't need to be totally precise. + * + * @returns Range to pass to {@link Query.matches} + */ +export function getQuerySearchRange( + document: TextDocument, + position: Position, + direction: Direction, + { + containment, + distalPosition, + allowAdjacentScopes, + }: ScopeIteratorRequirements, +) { + const { start, end } = getQuerySearchRangeCore( + document.offsetAt(position), + document.offsetAt(distalPosition), + direction, + containment, + allowAdjacentScopes, + ); + + return { + start: document.positionAt(start), + end: document.positionAt(end), + }; +} + +/** Helper function for {@link getQuerySearchRange} */ +function getQuerySearchRangeCore( + offset: number, + distalOffset: number, + direction: Direction, + containment: ContainmentPolicy | null, + allowAdjacentScopes: boolean, +) { + /** + * If we allow adjacent scopes, we need to shift some of our offsets by one + * character + */ + const adjacentShift = allowAdjacentScopes ? 1 : 0; + + if (containment === "required") { + // If containment is required, we smear the position left and right by one + // character so that we have a non-empty intersection with any scope that + // touches position. Note that we can skip shifting the initial position + // if we disallow adjacent scopes. + return direction === "forward" + ? { + start: offset - adjacentShift, + end: offset + 1, + } + : { + start: offset - 1, + end: offset + adjacentShift, + }; + } + + // If containment is disallowed, we can shift the position forward by a + // character to avoid matching scopes that touch position. Otherwise, we + // shift the position backward by a character to ensure we get scopes that + // touch position, if we allow adjacent scopes. + const proximalShift = containment === "disallowed" ? 1 : -adjacentShift; + + // FIXME: Don't go all the way to end of document when there is no + // distalOffset? Seems wasteful to query all the way to end of document for + // something like "next funk". Might be better to start smaller and + // exponentially grow + return direction === "forward" + ? { + start: offset + proximalShift, + end: distalOffset + adjacentShift, + } + : { + start: distalOffset - adjacentShift, + end: offset - proximalShift, + }; +} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/scopeHandler.types.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/scopeHandler.types.ts index 730adf60e4..0242b87780 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/scopeHandler.types.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/scopeHandler.types.ts @@ -1,5 +1,9 @@ -import type { Position, TextEditor } from "@cursorless/common"; -import type { Direction, ScopeType } from "@cursorless/common"; +import type { + Direction, + Position, + ScopeType, + TextEditor, +} from "@cursorless/common"; import type { TargetScope } from "./scope.types"; /** @@ -12,6 +16,30 @@ export interface CustomScopeType { scopeHandler: ScopeHandler; } +/** + * Used to handle fallback scope types. The scope types are yielded in specified + * order. + */ +export interface FallbackScopeType { + type: "fallback"; + scopeTypes: (ScopeType | ComplexScopeType)[]; +} + +/** + * Used to handle conditional scope types. The predicate determines if the + * scope should be yielded or not. + */ +export interface ConditionalScopeType { + type: "conditional"; + scopeType: ScopeType; + predicate: (scope: TargetScope) => boolean; +} + +export type ComplexScopeType = + | CustomScopeType + | FallbackScopeType + | ConditionalScopeType; + /** * Represents a scope type. The functions in this interface allow us to find * specific instances of the given scope type in a document. These functions are @@ -44,7 +72,7 @@ export interface ScopeHandler { * scope type will be used when the input target has no explicit range (ie * {@link Target.hasExplicitRange} is `false`). */ - readonly iterationScopeType: ScopeType | CustomScopeType; + readonly iterationScopeType: ScopeType | ComplexScopeType; /** * Returns an iterable of scopes meeting the requirements in diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/util/OneWayNestedRangeFinder.test.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/util/OneWayNestedRangeFinder.test.ts new file mode 100644 index 0000000000..3c2c86d3b5 --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/util/OneWayNestedRangeFinder.test.ts @@ -0,0 +1,30 @@ +import { Range } from "@cursorless/common"; +import { OneWayNestedRangeFinder } from "./OneWayNestedRangeFinder"; +import assert from "assert"; + +const items = [ + { range: new Range(0, 0, 0, 5) }, + { range: new Range(0, 1, 0, 4) }, + { range: new Range(0, 2, 0, 3) }, + { range: new Range(0, 6, 0, 8) }, +]; + +suite("OneWayNestedRangeFinder", () => { + test("getSmallestContaining 1", () => { + const finder = new OneWayNestedRangeFinder(items); + const actual = finder.getSmallestContaining(new Range(0, 2, 0, 2)); + assert.equal(actual?.range.toString(), new Range(0, 2, 0, 3).toString()); + }); + + test("getSmallestContaining 2", () => { + const finder = new OneWayNestedRangeFinder(items); + const actual = finder.getSmallestContaining(new Range(0, 7, 0, 7)); + assert.equal(actual?.range.toString(), new Range(0, 6, 0, 8).toString()); + }); + + test("getSmallestContaining 3", () => { + const finder = new OneWayNestedRangeFinder(items); + const actual = finder.getSmallestContaining(new Range(0, 0, 0, 0)); + assert.equal(actual?.range.toString(), new Range(0, 0, 0, 5).toString()); + }); +}); diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/util/OneWayNestedRangeFinder.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/util/OneWayNestedRangeFinder.ts new file mode 100644 index 0000000000..d001dcf264 --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/util/OneWayNestedRangeFinder.ts @@ -0,0 +1,76 @@ +import type { Range } from "@cursorless/common"; +import { OneWayRangeFinder } from "./OneWayRangeFinder"; + +/** + * Given a list of ranges (the haystack), allows the client to search for smallest range containing a range (the needle). + * Has the following requirements: + * - the haystack must be sorted in document order + * - **the needles must be in document order as well**. This enables us to avoid backtracking as you search for a sequence of items. + * - the haystack entries **may** be nested, but one haystack entry cannot partially contain another + */ +export class OneWayNestedRangeFinder { + private children: OneWayRangeFinder>; + + /** + * @param items The items to search in. Must be sorted in document order. + */ + constructor(items: T[]) { + this.children = createNodes(items); + } + + getSmallestContaining(separator: Range): T | undefined { + return this.children + .getContaining(separator) + ?.getSmallestContaining(separator); + } +} + +function createNodes( + items: T[], +): OneWayRangeFinder> { + const results: RangeLookupTreeNode[] = []; + const parents: RangeLookupTreeNode[] = []; + + for (const item of items) { + const node = new RangeLookupTreeNode(item); + + while ( + parents.length > 0 && + !parents[parents.length - 1].range.contains(item.range) + ) { + parents.pop(); + } + + const parent = parents[parents.length - 1]; + + if (parent != null) { + parent.children.add(node); + } else { + results.push(node); + } + + parents.push(node); + } + + return new OneWayRangeFinder(results); +} + +class RangeLookupTreeNode { + public children: OneWayRangeFinder>; + + constructor(private item: T) { + this.children = new OneWayRangeFinder([]); + } + + get range(): Range { + return this.item.range; + } + + getSmallestContaining(range: Range): T { + const child = this.children + .getContaining(range) + ?.getSmallestContaining(range); + + return child ?? this.item; + } +} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/util/OneWayRangeFinder.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/util/OneWayRangeFinder.ts new file mode 100644 index 0000000000..c0b9b6e188 --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/util/OneWayRangeFinder.ts @@ -0,0 +1,52 @@ +import type { Range } from "@cursorless/common"; + +/** + * Given a list of ranges (the haystack), allows the client to search for a sequence of ranges (the needles). + * Has the following requirements: + * - the haystack must be sorted in document order + * - **the needles must be in document order as well**. This enables us to avoid backtracking as you search for a sequence of items. + * - the haystack entries must not overlap. Adjacent is fine + */ +export class OneWayRangeFinder { + private index = 0; + + /** + * @param items The items to search in. Must be sorted in document order. + */ + constructor(private items: T[]) {} + + add(item: T) { + this.items.push(item); + } + + contains(searchItem: Range): boolean { + return this.advance(searchItem); + } + + getContaining(searchItem: Range): T | undefined { + if (this.advance(searchItem)) { + return this.items[this.index]; + } + + return undefined; + } + + private advance(searchItem: Range): boolean { + while (this.index < this.items.length) { + const range = this.items[this.index].range; + + if (range.contains(searchItem)) { + return true; + } + + // Search item is before the range. Since the ranges are sorted, we can stop here. + if (searchItem.end.isBeforeOrEqual(range.start)) { + return false; + } + + this.index++; + } + + return false; + } +} diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts index 7578a5195b..a1b982a5cd 100644 --- a/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeInfoProvider.ts @@ -158,6 +158,7 @@ function isLanguageSpecific(scopeType: ScopeType): boolean { case "environment": case "textFragment": case "disqualifyDelimiter": + case "pairDelimiter": return true; case "character": diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts b/packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts index c69fe4c860..5ae8efc6b0 100644 --- a/packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeRangeProvider.ts @@ -29,7 +29,7 @@ export class ScopeRangeProvider { editor: TextEditor, { scopeType, visibleOnly }: ScopeRangeConfig, ): ScopeRanges[] { - const scopeHandler = this.scopeHandlerFactory.create( + const scopeHandler = this.scopeHandlerFactory.maybeCreate( scopeType, editor.document.languageId, ); @@ -50,13 +50,16 @@ export class ScopeRangeProvider { { scopeType, visibleOnly, includeNestedTargets }: IterationScopeRangeConfig, ): IterationScopeRanges[] { const { languageId } = editor.document; - const scopeHandler = this.scopeHandlerFactory.create(scopeType, languageId); + const scopeHandler = this.scopeHandlerFactory.maybeCreate( + scopeType, + languageId, + ); if (scopeHandler == null) { return []; } - const iterationScopeHandler = this.scopeHandlerFactory.create( + const iterationScopeHandler = this.scopeHandlerFactory.maybeCreate( scopeHandler.iterationScopeType, languageId, ); diff --git a/packages/cursorless-engine/src/scopeProviders/ScopeSupportChecker.ts b/packages/cursorless-engine/src/scopeProviders/ScopeSupportChecker.ts index a3844346eb..73918bd104 100644 --- a/packages/cursorless-engine/src/scopeProviders/ScopeSupportChecker.ts +++ b/packages/cursorless-engine/src/scopeProviders/ScopeSupportChecker.ts @@ -29,7 +29,10 @@ export class ScopeSupportChecker { */ getScopeSupport(editor: TextEditor, scopeType: ScopeType): ScopeSupport { const { languageId } = editor.document; - const scopeHandler = this.scopeHandlerFactory.create(scopeType, languageId); + const scopeHandler = this.scopeHandlerFactory.maybeCreate( + scopeType, + languageId, + ); if (scopeHandler == null) { return getLegacyScopeSupport(languageId, scopeType); @@ -53,13 +56,16 @@ export class ScopeSupportChecker { scopeType: ScopeType, ): ScopeSupport { const { languageId } = editor.document; - const scopeHandler = this.scopeHandlerFactory.create(scopeType, languageId); + const scopeHandler = this.scopeHandlerFactory.maybeCreate( + scopeType, + languageId, + ); if (scopeHandler == null) { return getLegacyScopeSupport(languageId, scopeType); } - const iterationScopeHandler = this.scopeHandlerFactory.create( + const iterationScopeHandler = this.scopeHandlerFactory.maybeCreate( scopeHandler.iterationScopeType, languageId, ); @@ -88,9 +94,6 @@ function getLegacyScopeSupport( scopeType: ScopeType, ): ScopeSupport { switch (scopeType.type) { - case "boundedNonWhitespaceSequence": - case "surroundingPair": - return ScopeSupport.supportedLegacy; case "notebookCell": // FIXME: What to do here return ScopeSupport.unsupported; diff --git a/packages/cursorless-engine/src/spokenForms/defaultSpokenFormMapCore.ts b/packages/cursorless-engine/src/spokenForms/defaultSpokenFormMapCore.ts index 7717823ec9..c9a2661173 100644 --- a/packages/cursorless-engine/src/spokenForms/defaultSpokenFormMapCore.ts +++ b/packages/cursorless-engine/src/spokenForms/defaultSpokenFormMapCore.ts @@ -103,6 +103,7 @@ export const defaultSpokenFormMapCore: DefaultSpokenFormMapDefinition = { string: isPrivate("parse tree string"), textFragment: isPrivate("text fragment"), disqualifyDelimiter: isPrivate("disqualify delimiter"), + pairDelimiter: isPrivate("pair delimiter"), ["private.fieldAccess"]: isPrivate("access"), ["private.switchStatementSubject"]: isPrivate("subject"), }, @@ -144,6 +145,9 @@ export const defaultSpokenFormMapCore: DefaultSpokenFormMapDefinition = { customRegex: {}, action: { + addSelection: "append", + addSelectionAfter: "append post", + addSelectionBefore: "append pre", breakLine: "break", scrollToBottom: "bottom", toggleLineBreakpoint: "break point", diff --git a/packages/cursorless-everywhere-talon-core/src/ide/TalonJsIDE.ts b/packages/cursorless-everywhere-talon-core/src/ide/TalonJsIDE.ts index eac3f302c4..48c3623ed6 100644 --- a/packages/cursorless-everywhere-talon-core/src/ide/TalonJsIDE.ts +++ b/packages/cursorless-everywhere-talon-core/src/ide/TalonJsIDE.ts @@ -24,14 +24,14 @@ import { Notifier, type KeyValueStore } from "@cursorless/common"; import { pull } from "lodash-es"; import type { Talon } from "../types/talon.types"; import type { EditorState } from "../types/types"; +import { createTextEditor } from "./createTextEditor"; +import { flashRanges } from "./flashRanges"; import { TalonJsCapabilities } from "./TalonJsCapabilities"; import { TalonJsClipboard } from "./TalonJsClipboard"; import { TalonJsConfiguration } from "./TalonJsConfiguration"; import { TalonJsEditor } from "./TalonJsEditor"; -import { TalonJsMessages } from "./TalonJsMessages"; - -import { createTextEditor } from "./createTextEditor"; import { TalonJsKeyValueStore } from "./TalonJsKeyValueStore"; +import { TalonJsMessages } from "./TalonJsMessages"; export class TalonJsIDE implements IDE { configuration: Configuration; @@ -134,8 +134,8 @@ export class TalonJsIDE implements IDE { throw new Error("executeCommand not implemented."); } - flashRanges(_flashDescriptors: FlashDescriptor[]): Promise { - return Promise.resolve(); + flashRanges(flashDescriptors: FlashDescriptor[]): Promise { + return flashRanges(this.talon, flashDescriptors); } setHighlightRanges( diff --git a/packages/cursorless-everywhere-talon-core/src/ide/flashRanges.ts b/packages/cursorless-everywhere-talon-core/src/ide/flashRanges.ts new file mode 100644 index 0000000000..124262d1c1 --- /dev/null +++ b/packages/cursorless-everywhere-talon-core/src/ide/flashRanges.ts @@ -0,0 +1,18 @@ +import type { FlashDescriptor } from "@cursorless/common"; +import type { Talon } from "../types/talon.types"; +import type { RangeOffsets } from "../types/types"; +import { toCharacterRangeOffsets } from "./toCharacterRangeOffsets"; + +export function flashRanges( + talon: Talon, + flashDescriptors: FlashDescriptor[], +): Promise { + const ranges = flashDescriptors.map( + (descriptor): RangeOffsets => + toCharacterRangeOffsets(descriptor.editor, descriptor.range), + ); + + talon.actions.user.cursorless_everywhere_flash_ranges(ranges); + + return Promise.resolve(); +} diff --git a/packages/cursorless-everywhere-talon-core/src/ide/toCharacterRangeOffsets.ts b/packages/cursorless-everywhere-talon-core/src/ide/toCharacterRangeOffsets.ts new file mode 100644 index 0000000000..2feedd6829 --- /dev/null +++ b/packages/cursorless-everywhere-talon-core/src/ide/toCharacterRangeOffsets.ts @@ -0,0 +1,20 @@ +import type { GeneralizedRange, TextEditor } from "@cursorless/common"; +import type { RangeOffsets } from "../types/types"; + +export function toCharacterRangeOffsets( + editor: TextEditor, + range: GeneralizedRange, +): RangeOffsets { + if (range.type === "line") { + const startLine = editor.document.lineAt(range.start).range; + const endLine = editor.document.lineAt(range.end).range; + return { + start: editor.document.offsetAt(startLine.start), + end: editor.document.offsetAt(endLine.end), + }; + } + return { + start: editor.document.offsetAt(range.start), + end: editor.document.offsetAt(range.end), + }; +} diff --git a/packages/cursorless-everywhere-talon-core/src/types/talon.types.ts b/packages/cursorless-everywhere-talon-core/src/types/talon.types.ts index 4cf6e74fdb..2381484745 100644 --- a/packages/cursorless-everywhere-talon-core/src/types/talon.types.ts +++ b/packages/cursorless-everywhere-talon-core/src/types/talon.types.ts @@ -1,4 +1,9 @@ -import type { EditorEdit, EditorState, SelectionOffsets } from "./types"; +import type { + RangeOffsets, + EditorEdit, + EditorState, + SelectionOffsets, +} from "./types"; export type TalonNamespace = "user"; @@ -17,6 +22,7 @@ export interface TalonActions { cursorless_everywhere_get_editor_state(): EditorState; cursorless_everywhere_set_selections(selections: SelectionOffsets[]): void; cursorless_everywhere_edit_text(edit: EditorEdit): void; + cursorless_everywhere_flash_ranges(ranges: RangeOffsets[]): void; }; } diff --git a/packages/cursorless-everywhere-talon-core/src/types/types.ts b/packages/cursorless-everywhere-talon-core/src/types/types.ts index 979b10d8cb..ba2c8c0fb0 100644 --- a/packages/cursorless-everywhere-talon-core/src/types/types.ts +++ b/packages/cursorless-everywhere-talon-core/src/types/types.ts @@ -14,6 +14,11 @@ export interface SelectionOffsets { active: number; } +export interface RangeOffsets { + start: number; + end: number; +} + export interface EditorState { text: string; languageId?: string; diff --git a/packages/cursorless-everywhere-talon-e2e/src/talonMock.ts b/packages/cursorless-everywhere-talon-e2e/src/talonMock.ts index a1ad69876b..94a2c47595 100644 --- a/packages/cursorless-everywhere-talon-e2e/src/talonMock.ts +++ b/packages/cursorless-everywhere-talon-e2e/src/talonMock.ts @@ -1,4 +1,5 @@ import type { + RangeOffsets, EditorEdit, EditorState, SelectionOffsets, @@ -53,6 +54,9 @@ const actions: TalonActions = { } _finalEditorState.text = edit.text; }, + cursorless_everywhere_flash_ranges(_ranges: RangeOffsets[]): void { + // Do nothing + }, }, }; diff --git a/packages/cursorless-org-docs/src/docs/contributing/CONTRIBUTING.md b/packages/cursorless-org-docs/src/docs/contributing/CONTRIBUTING.md index e147c95dea..b506873d1c 100644 --- a/packages/cursorless-org-docs/src/docs/contributing/CONTRIBUTING.md +++ b/packages/cursorless-org-docs/src/docs/contributing/CONTRIBUTING.md @@ -49,7 +49,7 @@ extension](#running--testing-extension-locally). You may also find the [VSCode A Also note that if you are adding support for a new language that isn't in the default list of [language identifiers](https://code.visualstudio.com/docs/languages/identifiers#_known-language-identifiers) supported by VSCode, you may need to add an extension dependency. See [Adding a new language](./adding-a-new-language.md#2-ensure-file-type-is-supported-by-vscode) for more details. -6. Copy / symlink `cursorless-talon-dev` into your Talon user directory for some useful voice commands for developing Cursorless. +6. Copy / symlink [`cursorless-talon-dev`](../../cursorless-talon-dev) into your Talon user directory for some useful voice commands for developing Cursorless. ## Running / testing extension locally @@ -65,7 +65,7 @@ If you don't have the `cursorless-talon-dev` files in your Talon user directory The entire test suite takes a little while to run (1-2 mins), so you may want to run just a subset of the tests. -To specify the set of tests to run, say `"debug generate subset"`, or if you haven't installed the cursorless-talon-dev files, run the VSCode task "Generate test subset file". To do this, choose "Tasks: Run Task" from the command pallete. +To specify the set of tests to run, say `"debug generate subset"`, or if you haven't installed the cursorless-talon-dev files, run the VSCode task "Generate test subset file". To do this, choose "Tasks: Run Task" from the command palette. To run the specified subset of tests, say `"debug test subset"` (or use the "Run test subset" launch config). diff --git a/packages/cursorless-org-docs/src/docs/user/README.md b/packages/cursorless-org-docs/src/docs/user/README.md index fbf667ee25..a6b43c6a45 100644 --- a/packages/cursorless-org-docs/src/docs/user/README.md +++ b/packages/cursorless-org-docs/src/docs/user/README.md @@ -531,9 +531,12 @@ Despite the name cursorless, some of the most basic commands in cursorless are f Note that when combined with list targets, `take`/`pre`/`post` commands will result in multiple cursors. +- `"take "`: Selects the given target. - `"pre "`: Places the cursor before the given target. - `"post "`: Places the cursor after the given target. -- `"take "`: Selects the given target. +- `"append "`: Selects the given target, while preserving your existing selections. +- `"append pre "`: Adds a new cursor before the given target, while preserving your existing selections. +- `"append post "`: Adds a new cursor after the given target, while preserving your existing selections. - `"give "`: Deselects the given target. eg: diff --git a/packages/cursorless-org-docs/src/docs/user/installation.md b/packages/cursorless-org-docs/src/docs/user/installation.md index 87ed03c0a1..f64d064bb9 100644 --- a/packages/cursorless-org-docs/src/docs/user/installation.md +++ b/packages/cursorless-org-docs/src/docs/user/installation.md @@ -1,6 +1,7 @@ # Installation 1. Install [Talon](https://talonvoice.com/) + - Make sure a speech engine is installed (click the Talon tray icon, then select one from the `Speech Recognition` menu) 2. Install the [community Talon commands](https://github.com/talonhub/community). _(Or see [here](https://github.com/cursorless-dev/cursorless/wiki/Talon-home-requirements) if you prefer not to use community.)_ 3. Install [VSCode](https://code.visualstudio.com/) diff --git a/packages/cursorless-org/package.json b/packages/cursorless-org/package.json index 08e5b74a42..88aae629fd 100644 --- a/packages/cursorless-org/package.json +++ b/packages/cursorless-org/package.json @@ -17,7 +17,7 @@ "@mdx-js/loader": "3.0.1", "@mdx-js/react": "3.0.1", "@next/mdx": "14.2.15", - "next": "14.2.15", + "next": "14.2.21", "react": "^18.3.1", "react-dom": "^18.3.1", "react-player": "2.16.0" diff --git a/packages/cursorless-vscode-e2e/src/suite/performance.vscode.test.ts b/packages/cursorless-vscode-e2e/src/suite/performance.vscode.test.ts new file mode 100644 index 0000000000..c8dc572507 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/performance.vscode.test.ts @@ -0,0 +1,190 @@ +import { + asyncSafety, + type ActionDescriptor, + type Modifier, + type ScopeType, + type SimpleScopeTypeType, +} from "@cursorless/common"; +import { openNewEditor, runCursorlessCommand } from "@cursorless/vscode-common"; +import assert from "assert"; +import * as vscode from "vscode"; +import { endToEndTestSetup } from "../endToEndTestSetup"; + +const testData = generateTestData(100); + +const smallThresholdMs = 100; +const largeThresholdMs = 500; + +type ModifierType = "containing" | "previous" | "every"; + +suite("Performance", async function () { + endToEndTestSetup(this); + + let previousTitle = ""; + + // Before each test, print the test title. This is done we have the test + // title before the test run time / duration. + this.beforeEach(function () { + const title = this.currentTest!.title; + if (title !== previousTitle) { + console.log(` ${title}`); + previousTitle = title; + } + }); + + test( + "Remove token", + asyncSafety(() => removeToken(smallThresholdMs)), + ); + + const fixtures: ( + | [SimpleScopeTypeType | ScopeType, number] + | [SimpleScopeTypeType | ScopeType, number, ModifierType] + )[] = [ + // Text based + ["character", smallThresholdMs], + ["word", smallThresholdMs], + ["token", smallThresholdMs], + ["identifier", smallThresholdMs], + ["line", smallThresholdMs], + ["sentence", smallThresholdMs], + ["paragraph", smallThresholdMs], + ["document", smallThresholdMs], + ["nonWhitespaceSequence", smallThresholdMs], + // Parse tree based, containing/every scope + ["string", smallThresholdMs], + ["map", smallThresholdMs], + ["collectionKey", smallThresholdMs], + ["value", smallThresholdMs], + ["collectionKey", smallThresholdMs, "every"], + ["value", smallThresholdMs, "every"], + // Parse tree based, relative scope + ["collectionKey", largeThresholdMs, "previous"], + ["value", largeThresholdMs, "previous"], + // Text based, but utilizes surrounding pair + ["boundedParagraph", largeThresholdMs], + ["boundedNonWhitespaceSequence", largeThresholdMs], + ["collectionItem", largeThresholdMs], + // Surrounding pair + [{ type: "surroundingPair", delimiter: "any" }, largeThresholdMs], + [{ type: "surroundingPair", delimiter: "curlyBrackets" }, largeThresholdMs], + [{ type: "surroundingPair", delimiter: "any" }, largeThresholdMs, "every"], + [ + { type: "surroundingPair", delimiter: "any" }, + largeThresholdMs, + "previous", + ], + ]; + + for (const [scope, threshold, modifierType] of fixtures) { + const [scopeType, scopeTitle] = getScopeTypeAndTitle(scope); + const title = modifierType + ? `${modifierType} ${scopeTitle}` + : `${scopeTitle}`; + test( + `Select ${title}`, + asyncSafety(() => selectScopeType(scopeType, threshold, modifierType)), + ); + } +}); + +async function removeToken(thresholdMs: number) { + await testPerformance(thresholdMs, { + name: "remove", + target: { + type: "primitive", + modifiers: [{ type: "containingScope", scopeType: { type: "token" } }], + }, + }); +} + +async function selectScopeType( + scopeType: ScopeType, + thresholdMs: number, + modifierType?: ModifierType, +) { + await testPerformance(thresholdMs, { + name: "setSelection", + target: { + type: "primitive", + modifiers: [getModifier(scopeType, modifierType)], + }, + }); +} + +function getModifier( + scopeType: ScopeType, + modifierType: ModifierType = "containing", +): Modifier { + switch (modifierType) { + case "containing": + return { type: "containingScope", scopeType }; + case "every": + return { type: "everyScope", scopeType }; + case "previous": + return { + type: "relativeScope", + direction: "backward", + offset: 1, + length: 1, + scopeType, + }; + } +} + +async function testPerformance(thresholdMs: number, action: ActionDescriptor) { + const editor = await openNewEditor(testData, { languageId: "json" }); + // This is the position of the last json key in the document + const position = new vscode.Position(editor.document.lineCount - 3, 5); + const selection = new vscode.Selection(position, position); + editor.selections = [selection]; + editor.revealRange(selection); + + const start = performance.now(); + + await runCursorlessCommand({ + version: 7, + usePrePhraseSnapshot: false, + action, + }); + + const duration = Math.round(performance.now() - start); + + console.log(` ${duration} ms`); + + assert.ok( + duration < thresholdMs, + `Duration ${duration}ms exceeds threshold ${thresholdMs}ms`, + ); +} + +function getScopeTypeAndTitle( + scope: SimpleScopeTypeType | ScopeType, +): [ScopeType, string] { + if (typeof scope === "string") { + return [{ type: scope }, scope]; + } + switch (scope.type) { + case "surroundingPair": + return [scope, `${scope.type}.${scope.delimiter}`]; + } + throw Error(`Unexpected scope type: ${scope.type}`); +} + +/** + * Generate a large JSON object with n-keys, each with n-values. + * { + * "0": { "0": "value", ..., "n-1": "value" }, + * ... + * "n-1": { "0": "value", ..., "n-1": "value" } + * } + */ +function generateTestData(n: number): string { + const value = Object.fromEntries( + new Array(n).fill("").map((_, i) => [i.toString(), "value"]), + ); + const obj = Object.fromEntries( + new Array(n).fill("").map((_, i) => [i.toString(), value]), + ); + return JSON.stringify(obj, null, 2); +} diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts index 1bd4beb106..f3de48e5f4 100644 --- a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runBasicScopeInfoTest.ts @@ -54,11 +54,14 @@ function helloWorld() { } `; -function getExpectedScope(scopeSupport: ScopeSupport): ScopeSupportInfo { +function getExpectedScope( + scopeSupport: ScopeSupport, + iterationScopeSupport?: ScopeSupport, +): ScopeSupportInfo { return { humanReadableName: "named function", isLanguageSpecific: true, - iterationScopeSupport: scopeSupport, + iterationScopeSupport: iterationScopeSupport ?? scopeSupport, scopeType: { type: "namedFunction", }, @@ -71,5 +74,8 @@ function getExpectedScope(scopeSupport: ScopeSupport): ScopeSupportInfo { } const unsupported = getExpectedScope(ScopeSupport.unsupported); -const supported = getExpectedScope(ScopeSupport.supportedButNotPresentInEditor); +const supported = getExpectedScope( + ScopeSupport.supportedButNotPresentInEditor, + ScopeSupport.supportedAndPresentInEditor, +); const present = getExpectedScope(ScopeSupport.supportedAndPresentInEditor); diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts index 53b2f7c7fc..88c846da81 100644 --- a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomSpokenFormScopeInfoTest.ts @@ -60,7 +60,7 @@ export async function runCustomSpokenFormScopeInfoTest() { await unlink(cursorlessTalonStateJsonPath); // Sleep to ensure that the scope support provider has time to update // before the next test starts - await sleep(250); + await sleep(400); } catch (_e) { // Do nothing } diff --git a/packages/cursorless-vscode/src/ScopeTreeProvider.ts b/packages/cursorless-vscode/src/ScopeTreeProvider.ts index b9d1aa3d8b..75f3e20c8a 100644 --- a/packages/cursorless-vscode/src/ScopeTreeProvider.ts +++ b/packages/cursorless-vscode/src/ScopeTreeProvider.ts @@ -112,7 +112,7 @@ export class ScopeTreeProvider implements TreeDataProvider { getChildren(element?: MyTreeItem): MyTreeItem[] { if (element == null) { void this.possiblyShowUpdateTalonMessage(); - return getSupportCategories(); + return getSupportCategories(this.hasLegacyScopes()); } if (element instanceof SupportCategoryTreeItem) { @@ -156,7 +156,15 @@ export class ScopeTreeProvider implements TreeDataProvider { } } - getScopeTypesWithSupport(scopeSupport: ScopeSupport): ScopeSupportTreeItem[] { + private hasLegacyScopes(): boolean { + return this.supportLevels.some( + (supportLevel) => supportLevel.support === ScopeSupport.supportedLegacy, + ); + } + + private getScopeTypesWithSupport( + scopeSupport: ScopeSupport, + ): ScopeSupportTreeItem[] { return this.supportLevels .filter( (supportLevel) => @@ -202,11 +210,15 @@ export class ScopeTreeProvider implements TreeDataProvider { } } -function getSupportCategories(): SupportCategoryTreeItem[] { +function getSupportCategories( + includeLegacy: boolean, +): SupportCategoryTreeItem[] { return [ new SupportCategoryTreeItem(ScopeSupport.supportedAndPresentInEditor), new SupportCategoryTreeItem(ScopeSupport.supportedButNotPresentInEditor), - new SupportCategoryTreeItem(ScopeSupport.supportedLegacy), + ...(includeLegacy + ? [new SupportCategoryTreeItem(ScopeSupport.supportedLegacy)] + : []), new SupportCategoryTreeItem(ScopeSupport.unsupported), ]; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49fcc12cce..717ed3fded 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -495,8 +495,8 @@ importers: specifier: 14.2.15 version: 14.2.15(@mdx-js/loader@3.0.1(webpack@5.95.0(esbuild@0.24.0)))(@mdx-js/react@3.0.1(@types/react@18.3.11)(react@18.3.1)) next: - specifier: 14.2.15 - version: 14.2.15(@babel/core@7.25.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 14.2.21 + version: 14.2.21(@babel/core@7.25.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -2325,8 +2325,8 @@ packages: resolution: {integrity: sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==} engines: {node: '>= 10'} - '@next/env@14.2.15': - resolution: {integrity: sha512-S1qaj25Wru2dUpcIZMjxeMVSwkt8BK4dmWHHiBuRstcIyOsMapqT4A4jSB6onvqeygkSSmOkyny9VVx8JIGamQ==} + '@next/env@14.2.21': + resolution: {integrity: sha512-lXcwcJd5oR01tggjWJ6SrNNYFGuOOMB9c251wUNkjCpkoXOPkDeF/15c3mnVlBqrW4JJXb2kVxDFhC4GduJt2A==} '@next/eslint-plugin-next@14.2.15': resolution: {integrity: sha512-pKU0iqKRBlFB/ocOI1Ip2CkKePZpYpnw5bEItEkuZ/Nr9FQP1+p7VDWr4VfOdff4i9bFmrOaeaU1bFEyAcxiMQ==} @@ -2342,56 +2342,56 @@ packages: '@mdx-js/react': optional: true - '@next/swc-darwin-arm64@14.2.15': - resolution: {integrity: sha512-Rvh7KU9hOUBnZ9TJ28n2Oa7dD9cvDBKua9IKx7cfQQ0GoYUwg9ig31O2oMwH3wm+pE3IkAQ67ZobPfEgurPZIA==} + '@next/swc-darwin-arm64@14.2.21': + resolution: {integrity: sha512-HwEjcKsXtvszXz5q5Z7wCtrHeTTDSTgAbocz45PHMUjU3fBYInfvhR+ZhavDRUYLonm53aHZbB09QtJVJj8T7g==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@14.2.15': - resolution: {integrity: sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg==} + '@next/swc-darwin-x64@14.2.21': + resolution: {integrity: sha512-TSAA2ROgNzm4FhKbTbyJOBrsREOMVdDIltZ6aZiKvCi/v0UwFmwigBGeqXDA97TFMpR3LNNpw52CbVelkoQBxA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@14.2.15': - resolution: {integrity: sha512-3Bwv4oc08ONiQ3FiOLKT72Q+ndEMyLNsc/D3qnLMbtUYTQAmkx9E/JRu0DBpHxNddBmNT5hxz1mYBphJ3mfrrw==} + '@next/swc-linux-arm64-gnu@14.2.21': + resolution: {integrity: sha512-0Dqjn0pEUz3JG+AImpnMMW/m8hRtl1GQCNbO66V1yp6RswSTiKmnHf3pTX6xMdJYSemf3O4Q9ykiL0jymu0TuA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@14.2.15': - resolution: {integrity: sha512-k5xf/tg1FBv/M4CMd8S+JL3uV9BnnRmoe7F+GWC3DxkTCD9aewFRH1s5rJ1zkzDa+Do4zyN8qD0N8c84Hu96FQ==} + '@next/swc-linux-arm64-musl@14.2.21': + resolution: {integrity: sha512-Ggfw5qnMXldscVntwnjfaQs5GbBbjioV4B4loP+bjqNEb42fzZlAaK+ldL0jm2CTJga9LynBMhekNfV8W4+HBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@14.2.15': - resolution: {integrity: sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA==} + '@next/swc-linux-x64-gnu@14.2.21': + resolution: {integrity: sha512-uokj0lubN1WoSa5KKdThVPRffGyiWlm/vCc/cMkWOQHw69Qt0X1o3b2PyLLx8ANqlefILZh1EdfLRz9gVpG6tg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@14.2.15': - resolution: {integrity: sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ==} + '@next/swc-linux-x64-musl@14.2.21': + resolution: {integrity: sha512-iAEBPzWNbciah4+0yI4s7Pce6BIoxTQ0AGCkxn/UBuzJFkYyJt71MadYQkjPqCQCJAFQ26sYh7MOKdU+VQFgPg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@14.2.15': - resolution: {integrity: sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g==} + '@next/swc-win32-arm64-msvc@14.2.21': + resolution: {integrity: sha512-plykgB3vL2hB4Z32W3ktsfqyuyGAPxqwiyrAi2Mr8LlEUhNn9VgkiAl5hODSBpzIfWweX3er1f5uNpGDygfQVQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-ia32-msvc@14.2.15': - resolution: {integrity: sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==} + '@next/swc-win32-ia32-msvc@14.2.21': + resolution: {integrity: sha512-w5bacz4Vxqrh06BjWgua3Yf7EMDb8iMcVhNrNx8KnJXt8t+Uu0Zg4JHLDL/T7DkTCEEfKXO/Er1fcfWxn2xfPA==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@next/swc-win32-x64-msvc@14.2.15': - resolution: {integrity: sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g==} + '@next/swc-win32-x64-msvc@14.2.21': + resolution: {integrity: sha512-sT6+llIkzpsexGYZq8cjjthRyRGe5cJVhqh12FmlbxHqna6zsDDK8UNaV7g41T6atFHCJUPeLb3uyAwrBwy0NA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -7287,8 +7287,8 @@ packages: nerf-dart@1.0.0: resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==} - next@14.2.15: - resolution: {integrity: sha512-h9ctmOokpoDphRvMGnwOJAedT6zKhwqyZML9mDtspgf4Rh3Pn7UTYKqePNoDvhsWBAO5GoPNYshnAUGIazVGmw==} + next@14.2.21: + resolution: {integrity: sha512-rZmLwucLHr3/zfDMYbJXbw0ZeoBpirxkXuvsJbk7UPorvPYZhP7vq7aHbKnU7dQNCYIimRrbB2pp3xmf+wsYUg==} engines: {node: '>=18.17.0'} hasBin: true peerDependencies: @@ -12249,7 +12249,7 @@ snapshots: '@msgpack/msgpack@2.8.0': {} - '@next/env@14.2.15': {} + '@next/env@14.2.21': {} '@next/eslint-plugin-next@14.2.15': dependencies: @@ -12262,31 +12262,31 @@ snapshots: '@mdx-js/loader': 3.0.1(webpack@5.95.0(esbuild@0.24.0)) '@mdx-js/react': 3.0.1(@types/react@18.3.11)(react@18.3.1) - '@next/swc-darwin-arm64@14.2.15': + '@next/swc-darwin-arm64@14.2.21': optional: true - '@next/swc-darwin-x64@14.2.15': + '@next/swc-darwin-x64@14.2.21': optional: true - '@next/swc-linux-arm64-gnu@14.2.15': + '@next/swc-linux-arm64-gnu@14.2.21': optional: true - '@next/swc-linux-arm64-musl@14.2.15': + '@next/swc-linux-arm64-musl@14.2.21': optional: true - '@next/swc-linux-x64-gnu@14.2.15': + '@next/swc-linux-x64-gnu@14.2.21': optional: true - '@next/swc-linux-x64-musl@14.2.15': + '@next/swc-linux-x64-musl@14.2.21': optional: true - '@next/swc-win32-arm64-msvc@14.2.15': + '@next/swc-win32-arm64-msvc@14.2.21': optional: true - '@next/swc-win32-ia32-msvc@14.2.15': + '@next/swc-win32-ia32-msvc@14.2.21': optional: true - '@next/swc-win32-x64-msvc@14.2.15': + '@next/swc-win32-x64-msvc@14.2.21': optional: true '@nodelib/fs.scandir@2.1.5': @@ -18598,9 +18598,9 @@ snapshots: nerf-dart@1.0.0: {} - next@14.2.15(@babel/core@7.25.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@14.2.21(@babel/core@7.25.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@next/env': 14.2.15 + '@next/env': 14.2.21 '@swc/helpers': 0.5.5 busboy: 1.6.0 caniuse-lite: 1.0.30001669 @@ -18610,15 +18610,15 @@ snapshots: react-dom: 18.3.1(react@18.3.1) styled-jsx: 5.1.1(@babel/core@7.25.8)(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 14.2.15 - '@next/swc-darwin-x64': 14.2.15 - '@next/swc-linux-arm64-gnu': 14.2.15 - '@next/swc-linux-arm64-musl': 14.2.15 - '@next/swc-linux-x64-gnu': 14.2.15 - '@next/swc-linux-x64-musl': 14.2.15 - '@next/swc-win32-arm64-msvc': 14.2.15 - '@next/swc-win32-ia32-msvc': 14.2.15 - '@next/swc-win32-x64-msvc': 14.2.15 + '@next/swc-darwin-arm64': 14.2.21 + '@next/swc-darwin-x64': 14.2.21 + '@next/swc-linux-arm64-gnu': 14.2.21 + '@next/swc-linux-arm64-musl': 14.2.21 + '@next/swc-linux-x64-gnu': 14.2.21 + '@next/swc-linux-x64-musl': 14.2.21 + '@next/swc-win32-arm64-msvc': 14.2.21 + '@next/swc-win32-ia32-msvc': 14.2.21 + '@next/swc-win32-x64-msvc': 14.2.21 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros diff --git a/queries/c.scm b/queries/c.scm index a9763a054f..b9afe76b22 100644 --- a/queries/c.scm +++ b/queries/c.scm @@ -182,7 +182,7 @@ ) @_.domain ;;!! aaa = 0; -( +(_ (assignment_expression left: (_) @name @value.leading.endOf right: (_) @value @name.trailing.startOf diff --git a/queries/clojure.scm b/queries/clojure.scm index 753d2f488e..31f196e9c3 100644 --- a/queries/clojure.scm +++ b/queries/clojure.scm @@ -11,3 +11,33 @@ (quoting_lit (list_lit) ) @list + +;;!! '(foo bar) +;;! ^^^ ^^^ +(list_lit + (_)? @_.leading.endOf + . + (_) @collectionItem.start + . + (_)? @_.trailing.startOf +) + +(list_lit + open: "(" @collectionItem.iteration.start.startOf + close: ")" @collectionItem.iteration.end.endOf +) @collectionItem.iteration.domain + +;;!! [foo bar] +;;! ^^^ ^^^ +(vec_lit + (_)? @_.leading.endOf + . + (_) @collectionItem.start + . + (_)? @_.trailing.startOf +) + +(vec_lit + open: "[" @collectionItem.iteration.start.startOf + close: "]" @collectionItem.iteration.end.endOf +) @collectionItem.iteration.domain diff --git a/queries/java.scm b/queries/java.scm index c438a00f71..10e1e28f50 100644 --- a/queries/java.scm +++ b/queries/java.scm @@ -316,6 +316,7 @@ value: (_)? @value @name.trailing.startOf ) ) @_.domain + (field_declaration (variable_declarator name: (_) @name @value.leading.endOf @@ -323,6 +324,49 @@ ) ) @_.domain +;;!! int foo, bar; +;;! ^^^ ^^^ +( + (local_variable_declaration + type: (_) + (variable_declarator)? @_.leading.endOf + . + (variable_declarator) @collectionItem + . + (variable_declarator)? @_.trailing.startOf + ) + (#insertion-delimiter! @collectionItem ", ") +) + +( + (field_declaration + type: (_) + (variable_declarator)? @_.leading.endOf + . + (variable_declarator) @collectionItem + . + (variable_declarator)? @_.trailing.startOf + ) + (#insertion-delimiter! @collectionItem ", ") +) + +;;!! int foo, bar; +;;! ^^^^^^^^ +;;! ------------- +(local_variable_declaration + type: (_) + . + (_) @collectionItem.iteration.start.startOf + ";"? @collectionItem.iteration.end.startOf +) @collectionItem.iteration.domain + +(field_declaration + type: (_) + . + (_) @collectionItem.iteration.start.startOf + ";"? @collectionItem.iteration.end.startOf +) @collectionItem.iteration.domain + ;;!! value = 1; ;;! ^ ;;! xxxx diff --git a/queries/javascript.scm b/queries/javascript.scm index 26c5b32696..5bcc15ce60 100644 --- a/queries/javascript.scm +++ b/queries/javascript.scm @@ -6,7 +6,7 @@ ;; Define this here because the `field_definition` node type doesn't exist ;; in typescript. -( +(_ ;;!! class Foo { ;;!! foo = () => {}; ;;! ^^^^^^^^^^^^^^^ @@ -31,7 +31,7 @@ ";"? @namedFunction.end @functionName.domain.end ) -( +(_ ;;!! foo = ...; ;;! ^^^------- (field_definition diff --git a/queries/latex.scm b/queries/latex.scm index c0ce621d5c..a67decdc20 100644 --- a/queries/latex.scm +++ b/queries/latex.scm @@ -35,3 +35,18 @@ ">" ] @disqualifyDelimiter ) + +;;!! \item one \LaTeX +;;! ^^^^^^^^^^ +( + (_ + (enum_item + (text) @collectionItem.start.startOf + ) @collectionItem.leading.startOf @collectionItem.end.endOf + ) +) + +(generic_environment + (begin) @collectionItem.iteration.start.endOf + (end) @collectionItem.iteration.end.startOf +) @collectionItem.iteration.domain diff --git a/queries/python.scm b/queries/python.scm index e1954e836f..f5e61291d0 100644 --- a/queries/python.scm +++ b/queries/python.scm @@ -652,3 +652,8 @@ operator: [ (function_definition "->" @disqualifyDelimiter ) + +( + (string_start) @pairDelimiter + (#match? @pairDelimiter "^[a-zA-Z]+") +) diff --git a/queries/ruby.scm b/queries/ruby.scm index 99a5a5e905..473a701a53 100644 --- a/queries/ruby.scm +++ b/queries/ruby.scm @@ -69,3 +69,29 @@ operator: [ (match_pattern "=>" @disqualifyDelimiter ) + +;;!! %w(foo bar) +;;! ^^^ ^^^ +( + (string_array + (bare_string)? @_.leading.endOf + . + (bare_string) @collectionItem + . + (bare_string)? @_.trailing.startOf + ) + (#insertion-delimiter! @collectionItem " ") +) + +;;!! %i(foo bar) +;;! ^^^ ^^^ +( + (symbol_array + (bare_symbol)? @_.leading.endOf + . + (bare_symbol) @collectionItem + . + (bare_symbol)? @_.trailing.startOf + ) + (#insertion-delimiter! @collectionItem " ") +) diff --git a/queries/rust.scm b/queries/rust.scm index 22dfafefce..1031506c46 100644 --- a/queries/rust.scm +++ b/queries/rust.scm @@ -82,3 +82,6 @@ operator: [ (macro_rule "=>" @disqualifyDelimiter ) +(lifetime + "'" @disqualifyDelimiter +) diff --git a/queries/typescript.core.scm b/queries/typescript.core.scm index 8e1305f0a2..88e2871e86 100644 --- a/queries/typescript.core.scm +++ b/queries/typescript.core.scm @@ -39,7 +39,7 @@ ) @_.domain ;; Define these here because these node types don't exist in javascript. -( +(_ [ ;;!! class Foo { foo() {} } ;;! ^^^^^^^^ @@ -80,7 +80,7 @@ ";"? @namedFunction.end @functionName.domain.end @name.domain.end ) -( +(_ ;;!! (public | private | protected) foo = ...; ;;! ----------------------------------------- (public_field_definition @@ -92,7 +92,7 @@ ";"? @_.domain.end ) -( +(_ ;;!! (public | private | protected) foo: Bar = ...; ;;! ---------------------------------------------- (public_field_definition @@ -340,7 +340,7 @@ ;;! ^^^^ ;;! xxxxxx ;;! ------------ -( +(_ (property_signature name: (_) @collectionKey @type.leading.endOf type: (_ @@ -348,6 +348,7 @@ (_) @type @collectionKey.trailing.startOf ) ) @_.domain.start + . ";"? @_.domain.end ) @@ -372,11 +373,17 @@ ) ;; Statements with optional trailing `;` -( +(_ [ (property_signature) (public_field_definition) (abstract_method_signature) ] @statement.start + . ";"? @statement.end ) + +;; () => number +(function_type + "=>" @disqualifyDelimiter +)