@nudojs/parser 0.1.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/CHANGELOG.md +12 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +14833 -0
- package/package.json +15 -0
- package/src/__tests__/directives-phase2.test.ts +113 -0
- package/src/__tests__/directives-phase3.test.ts +93 -0
- package/src/__tests__/directives.test.ts +129 -0
- package/src/directives.ts +312 -0
- package/src/index.ts +13 -0
- package/src/parse.ts +10 -0
- package/tsconfig.json +9 -0
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nudojs/parser",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@nudojs/core": "0.1.0"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsup src/index.ts --format esm --dts"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parse } from "../parse.ts";
|
|
3
|
+
import { extractDirectives, parseTypeValueExpr } from "../directives.ts";
|
|
4
|
+
import { T, typeValueEquals, typeValueToString } from "@nudojs/core";
|
|
5
|
+
|
|
6
|
+
describe("parseTypeValueExpr: Phase 2 enhancements", () => {
|
|
7
|
+
it("parses T.array(T.number)", () => {
|
|
8
|
+
const result = parseTypeValueExpr("T.array(T.number)");
|
|
9
|
+
expect(result.kind).toBe("array");
|
|
10
|
+
if (result.kind === "array") {
|
|
11
|
+
expect(typeValueEquals(result.element, T.number)).toBe(true);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("parses T.tuple([T.number, T.string])", () => {
|
|
16
|
+
const result = parseTypeValueExpr("T.tuple([T.number, T.string])");
|
|
17
|
+
expect(result.kind).toBe("tuple");
|
|
18
|
+
if (result.kind === "tuple") {
|
|
19
|
+
expect(result.elements).toHaveLength(2);
|
|
20
|
+
expect(typeValueEquals(result.elements[0], T.number)).toBe(true);
|
|
21
|
+
expect(typeValueEquals(result.elements[1], T.string)).toBe(true);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("parses T.object({ x: T.number, y: T.string })", () => {
|
|
26
|
+
const result = parseTypeValueExpr("T.object({ x: T.number, y: T.string })");
|
|
27
|
+
expect(result.kind).toBe("object");
|
|
28
|
+
if (result.kind === "object") {
|
|
29
|
+
expect(typeValueEquals(result.properties.x, T.number)).toBe(true);
|
|
30
|
+
expect(typeValueEquals(result.properties.y, T.string)).toBe(true);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("parses nested T.object with T.array", () => {
|
|
35
|
+
const result = parseTypeValueExpr("T.object({ items: T.array(T.number) })");
|
|
36
|
+
expect(result.kind).toBe("object");
|
|
37
|
+
if (result.kind === "object") {
|
|
38
|
+
expect(result.properties.items.kind).toBe("array");
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("parses T.object({})", () => {
|
|
43
|
+
const result = parseTypeValueExpr("T.object({})");
|
|
44
|
+
expect(result.kind).toBe("object");
|
|
45
|
+
if (result.kind === "object") {
|
|
46
|
+
expect(Object.keys(result.properties)).toHaveLength(0);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("parses T.tuple([])", () => {
|
|
51
|
+
const result = parseTypeValueExpr("T.tuple([])");
|
|
52
|
+
expect(result.kind).toBe("tuple");
|
|
53
|
+
if (result.kind === "tuple") {
|
|
54
|
+
expect(result.elements).toHaveLength(0);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("extractDirectives: @nudo:mock", () => {
|
|
60
|
+
it("extracts inline mock directive", () => {
|
|
61
|
+
const source = `
|
|
62
|
+
/**
|
|
63
|
+
* @nudo:mock fetch = T.unknown
|
|
64
|
+
* @nudo:case "test" (1)
|
|
65
|
+
*/
|
|
66
|
+
function foo(x) { return x; }
|
|
67
|
+
`;
|
|
68
|
+
const ast = parse(source);
|
|
69
|
+
const results = extractDirectives(ast);
|
|
70
|
+
expect(results).toHaveLength(1);
|
|
71
|
+
const mocks = results[0].directives.filter((d) => d.kind === "mock");
|
|
72
|
+
expect(mocks).toHaveLength(1);
|
|
73
|
+
expect(mocks[0].kind).toBe("mock");
|
|
74
|
+
if (mocks[0].kind === "mock") {
|
|
75
|
+
expect(mocks[0].name).toBe("fetch");
|
|
76
|
+
expect(mocks[0].expression).toBe("T.unknown");
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("extracts mock from file directive", () => {
|
|
81
|
+
const source = `
|
|
82
|
+
/**
|
|
83
|
+
* @nudo:mock utils from "./utils.mock.js"
|
|
84
|
+
* @nudo:case "test" (1)
|
|
85
|
+
*/
|
|
86
|
+
function foo(x) { return x; }
|
|
87
|
+
`;
|
|
88
|
+
const ast = parse(source);
|
|
89
|
+
const results = extractDirectives(ast);
|
|
90
|
+
const mocks = results[0].directives.filter((d) => d.kind === "mock");
|
|
91
|
+
expect(mocks).toHaveLength(1);
|
|
92
|
+
if (mocks[0].kind === "mock") {
|
|
93
|
+
expect(mocks[0].name).toBe("utils");
|
|
94
|
+
expect(mocks[0].fromPath).toBe("./utils.mock.js");
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("extracts both mock and case directives", () => {
|
|
99
|
+
const source = `
|
|
100
|
+
/**
|
|
101
|
+
* @nudo:mock helper = T.number
|
|
102
|
+
* @nudo:case "test" (T.number)
|
|
103
|
+
*/
|
|
104
|
+
function foo(x) { return x; }
|
|
105
|
+
`;
|
|
106
|
+
const ast = parse(source);
|
|
107
|
+
const results = extractDirectives(ast);
|
|
108
|
+
const mocks = results[0].directives.filter((d) => d.kind === "mock");
|
|
109
|
+
const cases = results[0].directives.filter((d) => d.kind === "case");
|
|
110
|
+
expect(mocks).toHaveLength(1);
|
|
111
|
+
expect(cases).toHaveLength(1);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { T } from "@nudojs/core";
|
|
3
|
+
import { extractDirectives } from "../directives.ts";
|
|
4
|
+
import { parse } from "../parse.ts";
|
|
5
|
+
|
|
6
|
+
function getDirectives(source: string) {
|
|
7
|
+
const ast = parse(source);
|
|
8
|
+
return extractDirectives(ast);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("@nudo:pure directive", () => {
|
|
12
|
+
it("parses @nudo:pure", () => {
|
|
13
|
+
const fns = getDirectives(`
|
|
14
|
+
/**
|
|
15
|
+
* @nudo:pure
|
|
16
|
+
* @nudo:case "test" (1, 2)
|
|
17
|
+
*/
|
|
18
|
+
function add(a, b) { return a + b; }
|
|
19
|
+
`);
|
|
20
|
+
expect(fns.length).toBe(1);
|
|
21
|
+
const directives = fns[0].directives;
|
|
22
|
+
expect(directives.some((d) => d.kind === "pure")).toBe(true);
|
|
23
|
+
expect(directives.some((d) => d.kind === "case")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("@nudo:skip directive", () => {
|
|
28
|
+
it("parses @nudo:skip without return type", () => {
|
|
29
|
+
const fns = getDirectives(`
|
|
30
|
+
/**
|
|
31
|
+
* @nudo:skip
|
|
32
|
+
*/
|
|
33
|
+
function external() {}
|
|
34
|
+
`);
|
|
35
|
+
expect(fns.length).toBe(1);
|
|
36
|
+
const skip = fns[0].directives.find((d) => d.kind === "skip");
|
|
37
|
+
expect(skip).toBeDefined();
|
|
38
|
+
if (skip && skip.kind === "skip") {
|
|
39
|
+
expect(skip.returns).toBeUndefined();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("parses @nudo:skip with return type", () => {
|
|
44
|
+
const fns = getDirectives(`
|
|
45
|
+
/**
|
|
46
|
+
* @nudo:skip T.number
|
|
47
|
+
*/
|
|
48
|
+
function external() {}
|
|
49
|
+
`);
|
|
50
|
+
expect(fns.length).toBe(1);
|
|
51
|
+
const skip = fns[0].directives.find((d) => d.kind === "skip");
|
|
52
|
+
expect(skip).toBeDefined();
|
|
53
|
+
if (skip && skip.kind === "skip") {
|
|
54
|
+
expect(skip.returns).toEqual(T.number);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("@nudo:sample directive", () => {
|
|
60
|
+
it("parses @nudo:sample with count", () => {
|
|
61
|
+
const fns = getDirectives(`
|
|
62
|
+
/**
|
|
63
|
+
* @nudo:sample 5
|
|
64
|
+
* @nudo:case "test" (T.number)
|
|
65
|
+
*/
|
|
66
|
+
function loop(n) { return n; }
|
|
67
|
+
`);
|
|
68
|
+
expect(fns.length).toBe(1);
|
|
69
|
+
const sample = fns[0].directives.find((d) => d.kind === "sample");
|
|
70
|
+
expect(sample).toBeDefined();
|
|
71
|
+
if (sample && sample.kind === "sample") {
|
|
72
|
+
expect(sample.count).toBe(5);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("@nudo:returns directive", () => {
|
|
78
|
+
it("parses @nudo:returns with type", () => {
|
|
79
|
+
const fns = getDirectives(`
|
|
80
|
+
/**
|
|
81
|
+
* @nudo:returns (T.number)
|
|
82
|
+
* @nudo:case "test" (1, 2)
|
|
83
|
+
*/
|
|
84
|
+
function add(a, b) { return a + b; }
|
|
85
|
+
`);
|
|
86
|
+
expect(fns.length).toBe(1);
|
|
87
|
+
const returns = fns[0].directives.find((d) => d.kind === "returns");
|
|
88
|
+
expect(returns).toBeDefined();
|
|
89
|
+
if (returns && returns.kind === "returns") {
|
|
90
|
+
expect(returns.expected).toEqual(T.number);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parse } from "../parse.ts";
|
|
3
|
+
import { extractDirectives, parseTypeValueExpr } from "../directives.ts";
|
|
4
|
+
import { T, typeValueEquals } from "@nudojs/core";
|
|
5
|
+
|
|
6
|
+
describe("parseTypeValueExpr", () => {
|
|
7
|
+
it("parses T.number", () => {
|
|
8
|
+
expect(typeValueEquals(parseTypeValueExpr("T.number"), T.number)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("parses T.string", () => {
|
|
12
|
+
expect(typeValueEquals(parseTypeValueExpr("T.string"), T.string)).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("parses T.boolean", () => {
|
|
16
|
+
expect(typeValueEquals(parseTypeValueExpr("T.boolean"), T.boolean)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("parses numeric literals", () => {
|
|
20
|
+
expect(typeValueEquals(parseTypeValueExpr("42"), T.literal(42))).toBe(true);
|
|
21
|
+
expect(typeValueEquals(parseTypeValueExpr("-3"), T.literal(-3))).toBe(true);
|
|
22
|
+
expect(typeValueEquals(parseTypeValueExpr("1.5"), T.literal(1.5))).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("parses string literals", () => {
|
|
26
|
+
expect(typeValueEquals(parseTypeValueExpr('"hello"'), T.literal("hello"))).toBe(true);
|
|
27
|
+
expect(typeValueEquals(parseTypeValueExpr("'world'"), T.literal("world"))).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("parses boolean literals", () => {
|
|
31
|
+
expect(typeValueEquals(parseTypeValueExpr("true"), T.literal(true))).toBe(true);
|
|
32
|
+
expect(typeValueEquals(parseTypeValueExpr("false"), T.literal(false))).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("parses null and undefined", () => {
|
|
36
|
+
expect(typeValueEquals(parseTypeValueExpr("null"), T.literal(null))).toBe(true);
|
|
37
|
+
expect(typeValueEquals(parseTypeValueExpr("undefined"), T.literal(undefined))).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("parses T.literal(...)", () => {
|
|
41
|
+
expect(typeValueEquals(parseTypeValueExpr("T.literal(42)"), T.literal(42))).toBe(true);
|
|
42
|
+
expect(typeValueEquals(parseTypeValueExpr('T.literal("hi")'), T.literal("hi"))).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("parses T.union(...)", () => {
|
|
46
|
+
const result = parseTypeValueExpr("T.union(T.number, T.string)");
|
|
47
|
+
const expected = T.union(T.number, T.string);
|
|
48
|
+
expect(typeValueEquals(result, expected)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("extractDirectives", () => {
|
|
53
|
+
it("extracts @nudo:case directives from function", () => {
|
|
54
|
+
const source = `
|
|
55
|
+
/**
|
|
56
|
+
* @nudo:case "concrete" (1, 2)
|
|
57
|
+
* @nudo:case "symbolic" (T.number, T.number)
|
|
58
|
+
*/
|
|
59
|
+
function calc(a, b) {
|
|
60
|
+
return a + b;
|
|
61
|
+
}
|
|
62
|
+
`;
|
|
63
|
+
const ast = parse(source);
|
|
64
|
+
const results = extractDirectives(ast);
|
|
65
|
+
expect(results).toHaveLength(1);
|
|
66
|
+
expect(results[0].name).toBe("calc");
|
|
67
|
+
expect(results[0].directives).toHaveLength(2);
|
|
68
|
+
|
|
69
|
+
const d0 = results[0].directives[0];
|
|
70
|
+
expect(d0.kind).toBe("case");
|
|
71
|
+
expect(d0.name).toBe("concrete");
|
|
72
|
+
expect(d0.args).toHaveLength(2);
|
|
73
|
+
expect(typeValueEquals(d0.args[0], T.literal(1))).toBe(true);
|
|
74
|
+
expect(typeValueEquals(d0.args[1], T.literal(2))).toBe(true);
|
|
75
|
+
|
|
76
|
+
const d1 = results[0].directives[1];
|
|
77
|
+
expect(d1.name).toBe("symbolic");
|
|
78
|
+
expect(typeValueEquals(d1.args[0], T.number)).toBe(true);
|
|
79
|
+
expect(typeValueEquals(d1.args[1], T.number)).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("extracts from multiple functions", () => {
|
|
83
|
+
const source = `
|
|
84
|
+
/**
|
|
85
|
+
* @nudo:case "test" (1)
|
|
86
|
+
*/
|
|
87
|
+
function foo(x) { return x; }
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @nudo:case "test2" ("hello")
|
|
91
|
+
*/
|
|
92
|
+
function bar(s) { return s; }
|
|
93
|
+
`;
|
|
94
|
+
const ast = parse(source);
|
|
95
|
+
const results = extractDirectives(ast);
|
|
96
|
+
expect(results).toHaveLength(2);
|
|
97
|
+
expect(results[0].name).toBe("foo");
|
|
98
|
+
expect(results[1].name).toBe("bar");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("ignores functions without directives", () => {
|
|
102
|
+
const source = `
|
|
103
|
+
function noDirective(x) { return x; }
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @nudo:case "test" (T.number)
|
|
107
|
+
*/
|
|
108
|
+
function withDirective(x) { return x + 1; }
|
|
109
|
+
`;
|
|
110
|
+
const ast = parse(source);
|
|
111
|
+
const results = extractDirectives(ast);
|
|
112
|
+
expect(results).toHaveLength(1);
|
|
113
|
+
expect(results[0].name).toBe("withDirective");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("handles string arguments in case", () => {
|
|
117
|
+
const source = `
|
|
118
|
+
/**
|
|
119
|
+
* @nudo:case "string test" ("hello", "world")
|
|
120
|
+
*/
|
|
121
|
+
function greet(a, b) { return a + b; }
|
|
122
|
+
`;
|
|
123
|
+
const ast = parse(source);
|
|
124
|
+
const results = extractDirectives(ast);
|
|
125
|
+
expect(results[0].directives[0].args).toHaveLength(2);
|
|
126
|
+
expect(typeValueEquals(results[0].directives[0].args[0], T.literal("hello"))).toBe(true);
|
|
127
|
+
expect(typeValueEquals(results[0].directives[0].args[1], T.literal("world"))).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import type { Node, Comment } from "@babel/types";
|
|
2
|
+
import { type TypeValue, T } from "@nudojs/core";
|
|
3
|
+
|
|
4
|
+
export type CaseDirective = {
|
|
5
|
+
kind: "case";
|
|
6
|
+
name: string;
|
|
7
|
+
args: TypeValue[];
|
|
8
|
+
expected?: TypeValue;
|
|
9
|
+
commentLine?: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type MockDirective = {
|
|
13
|
+
kind: "mock";
|
|
14
|
+
name: string;
|
|
15
|
+
expression?: string;
|
|
16
|
+
fromPath?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type PureDirective = {
|
|
20
|
+
kind: "pure";
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SkipDirective = {
|
|
24
|
+
kind: "skip";
|
|
25
|
+
returns?: TypeValue;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type SampleDirective = {
|
|
29
|
+
kind: "sample";
|
|
30
|
+
count: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ReturnsDirective = {
|
|
34
|
+
kind: "returns";
|
|
35
|
+
expected: TypeValue;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type Directive = CaseDirective | MockDirective | PureDirective | SkipDirective | SampleDirective | ReturnsDirective;
|
|
39
|
+
|
|
40
|
+
export type FunctionWithDirectives = {
|
|
41
|
+
node: Node;
|
|
42
|
+
name: string;
|
|
43
|
+
directives: Directive[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const CASE_NAME_REGEX = /@nudo:case\s+"([^"]+)"\s*\(/g;
|
|
47
|
+
const MOCK_INLINE_REGEX = /@nudo:mock\s+(\w+)\s*=\s*(.+)/g;
|
|
48
|
+
const MOCK_FROM_REGEX = /@nudo:mock\s+(\w+)\s+from\s+"([^"]+)"/g;
|
|
49
|
+
const PURE_REGEX = /@nudo:pure\b/g;
|
|
50
|
+
const SKIP_REGEX = /@nudo:skip(?:\s+(.+))?/g;
|
|
51
|
+
const SAMPLE_REGEX = /@nudo:sample\s+(\d+)/g;
|
|
52
|
+
const RETURNS_REGEX = /@nudo:returns\s*\(/g;
|
|
53
|
+
|
|
54
|
+
export function parseTypeValueExpr(expr: string): TypeValue {
|
|
55
|
+
const s = expr.trim();
|
|
56
|
+
|
|
57
|
+
if (s === "T.number") return T.number;
|
|
58
|
+
if (s === "T.string") return T.string;
|
|
59
|
+
if (s === "T.boolean") return T.boolean;
|
|
60
|
+
if (s === "T.unknown") return T.unknown;
|
|
61
|
+
if (s === "T.never") return T.never;
|
|
62
|
+
if (s === "T.null") return T.null;
|
|
63
|
+
if (s === "T.undefined") return T.undefined;
|
|
64
|
+
|
|
65
|
+
if (s === "true") return T.literal(true);
|
|
66
|
+
if (s === "false") return T.literal(false);
|
|
67
|
+
if (s === "null") return T.literal(null);
|
|
68
|
+
if (s === "undefined") return T.literal(undefined);
|
|
69
|
+
|
|
70
|
+
const literalMatch = s.match(/^T\.literal\((.+)\)$/);
|
|
71
|
+
if (literalMatch) {
|
|
72
|
+
return T.literal(parsePrimitiveValue(literalMatch[1].trim()));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (s.startsWith("T.union(") && s.endsWith(")")) {
|
|
76
|
+
const inner = s.slice("T.union(".length, -1);
|
|
77
|
+
const args = splitTopLevelArgs(inner);
|
|
78
|
+
return T.union(...args.map(parseTypeValueExpr));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (s.startsWith("T.array(") && s.endsWith(")")) {
|
|
82
|
+
const inner = s.slice("T.array(".length, -1);
|
|
83
|
+
return T.array(parseTypeValueExpr(inner));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (s.startsWith("T.tuple(") && s.endsWith(")")) {
|
|
87
|
+
const inner = s.slice("T.tuple(".length, -1).trim();
|
|
88
|
+
if (inner.startsWith("[") && inner.endsWith("]")) {
|
|
89
|
+
const elements = splitTopLevelArgs(inner.slice(1, -1));
|
|
90
|
+
return T.tuple(elements.map(parseTypeValueExpr));
|
|
91
|
+
}
|
|
92
|
+
return T.tuple([]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (s.startsWith("T.object(") && s.endsWith(")")) {
|
|
96
|
+
const inner = s.slice("T.object(".length, -1).trim();
|
|
97
|
+
if (inner.startsWith("{") && inner.endsWith("}")) {
|
|
98
|
+
const content = inner.slice(1, -1).trim();
|
|
99
|
+
if (!content) return T.object({});
|
|
100
|
+
const entries = splitTopLevelArgs(content);
|
|
101
|
+
const props: Record<string, TypeValue> = {};
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
const colonIdx = entry.indexOf(":");
|
|
104
|
+
if (colonIdx === -1) continue;
|
|
105
|
+
const key = entry.slice(0, colonIdx).trim();
|
|
106
|
+
const val = entry.slice(colonIdx + 1).trim();
|
|
107
|
+
props[key] = parseTypeValueExpr(val);
|
|
108
|
+
}
|
|
109
|
+
return T.object(props);
|
|
110
|
+
}
|
|
111
|
+
return T.object({});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (/^-?\d+(\.\d+)?$/.test(s)) return T.literal(Number(s));
|
|
115
|
+
|
|
116
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
117
|
+
return T.literal(s.slice(1, -1));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (s.startsWith("{") && s.endsWith("}")) {
|
|
121
|
+
const content = s.slice(1, -1).trim();
|
|
122
|
+
if (!content) return T.object({});
|
|
123
|
+
const entries = splitTopLevelArgs(content);
|
|
124
|
+
const props: Record<string, TypeValue> = {};
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
const colonIdx = findTopLevelColon(entry);
|
|
127
|
+
if (colonIdx === -1) continue;
|
|
128
|
+
const key = entry.slice(0, colonIdx).trim().replace(/^["']|["']$/g, "");
|
|
129
|
+
const val = entry.slice(colonIdx + 1).trim();
|
|
130
|
+
props[key] = parseTypeValueExpr(val);
|
|
131
|
+
}
|
|
132
|
+
return T.object(props);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (s.startsWith("[") && s.endsWith("]")) {
|
|
136
|
+
const content = s.slice(1, -1).trim();
|
|
137
|
+
if (!content) return T.tuple([]);
|
|
138
|
+
const elements = splitTopLevelArgs(content);
|
|
139
|
+
return T.tuple(elements.map(parseTypeValueExpr));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return T.unknown;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parsePrimitiveValue(s: string): string | number | boolean | null | undefined {
|
|
146
|
+
if (s === "true") return true;
|
|
147
|
+
if (s === "false") return false;
|
|
148
|
+
if (s === "null") return null;
|
|
149
|
+
if (s === "undefined") return undefined;
|
|
150
|
+
if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
|
|
151
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
152
|
+
return s.slice(1, -1);
|
|
153
|
+
}
|
|
154
|
+
return s;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function splitTopLevelArgs(s: string): string[] {
|
|
158
|
+
const result: string[] = [];
|
|
159
|
+
let depth = 0;
|
|
160
|
+
let current = "";
|
|
161
|
+
for (const ch of s) {
|
|
162
|
+
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
163
|
+
if (ch === ")" || ch === "]" || ch === "}") depth--;
|
|
164
|
+
if (ch === "," && depth === 0) {
|
|
165
|
+
result.push(current.trim());
|
|
166
|
+
current = "";
|
|
167
|
+
} else {
|
|
168
|
+
current += ch;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (current.trim()) result.push(current.trim());
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function findTopLevelColon(s: string): number {
|
|
176
|
+
let depth = 0;
|
|
177
|
+
for (let i = 0; i < s.length; i++) {
|
|
178
|
+
const ch = s[i];
|
|
179
|
+
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
180
|
+
if (ch === ")" || ch === "]" || ch === "}") depth--;
|
|
181
|
+
if (ch === ":" && depth === 0) return i;
|
|
182
|
+
}
|
|
183
|
+
return -1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function extractBalancedParens(text: string, startIdx: number): string | null {
|
|
187
|
+
if (text[startIdx] !== "(") return null;
|
|
188
|
+
let depth = 0;
|
|
189
|
+
for (let i = startIdx; i < text.length; i++) {
|
|
190
|
+
if (text[i] === "(") depth++;
|
|
191
|
+
if (text[i] === ")") depth--;
|
|
192
|
+
if (depth === 0) return text.slice(startIdx + 1, i);
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseDirectivesFromComments(comments: readonly Comment[]): Directive[] {
|
|
198
|
+
const directives: Directive[] = [];
|
|
199
|
+
for (const comment of comments) {
|
|
200
|
+
const text = comment.value;
|
|
201
|
+
|
|
202
|
+
MOCK_FROM_REGEX.lastIndex = 0;
|
|
203
|
+
let mockFromMatch: RegExpExecArray | null;
|
|
204
|
+
const mockFromRanges: [number, number][] = [];
|
|
205
|
+
while ((mockFromMatch = MOCK_FROM_REGEX.exec(text)) !== null) {
|
|
206
|
+
directives.push({
|
|
207
|
+
kind: "mock",
|
|
208
|
+
name: mockFromMatch[1],
|
|
209
|
+
fromPath: mockFromMatch[2],
|
|
210
|
+
});
|
|
211
|
+
mockFromRanges.push([mockFromMatch.index, mockFromMatch.index + mockFromMatch[0].length]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
MOCK_INLINE_REGEX.lastIndex = 0;
|
|
215
|
+
let mockMatch: RegExpExecArray | null;
|
|
216
|
+
while ((mockMatch = MOCK_INLINE_REGEX.exec(text)) !== null) {
|
|
217
|
+
const inFromRange = mockFromRanges.some(
|
|
218
|
+
([s, e]) => mockMatch!.index >= s && mockMatch!.index < e,
|
|
219
|
+
);
|
|
220
|
+
if (inFromRange) continue;
|
|
221
|
+
directives.push({
|
|
222
|
+
kind: "mock",
|
|
223
|
+
name: mockMatch[1],
|
|
224
|
+
expression: mockMatch[2].trim(),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
CASE_NAME_REGEX.lastIndex = 0;
|
|
229
|
+
let match: RegExpExecArray | null;
|
|
230
|
+
const commentStartLine = comment.loc?.start.line ?? 0;
|
|
231
|
+
while ((match = CASE_NAME_REGEX.exec(text)) !== null) {
|
|
232
|
+
const name = match[1];
|
|
233
|
+
const parenStart = match.index + match[0].length - 1;
|
|
234
|
+
const argsStr = extractBalancedParens(text, parenStart);
|
|
235
|
+
if (argsStr === null) continue;
|
|
236
|
+
const args = splitTopLevelArgs(argsStr).map(parseTypeValueExpr);
|
|
237
|
+
|
|
238
|
+
const afterParen = parenStart + argsStr.length + 2;
|
|
239
|
+
const restLine = text.slice(afterParen).split("\n")[0].trim();
|
|
240
|
+
const arrowMatch = restLine.match(/^=>\s*(.+)/);
|
|
241
|
+
const expected = arrowMatch ? parseTypeValueExpr(arrowMatch[1].trim()) : undefined;
|
|
242
|
+
|
|
243
|
+
const linesBeforeMatch = text.slice(0, match.index).split("\n").length - 1;
|
|
244
|
+
const commentLine = commentStartLine + linesBeforeMatch;
|
|
245
|
+
|
|
246
|
+
directives.push({ kind: "case", name, args, expected, commentLine });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
PURE_REGEX.lastIndex = 0;
|
|
250
|
+
if (PURE_REGEX.test(text)) {
|
|
251
|
+
directives.push({ kind: "pure" });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
SKIP_REGEX.lastIndex = 0;
|
|
255
|
+
let skipMatch: RegExpExecArray | null;
|
|
256
|
+
while ((skipMatch = SKIP_REGEX.exec(text)) !== null) {
|
|
257
|
+
const returnsExpr = skipMatch[1]?.trim();
|
|
258
|
+
directives.push({
|
|
259
|
+
kind: "skip",
|
|
260
|
+
returns: returnsExpr ? parseTypeValueExpr(returnsExpr) : undefined,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
SAMPLE_REGEX.lastIndex = 0;
|
|
265
|
+
let sampleMatch: RegExpExecArray | null;
|
|
266
|
+
while ((sampleMatch = SAMPLE_REGEX.exec(text)) !== null) {
|
|
267
|
+
directives.push({ kind: "sample", count: Number(sampleMatch[1]) });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
RETURNS_REGEX.lastIndex = 0;
|
|
271
|
+
let returnsMatch: RegExpExecArray | null;
|
|
272
|
+
while ((returnsMatch = RETURNS_REGEX.exec(text)) !== null) {
|
|
273
|
+
const parenStart = returnsMatch.index + returnsMatch[0].length - 1;
|
|
274
|
+
const argsStr = extractBalancedParens(text, parenStart);
|
|
275
|
+
if (argsStr === null) continue;
|
|
276
|
+
directives.push({ kind: "returns", expected: parseTypeValueExpr(argsStr) });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return directives;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function getFunctionName(node: Node): string {
|
|
283
|
+
if (node.type === "FunctionDeclaration" && node.id) return node.id.name;
|
|
284
|
+
if (node.type === "ExportDefaultDeclaration" && node.declaration.type === "FunctionDeclaration" && node.declaration.id) {
|
|
285
|
+
return node.declaration.id.name;
|
|
286
|
+
}
|
|
287
|
+
if (node.type === "VariableDeclaration") {
|
|
288
|
+
const decl = node.declarations[0];
|
|
289
|
+
if (decl.id.type === "Identifier") return decl.id.name;
|
|
290
|
+
}
|
|
291
|
+
return "<anonymous>";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function extractDirectives(ast: Node): FunctionWithDirectives[] {
|
|
295
|
+
const results: FunctionWithDirectives[] = [];
|
|
296
|
+
|
|
297
|
+
if (ast.type !== "File") return results;
|
|
298
|
+
const body = ast.program.body;
|
|
299
|
+
|
|
300
|
+
for (const stmt of body) {
|
|
301
|
+
const leadingComments = stmt.leadingComments;
|
|
302
|
+
if (!leadingComments || leadingComments.length === 0) continue;
|
|
303
|
+
|
|
304
|
+
const directives = parseDirectivesFromComments(leadingComments);
|
|
305
|
+
if (directives.length === 0) continue;
|
|
306
|
+
|
|
307
|
+
const name = getFunctionName(stmt);
|
|
308
|
+
results.push({ node: stmt, name, directives });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return results;
|
|
312
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { parse } from "./parse.ts";
|
|
2
|
+
export {
|
|
3
|
+
type Directive,
|
|
4
|
+
type CaseDirective,
|
|
5
|
+
type MockDirective,
|
|
6
|
+
type PureDirective,
|
|
7
|
+
type SkipDirective,
|
|
8
|
+
type SampleDirective,
|
|
9
|
+
type ReturnsDirective,
|
|
10
|
+
type FunctionWithDirectives,
|
|
11
|
+
extractDirectives,
|
|
12
|
+
parseTypeValueExpr,
|
|
13
|
+
} from "./directives.ts";
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { parse as babelParse } from "@babel/parser";
|
|
2
|
+
import type { File } from "@babel/types";
|
|
3
|
+
|
|
4
|
+
export function parse(source: string): File {
|
|
5
|
+
return babelParse(source, {
|
|
6
|
+
sourceType: "module",
|
|
7
|
+
plugins: ["typescript", "jsx"],
|
|
8
|
+
attachComment: true,
|
|
9
|
+
});
|
|
10
|
+
}
|