@xlr-lib/xlr-utils 0.1.1-next.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.
@@ -0,0 +1,141 @@
1
+ import { test, expect, describe } from "vitest";
2
+ import type { ObjectType } from "@xlr-lib/xlr";
3
+ import { computeEffectiveObject, makePropertyMap } from "../validation-helpers";
4
+ import { parseTree } from "jsonc-parser";
5
+
6
+ describe("computeEffectiveObject tests", () => {
7
+ test("mixed test", () => {
8
+ const type1: ObjectType = {
9
+ type: "object",
10
+ properties: {
11
+ foo: {
12
+ required: true,
13
+ node: {
14
+ type: "string",
15
+ },
16
+ },
17
+ },
18
+ additionalProperties: false,
19
+ };
20
+
21
+ const type2: ObjectType = {
22
+ type: "object",
23
+ properties: {
24
+ bar: {
25
+ required: true,
26
+ node: {
27
+ type: "number",
28
+ },
29
+ },
30
+ },
31
+ additionalProperties: {
32
+ type: "unknown",
33
+ },
34
+ };
35
+
36
+ expect(computeEffectiveObject(type1, type2)).toMatchSnapshot();
37
+ });
38
+
39
+ test("Error on property overlap", () => {
40
+ const type1: ObjectType = {
41
+ type: "object",
42
+ properties: {
43
+ foo: {
44
+ required: true,
45
+ node: {
46
+ type: "string",
47
+ },
48
+ },
49
+ },
50
+ additionalProperties: false,
51
+ };
52
+
53
+ const type2: ObjectType = {
54
+ type: "object",
55
+ properties: {
56
+ foo: {
57
+ required: true,
58
+ node: {
59
+ type: "number",
60
+ },
61
+ },
62
+ },
63
+ additionalProperties: {
64
+ type: "unknown",
65
+ },
66
+ };
67
+
68
+ expect(() =>
69
+ computeEffectiveObject(type1, type2, true),
70
+ ).toThrowErrorMatchingInlineSnapshot(
71
+ `[Error: Can't compute effective type for object literal and object literal because of conflicting properties foo]`,
72
+ );
73
+ });
74
+
75
+ test("Merges equal additionalProperties", () => {
76
+ const type1: ObjectType = {
77
+ type: "object",
78
+ properties: {
79
+ foo: {
80
+ required: true,
81
+ node: {
82
+ type: "string",
83
+ },
84
+ },
85
+ },
86
+ additionalProperties: {
87
+ type: "unknown",
88
+ },
89
+ };
90
+
91
+ const type2: ObjectType = {
92
+ type: "object",
93
+ properties: {
94
+ bar: {
95
+ required: true,
96
+ node: {
97
+ type: "number",
98
+ },
99
+ },
100
+ },
101
+ additionalProperties: {
102
+ type: "unknown",
103
+ },
104
+ };
105
+
106
+ expect(computeEffectiveObject(type1, type2)).toMatchSnapshot();
107
+ });
108
+ });
109
+
110
+ describe("makePropertyMap tests", () => {
111
+ test("basic test", () => {
112
+ const jsonObject = {
113
+ key1: "value",
114
+ key2: true,
115
+ key3: 1,
116
+ };
117
+
118
+ const treeObject = parseTree(JSON.stringify(jsonObject));
119
+
120
+ const propertyMap = makePropertyMap(treeObject);
121
+
122
+ expect(propertyMap.get("key1")?.value).toStrictEqual("value");
123
+ expect(propertyMap.get("key2")?.value).toStrictEqual(true);
124
+ expect(propertyMap.get("key3")?.value).toStrictEqual(1);
125
+ });
126
+
127
+ test("escaped key", () => {
128
+ const jsonObject = {
129
+ "some-key": "value",
130
+ // eslint-disable-next-line prettier/prettier
131
+ 'some-otherkey': "value",
132
+ };
133
+
134
+ const treeObject = parseTree(JSON.stringify(jsonObject));
135
+
136
+ const propertyMap = makePropertyMap(treeObject);
137
+
138
+ expect(propertyMap.get("'some-key'")?.value).toStrictEqual("value");
139
+ expect(propertyMap.get("'some-otherkey'")?.value).toStrictEqual("value");
140
+ });
141
+ });
@@ -0,0 +1,237 @@
1
+ import ts from "typescript";
2
+ import type { Annotations } from "@xlr-lib/xlr";
3
+
4
+ interface JSDocContainer {
5
+ /** */
6
+ jsDoc: Array<ts.JSDoc>;
7
+ }
8
+
9
+ /**
10
+ *
11
+ */
12
+ function extractDescription(text: string | undefined): Annotations {
13
+ if (!text) {
14
+ return {};
15
+ }
16
+
17
+ return { description: text };
18
+ }
19
+
20
+ /**
21
+ * Checks if the parent node is a non-object type
22
+ */
23
+ function parentIsNonObjectPath(node: ts.Node) {
24
+ return (
25
+ node.parent &&
26
+ (ts.isArrayTypeNode(node.parent) ||
27
+ ts.isTupleTypeNode(node.parent) ||
28
+ ts.isOptionalTypeNode(node.parent) ||
29
+ ts.isRestTypeNode(node.parent) ||
30
+ ts.isUnionTypeNode(node.parent))
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Traverses up the node tree to build the title path down to the initial node
36
+ */
37
+ function recurseTypeChain(
38
+ node: ts.Node,
39
+ child: ts.Node | undefined,
40
+ ): Array<string> {
41
+ if (!node) {
42
+ return [];
43
+ }
44
+
45
+ if (
46
+ ts.isArrayTypeNode(node) &&
47
+ node.parent &&
48
+ ts.isRestTypeNode(node.parent)
49
+ ) {
50
+ return recurseTypeChain(node.parent, node);
51
+ }
52
+
53
+ if (ts.isRestTypeNode(node)) {
54
+ return recurseTypeChain(node.parent, node);
55
+ }
56
+
57
+ if (ts.isOptionalTypeNode(node)) {
58
+ return recurseTypeChain(node.parent, node);
59
+ }
60
+
61
+ if (ts.isUnionTypeNode(node)) {
62
+ return recurseTypeChain(node.parent, node);
63
+ }
64
+
65
+ if (ts.isParenthesizedTypeNode(node)) {
66
+ return recurseTypeChain(node.parent, node);
67
+ }
68
+
69
+ if (ts.isTypeLiteralNode(node)) {
70
+ return recurseTypeChain(node.parent, node);
71
+ }
72
+
73
+ if (ts.isArrayTypeNode(node)) {
74
+ return ["[]", ...recurseTypeChain(node.parent, node)];
75
+ }
76
+
77
+ if (ts.isTupleTypeNode(node)) {
78
+ const pos = node.elements.indexOf(child as any);
79
+ return [
80
+ ...(pos === -1 ? [] : [`${pos}`]),
81
+ ...recurseTypeChain(node.parent, node),
82
+ ];
83
+ }
84
+
85
+ if (
86
+ ts.isTypeAliasDeclaration(node) ||
87
+ ts.isInterfaceDeclaration(node) ||
88
+ ts.isPropertySignature(node)
89
+ ) {
90
+ return [node.name.getText(), ...recurseTypeChain(node.parent, node)];
91
+ }
92
+
93
+ if (parentIsNonObjectPath(node)) {
94
+ return recurseTypeChain(node.parent, node);
95
+ }
96
+
97
+ return [];
98
+ }
99
+
100
+ /**
101
+ * Builds the `Title` property by traversing up and noting the named types in the tree
102
+ */
103
+ function extractTitle(node: ts.Node): Annotations {
104
+ const typeNames = recurseTypeChain(node, undefined).reverse().join(".");
105
+
106
+ if (!typeNames.length) {
107
+ return {};
108
+ }
109
+
110
+ return { title: typeNames };
111
+ }
112
+
113
+ /**
114
+ *
115
+ */
116
+ function stringifyDoc(
117
+ docString: undefined | string | ts.NodeArray<ts.JSDocComment>,
118
+ ): string | undefined {
119
+ if (typeof docString === "undefined" || typeof docString === "string") {
120
+ return docString;
121
+ }
122
+
123
+ return docString.map(({ text }) => text).join(" ");
124
+ }
125
+
126
+ /**
127
+ * Extracts JSDoc tags to strings
128
+ */
129
+ function extractTags(tags: ReadonlyArray<ts.JSDocTag>): Annotations {
130
+ const descriptions: Array<string> = [];
131
+ const examples: Array<string> = [];
132
+ const _default: Array<string> = [];
133
+ const see: Array<string> = [];
134
+ const meta: Record<string, string> = {};
135
+
136
+ /**
137
+ *
138
+ */
139
+ const extractSee = (tag: ts.JSDocSeeTag) => {
140
+ return `${tag.tagName ? `${tag.tagName?.getText()} ` : ""}${
141
+ stringifyDoc(tag.comment)?.trim() ?? ""
142
+ }`;
143
+ };
144
+
145
+ tags.forEach((tag) => {
146
+ if (!tag.comment) {
147
+ return;
148
+ }
149
+
150
+ if (tag.tagName.text === "example") {
151
+ examples.push(stringifyDoc(tag.comment)?.trim() ?? "");
152
+ } else if (tag.tagName.text === "default") {
153
+ _default.push(stringifyDoc(tag.comment)?.trim() ?? "");
154
+ } else if (tag.tagName.text === "see") {
155
+ see.push(extractSee(tag as ts.JSDocSeeTag));
156
+ } else if (tag.tagName.text === "meta") {
157
+ const [key, value] = tag.comment.toString().split(/:(.*)/);
158
+ meta[key] = value?.trim() ?? "";
159
+ } else {
160
+ const text = stringifyDoc(tag.comment)?.trim() ?? "";
161
+ descriptions.push(`@${tag.tagName.text} ${text}`);
162
+ }
163
+ });
164
+
165
+ return {
166
+ ...(descriptions.length === 0
167
+ ? {}
168
+ : { description: descriptions.join("\n") }),
169
+ ...(examples.length === 0 ? {} : { examples }),
170
+ ...(_default.length === 0 ? {} : { default: _default.join("\n") }),
171
+ ...(see.length === 0 ? {} : { see }),
172
+ ...(meta && Object.keys(meta).length === 0 ? {} : { meta }),
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Joins Arrays of maybe strings with a given separator
178
+ */
179
+ function join(t: Array<string | undefined>, separator = "\n") {
180
+ const unique = new Set(t).values();
181
+ return Array.from(unique)
182
+ .filter((s) => s !== undefined)
183
+ .join(separator)
184
+ .trim();
185
+ }
186
+
187
+ /**
188
+ * Merges Annotation nodes for various nodes
189
+ */
190
+ function mergeAnnotations(nodes: Array<Annotations>): Annotations {
191
+ const name = nodes.find((n) => n.name)?.name;
192
+ const title = join(
193
+ nodes.map((n) => n.title),
194
+ ", ",
195
+ );
196
+ const description = join(nodes.map((n) => n.description));
197
+ const _default = join(nodes.map((n) => n.default));
198
+ const comment = join(nodes.map((n) => n.comment));
199
+ const examples = join(
200
+ nodes.map((n) =>
201
+ Array.isArray(n.examples) ? join(n.examples) : n.examples,
202
+ ),
203
+ );
204
+ const see = join(
205
+ nodes.map((n) => (Array.isArray(n.see) ? join(n.see) : n.see)),
206
+ );
207
+ const meta = nodes.find((n) => n.meta)?.meta;
208
+ return {
209
+ ...(name ? { name } : {}),
210
+ ...(title ? { title } : {}),
211
+ ...(description ? { description } : {}),
212
+ ...(examples ? { examples } : {}),
213
+ ...(_default ? { default: _default } : {}),
214
+ ...(see ? { see } : {}),
215
+ ...(comment ? { comment } : {}),
216
+ ...(meta ? { meta } : {}),
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Converts JSDoc comments to strings
222
+ */
223
+ export function decorateNode(node: ts.Node): Annotations {
224
+ const { jsDoc } = node as unknown as JSDocContainer;
225
+ const titleAnnotation = extractTitle(node);
226
+
227
+ if (jsDoc && jsDoc.length) {
228
+ const first = jsDoc[0];
229
+ return mergeAnnotations([
230
+ extractDescription(stringifyDoc(first.comment)),
231
+ titleAnnotation,
232
+ extractTags(first.tags ?? []),
233
+ ]);
234
+ }
235
+
236
+ return titleAnnotation;
237
+ }
@@ -0,0 +1,243 @@
1
+ import ts from "typescript";
2
+ import type { SymbolDisplayPart } from "typescript";
3
+ import type { NodeType } from "@xlr-lib/xlr";
4
+ import { isPrimitiveTypeNode } from "./type-checks";
5
+
6
+ const { SymbolDisplayPartKind, displayPartsToString } = ts;
7
+
8
+ /** Like `.join()` but for arrays */
9
+ function insertBetweenElements<T>(array: Array<T>, separator: T): T[] {
10
+ return array.reduce((acc, item, index) => {
11
+ if (index === 0) {
12
+ return [item];
13
+ }
14
+
15
+ return [...acc, separator, item];
16
+ }, [] as T[]);
17
+ }
18
+
19
+ /**
20
+ * Generate a documentation string for a given node
21
+ *
22
+ * @param node - The source node to author the docs string for
23
+ * @returns - documentation string
24
+ */
25
+ export function createTSDocString(node: NodeType): Array<SymbolDisplayPart> {
26
+ if (node.type === "ref") {
27
+ return [
28
+ {
29
+ text: node.ref,
30
+ kind: SymbolDisplayPartKind.keyword as any,
31
+ },
32
+ ];
33
+ }
34
+
35
+ if (node.type === "or" || node.type === "and") {
36
+ const items = node.type === "and" ? node.and : node.or;
37
+
38
+ return insertBetweenElements(
39
+ items.map((subnode) => createTSDocString(subnode)),
40
+ [
41
+ {
42
+ kind: SymbolDisplayPartKind.punctuation as any,
43
+ text: node.type === "and" ? " & " : " | ",
44
+ },
45
+ ],
46
+ ).flat();
47
+ }
48
+
49
+ if (node.type === "function") {
50
+ return [
51
+ {
52
+ kind: SymbolDisplayPartKind.keyword as any,
53
+ text: "function",
54
+ },
55
+ {
56
+ kind: SymbolDisplayPartKind.space as any,
57
+ text: " ",
58
+ },
59
+ ...(node.name
60
+ ? [{ text: node.name, kind: SymbolDisplayPartKind.methodName }]
61
+ : []),
62
+ {
63
+ kind: SymbolDisplayPartKind.punctuation as any,
64
+ text: "(",
65
+ },
66
+ ...insertBetweenElements(
67
+ node.parameters.map((p) => {
68
+ if (p.name) {
69
+ return [
70
+ {
71
+ kind: SymbolDisplayPartKind.parameterName as any,
72
+ text: p.name,
73
+ },
74
+ {
75
+ kind: SymbolDisplayPartKind.punctuation as any,
76
+ text: p.optional ? "?" : "",
77
+ },
78
+ {
79
+ kind: SymbolDisplayPartKind.punctuation as any,
80
+ text: ": ",
81
+ },
82
+ ...createTSDocString(p.type),
83
+ ];
84
+ }
85
+
86
+ return createTSDocString(p.type);
87
+ }),
88
+ [
89
+ {
90
+ kind: SymbolDisplayPartKind.punctuation as any,
91
+ text: ", ",
92
+ },
93
+ ],
94
+ ).flat(),
95
+ {
96
+ kind: SymbolDisplayPartKind.punctuation as any,
97
+ text: ")",
98
+ },
99
+ ...(node.returnType
100
+ ? [
101
+ {
102
+ kind: SymbolDisplayPartKind.punctuation as any,
103
+ text: ": ",
104
+ },
105
+ ...createTSDocString(node.returnType),
106
+ ]
107
+ : []),
108
+ ];
109
+ }
110
+
111
+ if (node.type === "tuple") {
112
+ return [
113
+ {
114
+ kind: SymbolDisplayPartKind.punctuation as any,
115
+ text: "[",
116
+ },
117
+ ...insertBetweenElements(
118
+ node.elementTypes.map((t) => {
119
+ if (t.name) {
120
+ return [
121
+ {
122
+ kind: SymbolDisplayPartKind.propertyName as any,
123
+ text: t.name,
124
+ },
125
+ {
126
+ kind: SymbolDisplayPartKind.punctuation as any,
127
+ text: ": ",
128
+ },
129
+ ...createTSDocString(t.type),
130
+ ];
131
+ }
132
+
133
+ return createTSDocString(t.type);
134
+ }),
135
+ [
136
+ {
137
+ kind: SymbolDisplayPartKind.punctuation as any,
138
+ text: ", ",
139
+ },
140
+ ],
141
+ ).flat(),
142
+ {
143
+ kind: SymbolDisplayPartKind.punctuation as any,
144
+ text: "]",
145
+ },
146
+ ];
147
+ }
148
+
149
+ if (node.type === "array") {
150
+ return [
151
+ {
152
+ kind: SymbolDisplayPartKind.interfaceName as any,
153
+ text: "Array",
154
+ },
155
+ {
156
+ kind: SymbolDisplayPartKind.punctuation as any,
157
+ text: "<",
158
+ },
159
+ ...createTSDocString(node.elementType),
160
+ {
161
+ kind: SymbolDisplayPartKind.punctuation as any,
162
+ text: ">",
163
+ },
164
+ ];
165
+ }
166
+
167
+ if (node.type === "record") {
168
+ return [
169
+ {
170
+ kind: SymbolDisplayPartKind.interfaceName as any,
171
+ text: "Record",
172
+ },
173
+ {
174
+ kind: SymbolDisplayPartKind.punctuation as any,
175
+ text: "<",
176
+ },
177
+ ...createTSDocString(node.keyType),
178
+ {
179
+ kind: SymbolDisplayPartKind.punctuation as any,
180
+ text: ", ",
181
+ },
182
+ ...createTSDocString(node.valueType),
183
+ {
184
+ kind: SymbolDisplayPartKind.punctuation as any,
185
+ text: ">",
186
+ },
187
+ ];
188
+ }
189
+
190
+ if (
191
+ (node.type === "string" ||
192
+ node.type === "boolean" ||
193
+ node.type === "number") &&
194
+ node.const !== undefined
195
+ ) {
196
+ return [
197
+ {
198
+ kind: SymbolDisplayPartKind.keyword as any,
199
+ text:
200
+ typeof node.const === "string"
201
+ ? `"${node.const}"`
202
+ : String(node.const),
203
+ },
204
+ ];
205
+ }
206
+
207
+ if (isPrimitiveTypeNode(node) && node.type !== "null") {
208
+ return [
209
+ {
210
+ kind: SymbolDisplayPartKind.keyword as any,
211
+ text: node.type,
212
+ },
213
+ ];
214
+ }
215
+
216
+ if (node.type === "object" && node.name) {
217
+ return [
218
+ {
219
+ kind: SymbolDisplayPartKind.interfaceName as any,
220
+ text: node.name,
221
+ },
222
+ ];
223
+ }
224
+
225
+ return [
226
+ {
227
+ kind: SymbolDisplayPartKind.localName as any,
228
+ text: node.type,
229
+ },
230
+ ];
231
+ }
232
+
233
+ /** Convert the TS SymbolDisplayParts into a single string */
234
+ export function symbolDisplayToString(
235
+ displayParts: Array<SymbolDisplayPart>,
236
+ ): string {
237
+ return displayPartsToString(displayParts);
238
+ }
239
+
240
+ /** Create a documentation string from node */
241
+ export function createDocString(node: NodeType): string {
242
+ return symbolDisplayToString(createTSDocString(node));
243
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./annotations";
2
+ export * from "./ts-helpers";
3
+ export * from "./type-checks";
4
+ export * from "./validation-helpers";
5
+ export * from "./documentation";