@dxos/effect 0.6.13-main.548ca8d → 0.6.14-main.1366248

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,124 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { AST, Schema as S } from '@effect/schema';
6
+ import { describe, test } from 'vitest';
7
+
8
+ import { invariant } from '@dxos/invariant';
9
+
10
+ import {
11
+ findAnnotation,
12
+ findNode,
13
+ findProperty,
14
+ getAnnotation,
15
+ getSimpleType,
16
+ isSimpleType,
17
+ visit,
18
+ type JsonPath,
19
+ type JsonProp,
20
+ } from './ast';
21
+
22
+ const ZipCode = S.String.pipe(
23
+ S.pattern(/^\d{5}$/, {
24
+ typeId: Symbol.for('@example/schema/ZipCode'),
25
+ identifier: 'ZipCode',
26
+ title: 'ZIP code',
27
+ description: 'Simple 5 digit zip code',
28
+ }),
29
+ );
30
+
31
+ const LatLng = S.Struct({
32
+ lat: S.Number,
33
+ lng: S.Number,
34
+ });
35
+
36
+ const Contact = S.Struct({
37
+ name: S.String,
38
+ address: S.Struct({
39
+ zip: ZipCode,
40
+ location: S.optional(LatLng),
41
+ }),
42
+ });
43
+
44
+ const getTitle = getAnnotation(AST.TitleAnnotationId);
45
+
46
+ describe('AST', () => {
47
+ test('validation', ({ expect }) => {
48
+ const validate = S.validateSync(ZipCode);
49
+ validate('11205');
50
+
51
+ expect(() => validate(null)).to.throw();
52
+ expect(() => validate(12345)).to.throw();
53
+ expect(() => validate('')).to.throw();
54
+ expect(() => validate('1234')).to.throw();
55
+ });
56
+
57
+ test('findNode', ({ expect }) => {
58
+ const TestSchema = S.Struct({
59
+ name: S.optional(S.String),
60
+ }).pipe(S.mutable);
61
+
62
+ const prop = findProperty(TestSchema, 'name' as JsonProp);
63
+ invariant(prop);
64
+ const node = findNode(prop, isSimpleType);
65
+ invariant(node);
66
+ const type = getSimpleType(node);
67
+ expect(type).to.eq('string');
68
+ });
69
+
70
+ test('findProperty', ({ expect }) => {
71
+ {
72
+ const prop = findProperty(Contact, 'name' as JsonPath);
73
+ expect(prop).to.exist;
74
+ }
75
+ {
76
+ const prop = findProperty(Contact, 'address.zip' as JsonPath);
77
+ invariant(prop);
78
+ expect(getTitle(prop)).to.eq('ZIP code');
79
+ }
80
+ {
81
+ const prop = findProperty(Contact, 'address.location.lat' as JsonPath);
82
+ invariant(prop);
83
+ expect(AST.isNumberKeyword(prop)).to.be.true;
84
+ }
85
+ {
86
+ const prop = findProperty(Contact, 'address.city' as JsonPath);
87
+ expect(prop).not.to.exist;
88
+ }
89
+ });
90
+
91
+ test('findAnnotation', ({ expect }) => {
92
+ const Type = S.NonEmptyString.pipe(S.pattern(/^\d{5}$/)).annotations({
93
+ [AST.TitleAnnotationId]: 'original title',
94
+ });
95
+ const Contact = S.Struct({
96
+ p1: Type.annotations({ [AST.TitleAnnotationId]: 'new title' }),
97
+ p2: Type.annotations({ [AST.TitleAnnotationId]: 'new title' }).pipe(S.optional),
98
+ p3: S.optional(Type.annotations({ [AST.TitleAnnotationId]: 'new title' })),
99
+ });
100
+
101
+ {
102
+ const prop = findProperty(Contact, 'p3' as JsonPath);
103
+ invariant(prop);
104
+ const value = findAnnotation(prop, AST.TitleAnnotationId);
105
+ expect(value).to.eq('new title');
106
+ }
107
+ });
108
+
109
+ test('visit', ({ expect }) => {
110
+ const TestSchema = S.Struct({
111
+ name: S.NonEmptyString,
112
+ emails: S.optional(S.mutable(S.Array(S.String))),
113
+ address: S.optional(
114
+ S.Struct({
115
+ zip: S.String,
116
+ }),
117
+ ),
118
+ });
119
+
120
+ const props: string[] = [];
121
+ visit(TestSchema.ast, (_, path) => props.push(path.join('.')));
122
+ console.log(JSON.stringify(props, null, 2));
123
+ });
124
+ });
package/src/ast.ts ADDED
@@ -0,0 +1,253 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { AST, Schema as S } from '@effect/schema';
6
+ import { Option, pipe } from 'effect';
7
+
8
+ import { invariant } from '@dxos/invariant';
9
+
10
+ //
11
+ // Refs
12
+ // https://effect.website/docs/guides/schema
13
+ // https://www.npmjs.com/package/@effect/schema
14
+ // https://effect-ts.github.io/effect/schema/AST.ts.html
15
+ //
16
+
17
+ export type SimpleType = 'object' | 'string' | 'number' | 'boolean' | 'enum' | 'literal';
18
+
19
+ export const getSimpleType = (node: AST.AST): SimpleType | undefined => {
20
+ if (AST.isObjectKeyword(node)) {
21
+ return 'object';
22
+ }
23
+ if (AST.isStringKeyword(node)) {
24
+ return 'string';
25
+ }
26
+ if (AST.isNumberKeyword(node)) {
27
+ return 'number';
28
+ }
29
+ if (AST.isBooleanKeyword(node)) {
30
+ return 'boolean';
31
+ }
32
+ if (AST.isEnums(node)) {
33
+ return 'enum';
34
+ }
35
+ if (AST.isLiteral(node)) {
36
+ return 'literal';
37
+ }
38
+ };
39
+
40
+ export const isSimpleType = (node: AST.AST) => !!getSimpleType(node);
41
+
42
+ //
43
+ // Branded types
44
+ //
45
+
46
+ export type JsonProp = string & { __JsonProp: true };
47
+ export type JsonPath = string & { __JsonPath: true };
48
+
49
+ const PATH_REGEX = /[a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*/;
50
+ const PROP_REGEX = /\w+/;
51
+
52
+ /**
53
+ * https://www.ietf.org/archive/id/draft-goessner-dispatch-jsonpath-00.html
54
+ */
55
+ export const JsonPath = S.NonEmptyString.pipe(S.pattern(PATH_REGEX)) as any as S.Schema<JsonPath>;
56
+ export const JsonProp = S.NonEmptyString.pipe(S.pattern(PROP_REGEX)) as any as S.Schema<JsonProp>;
57
+
58
+ /**
59
+ * Get annotation or return undefined.
60
+ */
61
+ export const getAnnotation =
62
+ <T>(annotationId: symbol) =>
63
+ (node: AST.Annotated): T | undefined =>
64
+ pipe(AST.getAnnotation<T>(annotationId)(node), Option.getOrUndefined);
65
+
66
+ export enum VisitResult {
67
+ CONTINUE = 0,
68
+ /**
69
+ * Skip visiting children.
70
+ */
71
+ SKIP = 1,
72
+ /**
73
+ * Stop traversing immediately.
74
+ */
75
+ EXIT = 2,
76
+ }
77
+
78
+ export type Path = (string | number)[];
79
+
80
+ export type Tester = (node: AST.AST, path: Path, depth: number) => VisitResult | undefined;
81
+ export type Visitor = (node: AST.AST, path: Path, depth: number) => void;
82
+
83
+ const defaultTest: Tester = (node) => (isSimpleType(node) ? VisitResult.CONTINUE : VisitResult.SKIP);
84
+
85
+ /**
86
+ * Visit leaf nodes.
87
+ * Refs:
88
+ * - https://github.com/syntax-tree/unist-util-visit?tab=readme-ov-file#visitor
89
+ * - https://github.com/syntax-tree/unist-util-is?tab=readme-ov-file#test
90
+ */
91
+ export const visit: {
92
+ (node: AST.AST, visitor: Visitor): void;
93
+ (node: AST.AST, test: Tester, visitor: Visitor): void;
94
+ } = (node: AST.AST, testOrVisitor: Tester | Visitor, visitor?: Visitor): void => {
95
+ if (!visitor) {
96
+ visitNode(node, defaultTest, testOrVisitor);
97
+ } else {
98
+ visitNode(node, testOrVisitor as Tester, visitor);
99
+ }
100
+ };
101
+
102
+ const visitNode = (
103
+ node: AST.AST,
104
+ test: Tester | undefined,
105
+ visitor: Visitor,
106
+ path: Path = [],
107
+ depth = 0,
108
+ ): VisitResult | undefined => {
109
+ const result = test?.(node, path, depth) ?? VisitResult.CONTINUE;
110
+ if (result === VisitResult.EXIT) {
111
+ return result;
112
+ }
113
+ if (result !== VisitResult.SKIP) {
114
+ visitor(node, path, depth);
115
+ }
116
+
117
+ // Object.
118
+ if (AST.isTypeLiteral(node)) {
119
+ for (const prop of AST.getPropertySignatures(node)) {
120
+ const currentPath = [...path, prop.name.toString()];
121
+ const result = visitNode(prop.type, test, visitor, currentPath, depth + 1);
122
+ if (result === VisitResult.EXIT) {
123
+ return result;
124
+ }
125
+ }
126
+ }
127
+
128
+ // Array.
129
+ else if (AST.isTupleType(node)) {
130
+ for (const [i, element] of node.elements.entries()) {
131
+ const currentPath = [...path, i];
132
+ const result = visitNode(element.type, test, visitor, currentPath, depth);
133
+ if (result === VisitResult.EXIT) {
134
+ return result;
135
+ }
136
+ }
137
+ }
138
+
139
+ // Branching union.
140
+ else if (AST.isUnion(node)) {
141
+ for (const type of node.types) {
142
+ const result = visitNode(type, test, visitor, path, depth);
143
+ if (result === VisitResult.EXIT) {
144
+ return result;
145
+ }
146
+ }
147
+ }
148
+
149
+ // Refinement.
150
+ else if (AST.isRefinement(node)) {
151
+ const result = visitNode(node.from, test, visitor, path, depth);
152
+ if (result === VisitResult.EXIT) {
153
+ return result;
154
+ }
155
+ }
156
+
157
+ // TODO(burdon): Transform?
158
+ };
159
+
160
+ /**
161
+ * Recursively descend into AST to find first node that passes the test.
162
+ */
163
+ // TODO(burdon): Reuse visitor.
164
+ export const findNode = (node: AST.AST, test: (node: AST.AST) => boolean): AST.AST | undefined => {
165
+ if (test(node)) {
166
+ return node;
167
+ }
168
+
169
+ // Object.
170
+ else if (AST.isTypeLiteral(node)) {
171
+ for (const prop of AST.getPropertySignatures(node)) {
172
+ const child = findNode(prop.type, test);
173
+ if (child) {
174
+ return child;
175
+ }
176
+ }
177
+ }
178
+
179
+ // Array.
180
+ else if (AST.isTupleType(node)) {
181
+ for (const [_, element] of node.elements.entries()) {
182
+ const child = findNode(element.type, test);
183
+ if (child) {
184
+ return child;
185
+ }
186
+ }
187
+ }
188
+
189
+ // Branching union.
190
+ else if (AST.isUnion(node)) {
191
+ for (const type of node.types) {
192
+ const child = findNode(type, test);
193
+ if (child) {
194
+ return child;
195
+ }
196
+ }
197
+ }
198
+
199
+ // Refinement.
200
+ else if (AST.isRefinement(node)) {
201
+ return findNode(node.from, test);
202
+ }
203
+
204
+ return undefined;
205
+ };
206
+
207
+ /**
208
+ * Get the AST node for the given property (dot-path).
209
+ */
210
+ export const findProperty = (schema: S.Schema<any>, path: JsonPath | JsonProp): AST.AST | undefined => {
211
+ const getProp = (node: AST.AST, path: JsonProp[]): AST.AST | undefined => {
212
+ const [name, ...rest] = path;
213
+ const typeNode = findNode(node, AST.isTypeLiteral);
214
+ invariant(typeNode);
215
+ for (const prop of AST.getPropertySignatures(typeNode)) {
216
+ if (prop.name === name) {
217
+ if (rest.length) {
218
+ return getProp(prop.type, rest);
219
+ } else {
220
+ return prop.type;
221
+ }
222
+ }
223
+ }
224
+
225
+ return undefined;
226
+ };
227
+
228
+ return getProp(schema.ast, path.split('.') as JsonProp[]);
229
+ };
230
+
231
+ /**
232
+ * Recursively descend into AST to find first matching annotations
233
+ */
234
+ export const findAnnotation = <T>(node: AST.AST, annotationId: symbol): T | undefined => {
235
+ const getAnnotationById = getAnnotation(annotationId);
236
+ const getBaseAnnotation = (node: AST.AST): T | undefined => {
237
+ const value = getAnnotationById(node);
238
+ if (value !== undefined) {
239
+ return value as T;
240
+ }
241
+
242
+ if (AST.isUnion(node)) {
243
+ for (const type of node.types) {
244
+ const value = getBaseAnnotation(type);
245
+ if (value !== undefined) {
246
+ return value as T;
247
+ }
248
+ }
249
+ }
250
+ };
251
+
252
+ return getBaseAnnotation(node);
253
+ };
package/src/index.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  import { AST, JSONSchema, Schema as S } from '@effect/schema';
6
6
  import type * as Types from 'effect/Types';
7
7
 
8
+ // TODO(dmaretskyi): Remove re-exports.
8
9
  export { AST, JSONSchema, S, Types };
9
10
 
11
+ export * from './ast';
10
12
  export * from './url';
11
- export * from './util';
package/src/url.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import { AST, type Schema as S } from '@effect/schema';
6
6
  import { Option, pipe } from 'effect';
7
7
 
8
- import { decamelize } from './decamelize';
8
+ import { decamelize } from '@dxos/util';
9
9
 
10
10
  const ParamKeyAnnotationId = Symbol.for('@dxos/schema/annotation/ParamKey');
11
11
 
@@ -1,2 +0,0 @@
1
- export declare const decamelize: (str: string) => string;
2
- //# sourceMappingURL=decamelize.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"decamelize.d.ts","sourceRoot":"","sources":["../../../src/decamelize.ts"],"names":[],"mappings":"AAgBA,eAAO,MAAM,UAAU,QAAS,MAAM,WAmBrC,CAAC"}
@@ -1,3 +0,0 @@
1
- import { AST } from '@effect/schema';
2
- export declare const getAnnotation: <T>(annotationId: symbol) => (annotated: AST.Annotated) => T | undefined;
3
- //# sourceMappingURL=util.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../../../src/util.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,GAAG,EAAE,MAAM,gBAAgB,CAAC;AAGrC,eAAO,MAAM,aAAa,GACvB,CAAC,gBAAgB,MAAM,iBACZ,GAAG,CAAC,SAAS,KAAG,CAAC,GAAG,SAC4C,CAAC"}
package/src/decamelize.ts DELETED
@@ -1,36 +0,0 @@
1
- //
2
- // Copyright 2024 DXOS.org
3
- //
4
-
5
- const LOW_DASH = '_'.codePointAt(0)!;
6
- const SMALL_A = 'a'.codePointAt(0)!;
7
- const CAPITAL_A = 'A'.codePointAt(0)!;
8
- const SMALL_Z = 'z'.codePointAt(0)!;
9
- const CAPITAL_Z = 'Z'.codePointAt(0)!;
10
-
11
- const isLower = (char: number) => char >= SMALL_A && char <= SMALL_Z;
12
-
13
- const isUpper = (char: number) => char >= CAPITAL_A && char <= CAPITAL_Z;
14
-
15
- const toLower = (char: number) => char + 0x20;
16
-
17
- export const decamelize = (str: string) => {
18
- const firstChar = str.charCodeAt(0);
19
- if (!isLower(firstChar)) {
20
- return str;
21
- }
22
- const length = str.length;
23
- let changed = false;
24
- const out: number[] = [];
25
- for (let i = 0; i < length; ++i) {
26
- const c = str.charCodeAt(i);
27
- if (isUpper(c)) {
28
- out.push(LOW_DASH);
29
- out.push(toLower(c));
30
- changed = true;
31
- } else {
32
- out.push(c);
33
- }
34
- }
35
- return changed ? String.fromCharCode.apply(undefined, out) : str;
36
- };
package/src/util.ts DELETED
@@ -1,11 +0,0 @@
1
- //
2
- // Copyright 2024 DXOS.org
3
- //
4
-
5
- import { AST } from '@effect/schema';
6
- import { Option, pipe } from 'effect';
7
-
8
- export const getAnnotation =
9
- <T>(annotationId: symbol) =>
10
- (annotated: AST.Annotated): T | undefined =>
11
- pipe(AST.getAnnotation<T>(annotationId)(annotated), Option.getOrUndefined);