-
-
Notifications
You must be signed in to change notification settings - Fork 81
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Validate query capture names (#2147)
Show error message when an invalid capture name is used Fixes #1433 ## Checklist - [x] I have added [tests](https://www.cursorless.org/docs/contributing/test-case-recorder/) - [-] I have updated the [docs](https://github.com/cursorless-dev/cursorless/tree/main/docs) and [cheatsheet](https://github.com/cursorless-dev/cursorless/tree/main/cursorless-talon/src/cheatsheet) - [-] I have not broken the cheatsheet --------- Co-authored-by: Pokey Rule <755842+pokey@users.noreply.github.com>
- Loading branch information
1 parent
3aff2da
commit c5a3681
Showing
10 changed files
with
295 additions
and
55 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
110 changes: 110 additions & 0 deletions
110
packages/cursorless-engine/src/languages/TreeSitterQuery/validateQueryCaptures.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
import { FakeIDE } from "@cursorless/common"; | ||
import assert from "assert"; | ||
import { injectIde } from "../../singletons/ide.singleton"; | ||
import { validateQueryCaptures } from "./validateQueryCaptures"; | ||
|
||
const testCases: { name: string; isOk: boolean; content: string }[] = [ | ||
{ | ||
name: "Scope captures", | ||
isOk: true, | ||
content: "(if_statement) @statement @ifStatement @comment", | ||
}, | ||
{ | ||
name: "Relationships", | ||
isOk: true, | ||
content: "(if_statement) @statement.domain @statement.interior @_.removal", | ||
}, | ||
{ | ||
name: "Position captures", | ||
isOk: true, | ||
content: | ||
"(if_statement) @statement.startOf @statement.leading.startOf @statement.trailing.endOf", | ||
}, | ||
{ | ||
name: "Range captures", | ||
isOk: true, | ||
content: | ||
"(if_statement) @statement.start @statement.start.endOf @statement.removal.start @statement.interior.start.endOf", | ||
}, | ||
{ | ||
name: "Dummy capture", | ||
isOk: true, | ||
content: "(if_statement) @_foo", | ||
}, | ||
{ | ||
name: "No range dummy relationships", | ||
isOk: false, | ||
content: "(if_statement) @_foo.start @_foo.startOf", | ||
}, | ||
{ | ||
name: "Text fragment", | ||
isOk: true, | ||
content: "(comment) @textFragment", | ||
}, | ||
{ | ||
name: "Iteration", | ||
isOk: true, | ||
content: "(document) @statement.iteration @statement.iteration.domain", | ||
}, | ||
{ | ||
name: "Unknown capture in comment", | ||
isOk: true, | ||
content: ";; (if_statement) @unknown", | ||
}, | ||
{ | ||
name: "Unknown capture", | ||
isOk: false, | ||
content: "(if_statement) @unknown", | ||
}, | ||
{ | ||
name: "Unknown relationship", | ||
isOk: false, | ||
content: "(if_statement) @statement.unknown", | ||
}, | ||
{ | ||
name: "Single @", | ||
isOk: false, | ||
content: "(if_statement) @", | ||
}, | ||
{ | ||
name: "Single wildcard", | ||
isOk: false, | ||
content: "(if_statement) @_", | ||
}, | ||
{ | ||
name: "Wildcard start", | ||
isOk: false, | ||
content: "(if_statement) @_.start", | ||
}, | ||
{ | ||
name: "Leading start", | ||
isOk: false, | ||
content: "(if_statement) @statement.leading.start", | ||
}, | ||
{ | ||
name: "Text fragment removal", | ||
isOk: false, | ||
content: "(comment) @textFragment.removal", | ||
}, | ||
]; | ||
|
||
suite("validateQueryCaptures", function () { | ||
suiteSetup(() => { | ||
injectIde(new FakeIDE()); | ||
}); | ||
|
||
for (const testCase of testCases) { | ||
const name = [testCase.isOk ? "OK" : "Error", testCase.name].join(": "); | ||
|
||
test(name, () => { | ||
const runTest = () => | ||
validateQueryCaptures(testCase.name, testCase.content); | ||
|
||
if (testCase.isOk) { | ||
assert.doesNotThrow(runTest); | ||
} else { | ||
assert.throws(runTest); | ||
} | ||
}); | ||
} | ||
}); |
110 changes: 110 additions & 0 deletions
110
packages/cursorless-engine/src/languages/TreeSitterQuery/validateQueryCaptures.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
import { showError, simpleScopeTypeTypes } from "@cursorless/common"; | ||
import { ide } from "../../singletons/ide.singleton"; | ||
|
||
const wildcard = "_"; | ||
const textFragment = "textFragment"; | ||
const captureNames = [wildcard, ...simpleScopeTypeTypes]; | ||
|
||
const positionRelationships = ["prefix", "leading", "trailing"]; | ||
const positionSuffixes = ["startOf", "endOf"]; | ||
|
||
const rangeRelationships = [ | ||
"domain", | ||
"removal", | ||
"interior", | ||
"iteration", | ||
"iteration.domain", | ||
]; | ||
const rangeSuffixes = [ | ||
"start", | ||
"end", | ||
"start.startOf", | ||
"start.endOf", | ||
"end.startOf", | ||
"end.endOf", | ||
]; | ||
|
||
const allowedCaptures = new Set<string>(); | ||
|
||
allowedCaptures.add(textFragment); | ||
|
||
for (const suffix of rangeSuffixes) { | ||
allowedCaptures.add(`${textFragment}.${suffix}`); | ||
} | ||
|
||
for (const captureName of captureNames) { | ||
// Wildcard is not allowed by itself without a relationship | ||
if (captureName !== wildcard) { | ||
// eg: statement | ||
allowedCaptures.add(captureName); | ||
|
||
// eg: statement.start | statement.start.endOf | ||
for (const suffix of rangeSuffixes) { | ||
allowedCaptures.add(`${captureName}.${suffix}`); | ||
} | ||
} | ||
|
||
for (const relationship of positionRelationships) { | ||
// eg: statement.leading | ||
allowedCaptures.add(`${captureName}.${relationship}`); | ||
|
||
for (const suffix of positionSuffixes) { | ||
// eg: statement.leading.endOf | ||
allowedCaptures.add(`${captureName}.${relationship}.${suffix}`); | ||
} | ||
} | ||
|
||
for (const relationship of rangeRelationships) { | ||
// eg: statement.domain | ||
allowedCaptures.add(`${captureName}.${relationship}`); | ||
|
||
for (const suffix of rangeSuffixes) { | ||
// eg: statement.domain.start | statement.domain.start.endOf | ||
allowedCaptures.add(`${captureName}.${relationship}.${suffix}`); | ||
} | ||
} | ||
} | ||
|
||
// Not a comment. ie line is not starting with `;;` | ||
// Capture starts with `@` and is followed by words and/or dots | ||
const capturePattern = new RegExp(`^(?!;;).*@([\\w.]*)`, "gm"); | ||
|
||
export function validateQueryCaptures(file: string, rawQuery: string): void { | ||
const matches = rawQuery.matchAll(capturePattern); | ||
|
||
const errors: string[] = []; | ||
|
||
for (const match of matches) { | ||
const captureName = match[1]; | ||
|
||
if ( | ||
captureName.length > 1 && | ||
!captureName.includes(".") && | ||
captureName.startsWith("_") | ||
) { | ||
// Allow @_foo dummy captures to use for referring to in query predicates | ||
continue; | ||
} | ||
|
||
if (!allowedCaptures.has(captureName)) { | ||
const lineNumber = match.input!.slice(0, match.index!).split("\n").length; | ||
errors.push(`${file}(${lineNumber}) invalid capture '@${captureName}'.`); | ||
} | ||
} | ||
|
||
if (errors.length === 0) { | ||
return; | ||
} | ||
|
||
const message = errors.join("\n"); | ||
|
||
showError( | ||
ide().messages, | ||
"validateQueryCaptures.invalidCaptureName", | ||
message, | ||
); | ||
|
||
if (ide().runMode === "test") { | ||
throw new Error(message); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.