ast-search 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/build/__tests__/dummyFiles/empty.js +1 -0
- package/build/__tests__/file.test.js +65 -0
- package/build/__tests__/main.test.js +68 -0
- package/build/__tests__/output.test.js +74 -0
- package/build/__tests__/query.test.js +335 -0
- package/build/__tests__/search.test.js +148 -0
- package/build/__tests__/setup.js +1110 -0
- package/build/__tests__/walk.test.js +89 -0
- package/build/file.js +58 -0
- package/build/helpers/nodes.js +149 -0
- package/build/main.js +99 -0
- package/build/output.js +20 -0
- package/build/query.js +59 -0
- package/build/search.js +71 -0
- package/build/walk.js +54 -0
- package/package.json +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# ast-search
|
|
2
|
+
|
|
3
|
+
A (somewhat) limited-case search tool meant to facilitate large-scale refactors.
|
|
4
|
+
|
|
5
|
+
Searches files for a given function or property name, and then determines whether those functions or properties contain a given expression or value.
|
|
6
|
+
|
|
7
|
+
## Example
|
|
8
|
+
|
|
9
|
+
For example, say you have a large number of VueJS single-file-components with `setup` functions that might be improperly accessing the `this.` instance of the file:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
<script>
|
|
13
|
+
...
|
|
14
|
+
export default {
|
|
15
|
+
setup() {
|
|
16
|
+
const store = useStore();
|
|
17
|
+
const dynamicTestValue = computed(() => {
|
|
18
|
+
return this.testValue
|
|
19
|
+
})
|
|
20
|
+
},
|
|
21
|
+
computed: {
|
|
22
|
+
testValue: 0
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
If you have hundreds of component files, this would be especially difficult (if not impossible) to find with a regular expression because it would require both searching across multiple lines and knowing the lexical scope of the `setup` function, which would vary widely across each file.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
`ast-search` leverages [ acornjs ](https://github.com/acornjs/acorn) to construct an Abstract Syntax Tree of each JavaScript file it parses, and then searches relevant paths for both the given top-level function or property as well as the desired search term.
|
|
33
|
+
|
|
34
|
+
For example, say the above VueJS SFC is a file at `src/components/TestValue.vue`. To search this file and determine whether the `setup` function has a `this.` expression, you'd do the following:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
$ ast-search -f ./src/components/TestValue.vue --fn setup -e ThisExpression
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
If it finds the expression in the function, it echoes the file path:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
$ ast-search -f ./src/components/TestValue.vue --fn setup -e ThisExpression
|
|
44
|
+
./src/components/TestValue.vue
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Arguments
|
|
48
|
+
|
|
49
|
+
| Flag | Definition |
|
|
50
|
+
| ------------------ | -------------------------------------------------------------------------------------------------- |
|
|
51
|
+
| `-f, --file` | the file path to search, relative or absolute |
|
|
52
|
+
| `--function, fn` | the function to search; can be any variation of a function declaration or expression |
|
|
53
|
+
| `-p, --property` | the property to search; expected to be a property on an object |
|
|
54
|
+
| `-m, --multiple` | used for searching function calls, of which a single file may contain several |
|
|
55
|
+
| `-e, --expression` | the type of expression to search for; must be a valid `acornjs` JavaScript expression (list below) |
|
|
56
|
+
| `-d, --debug` | output the full Abstract Syntax Tree to a file called `output.json` |
|
|
57
|
+
|
|
58
|
+
### Supported Expressions
|
|
59
|
+
|
|
60
|
+
| Expression Name | Reference | Common Operators |
|
|
61
|
+
| ------------------------ | ----------------------------------------------------- | ----------------------------- |
|
|
62
|
+
| ArrayExpression | begins an array expression | `[]` |
|
|
63
|
+
| ArrowFunctionExpression | begins an arrow function in any lexical context | `() =>` |
|
|
64
|
+
| AssignmentExpression | equals operator | `=` |
|
|
65
|
+
| AwaitExpression | asynchronous promise resolver | `await` |
|
|
66
|
+
| BinaryExpression | any left + right side operation | `1 + 1` |
|
|
67
|
+
| CallExpression | function calls | `fn()` |
|
|
68
|
+
| ChainExpression | chained property or method accessors | `store.state.someFn()` |
|
|
69
|
+
| ConditionalExpression | ternary expression | `x ? y : z` |
|
|
70
|
+
| FunctionExpression | named, non-assigned functions | `fn() {}` |
|
|
71
|
+
| ImportExpression | import statements | `import x from 'y'` |
|
|
72
|
+
| LogicalExpression | evaluates to a boolean, e.g. if statement expressions | `if (x > y)` |
|
|
73
|
+
| MemberExpression | object member accessor | `this.theMember` |
|
|
74
|
+
| NewExpression | `new` constructor declaration | `const x = new Item()` |
|
|
75
|
+
| ObjectExpression | an object as referenced by its brackets | `{}` |
|
|
76
|
+
| ParenthesizedExpression | an expression wrapped in parens | `const foo = (bar)` |
|
|
77
|
+
| SequenceExpression | deconstructor expressions | `({...(a,b),c})` |
|
|
78
|
+
| TaggedTemplateExpression | backtick expressions | <pre>`${test}`</pre> |
|
|
79
|
+
| ThisExpression | this accessors | `this.someItem` |
|
|
80
|
+
| UnaryExpression | standalone statements | ` return x + 1` (the `x + 1`) |
|
|
81
|
+
| UpdateExpression | increments, decrements | `x++` or `--y` |
|
|
82
|
+
| YieldExpression | yield statements in iterators | `yield x` |
|
|
83
|
+
|
|
84
|
+
## Support
|
|
85
|
+
|
|
86
|
+
Currently only supports VueJS's Single File Components.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { describe, expect, test } from "@jest/globals";
|
|
11
|
+
import { getAst, getAstFromPath, parseVueSFC } from "../file";
|
|
12
|
+
import { DefaultExport, defaultExport, fsMock, fullVueSFC, reactComponent, vue3SFC, } from "./setup";
|
|
13
|
+
jest.mock("node:fs/promises", () => (Object.assign(Object.assign({}, jest.requireActual("node:fs/promises")), { open: jest.fn().mockImplementation((path, flags) => {
|
|
14
|
+
return fsMock.promises.open(path, flags);
|
|
15
|
+
}) })));
|
|
16
|
+
describe("file", () => {
|
|
17
|
+
test("it creates an ast", () => {
|
|
18
|
+
const ast = getAst(DefaultExport);
|
|
19
|
+
expect(ast).toBeTruthy();
|
|
20
|
+
});
|
|
21
|
+
test("it parses a Vue SFC component", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
22
|
+
const { open } = fsMock.promises;
|
|
23
|
+
const file = yield open(fullVueSFC);
|
|
24
|
+
const lines = yield file.readFile();
|
|
25
|
+
const contents = parseVueSFC(lines);
|
|
26
|
+
[
|
|
27
|
+
"<template>",
|
|
28
|
+
"</template>",
|
|
29
|
+
"<script>",
|
|
30
|
+
"</script>",
|
|
31
|
+
"<style>",
|
|
32
|
+
"</style>",
|
|
33
|
+
].forEach((v) => {
|
|
34
|
+
expect(contents.indexOf(v)).toBe(-1);
|
|
35
|
+
});
|
|
36
|
+
}));
|
|
37
|
+
test("it parses a Vue 3 SFC with <script setup lang='ts'>", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
38
|
+
const { open } = fsMock.promises;
|
|
39
|
+
const file = yield open(vue3SFC);
|
|
40
|
+
const lines = yield file.readFile();
|
|
41
|
+
const contents = parseVueSFC(lines);
|
|
42
|
+
["<script setup lang=\"ts\">", "</script>", "<template>", "<style"].forEach((v) => {
|
|
43
|
+
expect(contents.indexOf(v)).toBe(-1);
|
|
44
|
+
});
|
|
45
|
+
expect(contents).toContain("greet");
|
|
46
|
+
}));
|
|
47
|
+
test("it parses a Vue 3 SFC from path", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
48
|
+
const { ast, file } = yield getAstFromPath(vue3SFC);
|
|
49
|
+
expect(ast).toBeTruthy();
|
|
50
|
+
expect(file).not.toBeNull();
|
|
51
|
+
yield file.close();
|
|
52
|
+
}));
|
|
53
|
+
test("it parses a React component", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
54
|
+
const { ast, file } = yield getAstFromPath(reactComponent);
|
|
55
|
+
expect(ast).toBeTruthy();
|
|
56
|
+
expect(file).not.toBeNull();
|
|
57
|
+
yield file.close();
|
|
58
|
+
}));
|
|
59
|
+
test("it opens a file from path", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
60
|
+
const { ast, file } = yield getAstFromPath(defaultExport);
|
|
61
|
+
expect(ast).toBeTruthy();
|
|
62
|
+
expect(file).not.toBeNull();
|
|
63
|
+
yield file.close();
|
|
64
|
+
}));
|
|
65
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { describe, expect, test } from "@jest/globals";
|
|
11
|
+
import { fsMock, vueSFCOnlyJS, reactListNoKey } from "./setup";
|
|
12
|
+
import { searchRepo } from "../main.js";
|
|
13
|
+
jest.mock("node:fs/promises", () => (Object.assign(Object.assign({}, jest.requireActual("node:fs/promises")), { open: jest.fn().mockImplementation((path, flags) => fsMock.promises.open(path, flags)), readdir: jest.fn().mockImplementation((path, options) => fsMock.promises.readdir(path, options)) })));
|
|
14
|
+
describe("searchRepo", () => {
|
|
15
|
+
test("type selector: finds FunctionDeclaration nodes across files", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
16
|
+
const matches = yield searchRepo("FunctionDeclaration", "/");
|
|
17
|
+
expect(matches.length).toBeGreaterThan(0);
|
|
18
|
+
expect(matches.every((m) => m.file !== "")).toBe(true);
|
|
19
|
+
}));
|
|
20
|
+
test("shorthand: 'this' expands to ThisExpression", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
21
|
+
const matches = yield searchRepo("ThisExpression", "/");
|
|
22
|
+
const shorthandMatches = yield searchRepo("this", "/");
|
|
23
|
+
expect(shorthandMatches.length).toBe(matches.length);
|
|
24
|
+
}));
|
|
25
|
+
test("descendant combinator: finds this inside methods", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
26
|
+
const matches = yield searchRepo("ObjectMethod ThisExpression", "/");
|
|
27
|
+
expect(matches.length).toBeGreaterThan(0);
|
|
28
|
+
expect(matches.some((m) => m.file === vueSFCOnlyJS)).toBe(true);
|
|
29
|
+
}));
|
|
30
|
+
test("returns empty array when nothing matches", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
31
|
+
const matches = yield searchRepo("DebuggerStatement", "/");
|
|
32
|
+
expect(matches).toHaveLength(0);
|
|
33
|
+
}));
|
|
34
|
+
test("each match has file, line, col, and source fields", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
35
|
+
const matches = yield searchRepo("FunctionDeclaration", "/");
|
|
36
|
+
const m = matches[0];
|
|
37
|
+
expect(typeof m.file).toBe("string");
|
|
38
|
+
expect(typeof m.line).toBe("number");
|
|
39
|
+
expect(typeof m.col).toBe("number");
|
|
40
|
+
expect(typeof m.source).toBe("string");
|
|
41
|
+
}));
|
|
42
|
+
test("match source contains the first line of the matched node", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
43
|
+
const matches = yield searchRepo("FunctionDeclaration", "/");
|
|
44
|
+
expect(matches[0].source).toBeTruthy();
|
|
45
|
+
expect(matches[0].source).not.toContain("\n");
|
|
46
|
+
}));
|
|
47
|
+
test("skips files that fail to parse without throwing", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
48
|
+
const matches = yield searchRepo("ThisExpression", "/");
|
|
49
|
+
expect(Array.isArray(matches)).toBe(true);
|
|
50
|
+
}));
|
|
51
|
+
test("match file paths are limited to the given dir", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
52
|
+
const matches = yield searchRepo("FunctionDeclaration", "/");
|
|
53
|
+
expect(matches.every((m) => m.file.startsWith("/"))).toBe(true);
|
|
54
|
+
}));
|
|
55
|
+
test("attribute selector: finds .map() calls", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
56
|
+
const matches = yield searchRepo('CallExpression[callee.property.name="map"]', "/");
|
|
57
|
+
expect(matches.length).toBeGreaterThan(0);
|
|
58
|
+
expect(matches.some((m) => m.file === reactListNoKey)).toBe(true);
|
|
59
|
+
}));
|
|
60
|
+
test(":has() + :not(): target query finds map-without-key components", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
61
|
+
const matches = yield searchRepo('VariableDeclarator:has(CallExpression[callee.property.name="map"]):not(:has(JSXAttribute[name.name="key"]))', "/");
|
|
62
|
+
expect(matches.some((m) => m.file === reactListNoKey)).toBe(true);
|
|
63
|
+
expect(matches.every((m) => !m.source.includes("ListWithKey"))).toBe(true);
|
|
64
|
+
}));
|
|
65
|
+
test("throws on invalid selector syntax", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
66
|
+
yield expect(searchRepo(">>> invalid ::::", "/")).rejects.toThrow();
|
|
67
|
+
}));
|
|
68
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from "@jest/globals";
|
|
2
|
+
import { formatMatches } from "../output.js";
|
|
3
|
+
const m = { file: "src/foo.ts", line: 42, col: 7, source: "const x = () => {}" };
|
|
4
|
+
describe("formatMatches — text format (default)", () => {
|
|
5
|
+
it("produces file:line:col: source prefix", () => {
|
|
6
|
+
const [out] = formatMatches([m], false);
|
|
7
|
+
expect(out).toMatch(/^src\/foo\.ts:42:7: /);
|
|
8
|
+
});
|
|
9
|
+
it("includes source text in output", () => {
|
|
10
|
+
const [out] = formatMatches([m], false);
|
|
11
|
+
expect(out).toContain("const x = () => {}");
|
|
12
|
+
});
|
|
13
|
+
it("no ANSI codes when isTTY=false", () => {
|
|
14
|
+
const [out] = formatMatches([m], false);
|
|
15
|
+
expect(out).not.toContain("\x1b");
|
|
16
|
+
});
|
|
17
|
+
it("includes ANSI codes when isTTY=true", () => {
|
|
18
|
+
const [out] = formatMatches([m], true);
|
|
19
|
+
expect(out).toContain("\x1b[");
|
|
20
|
+
});
|
|
21
|
+
it("ANSI output still contains the source text", () => {
|
|
22
|
+
const [out] = formatMatches([m], true);
|
|
23
|
+
expect(out).toContain("const x = () => {}");
|
|
24
|
+
});
|
|
25
|
+
it("ANSI output still has correct prefix", () => {
|
|
26
|
+
const [out] = formatMatches([m], true);
|
|
27
|
+
expect(out).toMatch(/src\/foo\.ts:42:7:/);
|
|
28
|
+
});
|
|
29
|
+
it("returns one line per match", () => {
|
|
30
|
+
const matches = [
|
|
31
|
+
{ file: "a.ts", line: 1, col: 0, source: "foo()" },
|
|
32
|
+
{ file: "b.ts", line: 2, col: 3, source: "bar()" },
|
|
33
|
+
];
|
|
34
|
+
const lines = formatMatches(matches, false);
|
|
35
|
+
expect(lines).toHaveLength(2);
|
|
36
|
+
expect(lines[0]).toContain("a.ts:1:0:");
|
|
37
|
+
expect(lines[1]).toContain("b.ts:2:3:");
|
|
38
|
+
});
|
|
39
|
+
it("returns empty array for no matches", () => {
|
|
40
|
+
expect(formatMatches([], false)).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
it("shows only location when source is empty", () => {
|
|
43
|
+
const noSource = { file: "x.ts", line: 1, col: 0, source: "" };
|
|
44
|
+
const [out] = formatMatches([noSource], false);
|
|
45
|
+
expect(out).toBe("x.ts:1:0");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe("formatMatches — json format", () => {
|
|
49
|
+
it("returns a single JSON string", () => {
|
|
50
|
+
const lines = formatMatches([m], false, "json");
|
|
51
|
+
expect(lines).toHaveLength(1);
|
|
52
|
+
const parsed = JSON.parse(lines[0]);
|
|
53
|
+
expect(parsed).toHaveLength(1);
|
|
54
|
+
expect(parsed[0].file).toBe("src/foo.ts");
|
|
55
|
+
});
|
|
56
|
+
it("returns empty JSON array for no matches", () => {
|
|
57
|
+
const lines = formatMatches([], false, "json");
|
|
58
|
+
expect(JSON.parse(lines[0])).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe("formatMatches — files format", () => {
|
|
62
|
+
it("returns deduplicated file paths", () => {
|
|
63
|
+
const matches = [
|
|
64
|
+
{ file: "a.ts", line: 1, col: 0, source: "x" },
|
|
65
|
+
{ file: "a.ts", line: 5, col: 0, source: "y" },
|
|
66
|
+
{ file: "b.ts", line: 2, col: 0, source: "z" },
|
|
67
|
+
];
|
|
68
|
+
const lines = formatMatches(matches, false, "files");
|
|
69
|
+
expect(lines).toEqual(["a.ts", "b.ts"]);
|
|
70
|
+
});
|
|
71
|
+
it("returns empty array for no matches", () => {
|
|
72
|
+
expect(formatMatches([], false, "files")).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { describe, expect, test } from "@jest/globals";
|
|
2
|
+
import { parseQuery, SHORTHANDS } from "../query.js";
|
|
3
|
+
describe("SHORTHANDS", () => {
|
|
4
|
+
const expected = {
|
|
5
|
+
this: "ThisExpression",
|
|
6
|
+
await: "AwaitExpression",
|
|
7
|
+
yield: "YieldExpression",
|
|
8
|
+
new: "NewExpression",
|
|
9
|
+
call: "CallExpression",
|
|
10
|
+
arrow: "ArrowFunctionExpression",
|
|
11
|
+
fn: "FunctionExpression",
|
|
12
|
+
member: "MemberExpression",
|
|
13
|
+
ternary: "ConditionalExpression",
|
|
14
|
+
template: "TemplateLiteral",
|
|
15
|
+
tagged: "TaggedTemplateExpression",
|
|
16
|
+
"import()": "ImportExpression",
|
|
17
|
+
assign: "AssignmentExpression",
|
|
18
|
+
binary: "BinaryExpression",
|
|
19
|
+
logical: "LogicalExpression",
|
|
20
|
+
spread: "SpreadElement",
|
|
21
|
+
};
|
|
22
|
+
Object.entries(expected).forEach(([shorthand, babelType]) => {
|
|
23
|
+
test(`'${shorthand}' maps to '${babelType}'`, () => {
|
|
24
|
+
expect(SHORTHANDS[shorthand]).toBe(babelType);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
test("has exactly 16 entries", () => {
|
|
28
|
+
expect(Object.keys(SHORTHANDS)).toHaveLength(16);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe("parseQuery — bare identifier", () => {
|
|
32
|
+
test("single lowercase word → bare-ident", () => {
|
|
33
|
+
const result = parseQuery("setup");
|
|
34
|
+
expect(result).toEqual({ kind: "bare-ident", name: "setup" });
|
|
35
|
+
});
|
|
36
|
+
test("camelCase identifier → bare-ident", () => {
|
|
37
|
+
const result = parseQuery("myFunction");
|
|
38
|
+
expect(result).toEqual({ kind: "bare-ident", name: "myFunction" });
|
|
39
|
+
});
|
|
40
|
+
test("identifier with $ → bare-ident", () => {
|
|
41
|
+
const result = parseQuery("$emit");
|
|
42
|
+
expect(result).toEqual({ kind: "bare-ident", name: "$emit" });
|
|
43
|
+
});
|
|
44
|
+
test("identifier with _ → bare-ident", () => {
|
|
45
|
+
const result = parseQuery("_private");
|
|
46
|
+
expect(result).toEqual({ kind: "bare-ident", name: "_private" });
|
|
47
|
+
});
|
|
48
|
+
test("identifier with digits → bare-ident", () => {
|
|
49
|
+
const result = parseQuery("handler2");
|
|
50
|
+
expect(result).toEqual({ kind: "bare-ident", name: "handler2" });
|
|
51
|
+
});
|
|
52
|
+
test("unknown lowercase word (not a shorthand) → bare-ident", () => {
|
|
53
|
+
const result = parseQuery("foo");
|
|
54
|
+
expect(result).toEqual({ kind: "bare-ident", name: "foo" });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe("parseQuery — bare expression (shorthand)", () => {
|
|
58
|
+
test("'this' → bare-expr with ThisExpression", () => {
|
|
59
|
+
const result = parseQuery("this");
|
|
60
|
+
expect(result).toEqual({
|
|
61
|
+
kind: "bare-expr",
|
|
62
|
+
expr: [[{ negated: false, babelType: "ThisExpression" }]],
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
test("'await' → bare-expr with AwaitExpression", () => {
|
|
66
|
+
const result = parseQuery("await");
|
|
67
|
+
expect(result).toEqual({
|
|
68
|
+
kind: "bare-expr",
|
|
69
|
+
expr: [[{ negated: false, babelType: "AwaitExpression" }]],
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
test("'import()' → bare-expr with ImportExpression", () => {
|
|
73
|
+
const result = parseQuery("import()");
|
|
74
|
+
expect(result).toEqual({
|
|
75
|
+
kind: "bare-expr",
|
|
76
|
+
expr: [[{ negated: false, babelType: "ImportExpression" }]],
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
test("known shorthands that collide with JS identifiers → bare-expr not bare-ident", () => {
|
|
80
|
+
const colliding = ["call", "fn", "arrow", "template", "member", "assign", "logical"];
|
|
81
|
+
for (const s of colliding) {
|
|
82
|
+
const result = parseQuery(s);
|
|
83
|
+
expect(result.kind).toBe("bare-expr");
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe("parseQuery — bare expression (PascalCase Babel type)", () => {
|
|
88
|
+
test("'ThisExpression' → bare-expr with ThisExpression", () => {
|
|
89
|
+
const result = parseQuery("ThisExpression");
|
|
90
|
+
expect(result).toEqual({
|
|
91
|
+
kind: "bare-expr",
|
|
92
|
+
expr: [[{ negated: false, babelType: "ThisExpression" }]],
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
test("'ArrowFunctionExpression' → bare-expr", () => {
|
|
96
|
+
const result = parseQuery("ArrowFunctionExpression");
|
|
97
|
+
expect(result).toEqual({
|
|
98
|
+
kind: "bare-expr",
|
|
99
|
+
expr: [[{ negated: false, babelType: "ArrowFunctionExpression" }]],
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe("parseQuery — bare expression with negation", () => {
|
|
104
|
+
test("'!await' → bare-expr with negated AwaitExpression", () => {
|
|
105
|
+
const result = parseQuery("!await");
|
|
106
|
+
expect(result).toEqual({
|
|
107
|
+
kind: "bare-expr",
|
|
108
|
+
expr: [[{ negated: true, babelType: "AwaitExpression" }]],
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
test("'!import()' → bare-expr with negated ImportExpression", () => {
|
|
112
|
+
const result = parseQuery("!import()");
|
|
113
|
+
expect(result).toEqual({
|
|
114
|
+
kind: "bare-expr",
|
|
115
|
+
expr: [[{ negated: true, babelType: "ImportExpression" }]],
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe("parseQuery — bare expression with boolean operators", () => {
|
|
120
|
+
test("'this && await' → bare-expr AND clause", () => {
|
|
121
|
+
const result = parseQuery("this && await");
|
|
122
|
+
expect(result).toEqual({
|
|
123
|
+
kind: "bare-expr",
|
|
124
|
+
expr: [
|
|
125
|
+
[
|
|
126
|
+
{ negated: false, babelType: "ThisExpression" },
|
|
127
|
+
{ negated: false, babelType: "AwaitExpression" },
|
|
128
|
+
],
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
test("'this || await' → bare-expr OR clause", () => {
|
|
133
|
+
const result = parseQuery("this || await");
|
|
134
|
+
expect(result).toEqual({
|
|
135
|
+
kind: "bare-expr",
|
|
136
|
+
expr: [
|
|
137
|
+
[{ negated: false, babelType: "ThisExpression" }],
|
|
138
|
+
[{ negated: false, babelType: "AwaitExpression" }],
|
|
139
|
+
],
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
test("'!import() && arrow' → bare-expr AND clause with negation", () => {
|
|
143
|
+
const result = parseQuery("!import() && arrow");
|
|
144
|
+
expect(result).toEqual({
|
|
145
|
+
kind: "bare-expr",
|
|
146
|
+
expr: [
|
|
147
|
+
[
|
|
148
|
+
{ negated: true, babelType: "ImportExpression" },
|
|
149
|
+
{ negated: false, babelType: "ArrowFunctionExpression" },
|
|
150
|
+
],
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
test("&& binds tighter than || — 'this && await || yield'", () => {
|
|
155
|
+
const result = parseQuery("this && await || yield");
|
|
156
|
+
expect(result).toEqual({
|
|
157
|
+
kind: "bare-expr",
|
|
158
|
+
expr: [
|
|
159
|
+
[
|
|
160
|
+
{ negated: false, babelType: "ThisExpression" },
|
|
161
|
+
{ negated: false, babelType: "AwaitExpression" },
|
|
162
|
+
],
|
|
163
|
+
[{ negated: false, babelType: "YieldExpression" }],
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
describe("parseQuery — scope query", () => {
|
|
169
|
+
test("'setup > this' → scope query (example 2)", () => {
|
|
170
|
+
const result = parseQuery("setup > this");
|
|
171
|
+
expect(result).toEqual({
|
|
172
|
+
kind: "scope",
|
|
173
|
+
scope: "setup",
|
|
174
|
+
expr: [[{ negated: false, babelType: "ThisExpression" }]],
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
test("'* > this' → wildcard scope (example 3)", () => {
|
|
178
|
+
const result = parseQuery("* > this");
|
|
179
|
+
expect(result).toEqual({
|
|
180
|
+
kind: "scope",
|
|
181
|
+
scope: "*",
|
|
182
|
+
expr: [[{ negated: false, babelType: "ThisExpression" }]],
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
test("'setup > !await' → negated predicate (example 4)", () => {
|
|
186
|
+
const result = parseQuery("setup > !await");
|
|
187
|
+
expect(result).toEqual({
|
|
188
|
+
kind: "scope",
|
|
189
|
+
scope: "setup",
|
|
190
|
+
expr: [[{ negated: true, babelType: "AwaitExpression" }]],
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
test("'setup > this && await' → AND composition (example 5)", () => {
|
|
194
|
+
const result = parseQuery("setup > this && await");
|
|
195
|
+
expect(result).toEqual({
|
|
196
|
+
kind: "scope",
|
|
197
|
+
scope: "setup",
|
|
198
|
+
expr: [
|
|
199
|
+
[
|
|
200
|
+
{ negated: false, babelType: "ThisExpression" },
|
|
201
|
+
{ negated: false, babelType: "AwaitExpression" },
|
|
202
|
+
],
|
|
203
|
+
],
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
test("'render > member || ternary' → OR composition (example 6)", () => {
|
|
207
|
+
const result = parseQuery("render > member || ternary");
|
|
208
|
+
expect(result).toEqual({
|
|
209
|
+
kind: "scope",
|
|
210
|
+
scope: "render",
|
|
211
|
+
expr: [
|
|
212
|
+
[{ negated: false, babelType: "MemberExpression" }],
|
|
213
|
+
[{ negated: false, babelType: "ConditionalExpression" }],
|
|
214
|
+
],
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
test("'useEffect > ArrowFunctionExpression' → PascalCase type (example 9)", () => {
|
|
218
|
+
const result = parseQuery("useEffect > ArrowFunctionExpression");
|
|
219
|
+
expect(result).toEqual({
|
|
220
|
+
kind: "scope",
|
|
221
|
+
scope: "useEffect",
|
|
222
|
+
expr: [[{ negated: false, babelType: "ArrowFunctionExpression" }]],
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
test("'* > !call' → wildcard + negation (example 10)", () => {
|
|
226
|
+
const result = parseQuery("* > !call");
|
|
227
|
+
expect(result).toEqual({
|
|
228
|
+
kind: "scope",
|
|
229
|
+
scope: "*",
|
|
230
|
+
expr: [[{ negated: true, babelType: "CallExpression" }]],
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
test("'fetchData > await || import()' → complex OR (example 11)", () => {
|
|
234
|
+
const result = parseQuery("fetchData > await || import()");
|
|
235
|
+
expect(result).toEqual({
|
|
236
|
+
kind: "scope",
|
|
237
|
+
scope: "fetchData",
|
|
238
|
+
expr: [
|
|
239
|
+
[{ negated: false, babelType: "AwaitExpression" }],
|
|
240
|
+
[{ negated: false, babelType: "ImportExpression" }],
|
|
241
|
+
],
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
test("scope with $ in name", () => {
|
|
245
|
+
const result = parseQuery("$el > this");
|
|
246
|
+
expect(result).toEqual({
|
|
247
|
+
kind: "scope",
|
|
248
|
+
scope: "$el",
|
|
249
|
+
expr: [[{ negated: false, babelType: "ThisExpression" }]],
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
test("scope with _ in name", () => {
|
|
253
|
+
const result = parseQuery("_init > await");
|
|
254
|
+
expect(result).toEqual({
|
|
255
|
+
kind: "scope",
|
|
256
|
+
scope: "_init",
|
|
257
|
+
expr: [[{ negated: false, babelType: "AwaitExpression" }]],
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
test("'template' in scope position matches identifier named 'template'", () => {
|
|
261
|
+
const result = parseQuery("template > this");
|
|
262
|
+
expect(result).toEqual({
|
|
263
|
+
kind: "scope",
|
|
264
|
+
scope: "template",
|
|
265
|
+
expr: [[{ negated: false, babelType: "ThisExpression" }]],
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
test("'setup > this && await || yield' — complex mixed operators", () => {
|
|
269
|
+
const result = parseQuery("setup > this && await || yield");
|
|
270
|
+
expect(result).toEqual({
|
|
271
|
+
kind: "scope",
|
|
272
|
+
scope: "setup",
|
|
273
|
+
expr: [
|
|
274
|
+
[
|
|
275
|
+
{ negated: false, babelType: "ThisExpression" },
|
|
276
|
+
{ negated: false, babelType: "AwaitExpression" },
|
|
277
|
+
],
|
|
278
|
+
[{ negated: false, babelType: "YieldExpression" }],
|
|
279
|
+
],
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
describe("parseQuery — parse errors", () => {
|
|
284
|
+
test("'setup>this' — missing spaces around > → error", () => {
|
|
285
|
+
expect(() => parseQuery("setup>this")).toThrow(/whitespace required/i);
|
|
286
|
+
});
|
|
287
|
+
test("'setup >this' — missing space after > → error", () => {
|
|
288
|
+
expect(() => parseQuery("setup >this")).toThrow(/whitespace required/i);
|
|
289
|
+
});
|
|
290
|
+
test("'setup> this' — missing space before > → error", () => {
|
|
291
|
+
expect(() => parseQuery("setup> this")).toThrow(/whitespace required/i);
|
|
292
|
+
});
|
|
293
|
+
test("'setup > foo' — unknown token in expression position → error", () => {
|
|
294
|
+
expect(() => parseQuery("setup > foo")).toThrow(/unknown expression atom/i);
|
|
295
|
+
});
|
|
296
|
+
test("'setup > ' — empty expression → error", () => {
|
|
297
|
+
expect(() => parseQuery("setup > ")).toThrow();
|
|
298
|
+
});
|
|
299
|
+
test("empty string → error", () => {
|
|
300
|
+
expect(() => parseQuery("")).toThrow(/empty query/i);
|
|
301
|
+
});
|
|
302
|
+
test("' ' — whitespace only → error", () => {
|
|
303
|
+
expect(() => parseQuery(" ")).toThrow(/empty query/i);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
describe("parseQuery — type narrowing", () => {
|
|
307
|
+
test("returns ScopeQuery for scope queries", () => {
|
|
308
|
+
const result = parseQuery("setup > this");
|
|
309
|
+
if (result.kind === "scope") {
|
|
310
|
+
expect(result.scope).toBe("setup");
|
|
311
|
+
expect(result.expr).toBeDefined();
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
throw new Error("Expected scope query");
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
test("returns BareExpr for shorthand queries", () => {
|
|
318
|
+
const result = parseQuery("this");
|
|
319
|
+
if (result.kind === "bare-expr") {
|
|
320
|
+
expect(result.expr).toBeDefined();
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
throw new Error("Expected bare-expr");
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
test("returns BareIdent for identifier queries", () => {
|
|
327
|
+
const result = parseQuery("setup");
|
|
328
|
+
if (result.kind === "bare-ident") {
|
|
329
|
+
expect(result.name).toBe("setup");
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
throw new Error("Expected bare-ident");
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
});
|