diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e9dd6b9849..6607279d3b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: # tests use strings with trailing white space to represent the final # document contents. For example # packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/languages/ruby/changeCondition.yml - exclude: ^packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/.*/[^/]*\.yml$|/generated/|^patches/ + exclude: ^packages/cursorless-vscode-e2e/src/suite/fixtures/recorded/.*/[^/]*\.yml$|\.scope$|/generated/|^patches/ - repo: local hooks: - id: eslint diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index b4ae0b9bea..b35bd962f7 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -94,3 +94,7 @@ export * from "./types/TestCaseFixture"; export * from "./util/getEnvironmentVariableStrict"; export * from "./util/CompositeKeyDefaultMap"; export * from "./util/toPlainObject"; +export * from "./scopeSupportFacets/scopeSupportFacets.types"; +export * from "./scopeSupportFacets/scopeSupportFacetInfos"; +export * from "./scopeSupportFacets/textualScopeSupportFacetInfos"; +export * from "./scopeSupportFacets/getLanguageScopeSupport"; diff --git a/packages/common/src/scopeSupportFacets/getLanguageScopeSupport.ts b/packages/common/src/scopeSupportFacets/getLanguageScopeSupport.ts new file mode 100644 index 0000000000..e813f8dfc1 --- /dev/null +++ b/packages/common/src/scopeSupportFacets/getLanguageScopeSupport.ts @@ -0,0 +1,15 @@ +import { htmlScopeSupport } from "./html"; +import { javascriptScopeSupport } from "./javascript"; +import { LanguageScopeSupportFacetMap } from "./scopeSupportFacets.types"; + +export function getLanguageScopeSupport( + languageId: string, +): LanguageScopeSupportFacetMap { + switch (languageId) { + case "javascript": + return javascriptScopeSupport; + case "html": + return htmlScopeSupport; + } + throw Error(`Unsupported language: '${languageId}'`); +} diff --git a/packages/common/src/scopeSupportFacets/html.ts b/packages/common/src/scopeSupportFacets/html.ts new file mode 100644 index 0000000000..d82b7a0683 --- /dev/null +++ b/packages/common/src/scopeSupportFacets/html.ts @@ -0,0 +1,19 @@ +import { + LanguageScopeSupportFacetMap, + ScopeSupportFacetLevel, +} from "./scopeSupportFacets.types"; + +const { supported, notApplicable } = ScopeSupportFacetLevel; + +export const htmlScopeSupport: LanguageScopeSupportFacetMap = { + ["key.attribute"]: supported, + ["tags"]: supported, + + namedFunction: notApplicable, + ["name.assignment"]: notApplicable, + ["key.mapPair"]: notApplicable, + ["key.mapPair.iteration"]: notApplicable, + ["value.mapPair"]: notApplicable, + ["value.mapPair.iteration"]: notApplicable, + ["value.assignment"]: notApplicable, +}; diff --git a/packages/common/src/scopeSupportFacets/javascript.ts b/packages/common/src/scopeSupportFacets/javascript.ts new file mode 100644 index 0000000000..f00dda2984 --- /dev/null +++ b/packages/common/src/scopeSupportFacets/javascript.ts @@ -0,0 +1,19 @@ +import { + LanguageScopeSupportFacetMap, + ScopeSupportFacetLevel, +} from "./scopeSupportFacets.types"; + +const { supported, notApplicable } = ScopeSupportFacetLevel; + +export const javascriptScopeSupport: LanguageScopeSupportFacetMap = { + namedFunction: supported, + ["name.assignment"]: supported, + ["key.mapPair"]: supported, + ["key.mapPair.iteration"]: supported, + ["value.mapPair"]: supported, + ["value.mapPair.iteration"]: supported, + ["value.assignment"]: supported, + + ["key.attribute"]: notApplicable, + ["tags"]: notApplicable, +}; diff --git a/packages/common/src/scopeSupportFacets/scopeSupportFacetInfos.ts b/packages/common/src/scopeSupportFacets/scopeSupportFacetInfos.ts new file mode 100644 index 0000000000..c50c1d6fe2 --- /dev/null +++ b/packages/common/src/scopeSupportFacets/scopeSupportFacetInfos.ts @@ -0,0 +1,48 @@ +import { + ScopeSupportFacet, + ScopeSupportFacetInfo, +} from "./scopeSupportFacets.types"; + +export const scopeSupportFacetInfos: Record< + ScopeSupportFacet, + ScopeSupportFacetInfo +> = { + namedFunction: { + description: "A named function", + scopeType: "namedFunction", + }, + ["name.assignment"]: { + description: "Name(LHS) of an assignment", + scopeType: "name", + }, + ["key.attribute"]: { + description: "Key(LHS) of an attribute", + scopeType: "collectionKey", + }, + ["key.mapPair"]: { + description: "Key(LHS) of a map pair", + scopeType: "collectionKey", + }, + ["key.mapPair.iteration"]: { + description: "Iteration of map pair keys", + scopeType: "collectionKey", + isIteration: true, + }, + ["value.assignment"]: { + description: "Value(RHS) of an assignment", + scopeType: "value", + }, + ["value.mapPair"]: { + description: "Key(RHS) of a map pair", + scopeType: "value", + }, + ["value.mapPair.iteration"]: { + description: "Iteration of map pair values", + scopeType: "value", + isIteration: true, + }, + ["tags"]: { + description: "Both tags in an xml element", + scopeType: "xmlBothTags", + }, +}; diff --git a/packages/common/src/scopeSupportFacets/scopeSupportFacets.types.ts b/packages/common/src/scopeSupportFacets/scopeSupportFacets.types.ts new file mode 100644 index 0000000000..c7fcaa681e --- /dev/null +++ b/packages/common/src/scopeSupportFacets/scopeSupportFacets.types.ts @@ -0,0 +1,80 @@ +import { SimpleScopeTypeType } from "../types/command/PartialTargetDescriptor.types"; + +const scopeSupportFacets = [ + // "list", + // "list.interior", + // "map", + // "map.interior", + // "collectionKey", + "namedFunction", + // "namedFunction.interior", + // "functionName", + // "anonymousFunction", + // "anonymousFunction.interior", + "name.assignment", + "key.attribute", + "key.mapPair", + "key.mapPair.iteration", + "value.assignment", + "value.mapPair", + "value.mapPair.iteration", + // "value.assignment.removal", + // "value.return", + // "value.return.removal", + // "value.collectionItem", + // "value.collectionItem.removal", + // "statement", + // "ifStatement", + // "condition.if", + // "condition.while", + // "condition.doWhile", + // "condition.for", + // "condition.ternary", + // "branch", + // "comment.line", + // "comment.block", + // "string.singleLine", + // "string.multiLine", + // "textFragment", + // "functionCall", + // "functionCallee", + // "argumentOrParameter.argument", + // "argumentOrParameter.argument.removal", + // "argumentOrParameter.parameter", + // "argumentOrParameter.parameter.removal", + // "class", + // "class.interior", + // "className", + // "type", + "tags", +] as const; + +const textualScopeSupportFacets = [ + "character", + "word", + "token", + "line", + "paragraph", + "document", +] as const; + +export interface ScopeSupportFacetInfo { + readonly description: string; + readonly scopeType: SimpleScopeTypeType; + readonly isIteration?: boolean; +} + +export enum ScopeSupportFacetLevel { + supported, + unsupported, + notApplicable, +} + +export type ScopeSupportFacet = (typeof scopeSupportFacets)[number]; + +export type TextualScopeSupportFacet = + (typeof textualScopeSupportFacets)[number]; + +export type LanguageScopeSupportFacetMap = Partial< + Record +>; diff --git a/packages/common/src/scopeSupportFacets/textualScopeSupportFacetInfos.ts b/packages/common/src/scopeSupportFacets/textualScopeSupportFacetInfos.ts new file mode 100644 index 0000000000..a2d3f78227 --- /dev/null +++ b/packages/common/src/scopeSupportFacets/textualScopeSupportFacetInfos.ts @@ -0,0 +1,35 @@ +import { + ScopeSupportFacetInfo, + TextualScopeSupportFacet, +} from "./scopeSupportFacets.types"; + +export const textualScopeSupportFacetInfos: Record< + TextualScopeSupportFacet, + ScopeSupportFacetInfo +> = { + character: { + description: "A single character in the document", + scopeType: "character", + }, + word: { + description: "A single word in a token", + scopeType: "word", + }, + token: { + description: "A single token in the document", + scopeType: "token", + }, + line: { + description: "A single line in the document", + scopeType: "line", + }, + paragraph: { + description: + "A single paragraph(contiguous block of lines) in the document", + scopeType: "paragraph", + }, + document: { + description: "The entire document", + scopeType: "document", + }, +}; diff --git a/packages/common/src/testUtil/getFixturePaths.ts b/packages/common/src/testUtil/getFixturePaths.ts index 196a6a348a..8e9cf0b465 100644 --- a/packages/common/src/testUtil/getFixturePaths.ts +++ b/packages/common/src/testUtil/getFixturePaths.ts @@ -21,6 +21,10 @@ export function getRecordedTestsDirPath() { return path.join(getFixturesPath(), "recorded"); } +export function getScopeTestsDirPath() { + return path.join(getFixturesPath(), "scopes"); +} + export function getRecordedTestPaths() { const directory = getRecordedTestsDirPath(); const relativeDir = path.dirname(directory); @@ -34,3 +38,17 @@ export function getRecordedTestPaths() { path: p, })); } + +export function getScopeTestPaths() { + const directory = getScopeTestsDirPath(); + const relativeDir = path.dirname(directory); + + return walkFilesSync(directory) + .filter((p) => p.endsWith(".scope")) + .map((p) => ({ + path: p, + name: path.relative(relativeDir, p.substring(0, p.lastIndexOf("."))), + languageId: path.dirname(path.relative(directory, p)).split(path.sep)[0], + facet: path.basename(p).match(/([a-zA-Z.]+)\d*\.scope/)![1], + })); +} diff --git a/packages/common/src/types/ScopeProvider.ts b/packages/common/src/types/ScopeProvider.ts index 4855995fd6..0c0cd7335a 100644 --- a/packages/common/src/types/ScopeProvider.ts +++ b/packages/common/src/types/ScopeProvider.ts @@ -178,7 +178,13 @@ export interface ScopeRanges { */ export interface TargetRanges { contentRange: Range; + removalRange: Range; removalHighlightRange: GeneralizedRange; + leadingDelimiter: TargetRanges | undefined; + trailingDelimiter: TargetRanges | undefined; + interior: TargetRanges[] | undefined; + boundary: TargetRanges[] | undefined; + insertionDelimiter: string; } /** diff --git a/packages/cursorless-engine/src/scopeProviders/getTargetRanges.ts b/packages/cursorless-engine/src/scopeProviders/getTargetRanges.ts index 8be5e52e72..6e4a7f5a4b 100644 --- a/packages/cursorless-engine/src/scopeProviders/getTargetRanges.ts +++ b/packages/cursorless-engine/src/scopeProviders/getTargetRanges.ts @@ -6,10 +6,36 @@ import { import { Target } from "../typings/target.types"; export function getTargetRanges(target: Target): TargetRanges { + const interior = (() => { + try { + target.getInteriorStrict().map(getTargetRanges); + } catch (error) { + return undefined; + } + })(); + + const boundary = (() => { + try { + target.getBoundaryStrict().map(getTargetRanges); + } catch (error) { + return undefined; + } + })(); + return { contentRange: target.contentRange, + removalRange: target.getRemovalRange(), removalHighlightRange: target.isLine ? toLineRange(target.getRemovalHighlightRange()) : toCharacterRange(target.getRemovalHighlightRange()), + leadingDelimiter: getOptionalTarget(target.getLeadingDelimiterTarget()), + trailingDelimiter: getOptionalTarget(target.getTrailingDelimiterTarget()), + interior, + boundary, + insertionDelimiter: target.insertionDelimiter, }; } + +function getOptionalTarget(target: Target | undefined) { + return target != null ? getTargetRanges(target) : undefined; +} diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/html/key.attribute.scope b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/html/key.attribute.scope new file mode 100644 index 0000000000..6bedb626d7 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/html/key.attribute.scope @@ -0,0 +1,20 @@ +
+--- + +[Content] = 0:5-0:7 +0|
+ >--< + +[Removal] = 0:5-0:8 +0|
+ >---< + +[Trailing delimiter] = 0:7-0:8 +0|
+ >-< + +[Domain] = 0:5-0:14 +0|
+ >---------< + +[Insertion delimiter] = " " diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/html/tags.scope b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/html/tags.scope new file mode 100644 index 0000000000..10ab2153df --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/html/tags.scope @@ -0,0 +1,20 @@ +
hello
+--- + +[.1 Content] = +[.1 Removal] = 0:0-0:5 +0|
hello
+ >-----< + +[.1 Insertion delimiter] = " " + +[.2 Content] = +[.2 Removal] = 0:10-0:16 +0|
hello
+ >------< + +[.2 Insertion delimiter] = " " + +[Domain] = 0:0-0:16 +0|
hello
+ >----------------< diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/key.mapPair.iteration.scope b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/key.mapPair.iteration.scope new file mode 100644 index 0000000000..6802f51c3a --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/key.mapPair.iteration.scope @@ -0,0 +1,7 @@ +{ value: 123 } +--- + +[Range] = +[Domain] = 0:1-0:13 +0| { value: 123 } + >------------< diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/key.mapPair.scope b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/key.mapPair.scope new file mode 100644 index 0000000000..12ed6538b7 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/key.mapPair.scope @@ -0,0 +1,20 @@ +{ value: 123 } +--- + +[Content] = 0:2-0:7 +0| { value: 123 } + >-----< + +[Removal] = 0:2-0:9 +0| { value: 123 } + >-------< + +[Trailing delimiter] = 0:7-0:9 +0| { value: 123 } + >--< + +[Domain] = 0:2-0:12 +0| { value: 123 } + >----------< + +[Insertion delimiter] = " " diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/name.assignment.scope b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/name.assignment.scope new file mode 100644 index 0000000000..5923a0157a --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/name.assignment.scope @@ -0,0 +1,24 @@ +const name = "Hello world"; +--- + +[Content] = 0:6-0:10 +0| const name = "Hello world"; + >----< + +[Removal] = 0:0-0:13 +0| const name = "Hello world"; + >-------------< + +[Leading delimiter] = 0:5-0:6 +0| const name = "Hello world"; + >-< + +[Trailing delimiter] = 0:10-0:11 +0| const name = "Hello world"; + >-< + +[Domain] = 0:0-0:27 +0| const name = "Hello world"; + >---------------------------< + +[Insertion delimiter] = " " diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/namedFunction.scope b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/namedFunction.scope new file mode 100644 index 0000000000..b0f16a02df --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/namedFunction.scope @@ -0,0 +1,16 @@ +function myFunk() { + +} +--- + +[Content] = +[Removal] = +[Domain] = 0:0-2:1 +0| function myFunk() { + >------------------- +1| + +2| } + -< + +[Insertion delimiter] = "\n\n" diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/value.assignment.scope b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/value.assignment.scope new file mode 100644 index 0000000000..caa452d3f8 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/value.assignment.scope @@ -0,0 +1,20 @@ +const name = "Hello world"; +--- + +[Content] = 0:13-0:26 +0| const name = "Hello world"; + >-------------< + +[Removal] = 0:10-0:26 +0| const name = "Hello world"; + >----------------< + +[Leading delimiter] = 0:10-0:13 +0| const name = "Hello world"; + >---< + +[Domain] = 0:0-0:27 +0| const name = "Hello world"; + >---------------------------< + +[Insertion delimiter] = " " diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/value.mapPair.iteration.scope b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/value.mapPair.iteration.scope new file mode 100644 index 0000000000..50f1cdca8c --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/value.mapPair.iteration.scope @@ -0,0 +1,13 @@ +{ value: 123 } +--- + +[#1 Range] = +[#1 Domain] = 0:0-0:14 +0| { value: 123 } + >--------------< + + +[#2 Range] = +[#2 Domain] = 0:1-0:13 +0| { value: 123 } + >------------< diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/value.mapPair.scope b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/value.mapPair.scope new file mode 100644 index 0000000000..ef3761a512 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/javascript/value.mapPair.scope @@ -0,0 +1,20 @@ +{ value: 123 } +--- + +[Content] = 0:9-0:12 +0| { value: 123 } + >---< + +[Removal] = 0:7-0:12 +0| { value: 123 } + >-----< + +[Leading delimiter] = 0:7-0:9 +0| { value: 123 } + >--< + +[Domain] = 0:2-0:12 +0| { value: 123 } + >----------< + +[Insertion delimiter] = " " diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/character.scope b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/character.scope new file mode 100644 index 0000000000..92ce8be6f4 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/character.scope @@ -0,0 +1,28 @@ +aaa +--- + +[#1 Content] = +[#1 Removal] = +[#1 Domain] = 0:0-0:1 +0| aaa + >-< + +[#1 Insertion delimiter] = "" + + +[#2 Content] = +[#2 Removal] = +[#2 Domain] = 0:1-0:2 +0| aaa + >-< + +[#2 Insertion delimiter] = "" + + +[#3 Content] = +[#3 Removal] = +[#3 Domain] = 0:2-0:3 +0| aaa + >-< + +[#3 Insertion delimiter] = "" diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/document.scope b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/document.scope new file mode 100644 index 0000000000..b602c396ce --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/document.scope @@ -0,0 +1,22 @@ +aaa + +bbb + +ccc +--- + +[Content] = +[Removal] = +[Domain] = 0:0-4:3 +0| aaa + >--- +1| + +2| bbb + --- +3| + +4| ccc + ---< + +[Insertion delimiter] = "\n" diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/line.scope b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/line.scope new file mode 100644 index 0000000000..61e4b32f55 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/line.scope @@ -0,0 +1,10 @@ +aaa +--- + +[Content] = +[Removal] = +[Domain] = 0:0-0:3 +0| aaa + >---< + +[Insertion delimiter] = "\n" diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/line2.scope b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/line2.scope new file mode 100644 index 0000000000..aa70ecc82b --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/line2.scope @@ -0,0 +1,95 @@ + +aaa + +--- + +[#1 Content] = +[#1 Domain] = 0:0-0:0 +0| + >< +1| aaa + +2| + + +[#1 Removal] = 0:0-1:0 +0| + > +1| aaa + < +2| + + +[#1 Trailing delimiter] = 0:0-1:0 +0| + > +1| aaa + < +2| + + +[#1 Insertion delimiter] = "\n" + + +[#2 Content] = +[#2 Domain] = 1:0-1:3 +0| + +1| aaa + >---< +2| + + +[#2 Removal] = 1:0-2:0 +0| + +1| aaa + >--- +2| + < + +[#2 Leading delimiter] = 0:0-1:0 +0| + > +1| aaa + < +2| + + +[#2 Trailing delimiter] = 1:3-2:0 +0| + +1| aaa + > +2| + < + +[#2 Insertion delimiter] = "\n" + + +[#3 Content] = +[#3 Domain] = 2:0-2:0 +0| + +1| aaa + +2| + >< + +[#3 Removal] = 1:3-2:0 +0| + +1| aaa + > +2| + < + +[#3 Leading delimiter] = 1:3-2:0 +0| + +1| aaa + > +2| + < + +[#3 Insertion delimiter] = "\n" diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/paragraph.scope b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/paragraph.scope new file mode 100644 index 0000000000..fb6b0a3fef --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/paragraph.scope @@ -0,0 +1,66 @@ + +aaa +bbb + +--- + +[Content] = +[Domain] = 1:0-2:3 +0| + +1| aaa + >--- +2| bbb + ---< +3| + + +[Removal] = 0:0-3:0 +0| + > +1| aaa + --- +2| bbb + --- +3| + < + +[Leading delimiter: Content] = 0:0-0:0 +0| + >< +1| aaa + +2| bbb + +3| + +[Leading delimiter: Removal] = 0:0-1:0 +0| + > +1| aaa + < +2| bbb + +3| + + +[Trailing delimiter: Content] = 3:0-3:0 +0| + +1| aaa + +2| bbb + +3| + >< +[Trailing delimiter: Removal] = 2:3-3:0 +0| + +1| aaa + +2| bbb + > +3| + < + +[Insertion delimiter] = "\n\n" diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/token.scope b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/token.scope new file mode 100644 index 0000000000..b40804aff9 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/token.scope @@ -0,0 +1,21 @@ + aaa +--- + +[Content] = +[Domain] = 0:2-0:5 +0| aaa + >---< + +[Removal] = 0:0-0:7 +0| aaa + >-------< + +[Leading delimiter] = 0:0-0:2 +0| aaa + >--< + +[Trailing delimiter] = 0:5-0:7 +0| aaa + >--< + +[Insertion delimiter] = " " diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/word.scope b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/word.scope new file mode 100644 index 0000000000..c8b08df664 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/word.scope @@ -0,0 +1,33 @@ +aaa_bbb +--- + +[#1 Content] = +[#1 Domain] = 0:0-0:3 +0| aaa_bbb + >---< + +[#1 Removal] = 0:0-0:4 +0| aaa_bbb + >----< + +[#1 Trailing delimiter] = 0:3-0:4 +0| aaa_bbb + >-< + +[#1 Insertion delimiter] = "_" + + +[#2 Content] = +[#2 Domain] = 0:4-0:7 +0| aaa_bbb + >---< + +[#2 Removal] = 0:3-0:7 +0| aaa_bbb + >----< + +[#2 Leading delimiter] = 0:3-0:4 +0| aaa_bbb + >-< + +[#2 Insertion delimiter] = "_" diff --git a/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/word2.scope b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/word2.scope new file mode 100644 index 0000000000..a94f3eb776 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/fixtures/scopes/textual/word2.scope @@ -0,0 +1,19 @@ +aaaBbb +--- + +[#1 Content] = +[#1 Removal] = +[#1 Domain] = 0:0-0:3 +0| aaaBbb + >---< + +[#1 Insertion delimiter] = "" + + +[#2 Content] = +[#2 Removal] = +[#2 Domain] = 0:3-0:6 +0| aaaBbb + >---< + +[#2 Insertion delimiter] = "" diff --git a/packages/cursorless-vscode-e2e/src/suite/scopes.vscode.test.ts b/packages/cursorless-vscode-e2e/src/suite/scopes.vscode.test.ts new file mode 100644 index 0000000000..95f065c663 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/scopes.vscode.test.ts @@ -0,0 +1,163 @@ +import { + asyncSafety, + getLanguageScopeSupport, + getScopeTestPaths, + ScopeSupportFacet, + scopeSupportFacetInfos, + ScopeSupportFacetLevel, + ScopeType, + shouldUpdateFixtures, + TextualScopeSupportFacet, + textualScopeSupportFacetInfos, +} from "@cursorless/common"; +import { getCursorlessApi, openNewEditor } from "@cursorless/vscode-common"; +import { assert } from "chai"; +import { groupBy, uniq } from "lodash"; +import { promises as fsp } from "node:fs"; +import { endToEndTestSetup } from "../endToEndTestSetup"; +import { + serializeIterationScopeFixture, + serializeScopeFixture, +} from "./serializeScopeFixture"; + +suite.only("Scope test cases", async function () { + endToEndTestSetup(this); + + const testPaths = getScopeTestPaths(); + const languages = groupBy(testPaths, (test) => test.languageId); + + if (!shouldUpdateFixtures()) { + Object.entries(languages).forEach(([languageId, testPaths]) => + test( + `${languageId} facet coverage`, + asyncSafety(() => + testLanguageSupport( + languageId, + testPaths.map((test) => test.facet), + ), + ), + ), + ); + } + + testPaths.forEach(({ path, name, languageId, facet }) => + test( + name, + asyncSafety(() => runTest(path, languageId, facet)), + ), + ); +}); + +/** + * Ensures that all supported facets for a language are tested, and that all + * tested facets are listed as supported in {@link getLanguageScopeSupport} + * @param languageId The language to test + * @param testedFacets The facets for {@link languageId} that are tested + */ +async function testLanguageSupport(languageId: string, testedFacets: string[]) { + const supportedFacets = (() => { + if (languageId === "textual") { + return Object.keys(textualScopeSupportFacetInfos); + } + + const scopeSupport = getLanguageScopeSupport(languageId); + + return Object.keys(scopeSupport).filter( + (facet) => + scopeSupport[facet as ScopeSupportFacet] === + ScopeSupportFacetLevel.supported, + ); + })(); + + // Assert that all tested facets are supported by the language + const unsupportedFacets = testedFacets.filter( + (testedFacet) => !supportedFacets.includes(testedFacet), + ); + if (unsupportedFacets.length > 0) { + const values = uniq(unsupportedFacets).join(", "); + assert.fail( + `Facets [${values}] are tested but not listed in getLanguageScopeSupport`, + ); + } + + // Assert that all supported facets are tested + const untestedFacets = supportedFacets.filter( + (supportedFacet) => !testedFacets.includes(supportedFacet), + ); + if (untestedFacets.length > 0) { + const values = untestedFacets.join(", "); + assert.fail(`Missing test for scope support facets [${values}]`); + } +} + +async function runTest(file: string, languageId: string, facetId: string) { + const { ide, scopeProvider } = (await getCursorlessApi()).testHelpers!; + const { scopeType, isIteration } = getScopeType(languageId, facetId); + const fixture = (await fsp.readFile(file, "utf8")) + .toString() + .replaceAll("\r\n", "\n"); + const delimiterIndex = fixture.match(/^---$/m)?.index; + + assert.isNotNull( + delimiterIndex, + "Can't find delimiter '---' in scope fixture", + ); + + const code = fixture.slice(0, delimiterIndex! - 1); + + await openNewEditor(code, { languageId }); + + const editor = ide.activeTextEditor!; + + const outputFixture = ((): string => { + const config = { + visibleOnly: false, + scopeType, + }; + + if (isIteration) { + const iterationScopes = scopeProvider.provideIterationScopeRanges( + editor, + { + ...config, + includeNestedTargets: false, + }, + ); + return serializeIterationScopeFixture(code, iterationScopes); + } + + const scopes = scopeProvider.provideScopeRanges(editor, config); + + return serializeScopeFixture(code, scopes); + })(); + + if (shouldUpdateFixtures()) { + await fsp.writeFile(file, outputFixture); + } else { + assert.equal(outputFixture, fixture); + } +} + +function getScopeType( + languageId: string, + facetId: string, +): { + scopeType: ScopeType; + isIteration: boolean; +} { + if (languageId === "textual") { + const { scopeType, isIteration } = + textualScopeSupportFacetInfos[facetId as TextualScopeSupportFacet]; + return { + scopeType: { type: scopeType }, + isIteration: isIteration ?? false, + }; + } + + const { scopeType, isIteration } = + scopeSupportFacetInfos[facetId as ScopeSupportFacet]; + return { + scopeType: { type: scopeType }, + isIteration: isIteration ?? false, + }; +} diff --git a/packages/cursorless-vscode-e2e/src/suite/serializeHeader.ts b/packages/cursorless-vscode-e2e/src/suite/serializeHeader.ts new file mode 100644 index 0000000000..ec54ee0876 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/serializeHeader.ts @@ -0,0 +1,60 @@ +import { Range } from "@cursorless/common"; + +interface SerializeHeaderArg { + prefix?: string; + header: string | undefined; + scopeNumber: number | undefined; + targetNumber: number | undefined; + range?: Range; +} + +/** + * Constructs a header string from a configuration object. For example: + * + * ``` + * serializeHeader({ + * prefix: "Leading delimiter", + * header: "Content", + * scopeNumber: 1, + * targetNumber: 2, + * range: new Range(new Position(1, 2), new Position(3, 4)), + * }) === "[#1.2 Leading delimiter: Content] = 1:2-3:4" + * ``` + * + * @param param A configuration object + * @returns A string representing the header + */ +export function serializeHeader({ + prefix, + header, + scopeNumber, + targetNumber, + range, +}: SerializeHeaderArg): string { + const parts: string[] = []; + if (scopeNumber != null || targetNumber != null) { + const numberParts: string[] = []; + if (scopeNumber != null) { + numberParts.push(`#${scopeNumber}`); + } + if (targetNumber != null) { + numberParts.push(`.${targetNumber}`); + } + parts.push(numberParts.join("")); + } + + if (prefix != null) { + if (header != null) { + parts.push(prefix + ":"); + } else { + parts.push(prefix); + } + } + + if (header != null) { + parts.push(header); + } + + const suffix = range != null ? ` ${range}` : ""; + return `[${parts.join(" ")}] =${suffix}`; +} diff --git a/packages/cursorless-vscode-e2e/src/suite/serializeScopeFixture.ts b/packages/cursorless-vscode-e2e/src/suite/serializeScopeFixture.ts new file mode 100644 index 0000000000..fdc0c39a3b --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/serializeScopeFixture.ts @@ -0,0 +1,328 @@ +import { + IterationScopeRanges, + Range, + ScopeRanges, + TargetRanges, +} from "@cursorless/common"; +import { serializeHeader } from "./serializeHeader"; +import { serializeTargetRange } from "./serializeTargetRange"; + +export function serializeScopeFixture( + code: string, + scopes: ScopeRanges[], +): string { + const codeLines = code.split("\n"); + + const serializedScopes = scopes.map((scope, index) => + serializeScope(codeLines, scope, scopes.length > 1 ? index + 1 : undefined), + ); + + return serializeScopeFixtureHelper(codeLines, serializedScopes); +} + +export function serializeIterationScopeFixture( + code: string, + scopes: IterationScopeRanges[], +): string { + const codeLines = code.split("\n"); + + const serializedScopes = scopes.map((scope, index) => + serializeIterationScope( + codeLines, + scope, + scopes.length > 1 ? index + 1 : undefined, + ), + ); + + return serializeScopeFixtureHelper(codeLines, serializedScopes); +} + +function serializeScopeFixtureHelper( + codeLines: string[], + scopes: string[], +): string { + const serializedScopes = scopes.join("\n\n"); + + return [...codeLines, "---", serializedScopes, ""].join("\n"); +} + +function serializeScope( + codeLines: string[], + { domain, targets }: ScopeRanges, + scopeNumber: number | undefined, +): string { + if (targets.length === 1) { + return serializeTarget({ + codeLines, + target: targets[0], + scopeNumber, + targetNumber: undefined, + domain, + }); + } + + // If we have multiple targets or the domain is not equal to the content range: add domain last + return [ + ...targets.map((target, index) => + serializeTarget({ + codeLines, + target, + scopeNumber, + targetNumber: index + 1, + }), + ), + "", + serializeHeader({ + header: "Domain", + scopeNumber, + targetNumber: undefined, + range: domain, + }), + serializeTargetRange(codeLines, domain), + ].join("\n"); +} + +function serializeIterationScope( + codeLines: string[], + { domain, ranges }: IterationScopeRanges, + scopeNumber: number | undefined, +): string { + const lines: string[] = [""]; + + const groupHeaders = !ranges.some( + (range) => !domain.isRangeEqual(range.range), + ); + + ranges.forEach((range, index) => { + if (!groupHeaders && index > 0) { + lines.push(""); + } + + lines.push( + serializeHeader({ + header: "Range", + scopeNumber, + targetNumber: ranges.length > 1 ? index + 1 : undefined, + range: groupHeaders ? undefined : range.range, + }), + ); + + if (!groupHeaders) { + lines.push(serializeTargetRange(codeLines, range.range)); + } + }); + + if (!groupHeaders) { + lines.push(""); + } + + lines.push( + serializeHeader({ + header: "Domain", + scopeNumber, + targetNumber: undefined, + range: domain, + }), + serializeTargetRange(codeLines, domain), + ); + + return lines.join("\n"); +} + +interface SerializeTargetArg { + codeLines: string[]; + target: TargetRanges; + scopeNumber: number | undefined; + targetNumber: number | undefined; + domain?: Range; +} + +function serializeTarget({ + codeLines, + target, + scopeNumber, + targetNumber, + domain, +}: SerializeTargetArg): string { + const lines: string[] = [""]; + + const headers = ["Content"]; + + // Add removal and domain headers below content header if their ranges are equal + if (target.contentRange.isRangeEqual(target.removalRange)) { + headers.push("Removal"); + } + if (domain != null && target.contentRange.isRangeEqual(domain)) { + headers.push("Domain"); + } + + lines.push( + ...headers.map((header, index) => + serializeHeader({ + header, + scopeNumber, + targetNumber, + range: index === headers.length - 1 ? target.contentRange : undefined, + }), + ), + serializeTargetRange(codeLines, target.contentRange), + ); + + // Add separate removal header below content if their ranges are not equal + if (!target.contentRange.isRangeEqual(target.removalRange)) { + lines.push( + "", + serializeHeader({ + header: "Removal", + scopeNumber, + targetNumber, + range: target.removalRange, + }), + serializeTargetRange(codeLines, target.removalRange), + ); + } + + if (target.leadingDelimiter != null) { + lines.push( + serializeTargetCompact({ + codeLines, + target: target.leadingDelimiter, + prefix: "Leading delimiter", + scopeNumber, + targetNumber, + }), + ); + } + + if (target.trailingDelimiter != null) { + lines.push( + serializeTargetCompact({ + codeLines, + target: target.trailingDelimiter, + prefix: "Trailing delimiter", + scopeNumber, + targetNumber, + }), + ); + } + + if (target.interior != null) { + lines.push( + ...target.interior.map((interior) => + serializeTargetCompact({ + codeLines, + target: interior, + prefix: "Interior", + scopeNumber, + targetNumber, + }), + ), + ); + } + + if (target.boundary != null) { + lines.push( + ...target.boundary.map((interior) => + serializeTargetCompact({ + codeLines, + target: interior, + prefix: "Boundary", + scopeNumber, + targetNumber, + }), + ), + ); + } + + if (domain != null && !target.contentRange.isRangeEqual(domain)) { + lines.push( + "", + serializeHeader({ + header: "Domain", + scopeNumber, + targetNumber, + range: domain, + }), + serializeTargetRange(codeLines, domain), + ); + } + + lines.push( + serializeTargetInsertionDelimiter(target, scopeNumber, targetNumber), + ); + + return lines.join("\n"); +} + +function serializeTargetInsertionDelimiter( + target: TargetRanges, + scopeNumber: number | undefined, + targetNumber: number | undefined, +): string { + const header = serializeHeader({ + header: "Insertion delimiter", + scopeNumber, + targetNumber, + }); + + return `\n${header} ${JSON.stringify(target.insertionDelimiter)}`; +} + +interface SerializeTargetCompactArg { + codeLines: string[]; + target: TargetRanges; + prefix: string | undefined; + scopeNumber: number | undefined; + targetNumber: number | undefined; +} + +/** + * Given a target, serialize it compactly, including only the content and + * removal ranges. We use this for auxiliary targets like delimiters and + * interior/boundary targets of a target that we're serializing. + * @param arg Configuration object + * @returns A string representing the target + */ +function serializeTargetCompact({ + codeLines, + target, + prefix, + scopeNumber, + targetNumber, +}: SerializeTargetCompactArg): string { + const lines: string[] = [""]; + + if (target.contentRange.isRangeEqual(target.removalRange)) { + lines.push( + serializeHeader({ + prefix, + header: undefined, + scopeNumber, + targetNumber, + range: target.contentRange, + }), + serializeTargetRange(codeLines, target.contentRange), + ); + } else { + lines.push( + serializeHeader({ + prefix, + header: "Content", + scopeNumber, + targetNumber, + range: target.contentRange, + }), + serializeTargetRange(codeLines, target.contentRange), + serializeHeader({ + prefix, + header: "Removal", + scopeNumber, + targetNumber, + range: target.removalRange, + }), + serializeTargetRange(codeLines, target.removalRange), + ); + } + + return lines.join("\n"); +} diff --git a/packages/cursorless-vscode-e2e/src/suite/serializeTargetRange.ts b/packages/cursorless-vscode-e2e/src/suite/serializeTargetRange.ts new file mode 100644 index 0000000000..13f5360478 --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/serializeTargetRange.ts @@ -0,0 +1,68 @@ +import { Range } from "@cursorless/common"; + +/** + * Given the code of a fixture and a range, return a string that annotates the + * range in the code. For example, given the code: + * + * ``` + * aaa bbb + * ccc + * + * ddd eee + * ``` + * + * and the range (0, 4)-(3, 3), this function will return: + * + * ``` + * 0| aaa bbb + * >--- + * 1| ccc + * --- + * 2| + * + * 3| ddd eee + * ---< + * ``` + * + * @param codeLines The code of the fixture, split into lines + * @param range The range to represent + * @returns A string that annotates {@link range} in {@link codeLines} + */ +export function serializeTargetRange( + codeLines: string[], + range: Range, +): string { + const { start, end } = range; + const lines: string[] = []; + + codeLines.forEach((codeLine, lineNumber) => { + // Output the line itself, prefixed by `n| `, eg `3| const foo = "bar"` + lines.push( + codeLine.length > 0 ? `${lineNumber}| ${codeLine}` : `${lineNumber}|`, + ); + + if (lineNumber === start.line) { + const prefix = fill(" ", start.character + 2) + ">"; + if (start.line === end.line) { + lines.push(prefix + fill("-", end.character - start.character) + "<"); + } else { + lines.push(prefix + fill("-", codeLine.length - start.character)); + } + } else if (lineNumber > start.line && lineNumber < end.line) { + if (codeLine.length > 0) { + lines.push(" " + fill("-", codeLine.length)); + } else { + lines.push(""); + } + } else if (lineNumber === end.line) { + lines.push(" " + fill("-", end.character) + "<"); + } else { + lines.push(""); + } + }); + + return lines.join("\n"); +} +function fill(character: string, count: number): string { + return new Array(count + 1).join(character); +}