Skip to content

Commit

Permalink
Validate query capture names (#2147)
Browse files Browse the repository at this point in the history
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
AndreasArvidsson and pokey authored Feb 10, 2024
1 parent 3aff2da commit c5a3681
Show file tree
Hide file tree
Showing 10 changed files with 295 additions and 55 deletions.
46 changes: 33 additions & 13 deletions packages/cursorless-engine/src/languages/LanguageDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import {
SimpleScopeType,
showError,
} from "@cursorless/common";
import { dirname, join } from "path";
import { basename, dirname, join } from "path";
import { TreeSitterScopeHandler } from "../processTargets/modifiers/scopeHandlers";
import { TreeSitterTextFragmentScopeHandler } from "../processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/TreeSitterTextFragmentScopeHandler";
import { ScopeHandler } from "../processTargets/modifiers/scopeHandlers/scopeHandler.types";
import { ide } from "../singletons/ide.singleton";
import { TreeSitter } from "../typings/TreeSitter";
import { matchAll } from "../util/regex";
import { TreeSitterQuery } from "./TreeSitterQuery";
import { validateQueryCaptures } from "./TreeSitterQuery/validateQueryCaptures";
import { TEXT_FRAGMENT_CAPTURE_NAME } from "./captureNames";

/**
Expand Down Expand Up @@ -105,6 +106,8 @@ async function readQueryFileAndImports(
[languageQueryPath]: null,
};

const doValidation = ide().runMode !== "production";

// Keep reading imports until we've read all the imports. Every time we
// encounter an import in a query file, we add it to the map with a value
// of null, so that it will be read on the next iteration
Expand All @@ -114,6 +117,8 @@ async function readQueryFileAndImports(
continue;
}

const fileName = basename(queryPath);

let rawQuery = await fileSystem.readBundledFile(queryPath);

if (rawQuery == null) {
Expand All @@ -137,6 +142,10 @@ async function readQueryFileAndImports(
rawQuery = "";
}

if (doValidation) {
validateQueryCaptures(fileName, rawQuery);
}

rawQueryStrings[queryPath] = rawQuery;
matchAll(
rawQuery,
Expand All @@ -150,18 +159,9 @@ async function readQueryFileAndImports(
/^[^\S\r\n]*;;?[^\S\r\n]*(?:import|include)[^\S\r\n]+['"]?([\w|/.]+)['"]?[^\S\r\n]*$/gm,
(match) => {
const relativeImportPath = match[1];
const canonicalSyntax = `;; import ${relativeImportPath}`;

if (match[0] !== canonicalSyntax) {
showError(
ide().messages,
"LanguageDefinition.readQueryFileAndImports.malformedImport",
`Malformed import statement in ${queryPath}: "${match[0]}". Import statements must be of the form "${canonicalSyntax}"`,
);

if (ide().runMode === "test") {
throw new Error("Invalid import statement");
}

if (doValidation) {
validateImportSyntax(fileName, relativeImportPath, match[0]);
}

const importQueryPath = join(dirname(queryPath), relativeImportPath);
Expand All @@ -174,3 +174,23 @@ async function readQueryFileAndImports(

return Object.values(rawQueryStrings).join("\n");
}

function validateImportSyntax(
file: string,
relativeImportPath: string,
actual: string,
) {
const canonicalSyntax = `;; import ${relativeImportPath}`;

if (actual !== canonicalSyntax) {
showError(
ide().messages,
"LanguageDefinition.readQueryFileAndImports.malformedImport",
`Malformed import statement in ${file}: "${actual}". Import statements must be of the form "${canonicalSyntax}"`,
);

if (ide().runMode === "test") {
throw new Error("Invalid import statement");
}
}
}
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);
}
});
}
});
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ initialState:
documentContents: |-
(
(_
(_) @dummy
(_) @_dummy
(capture) @name @_.domain.end
) @_.domain.start
)
Expand Down
8 changes: 4 additions & 4 deletions queries/go.scm
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,8 @@
(
(if_statement
consequence: (block) @branch.end.endOf
) @_if @branch.start.startOf
(#not-parent-type? @_if if_statement)
) @branch.start.startOf
(#not-parent-type? @branch.start.startOf if_statement)
(#insertion-delimiter! @branch.start.startOf " ")
)

Expand All @@ -271,6 +271,6 @@

;; iteration scope is always the outermost if statement
(
(if_statement) @_if @branch.iteration
(#not-parent-type? @_if if_statement)
(if_statement) @branch.iteration
(#not-parent-type? @branch.iteration if_statement)
)
16 changes: 8 additions & 8 deletions queries/javascript.core.scm
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@
(export_statement
(_
name: (_) @name
) @dummy
) @_dummy

;; We have a special case for this one. Note we don't need to list the other
;; special cases from above because they can't be exported
(#not-type? @dummy variable_declarator)
(#not-type? @_dummy variable_declarator)
) @_.domain

;; Special cases for `(let | const | var) foo = ...;` because the full statement
Expand Down Expand Up @@ -266,8 +266,8 @@
.
value: (_)? @value
) @_.domain
) @dummy
(#has-multiple-children-of-type? @dummy variable_declarator)
) @_dummy
(#has-multiple-children-of-type? @_dummy variable_declarator)
)

(expression_statement
Expand Down Expand Up @@ -696,9 +696,9 @@
(_) @argumentOrParameter
.
(_)? @_.trailing.startOf
) @dummy
) @_dummy
(#not-type? @argumentOrParameter "comment")
(#single-or-multi-line-delimiter! @argumentOrParameter @dummy ", " ",\n")
(#single-or-multi-line-delimiter! @argumentOrParameter @_dummy ", " ",\n")
)

;;!! foo("bar")
Expand All @@ -710,9 +710,9 @@
(_) @argumentOrParameter
.
(_)? @_.trailing.startOf
) @dummy
) @_dummy
(#not-type? @argumentOrParameter "comment")
(#single-or-multi-line-delimiter! @argumentOrParameter @dummy ", " ",\n")
(#single-or-multi-line-delimiter! @argumentOrParameter @_dummy ", " ",\n")
)

(_
Expand Down
Loading

0 comments on commit c5a3681

Please sign in to comment.