diff --git a/cursorless-talon/src/cheatsheet/sections/modifiers.py b/cursorless-talon/src/cheatsheet/sections/modifiers.py index 398f972808..deb3634c6f 100644 --- a/cursorless-talon/src/cheatsheet/sections/modifiers.py +++ b/cursorless-talon/src/cheatsheet/sections/modifiers.py @@ -60,6 +60,8 @@ def get_modifiers() -> list[ListItemDescriptor]: "trailing": "Trailing delimiter range", "start": "Empty position at start of target", "end": "Empty position at end of target", + "filename": "File name including extension", + "filenameWithoutExtension": "File name without extension", }, ) diff --git a/cursorless-talon/src/modifiers/modifiers.py b/cursorless-talon/src/modifiers/modifiers.py index d765033dff..37476a8cb3 100644 --- a/cursorless-talon/src/modifiers/modifiers.py +++ b/cursorless-talon/src/modifiers/modifiers.py @@ -33,6 +33,7 @@ def cursorless_simple_modifier(m) -> dict[str, str]: "", # inside "", # head, tail "", # start of, end of + "", # reference formats *head_tail_swallowed_modifiers, ] diff --git a/cursorless-talon/src/modifiers/reference.py b/cursorless-talon/src/modifiers/reference.py new file mode 100644 index 0000000000..8e578c729a --- /dev/null +++ b/cursorless-talon/src/modifiers/reference.py @@ -0,0 +1,89 @@ +from typing import Any + +from talon import Context, Module + +mod = Module() + +mod.list( + "cursorless_reference_modifier", + desc="Cursorless reference modifiers with snippet outputs", +) + + +REFERENCE_MODIFIER_DATA: dict[str, dict[str, Any]] = { + "relative": [ + { + "body": "$relative", + }, + ], + "absolute": [ + { + "body": "$absolute", + }, + ], + "remote": [ + { + "body": "$remote", + }, + ], + "remoteCanonical": [ + { + "body": "$remoteCanonical", + }, + ], + "relativeText": [ + { + "body": " $relative (`$content`) ", + "lineMode": "singleLine", + }, + { + "body": "\n\n$relative:\n\n```$languageId\n$content\n```\n\n", + "lineMode": "multiLine", + }, + ], + "remoteText": [ + { + "body": " $remote (`$content`) ", + "lineMode": "singleLine", + }, + { + "body": "\n\n$remote:\n\n```$languageId\n$content\n```\n\n", + "lineMode": "multiLine", + }, + ], + "remoteLink": [ + { + "body": "[`$name`]($remote)", + }, + ], + "relativeLink": [ + { + "body": "[`$name`]($relative)", + }, + ], +} + +REFERENCE_SPOKEN_FORMS: dict[str, str] = { + "local": "relative", + "absolute": "absolute", + "remote": "remote", + "canonical": "remoteCanonical", + "local text": "relativeText", + "remote text": "remoteText", + "local link": "relativeLink", + "remote link": "remoteLink", +} + +ctx = Context() +ctx.lists["user.cursorless_reference_modifier"] = REFERENCE_SPOKEN_FORMS + + +@mod.capture(rule="{user.cursorless_reference_modifier}") +def cursorless_reference_modifier(m) -> dict[str, Any]: + """Reference modifier snippets""" + modifier_id = m.cursorless_reference_modifier + data = REFERENCE_MODIFIER_DATA[modifier_id] + return { + "type": "reference", + "snippets": data, + } diff --git a/cursorless-talon/src/spoken_forms.json b/cursorless-talon/src/spoken_forms.json index dd332db2ab..d98d04358e 100644 --- a/cursorless-talon/src/spoken_forms.json +++ b/cursorless-talon/src/spoken_forms.json @@ -90,7 +90,10 @@ "content": "keepContentFilter", "empty": "keepEmptyFilter", "its": "inferPreviousMark", - "visible": "visible" + "visible": "visible", + "reference": "reference", + "file name": "filename", + "base": "filenameWithoutExtension" }, "every_scope_modifier": { "every": "every" }, "ancestor_scope_modifier": { "grand": "ancestor" }, diff --git a/data/fixtures/recorded/modifiers/bringBasename.yml b/data/fixtures/recorded/modifiers/bringBasename.yml new file mode 100644 index 0000000000..23ebabe5a8 --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringBasename.yml @@ -0,0 +1,37 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring basename + action: + name: replaceWithTarget + source: + type: primitive + modifiers: + - {type: filenameWithoutExtension} + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: "" + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: Untitled-1 + selections: + - anchor: {line: 0, character: 10} + active: {line: 0, character: 10} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 10} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 10} + end: {line: 0, character: 10} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringFilename.yml b/data/fixtures/recorded/modifiers/bringFilename.yml new file mode 100644 index 0000000000..76147dcd82 --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringFilename.yml @@ -0,0 +1,37 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring filename + action: + name: replaceWithTarget + source: + type: primitive + modifiers: + - {type: filename} + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: "" + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: Untitled-1 + selections: + - anchor: {line: 0, character: 10} + active: {line: 0, character: 10} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 10} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 10} + end: {line: 0, character: 10} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReference.yml b/data/fixtures/recorded/modifiers/bringReference.yml new file mode 100644 index 0000000000..a3baf5f254 --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReference.yml @@ -0,0 +1,42 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference + action: + name: replaceWithTarget + source: + type: primitive + modifiers: + - {type: reference} + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: |+ + aaa bbb ccc + + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 0} + marks: {} +finalState: + documentContents: |- + aaa bbb ccc + + Untitled-1#L3 + selections: + - anchor: {line: 2, character: 13} + active: {line: 2, character: 13} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 2, character: 0} + end: {line: 2, character: 13} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 2, character: 13} + end: {line: 2, character: 13} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceAir.yml b/data/fixtures/recorded/modifiers/bringReferenceAir.yml new file mode 100644 index 0000000000..6bf351687c --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceAir.yml @@ -0,0 +1,46 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference air + action: + name: replaceWithTarget + source: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: |+ + aaa bbb ccc + + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} +finalState: + documentContents: |- + aaa bbb ccc + + Untitled-1#L1 + selections: + - anchor: {line: 2, character: 13} + active: {line: 2, character: 13} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 2, character: 0} + end: {line: 2, character: 13} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceAirAfterBat.yml b/data/fixtures/recorded/modifiers/bringReferenceAirAfterBat.yml new file mode 100644 index 0000000000..51d14a1006 --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceAirAfterBat.yml @@ -0,0 +1,51 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference air after bat + action: + name: replaceWithTarget + source: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + destination: + type: primitive + insertionMode: after + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: b} + usePrePhraseSnapshot: true +initialState: + documentContents: | + aaa bbb + selections: + - anchor: {line: 1, character: 0} + active: {line: 1, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + default.b: + start: {line: 0, character: 4} + end: {line: 0, character: 7} +finalState: + documentContents: | + aaa bbb Untitled-1#L1 + selections: + - anchor: {line: 1, character: 0} + active: {line: 1, character: 0} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 8} + end: {line: 0, character: 21} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceAirAndBat.yml b/data/fixtures/recorded/modifiers/bringReferenceAirAndBat.yml new file mode 100644 index 0000000000..974bf8c5a8 --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceAirAndBat.yml @@ -0,0 +1,59 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference air and bat + action: + name: replaceWithTarget + source: + type: list + elements: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: b} + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: | + aaa + bbb + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + default.b: + start: {line: 1, character: 0} + end: {line: 1, character: 3} +finalState: + documentContents: |- + aaa + bbb + Untitled-1#L1, Untitled-1#L2 + selections: + - anchor: {line: 2, character: 28} + active: {line: 2, character: 28} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 2, character: 0} + end: {line: 2, character: 28} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 1, character: 0} + end: {line: 1, character: 3} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceAirAndBatAfterBat.yml b/data/fixtures/recorded/modifiers/bringReferenceAirAndBatAfterBat.yml new file mode 100644 index 0000000000..2fec10f4ea --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceAirAndBatAfterBat.yml @@ -0,0 +1,63 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference air and bat after bat + action: + name: replaceWithTarget + source: + type: list + elements: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: b} + destination: + type: primitive + insertionMode: after + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: b} + usePrePhraseSnapshot: true +initialState: + documentContents: | + aaa + bbb + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + default.b: + start: {line: 1, character: 0} + end: {line: 1, character: 3} +finalState: + documentContents: | + aaa + bbb Untitled-1#L1 Untitled-1#L2 + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 0} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 1, character: 4} + end: {line: 1, character: 31} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 1, character: 0} + end: {line: 1, character: 3} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceAirPastBat.yml b/data/fixtures/recorded/modifiers/bringReferenceAirPastBat.yml new file mode 100644 index 0000000000..d9f458462c --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceAirPastBat.yml @@ -0,0 +1,56 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference air past bat + action: + name: replaceWithTarget + source: + type: range + anchor: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + active: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: b} + excludeAnchor: false + excludeActive: false + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: |+ + aaa bbb ccc + + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + default.b: + start: {line: 0, character: 4} + end: {line: 0, character: 7} +finalState: + documentContents: |- + aaa bbb ccc + + Untitled-1#L1 + selections: + - anchor: {line: 2, character: 13} + active: {line: 2, character: 13} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 2, character: 0} + end: {line: 2, character: 13} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 7} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceAirToBat.yml b/data/fixtures/recorded/modifiers/bringReferenceAirToBat.yml new file mode 100644 index 0000000000..3940a35d9b --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceAirToBat.yml @@ -0,0 +1,51 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference air to bat + action: + name: replaceWithTarget + source: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + destination: + type: primitive + insertionMode: to + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: b} + usePrePhraseSnapshot: true +initialState: + documentContents: | + aaa bbb + selections: + - anchor: {line: 1, character: 0} + active: {line: 1, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + default.b: + start: {line: 0, character: 4} + end: {line: 0, character: 7} +finalState: + documentContents: | + aaa Untitled-1#L1 + selections: + - anchor: {line: 1, character: 0} + active: {line: 1, character: 0} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 4} + end: {line: 0, character: 17} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceBlockAir.yml b/data/fixtures/recorded/modifiers/bringReferenceBlockAir.yml new file mode 100644 index 0000000000..b64d3aefc8 --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceBlockAir.yml @@ -0,0 +1,65 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference block air + action: + name: replaceWithTarget + source: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - type: reference + snippets: + - {body: ' $relative (`$content`) ', lineMode: singleLine} + - {body: "\n\n$relative:\n\n```$languageId\n$content\n```\n\n", lineMode: multiLine} + - type: containingScope + scopeType: {type: paragraph} + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: |+ + aaa bbb + ccc ddd + + eee fff + + selections: + - anchor: {line: 5, character: 0} + active: {line: 5, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} +finalState: + documentContents: |+ + aaa bbb + ccc ddd + + eee fff + + + + Untitled-1#L1-L2: + + ```plaintext + aaa bbb + ccc ddd + ``` + + selections: + - anchor: {line: 14, character: 0} + active: {line: 14, character: 0} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 5, character: 0} + end: {line: 14, character: 0} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 1, character: 7} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceFile.yml b/data/fixtures/recorded/modifiers/bringReferenceFile.yml new file mode 100644 index 0000000000..eada96511b --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceFile.yml @@ -0,0 +1,39 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference file + action: + name: replaceWithTarget + source: + type: primitive + modifiers: + - {type: reference} + - type: containingScope + scopeType: {type: document} + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: "" + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: Untitled-1 + selections: + - anchor: {line: 0, character: 10} + active: {line: 0, character: 10} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 10} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 10} + end: {line: 0, character: 10} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceFile2.yml b/data/fixtures/recorded/modifiers/bringReferenceFile2.yml new file mode 100644 index 0000000000..ea6c271faa --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceFile2.yml @@ -0,0 +1,41 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference file + action: + name: replaceWithTarget + source: + type: primitive + modifiers: + - type: reference + snippets: + - {body: '[`$name`]($relative)'} + - type: containingScope + scopeType: {type: document} + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: "" + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: {} +finalState: + documentContents: "[`Untitled-1`](Untitled-1)" + selections: + - anchor: {line: 0, character: 26} + active: {line: 0, character: 26} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 26} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 26} + end: {line: 0, character: 26} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceFunk.yml b/data/fixtures/recorded/modifiers/bringReferenceFunk.yml new file mode 100644 index 0000000000..156543f2b5 --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceFunk.yml @@ -0,0 +1,45 @@ +languageId: typescript +command: + version: 7 + spokenForm: bring reference funk + action: + name: replaceWithTarget + source: + type: primitive + modifiers: + - {type: reference} + - type: containingScope + scopeType: {type: namedFunction} + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: |- + function aaa() { + "bbb"; + } + selections: + - anchor: {line: 2, character: 1} + active: {line: 2, character: 1} + marks: {} +finalState: + documentContents: |- + function aaa() { + "bbb"; + }Untitled-1#L1-L3 + selections: + - anchor: {line: 2, character: 17} + active: {line: 2, character: 17} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 2, character: 1} + end: {line: 2, character: 17} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 2, character: 1} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceFunkAir.yml b/data/fixtures/recorded/modifiers/bringReferenceFunkAir.yml new file mode 100644 index 0000000000..5baeb23282 --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceFunkAir.yml @@ -0,0 +1,54 @@ +languageId: typescript +command: + version: 7 + spokenForm: bring reference funk air + action: + name: replaceWithTarget + source: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - type: reference + snippets: + - {body: '[`$name`]($relative)'} + - type: containingScope + scopeType: {type: namedFunction} + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: |+ + function aaa() { + "bbb"; + } + + selections: + - anchor: {line: 4, character: 0} + active: {line: 4, character: 0} + marks: + default.a: + start: {line: 0, character: 9} + end: {line: 0, character: 12} +finalState: + documentContents: |- + function aaa() { + "bbb"; + } + + [`aaa`](Untitled-1#L1-L3) + selections: + - anchor: {line: 4, character: 25} + active: {line: 4, character: 25} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 4, character: 0} + end: {line: 4, character: 25} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 2, character: 1} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceJustAir.yml b/data/fixtures/recorded/modifiers/bringReferenceJustAir.yml new file mode 100644 index 0000000000..7262403785 --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceJustAir.yml @@ -0,0 +1,42 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference just air + action: + name: replaceWithTarget + source: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + - {type: toRawSelection} + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: a + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 1} +finalState: + documentContents: Untitled-1#L1C1-L1C2a + selections: + - anchor: {line: 0, character: 20} + active: {line: 0, character: 20} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 20} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 20} + end: {line: 0, character: 21} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceLineAir.yml b/data/fixtures/recorded/modifiers/bringReferenceLineAir.yml new file mode 100644 index 0000000000..38c463ec9c --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceLineAir.yml @@ -0,0 +1,48 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference line air + action: + name: replaceWithTarget + source: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + - type: containingScope + scopeType: {type: line} + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: |+ + aaa bbb ccc + + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} +finalState: + documentContents: |- + aaa bbb ccc + + Untitled-1#L1 + selections: + - anchor: {line: 2, character: 13} + active: {line: 2, character: 13} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 2, character: 0} + end: {line: 2, character: 13} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 11} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceLineAirAndBat.yml b/data/fixtures/recorded/modifiers/bringReferenceLineAirAndBat.yml new file mode 100644 index 0000000000..21ca424768 --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceLineAirAndBat.yml @@ -0,0 +1,61 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference line air and bat + action: + name: replaceWithTarget + source: + type: list + elements: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + - type: containingScope + scopeType: {type: line} + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: b} + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: | + aaa + bbb + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + default.b: + start: {line: 1, character: 0} + end: {line: 1, character: 3} +finalState: + documentContents: |- + aaa + bbb + Untitled-1#L1, Untitled-1#L2 + selections: + - anchor: {line: 2, character: 28} + active: {line: 2, character: 28} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 2, character: 0} + end: {line: 2, character: 28} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 1, character: 0} + end: {line: 1, character: 3} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceLineAirAndBatAfterBat.yml b/data/fixtures/recorded/modifiers/bringReferenceLineAirAndBatAfterBat.yml new file mode 100644 index 0000000000..39aa694e61 --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceLineAirAndBatAfterBat.yml @@ -0,0 +1,67 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference line air and bat after bat + action: + name: replaceWithTarget + source: + type: list + elements: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + - type: containingScope + scopeType: {type: line} + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: b} + destination: + type: primitive + insertionMode: after + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: b} + usePrePhraseSnapshot: true +initialState: + documentContents: | + aaa + bbb + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + default.b: + start: {line: 1, character: 0} + end: {line: 1, character: 3} +finalState: + documentContents: | + aaa + bbb + Untitled-1#L1 + Untitled-1#L2 + selections: + - anchor: {line: 4, character: 0} + active: {line: 4, character: 0} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 2, character: 0} + end: {line: 3, character: 13} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 1, character: 0} + end: {line: 1, character: 3} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceLineAirPastDrum.yml b/data/fixtures/recorded/modifiers/bringReferenceLineAirPastDrum.yml new file mode 100644 index 0000000000..eca62d3367 --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceLineAirPastDrum.yml @@ -0,0 +1,60 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference line air past drum + action: + name: replaceWithTarget + source: + type: range + anchor: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + - type: containingScope + scopeType: {type: line} + active: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: d} + excludeAnchor: false + excludeActive: false + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: |+ + aaa bbb ccc + ddd + + selections: + - anchor: {line: 3, character: 0} + active: {line: 3, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + default.d: + start: {line: 1, character: 0} + end: {line: 1, character: 3} +finalState: + documentContents: |- + aaa bbb ccc + ddd + + Untitled-1#L1-L2 + selections: + - anchor: {line: 3, character: 16} + active: {line: 3, character: 16} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 3, character: 0} + end: {line: 3, character: 16} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 1, character: 3} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceTokenAir.yml b/data/fixtures/recorded/modifiers/bringReferenceTokenAir.yml new file mode 100644 index 0000000000..86c1fb8259 --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceTokenAir.yml @@ -0,0 +1,48 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference token air + action: + name: replaceWithTarget + source: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + - type: containingScope + scopeType: {type: token} + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: |+ + aaa bbb + + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} +finalState: + documentContents: |- + aaa bbb + + Untitled-1#L1C1-L1C4 + selections: + - anchor: {line: 2, character: 20} + active: {line: 2, character: 20} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 2, character: 0} + end: {line: 2, character: 20} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceTokenAir2.yml b/data/fixtures/recorded/modifiers/bringReferenceTokenAir2.yml new file mode 100644 index 0000000000..15d48665a0 --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceTokenAir2.yml @@ -0,0 +1,51 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference token air + action: + name: replaceWithTarget + source: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - type: reference + snippets: + - {body: ' $relative (`$content`) ', lineMode: singleLine} + - {body: "\n\n$relative:\n\n```$languageId\n$content\n```\n\n", lineMode: multiLine} + - type: containingScope + scopeType: {type: token} + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: |+ + aaa + + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} +finalState: + documentContents: |- + aaa + + Untitled-1#L1C1-L1C4 (`aaa`) + selections: + - anchor: {line: 2, character: 30} + active: {line: 2, character: 30} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 2, character: 0} + end: {line: 2, character: 30} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceTokenAirAndBat.yml b/data/fixtures/recorded/modifiers/bringReferenceTokenAirAndBat.yml new file mode 100644 index 0000000000..c446ab161b --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceTokenAirAndBat.yml @@ -0,0 +1,59 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference token air and bat + action: + name: replaceWithTarget + source: + type: list + elements: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + - type: containingScope + scopeType: {type: token} + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: b} + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: | + aaa bbb + selections: + - anchor: {line: 1, character: 0} + active: {line: 1, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + default.b: + start: {line: 0, character: 4} + end: {line: 0, character: 7} +finalState: + documentContents: |- + aaa bbb + Untitled-1#L1C1-L1C4, Untitled-1#L1C5-L1C8 + selections: + - anchor: {line: 1, character: 42} + active: {line: 1, character: 42} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 1, character: 0} + end: {line: 1, character: 42} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + isReversed: false + hasExplicitRange: true + - type: UntypedTarget + contentRange: + start: {line: 0, character: 4} + end: {line: 0, character: 7} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceTokenAirPastBat.yml b/data/fixtures/recorded/modifiers/bringReferenceTokenAirPastBat.yml new file mode 100644 index 0000000000..bc77b0b2ee --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceTokenAirPastBat.yml @@ -0,0 +1,56 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference token air past bat + action: + name: replaceWithTarget + source: + type: range + anchor: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + - type: containingScope + scopeType: {type: token} + active: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: b} + excludeAnchor: false + excludeActive: false + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: | + aaa bbb ccc + selections: + - anchor: {line: 1, character: 0} + active: {line: 1, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + default.b: + start: {line: 0, character: 4} + end: {line: 0, character: 7} +finalState: + documentContents: |- + aaa bbb ccc + Untitled-1#L1C1-L1C8 + selections: + - anchor: {line: 1, character: 20} + active: {line: 1, character: 20} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 1, character: 0} + end: {line: 1, character: 20} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 7} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceTwoLinesAir.yml b/data/fixtures/recorded/modifiers/bringReferenceTwoLinesAir.yml new file mode 100644 index 0000000000..65a0176c1b --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceTwoLinesAir.yml @@ -0,0 +1,53 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference two lines air + action: + name: replaceWithTarget + source: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + - type: relativeScope + scopeType: {type: line} + offset: 0 + length: 2 + direction: forward + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: |+ + aaa bbb ccc + ddd + + selections: + - anchor: {line: 3, character: 0} + active: {line: 3, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} +finalState: + documentContents: |- + aaa bbb ccc + ddd + + Untitled-1#L1-L2 + selections: + - anchor: {line: 3, character: 16} + active: {line: 3, character: 16} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 3, character: 0} + end: {line: 3, character: 16} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 1, character: 3} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceTwoLinesAir2.yml b/data/fixtures/recorded/modifiers/bringReferenceTwoLinesAir2.yml new file mode 100644 index 0000000000..bebd6b90cc --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceTwoLinesAir2.yml @@ -0,0 +1,49 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference two lines air + action: + name: replaceWithTarget + source: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + - type: relativeScope + scopeType: {type: line} + offset: 0 + length: 2 + direction: forward + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: | + aaa + selections: + - anchor: {line: 1, character: 0} + active: {line: 1, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} +finalState: + documentContents: |- + aaa + Untitled-1#L1-L2 + selections: + - anchor: {line: 1, character: 16} + active: {line: 1, character: 16} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 1, character: 0} + end: {line: 1, character: 16} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 1, character: 0} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/bringReferenceTwoTokensAir.yml b/data/fixtures/recorded/modifiers/bringReferenceTwoTokensAir.yml new file mode 100644 index 0000000000..218a3a392b --- /dev/null +++ b/data/fixtures/recorded/modifiers/bringReferenceTwoTokensAir.yml @@ -0,0 +1,49 @@ +languageId: plaintext +command: + version: 7 + spokenForm: bring reference two tokens air + action: + name: replaceWithTarget + source: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + - type: relativeScope + scopeType: {type: token} + offset: 0 + length: 2 + direction: forward + destination: {type: implicit} + usePrePhraseSnapshot: true +initialState: + documentContents: | + aaa bbb ccc + selections: + - anchor: {line: 1, character: 0} + active: {line: 1, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} +finalState: + documentContents: |- + aaa bbb ccc + Untitled-1#L1C1-L1C8 + selections: + - anchor: {line: 1, character: 20} + active: {line: 1, character: 20} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 1, character: 0} + end: {line: 1, character: 20} + isReversed: false + hasExplicitRange: true + sourceMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 7} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/modifiers/copyReferenceAir.yml b/data/fixtures/recorded/modifiers/copyReferenceAir.yml new file mode 100644 index 0000000000..64183c8144 --- /dev/null +++ b/data/fixtures/recorded/modifiers/copyReferenceAir.yml @@ -0,0 +1,38 @@ +languageId: plaintext +command: + version: 7 + spokenForm: copy reference air + action: + name: copyToClipboard + target: + type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + usePrePhraseSnapshot: true +initialState: + documentContents: |+ + aaa bbb ccc + + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} +finalState: + documentContents: |+ + aaa bbb ccc + + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 0} + clipboard: Untitled-1#L1 + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + isReversed: false + hasExplicitRange: false diff --git a/data/fixtures/recorded/modifiers/copyReferenceAirAndBat.yml b/data/fixtures/recorded/modifiers/copyReferenceAirAndBat.yml new file mode 100644 index 0000000000..e1c9c2f744 --- /dev/null +++ b/data/fixtures/recorded/modifiers/copyReferenceAirAndBat.yml @@ -0,0 +1,53 @@ +languageId: plaintext +command: + version: 7 + spokenForm: copy reference air and bat + action: + name: copyToClipboard + target: + type: list + elements: + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: a} + modifiers: + - {type: reference} + - type: primitive + mark: {type: decoratedSymbol, symbolColor: default, character: b} + usePrePhraseSnapshot: true +initialState: + documentContents: | + aaa + bbb + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 0} + marks: + default.a: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + default.b: + start: {line: 1, character: 0} + end: {line: 1, character: 3} +finalState: + documentContents: | + aaa + bbb + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 0} + clipboard: |- + Untitled-1#L1 + Untitled-1#L2 + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 0} + end: {line: 0, character: 3} + isReversed: false + hasExplicitRange: false + - type: UntypedTarget + contentRange: + start: {line: 1, character: 0} + end: {line: 1, character: 3} + isReversed: false + hasExplicitRange: false diff --git a/packages/common/src/ide/PassthroughIDEBase.ts b/packages/common/src/ide/PassthroughIDEBase.ts index fc968e334e..969ba9aabc 100644 --- a/packages/common/src/ide/PassthroughIDEBase.ts +++ b/packages/common/src/ide/PassthroughIDEBase.ts @@ -15,6 +15,7 @@ import type { Disposable, IDE, OpenUntitledTextDocumentOptions, + ReferencePathMode, RunMode, WorkspaceFolder, } from "./types/ide.types"; @@ -144,6 +145,13 @@ export default class PassthroughIDEBase implements IDE { return this.original.workspaceFolders; } + public getReferencePath( + document: TextDocument, + mode: ReferencePathMode, + ): string | undefined { + return this.original.getReferencePath?.(document, mode); + } + public findInDocument(query: string, editor?: TextEditor): Promise { return this.original.findInDocument(query, editor); } diff --git a/packages/common/src/ide/types/ide.types.ts b/packages/common/src/ide/types/ide.types.ts index 354ebd6c14..c5dd407fa1 100644 --- a/packages/common/src/ide/types/ide.types.ts +++ b/packages/common/src/ide/types/ide.types.ts @@ -63,6 +63,15 @@ export interface IDE { */ readonly workspaceFolders: readonly WorkspaceFolder[] | undefined; + /** + * Provide a path / URI for the given document in the requested mode, eg + * workspace-relative path, absolute path, or remote Git URL. + */ + getReferencePath?( + document: TextDocument, + mode: ReferencePathMode, + ): string | undefined; + /** * The currently active editor or `undefined`. The active editor is the one * that currently has focus or, when none has focus, the one that has changed @@ -248,6 +257,12 @@ export interface IDE { ): Promise; } +export type ReferencePathMode = + | "relative" + | "absolute" + | "gitRemoteWithBranch" + | "gitRemoteCanonical"; + export interface WorkspaceFolder { uri: URI; name: string; diff --git a/packages/common/src/types/command/PartialTargetDescriptor.types.ts b/packages/common/src/types/command/PartialTargetDescriptor.types.ts index 3203c79690..9410a5dae3 100644 --- a/packages/common/src/types/command/PartialTargetDescriptor.types.ts +++ b/packages/common/src/types/command/PartialTargetDescriptor.types.ts @@ -418,6 +418,35 @@ export interface TailModifier { modifiers?: Modifier[]; } +export type ReferenceSnippetLineMode = "singleLine" | "multiLine"; + +export interface ReferenceSnippetDescriptor { + /** + * Snippet body supporting `$relative`, `$absolute`, `$remote`, `$canonical`, + * `$content`, and `$languageId` placeholders. + */ + body: string; + + /** + * Restrict snippet usage to either single-line or multi-line targets. When + * omitted, snippet applies to both. + */ + lineMode?: ReferenceSnippetLineMode; +} + +export interface ReferenceModifier { + type: "reference"; + snippets?: ReferenceSnippetDescriptor[]; +} + +export interface FilenameModifier { + type: "filename"; +} + +export interface FilenameWithoutExtensionModifier { + type: "filenameWithoutExtension"; +} + /** * Runs {@link modifier} if the target has no explicit scope type, ie if * {@link Target.hasExplicitScopeType} is `false`. @@ -478,7 +507,10 @@ export type Modifier = | RangeModifier | KeepContentFilterModifier | KeepEmptyFilterModifier - | InferPreviousMarkModifier; + | InferPreviousMarkModifier + | ReferenceModifier + | FilenameModifier + | FilenameWithoutExtensionModifier; export type ModifierType = Modifier["type"]; diff --git a/packages/cursorless-engine/src/actions/CopyToClipboard.ts b/packages/cursorless-engine/src/actions/CopyToClipboard.ts index c16d93236d..7bb2d00bb7 100644 --- a/packages/cursorless-engine/src/actions/CopyToClipboard.ts +++ b/packages/cursorless-engine/src/actions/CopyToClipboard.ts @@ -23,7 +23,12 @@ export class CopyToClipboard implements SimpleAction { targets: Target[], options: Options = { showDecorations: true }, ): Promise { - if (ide().capabilities.commands.clipboardCopy != null) { + const hasTextOnlyTarget = targets.some((target) => target.isTextOnly); + + if ( + !hasTextOnlyTarget && + ide().capabilities.commands.clipboardCopy != null + ) { const simpleAction = new CopyToClipboardSimple(this.rangeUpdater); return simpleAction.run(targets, options); } @@ -38,6 +43,6 @@ export class CopyToClipboard implements SimpleAction { await ide().clipboard.writeText(text); - return { thatTargets: targets }; + return { thatTargets: targets.map((t) => t.thatTarget) }; } } diff --git a/packages/cursorless-engine/src/core/handleHoistedModifiers.ts b/packages/cursorless-engine/src/core/handleHoistedModifiers.ts index 89909b27de..c4abbb0270 100644 --- a/packages/cursorless-engine/src/core/handleHoistedModifiers.ts +++ b/packages/cursorless-engine/src/core/handleHoistedModifiers.ts @@ -173,4 +173,14 @@ const hoistedModifierTypes: HoistedModifierType[] = [ }; }, }, + + // "reference" modifiers are also hoisted, eg "reference air past bat" + // because they can't be easily joined into a range + { + accept(modifier: Modifier) { + return { + accepted: modifier.type === "reference", + }; + }, + }, ]; diff --git a/packages/cursorless-engine/src/processTargets/ModifierStageFactoryImpl.ts b/packages/cursorless-engine/src/processTargets/ModifierStageFactoryImpl.ts index 12a9558133..077581dea5 100644 --- a/packages/cursorless-engine/src/processTargets/ModifierStageFactoryImpl.ts +++ b/packages/cursorless-engine/src/processTargets/ModifierStageFactoryImpl.ts @@ -9,6 +9,7 @@ import { ModifyIfUntypedStage } from "./modifiers/ConditionalModifierStages"; import { ContainingScopeStage } from "./modifiers/ContainingScopeStage"; import { EveryScopeStage } from "./modifiers/EveryScopeStage"; import { FallbackStage } from "./modifiers/FallbackStage"; +import { FilenameStage } from "./modifiers/FilenameStage"; import { KeepContentFilterStage, KeepEmptyFilterStage, @@ -22,6 +23,7 @@ import { EndOfStage, StartOfStage } from "./modifiers/PositionStage"; import { PreferredScopeStage } from "./modifiers/PreferredScopeStage"; import { RangeModifierStage } from "./modifiers/RangeModifierStage"; import { RawSelectionStage } from "./modifiers/RawSelectionStage"; +import { ReferenceStage } from "./modifiers/ReferenceStage"; import { RelativeScopeStage } from "./modifiers/RelativeScopeStage"; import { VisibleStage } from "./modifiers/VisibleStage"; import type { ScopeHandlerFactory } from "./modifiers/scopeHandlers/ScopeHandlerFactory"; @@ -115,6 +117,12 @@ export class ModifierStageFactoryImpl implements ModifierStageFactory { return new ModifyIfUntypedStage(this, modifier); case "range": return new RangeModifierStage(this, modifier); + case "reference": + return new ReferenceStage(this, modifier); + case "filename": + return new FilenameStage("filename"); + case "filenameWithoutExtension": + return new FilenameStage("filenameWithoutExtension"); case "inferPreviousMark": throw Error( `Unexpected modifier '${modifier.type}'; it should have been removed during inference`, diff --git a/packages/cursorless-engine/src/processTargets/modifiers/FilenameStage.ts b/packages/cursorless-engine/src/processTargets/modifiers/FilenameStage.ts new file mode 100644 index 0000000000..d595b65276 --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/FilenameStage.ts @@ -0,0 +1,34 @@ +import type { Target } from "../../typings/target.types"; +import type { + ModifierStage, + ModifierStateOptions, +} from "../PipelineStages.types"; +import { TextOnlyTarget } from "../targets/TextOnlyTarget"; + +/** + * Produces a {@link TextOnlyTarget} with the document filename (optionally + * without the extension) so that it can be inserted via normal actions. + */ +export class FilenameStage implements ModifierStage { + constructor(private mode: "filename" | "filenameWithoutExtension") {} + + run(target: Target, _options: ModifierStateOptions): Target[] { + return [ + new TextOnlyTarget({ + editor: target.editor, + isReversed: target.isReversed, + contentRange: target.contentRange, + text: + this.mode === "filename" + ? target.editor.document.filename + : getBaseName(target.editor.document.filename), + thatTarget: target, + }), + ]; + } +} + +function getBaseName(filename: string): string { + const lastDot = filename.lastIndexOf("."); + return lastDot === -1 ? filename : filename.slice(0, lastDot); +} diff --git a/packages/cursorless-engine/src/processTargets/modifiers/ReferenceStage.ts b/packages/cursorless-engine/src/processTargets/modifiers/ReferenceStage.ts new file mode 100644 index 0000000000..211f65b07d --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/modifiers/ReferenceStage.ts @@ -0,0 +1,160 @@ +import type { + Range, + ReferenceModifier, + ReferencePathMode, + ReferenceSnippetDescriptor, +} from "@cursorless/common"; +import { ide } from "../../singletons/ide.singleton"; +import type { Target, TextualType } from "../../typings/target.types"; +import type { ModifierStageFactory } from "../ModifierStageFactory"; +import type { + ModifierStage, + ModifierStateOptions, +} from "../PipelineStages.types"; +import { TextOnlyTarget } from "../targets/TextOnlyTarget"; +import { containingLineIfUntypedModifier } from "./commonContainingScopeIfUntypedModifiers"; + +const textualTypeMapping: Record = { + character: "lineAndColumn", + word: "lineAndColumn", + token: "lineAndColumn", + line: "line", + document: "file", +}; + +const defaultReferenceSnippets: ReferenceSnippetDescriptor[] = [ + { + body: "$relative", + }, +]; + +export class ReferenceStage implements ModifierStage { + private containingLineIfUntypedStage: ModifierStage; + private nameStage: ModifierStage; + + constructor( + modifierHandlerFactory: ModifierStageFactory, + private modifier: ReferenceModifier, + ) { + this.containingLineIfUntypedStage = modifierHandlerFactory.create( + containingLineIfUntypedModifier, + ); + this.nameStage = modifierHandlerFactory.create({ + type: "containingScope", + scopeType: { type: "name" }, + }); + } + + run(target: Target, _options: ModifierStateOptions): Target[] { + // First, expand to containing line if untyped + target = this.containingLineIfUntypedStage.run(target, _options)[0]; + + // Then, get the appropriate snippet + const snippets = this.modifier.snippets ?? defaultReferenceSnippets; + const isSingleLineMode = + target.contentRange.isSingleLine && + textualTypeMapping[target.textualType] === "lineAndColumn"; + const snippetDescriptor = + snippets.find((descriptor) => + matchesLineMode(descriptor.lineMode, isSingleLineMode), + ) ?? snippets[0]; + + const document = target.editor.document; + + let name: string | null = null; + if (target.textualType === "document") { + name = document.filename; + } else { + try { + name = this.nameStage.run(target, _options)[0].contentText; + } catch (e) {} + } + + const fragment = getFragment( + target.contentRange, + textualTypeMapping[target.textualType], + ); + + const getReferencePath = (referencePathMode: ReferencePathMode) => { + const path = ide().getReferencePath?.(document, referencePathMode); + if (path == null) { + return null; + } + return `${path}${fragment}`; + }; + + const remoteWithBranch = getReferencePath("gitRemoteWithBranch"); + const remoteCanonical = getReferencePath("gitRemoteCanonical"); + // Use canonical for specific ranges, non-canonical for whole document + const remote = + target.textualType === "document" ? remoteWithBranch : remoteCanonical; + + const variables: Record = { + content: target.contentText, + languageId: document.languageId, + absolute: getReferencePath("absolute"), + relative: getReferencePath("relative"), + remote, + remoteWithBranch, + remoteCanonical, + name, + }; + + const text = subsituteSnippet(snippetDescriptor.body, variables); + + return [ + new TextOnlyTarget({ + editor: target.editor, + isReversed: target.isReversed, + contentRange: target.contentRange, + text, + thatTarget: target, + }), + ]; + } +} + +function matchesLineMode( + lineMode: ReferenceSnippetDescriptor["lineMode"], + isSingleLineMode: boolean, +): boolean { + if (lineMode == null) { + return true; + } + return lineMode === "singleLine" ? isSingleLineMode : !isSingleLineMode; +} + +function subsituteSnippet( + body: string, + variables: Record, +) { + return body + .replaceAll(/\$(\w+)/g, (_, varName) => { + const value = variables[varName]; + if (value === undefined) { + throw new Error(`Unknown snippet variable: ${varName}`); + } + if (value === null) { + throw new Error(`Snippet variable not available: ${varName}`); + } + return value; + }) + .replaceAll(/\$\$/g, "$"); +} + +type ReferenceFormattingMode = "file" | "line" | "lineAndColumn"; + +function getFragment(range: Range, mode: ReferenceFormattingMode): string { + switch (mode) { + case "file": + return ""; + case "line": + return range.isSingleLine + ? `#L${range.start.line + 1}` + : `#L${range.start.line + 1}-L${range.end.line + 1}`; + case "lineAndColumn": + return range.isEmpty + ? `#L${range.start.line + 1}C${range.start.character + 1}` + : `#L${range.start.line + 1}C${range.start.character + 1}-L${range.end.line + 1}C${range.end.character + 1}`; + } +} diff --git a/packages/cursorless-engine/src/processTargets/targets/BaseTarget.ts b/packages/cursorless-engine/src/processTargets/targets/BaseTarget.ts index 7f24efb096..13f3b2e279 100644 --- a/packages/cursorless-engine/src/processTargets/targets/BaseTarget.ts +++ b/packages/cursorless-engine/src/processTargets/targets/BaseTarget.ts @@ -51,6 +51,7 @@ export abstract class BaseTarget< isRaw = false; isImplicit = false; isNotebookCell = false; + isTextOnly = false; textualType: TextualType = "token"; constructor(parameters: TParameters & CommonTargetParameters) { diff --git a/packages/cursorless-engine/src/processTargets/targets/DocumentTarget.ts b/packages/cursorless-engine/src/processTargets/targets/DocumentTarget.ts index ad26edea9e..986e0d30e2 100644 --- a/packages/cursorless-engine/src/processTargets/targets/DocumentTarget.ts +++ b/packages/cursorless-engine/src/processTargets/targets/DocumentTarget.ts @@ -6,7 +6,7 @@ import { PlainTarget } from "./PlainTarget"; export class DocumentTarget extends BaseTarget { type = "DocumentTarget"; - textualType: TextualType = "line"; + textualType: TextualType = "document"; insertionDelimiter = "\n\n"; constructor(parameters: CommonTargetParameters) { diff --git a/packages/cursorless-engine/src/processTargets/targets/ScopeTypeTarget.ts b/packages/cursorless-engine/src/processTargets/targets/ScopeTypeTarget.ts index cf74c9854d..d9ac86b9ef 100644 --- a/packages/cursorless-engine/src/processTargets/targets/ScopeTypeTarget.ts +++ b/packages/cursorless-engine/src/processTargets/targets/ScopeTypeTarget.ts @@ -46,6 +46,7 @@ export class ScopeTypeTarget extends BaseTarget { getInsertionDelimiter(parameters.scopeTypeType); this.hasDelimiterRange_ = !!this.leadingDelimiterRange_ || !!this.trailingDelimiterRange_; + this.textualType = getSmartRemovalTarget(this).textualType; } getLeadingDelimiterTarget(): Target | undefined { diff --git a/packages/cursorless-engine/src/processTargets/targets/TextOnlyTarget.ts b/packages/cursorless-engine/src/processTargets/targets/TextOnlyTarget.ts new file mode 100644 index 0000000000..0aa2cbd649 --- /dev/null +++ b/packages/cursorless-engine/src/processTargets/targets/TextOnlyTarget.ts @@ -0,0 +1,79 @@ +import type { + EnforceUndefined, + GeneralizedRange, + InsertionMode, + Range, +} from "@cursorless/common"; +import type { EditWithRangeUpdater } from "../../typings/Types"; +import type { Destination, Target } from "../../typings/target.types"; +import type { CommonTargetParameters } from "./BaseTarget"; +import { BaseTarget } from "./BaseTarget"; + +interface TextOnlyTargetParameters extends CommonTargetParameters { + text: string; +} + +export class TextOnlyTarget extends BaseTarget { + type = "TextOnlyTarget"; + insertionDelimiter: string; + isTextOnly = true; + + private readonly text: string; + + constructor(parameters: TextOnlyTargetParameters) { + super(parameters); + this.text = parameters.text; + this.insertionDelimiter = parameters.text.includes("\n") ? "\n\n" : ", "; + } + + override get contentText(): string { + return this.text; + } + + getLeadingDelimiterTarget(): Target | undefined { + return undefined; + } + + getTrailingDelimiterTarget(): Target | undefined { + return undefined; + } + + override getRemovalRange(): Range { + throw new Error( + "Text-only targets are read-only and cannot be used with destructive actions", + ); + } + + override constructRemovalEdit(): EditWithRangeUpdater { + throw new Error( + "Text-only targets are read-only and cannot be used with destructive actions", + ); + } + + override getRemovalHighlightRange(): GeneralizedRange { + throw new Error( + "Text-only targets are read-only and cannot be used with destructive actions", + ); + } + + override toDestination(insertionMode: InsertionMode): Destination { + // FIXME: Semantically, this is a bit wrong, since text-only targets don't + // really refer to the original target anymore. However, there are two + // reasons we do this: + // + // 1. It makes "bring reference air to bat" work despite inference, which + // seems harmless + // 2. It works around an oddness in our "bring" implementation where we + // convert sources to destinations for purposes of consistency when doing + // highlights and source marks. That could probably be reworked, but + // leaving that out of scope for now. + return this.thatTarget.toDestination(insertionMode); + } + + protected getCloneParameters(): EnforceUndefined { + return { + ...this.state, + text: this.text, + }; + } +} diff --git a/packages/cursorless-engine/src/processTargets/targets/index.ts b/packages/cursorless-engine/src/processTargets/targets/index.ts index 4000243a9e..52c37d7346 100644 --- a/packages/cursorless-engine/src/processTargets/targets/index.ts +++ b/packages/cursorless-engine/src/processTargets/targets/index.ts @@ -16,3 +16,4 @@ export * from "./ImplicitTarget"; export * from "./InteriorTarget"; export * from "./HeadTailTarget"; export * from "./BoundedParagraphTarget"; +export * from "./TextOnlyTarget"; diff --git a/packages/cursorless-engine/src/spokenForms/defaultSpokenFormMapCore.ts b/packages/cursorless-engine/src/spokenForms/defaultSpokenFormMapCore.ts index 3bf96c1d90..efff02c3f6 100644 --- a/packages/cursorless-engine/src/spokenForms/defaultSpokenFormMapCore.ts +++ b/packages/cursorless-engine/src/spokenForms/defaultSpokenFormMapCore.ts @@ -134,6 +134,9 @@ export const defaultSpokenFormMapCore: DefaultSpokenFormMapDefinition = { extendThroughStartOf: "head", extendThroughEndOf: "tail", everyScope: "every", + reference: "reference", + filename: "filename", + filenameWithoutExtension: "basename", }, modifierExtra: { diff --git a/packages/cursorless-engine/src/typings/target.types.ts b/packages/cursorless-engine/src/typings/target.types.ts index 9fe4df0027..70a5a4c374 100644 --- a/packages/cursorless-engine/src/typings/target.types.ts +++ b/packages/cursorless-engine/src/typings/target.types.ts @@ -29,7 +29,7 @@ import type { EditWithRangeUpdater } from "./Types"; export type EditNewActionType = "edit" | "insertLineAfter"; -export type TextualType = "character" | "word" | "token" | "line"; +export type TextualType = "character" | "word" | "token" | "line" | "document"; export interface Target { /** The text editor used for all ranges */ @@ -119,6 +119,15 @@ export interface Target { /** If true this target is a notebook cell */ readonly isNotebookCell: boolean; + /** + * If `true`, this target represents synthesized text that doesn't directly + * correspond to the underlying document contents. Actions that rely on + * reading from the document (eg clipboard copy via the IDE) should avoid + * using this target's {@link contentRange} and instead use + * {@link contentText}. + */ + readonly isTextOnly: boolean; + /** The text contained in the content range */ readonly contentText: string; diff --git a/packages/cursorless-engine/src/util/getScopeType.ts b/packages/cursorless-engine/src/util/getScopeType.ts index 1d6ad3b7cb..e9c07e23a5 100644 --- a/packages/cursorless-engine/src/util/getScopeType.ts +++ b/packages/cursorless-engine/src/util/getScopeType.ts @@ -25,6 +25,9 @@ export function getScopeType(modifier: Modifier): ScopeType | undefined { case "fallback": case "range": case "modifyIfUntyped": + case "reference": + case "filename": + case "filenameWithoutExtension": return undefined; default: { diff --git a/packages/cursorless-vscode/src/ide/vscode/VscodeIDE.ts b/packages/cursorless-vscode/src/ide/vscode/VscodeIDE.ts index c235ddd0f9..5cc2d8f036 100644 --- a/packages/cursorless-vscode/src/ide/vscode/VscodeIDE.ts +++ b/packages/cursorless-vscode/src/ide/vscode/VscodeIDE.ts @@ -8,8 +8,10 @@ import type { InputBoxOptions, NotebookEditor, OpenUntitledTextDocumentOptions, + ReferencePathMode, QuickPickOptions, RunMode, + TextDocument, TextDocumentChangeEvent, TextEditor, } from "@cursorless/common"; @@ -19,6 +21,7 @@ import { fromVscodeSelection, } from "@cursorless/vscode-common"; import { pull } from "lodash-es"; +import * as path from "path"; import { v4 as uuid } from "uuid"; import type { ExtensionContext, WorkspaceFolder } from "vscode"; import * as vscode from "vscode"; @@ -36,6 +39,7 @@ import { vscodeRunMode } from "./VscodeRunMode"; import { VscodeTextDocumentImpl } from "./VscodeTextDocumentImpl"; import { VscodeTextEditorImpl } from "./VscodeTextEditorImpl"; import { vscodeShowQuickPick } from "./vscodeShowQuickPick"; +import { getGitInfo } from "./getGitInfo"; export class VscodeIDE implements IDE { readonly configuration: VscodeConfiguration; @@ -101,6 +105,25 @@ export class VscodeIDE implements IDE { return workspace.workspaceFolders; } + getReferencePath( + document: TextDocument, + mode: ReferencePathMode, + ): string | undefined { + const absolutePath = document.uri.fsPath ?? document.uri.path; + switch (mode) { + case "relative": + return workspace.asRelativePath(absolutePath, false); + case "absolute": + return absolutePath; + case "gitRemoteWithBranch": + return getRemoteGitPath(absolutePath, false); + case "gitRemoteCanonical": + return getRemoteGitPath(absolutePath, true); + default: + return undefined; + } + } + get activeTextEditor(): VscodeTextEditorImpl | undefined { return this.getActiveTextEditor(); } @@ -248,3 +271,51 @@ export class VscodeIDE implements IDE { return () => pull(this.extensionContext.subscriptions, ...disposables); } } + +function normalizeRemote(remoteUrl: string): string | undefined { + const trimmed = remoteUrl.replace(/\.git$/, ""); + if (trimmed.startsWith("https://") || trimmed.startsWith("http://")) { + return trimmed; + } + + const sshMatch = trimmed.match(/^(?:ssh:\/\/)?git@([^:/]+)[:/](.+)$/); + if (sshMatch) { + return `https://${sshMatch[1]}/${sshMatch[2]}`; + } + + return undefined; +} + +function getRemoteGitPath( + absolutePath: string | undefined, + isCanonical: boolean, +): string | undefined { + if (absolutePath == null || absolutePath.length === 0) { + return undefined; + } + + const { remoteUrl, headSha, relativePath, defaultBranch } = + getGitInfo(absolutePath); + + if ( + remoteUrl == null || + relativePath == null || + relativePath.length === 0 || + relativePath.startsWith("..") + ) { + return undefined; + } + + const normalizedRemote = normalizeRemote(remoteUrl); + if (normalizedRemote == null) { + return undefined; + } + + const branchOrSha = isCanonical ? headSha : (defaultBranch ?? "main"); + + if (branchOrSha == null || branchOrSha.length === 0) { + return undefined; + } + + return `${normalizedRemote}/blob/${branchOrSha}/${relativePath}`; +} diff --git a/packages/cursorless-vscode/src/ide/vscode/getGitInfo.ts b/packages/cursorless-vscode/src/ide/vscode/getGitInfo.ts new file mode 100644 index 0000000000..ac4eb63a0c --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/getGitInfo.ts @@ -0,0 +1,54 @@ +import { execSync } from "node:child_process"; +import * as path from "node:path"; + +export interface GitInfo { + remoteUrl?: string; + headSha?: string; + relativePath?: string; + defaultBranch?: string; +} + +export function getGitInfo(absolutePath: string): GitInfo { + const cwd = path.dirname(absolutePath); + try { + const remoteUrl = tryGetValue("git config --get remote.origin.url", cwd); + const headSha = tryGetValue("git rev-parse HEAD", cwd); + const root = tryGetValue("git rev-parse --show-toplevel", cwd); + const relativePath = + root != null + ? path.relative(root, absolutePath).replace(/\\/g, "/") + : undefined; + const defaultBranch = sanitizeDefaultBranch( + tryGetValue("git symbolic-ref --short refs/remotes/origin/HEAD", cwd), + ); + + return { + remoteUrl: remoteUrl ?? undefined, + headSha: headSha ?? undefined, + relativePath, + defaultBranch, + }; + } catch (_err) { + return {}; + } +} + +function sanitizeDefaultBranch(value: string | null): string | undefined { + if (value == null || value.length === 0) { + return undefined; + } + return value.startsWith("origin/") ? value.slice("origin/".length) : value; +} + +function tryGetValue(command: string, cwd: string): string | null { + try { + return execSync(command, { + cwd, + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim(); + } catch { + return null; + } +} diff --git a/todo.md b/todo.md new file mode 100644 index 0000000000..6f5075e6e2 --- /dev/null +++ b/todo.md @@ -0,0 +1,15 @@ +- [x] Support "copy" + - Probably need a flag on Target indicating it's `textOnly` or something? +- [x] Support multiple targets + - Probably join with `, ` for lineAndColumn targets; with `\n\n` otherwise. Can prob use insertionDelimiter on the target? +- [x] Probably throw error if they try to acces range? +- [x] Rename `referenceTo` to just `reference`? +- [x] Record file target test +- [x] Auto expand implicit to line (and add “token air” test) +- [ ] Add spoken forms / field on modifier descriptor to allow including text +- [ ] Add spoken forms / field on modifier descriptor to allow specifying absolute path +- [ ] Add spoken forms / field on modifier descriptor to allow specifying to use GitHub link +- [ ] Add spoken forms / field on modifier descriptor to allow specifying to use canonical GitHub link +- [ ] Add lang id to code fence block +- [ ] Implement follow +- [ ] Switch to templates? Would need to support separate for inline vs block