@typescript-eslint/rule-schema-to-typescript-types 0.0.0 → 8.45.1-alpha.8

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,126 @@
1
+ import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema';
2
+
3
+ import { TSUtils } from '@typescript-eslint/utils';
4
+
5
+ import type { SchemaAST, RefMap } from './types.js';
6
+
7
+ import { NotSupportedError, UnexpectedError } from './errors.js';
8
+ import { generateArrayType } from './generateArrayType.js';
9
+ import { generateObjectType } from './generateObjectType.js';
10
+ import { generateUnionType } from './generateUnionType.js';
11
+ import { getCommentLines } from './getCommentLines.js';
12
+
13
+ // keywords we probably should support but currently do not support
14
+ const UNSUPPORTED_KEYWORDS = new Set([
15
+ 'allOf',
16
+ 'dependencies',
17
+ 'extends',
18
+ 'maxProperties',
19
+ 'minProperties',
20
+ 'multipleOf',
21
+ 'not',
22
+ 'patternProperties',
23
+ ]);
24
+
25
+ export function generateType(schema: JSONSchema4, refMap: RefMap): SchemaAST {
26
+ const unsupportedProps = Object.keys(schema).filter(key =>
27
+ UNSUPPORTED_KEYWORDS.has(key),
28
+ );
29
+ if (unsupportedProps.length > 0) {
30
+ throw new NotSupportedError(unsupportedProps.join(','), schema);
31
+ }
32
+
33
+ const commentLines = getCommentLines(schema);
34
+
35
+ if (schema.$ref) {
36
+ const refName = refMap.get(schema.$ref);
37
+ if (refName == null) {
38
+ throw new UnexpectedError(
39
+ `Could not find definition for $ref ${
40
+ schema.$ref
41
+ }.\nAvailable refs:\n${[...refMap.keys()].join('\n')})`,
42
+ schema,
43
+ );
44
+ }
45
+ return {
46
+ commentLines,
47
+ type: 'type-reference',
48
+ typeName: refName,
49
+ };
50
+ }
51
+ if ('enum' in schema && schema.enum) {
52
+ return {
53
+ ...generateUnionType(schema.enum, refMap),
54
+ commentLines,
55
+ };
56
+ }
57
+ if ('anyOf' in schema && schema.anyOf) {
58
+ return {
59
+ // a union isn't *TECHNICALLY* correct - technically anyOf is actually
60
+ // anyOf: [T, U, V] -> T | U | V | T & U | T & V | U & V
61
+ // in practice though it is most used to emulate a oneOf
62
+ ...generateUnionType(schema.anyOf, refMap),
63
+ commentLines,
64
+ };
65
+ }
66
+ if ('oneOf' in schema && schema.oneOf) {
67
+ return {
68
+ ...generateUnionType(schema.oneOf, refMap),
69
+ commentLines,
70
+ };
71
+ }
72
+
73
+ if (!('type' in schema) || schema.type == null) {
74
+ throw new NotSupportedError(
75
+ 'untyped schemas without one of [$ref, enum, oneOf]',
76
+ schema,
77
+ );
78
+ }
79
+ if (TSUtils.isArray(schema.type)) {
80
+ throw new NotSupportedError('schemas with multiple types', schema);
81
+ }
82
+
83
+ switch (schema.type) {
84
+ case 'any':
85
+ return {
86
+ commentLines,
87
+ type: 'type-reference',
88
+ typeName: 'unknown',
89
+ };
90
+
91
+ case 'null':
92
+ return {
93
+ commentLines,
94
+ type: 'type-reference',
95
+ typeName: 'null',
96
+ };
97
+
98
+ case 'number':
99
+ case 'string':
100
+ return {
101
+ code: schema.type,
102
+ commentLines,
103
+ type: 'literal',
104
+ };
105
+
106
+ case 'array':
107
+ return generateArrayType(schema, refMap);
108
+
109
+ case 'boolean':
110
+ return {
111
+ commentLines,
112
+ type: 'type-reference',
113
+ typeName: 'boolean',
114
+ };
115
+
116
+ case 'integer':
117
+ return {
118
+ commentLines,
119
+ type: 'type-reference',
120
+ typeName: 'number',
121
+ };
122
+
123
+ case 'object':
124
+ return generateObjectType(schema, refMap);
125
+ }
126
+ }
@@ -0,0 +1,54 @@
1
+ import type {
2
+ JSONSchema4,
3
+ JSONSchema4Type,
4
+ } from '@typescript-eslint/utils/json-schema';
5
+
6
+ import type { SchemaAST, RefMap, UnionAST } from './types.js';
7
+
8
+ import { NotSupportedError } from './errors.js';
9
+ import { generateType } from './generateType.js';
10
+
11
+ export function generateUnionType(
12
+ members: (JSONSchema4 | JSONSchema4Type)[],
13
+ refMap: RefMap,
14
+ ): UnionAST {
15
+ const elements: SchemaAST[] = [];
16
+
17
+ for (const memberSchema of members) {
18
+ elements.push(
19
+ ((): SchemaAST => {
20
+ switch (typeof memberSchema) {
21
+ case 'string':
22
+ return {
23
+ code: `'${memberSchema.replaceAll("'", "\\'")}'`,
24
+ commentLines: [],
25
+ type: 'literal',
26
+ };
27
+
28
+ case 'number':
29
+ case 'boolean':
30
+ return {
31
+ code: `${memberSchema}`,
32
+ commentLines: [],
33
+ type: 'literal',
34
+ };
35
+
36
+ case 'object':
37
+ if (memberSchema == null) {
38
+ throw new NotSupportedError('null in an enum', memberSchema);
39
+ }
40
+ if (Array.isArray(memberSchema)) {
41
+ throw new NotSupportedError('array in an enum', memberSchema);
42
+ }
43
+ return generateType(memberSchema, refMap);
44
+ }
45
+ })(),
46
+ );
47
+ }
48
+
49
+ return {
50
+ commentLines: [],
51
+ elements,
52
+ type: 'union',
53
+ };
54
+ }
@@ -0,0 +1,9 @@
1
+ import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema';
2
+
3
+ export function getCommentLines(schema: JSONSchema4): string[] {
4
+ const lines: string[] = [];
5
+ if (schema.description) {
6
+ lines.push(schema.description);
7
+ }
8
+ return lines;
9
+ }
package/src/index.ts ADDED
@@ -0,0 +1,80 @@
1
+ import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema';
2
+
3
+ import { TSUtils } from '@typescript-eslint/utils';
4
+
5
+ import type { SchemaAST } from './types.js';
6
+
7
+ import { generateType } from './generateType.js';
8
+ import { optimizeAST } from './optimizeAST.js';
9
+ import { printTypeAlias } from './printAST.js';
10
+
11
+ /**
12
+ * Converts rule options schema(s) to the equivalent TypeScript type string.
13
+ *
14
+ * @param schema Original rule schema(s) as declared in `meta.schema`.
15
+ * @returns Stringified TypeScript type(s) equivalent to the options schema(s).
16
+ */
17
+ export function schemaToTypes(
18
+ schema: JSONSchema4 | readonly JSONSchema4[],
19
+ ): string {
20
+ const [isArraySchema, schemaNormalized] = TSUtils.isArray(schema)
21
+ ? [true, schema]
22
+ : [false, [schema]];
23
+
24
+ if (schemaNormalized.length === 0) {
25
+ return ['/** No options declared */', 'type Options = [];'].join('\n');
26
+ }
27
+
28
+ const refTypes: string[] = [];
29
+ const types: SchemaAST[] = [];
30
+ for (let i = 0; i < schemaNormalized.length; i += 1) {
31
+ const result = compileSchema(schemaNormalized[i], i);
32
+ refTypes.push(...result.refTypes);
33
+ types.push(result.type);
34
+ }
35
+
36
+ const optionsType = isArraySchema
37
+ ? printTypeAlias('Options', {
38
+ commentLines: [],
39
+ elements: types,
40
+ spreadType: null,
41
+ type: 'tuple',
42
+ })
43
+ : printTypeAlias('Options', types[0]);
44
+
45
+ return [...refTypes, optionsType].join('\n\n');
46
+ }
47
+
48
+ function compileSchema(
49
+ schema: JSONSchema4,
50
+ index: number,
51
+ ): { refTypes: string[]; type: SchemaAST } {
52
+ const refTypes: string[] = [];
53
+
54
+ const refMap = new Map<string, string>();
55
+ // we only support defs at the top level for simplicity
56
+ const defs = schema.$defs ?? schema.definitions;
57
+ if (defs) {
58
+ for (const [defKey, defSchema] of Object.entries(defs)) {
59
+ const typeName = toPascalCase(defKey);
60
+ refMap.set(`#/$defs/${defKey}`, typeName);
61
+ refMap.set(`#/items/${index}/$defs/${defKey}`, typeName);
62
+
63
+ const type = generateType(defSchema, refMap);
64
+ optimizeAST(type);
65
+ refTypes.push(printTypeAlias(typeName, type));
66
+ }
67
+ }
68
+
69
+ const type = generateType(schema, refMap);
70
+ optimizeAST(type);
71
+
72
+ return {
73
+ refTypes,
74
+ type,
75
+ };
76
+ }
77
+
78
+ function toPascalCase(key: string): string {
79
+ return key[0].toUpperCase() + key.substring(1);
80
+ }
@@ -0,0 +1,72 @@
1
+ import type { SchemaAST, UnionAST } from './types.js';
2
+
3
+ export function optimizeAST(ast: SchemaAST | null): void {
4
+ if (ast == null) {
5
+ return;
6
+ }
7
+
8
+ switch (ast.type) {
9
+ case 'array': {
10
+ optimizeAST(ast.elementType);
11
+ return;
12
+ }
13
+
14
+ case 'literal':
15
+ return;
16
+
17
+ case 'object': {
18
+ for (const property of ast.properties) {
19
+ optimizeAST(property.type);
20
+ }
21
+ optimizeAST(ast.indexSignature);
22
+ return;
23
+ }
24
+
25
+ case 'tuple': {
26
+ for (const element of ast.elements) {
27
+ optimizeAST(element);
28
+ }
29
+ optimizeAST(ast.spreadType);
30
+ return;
31
+ }
32
+
33
+ case 'type-reference':
34
+ return;
35
+
36
+ case 'union': {
37
+ const elements = unwrapUnions(ast);
38
+ for (const element of elements) {
39
+ optimizeAST(element);
40
+ }
41
+
42
+ // hacky way to deduplicate union members
43
+ const uniqueElementsMap = new Map<string, SchemaAST>();
44
+ for (const element of elements) {
45
+ uniqueElementsMap.set(JSON.stringify(element), element);
46
+ }
47
+ const uniqueElements = [...uniqueElementsMap.values()];
48
+
49
+ // @ts-expect-error -- purposely overwriting the property with a flattened list
50
+ ast.elements = uniqueElements;
51
+ return;
52
+ }
53
+ }
54
+ }
55
+
56
+ function unwrapUnions(union: UnionAST): SchemaAST[] {
57
+ const elements: SchemaAST[] = [];
58
+ for (const element of union.elements) {
59
+ if (element.type === 'union') {
60
+ elements.push(...unwrapUnions(element));
61
+ } else {
62
+ elements.push(element);
63
+ }
64
+ }
65
+
66
+ if (elements.length > 0) {
67
+ // preserve the union's comment lines by prepending them to the first element's lines
68
+ elements[0].commentLines.unshift(...union.commentLines);
69
+ }
70
+
71
+ return elements;
72
+ }
@@ -0,0 +1,168 @@
1
+ import naturalCompare from 'natural-compare';
2
+
3
+ import type { SchemaAST, TupleAST } from './types.js';
4
+
5
+ export function printTypeAlias(aliasName: string, ast: SchemaAST): string {
6
+ return `${printComment(ast)}type ${aliasName} = ${printAST(ast).code}`;
7
+ }
8
+
9
+ export function printASTWithComment(ast: SchemaAST): string {
10
+ const result = printAST(ast);
11
+ return `${printComment(result)}${result.code}`;
12
+ }
13
+
14
+ function printComment({
15
+ commentLines: commentLinesIn,
16
+ }: {
17
+ readonly commentLines?: string[] | null | undefined;
18
+ }): string {
19
+ if (commentLinesIn == null || commentLinesIn.length === 0) {
20
+ return '';
21
+ }
22
+
23
+ const commentLines: string[] = [];
24
+ for (const line of commentLinesIn) {
25
+ commentLines.push(...line.split('\n'));
26
+ }
27
+
28
+ if (commentLines.length === 1) {
29
+ return `/** ${commentLines[0]} */\n`;
30
+ }
31
+
32
+ return ['/**', ...commentLines.map(l => ` * ${l}`), ' */', ''].join('\n');
33
+ }
34
+
35
+ interface CodeWithComments {
36
+ code: string;
37
+ commentLines: string[];
38
+ }
39
+ function printAST(ast: SchemaAST): CodeWithComments {
40
+ switch (ast.type) {
41
+ case 'array': {
42
+ const code = printAndMaybeParenthesise(ast.elementType);
43
+ return {
44
+ code: `${code.code}[]`,
45
+ commentLines: [...ast.commentLines, ...code.commentLines],
46
+ };
47
+ }
48
+
49
+ case 'literal':
50
+ return {
51
+ code: ast.code,
52
+ commentLines: ast.commentLines,
53
+ };
54
+
55
+ case 'object': {
56
+ const properties = [];
57
+ // sort the properties so that we get consistent output regardless
58
+ // of import declaration order
59
+ const sortedPropertyDefs = ast.properties.sort((a, b) =>
60
+ naturalCompare(a.name, b.name),
61
+ );
62
+ for (const property of sortedPropertyDefs) {
63
+ const result = printAST(property.type);
64
+ properties.push(
65
+ `${printComment(result)}${property.name}${
66
+ property.optional ? '?:' : ':'
67
+ } ${result.code}`,
68
+ );
69
+ }
70
+
71
+ if (ast.indexSignature) {
72
+ const result = printAST(ast.indexSignature);
73
+ properties.push(`${printComment(result)}[k: string]: ${result.code}`);
74
+ }
75
+ return {
76
+ // force insert a newline so prettier consistently prints all objects as multiline
77
+ code: `{\n${properties.join(';\n')}}`,
78
+ commentLines: ast.commentLines,
79
+ };
80
+ }
81
+
82
+ case 'tuple': {
83
+ const elements = [];
84
+ for (const element of ast.elements) {
85
+ elements.push(printASTWithComment(element));
86
+ }
87
+ if (ast.spreadType) {
88
+ const result = printAndMaybeParenthesise(ast.spreadType);
89
+ elements.push(`${printComment(result)}...${result.code}[]`);
90
+ }
91
+
92
+ return {
93
+ code: `[${elements.join(',')}]`,
94
+ commentLines: ast.commentLines,
95
+ };
96
+ }
97
+
98
+ case 'type-reference':
99
+ return {
100
+ code: ast.typeName,
101
+ commentLines: ast.commentLines,
102
+ };
103
+
104
+ case 'union':
105
+ return {
106
+ code: ast.elements
107
+ .map(element => {
108
+ const result = printAST(element);
109
+ const code = `${printComment(result)} | ${result.code}`;
110
+ return {
111
+ code,
112
+ element,
113
+ };
114
+ })
115
+ // sort the union members so that we get consistent output regardless
116
+ // of declaration order
117
+ .sort((a, b) => compareElements(a, b))
118
+ .map(el => el.code)
119
+ .join('\n'),
120
+ commentLines: ast.commentLines,
121
+ };
122
+ }
123
+ }
124
+
125
+ interface Element {
126
+ code: string;
127
+ element: SchemaAST;
128
+ }
129
+ function compareElements(a: Element, b: Element): number {
130
+ if (a.element.type !== b.element.type) {
131
+ return naturalCompare(a.code, b.code);
132
+ }
133
+
134
+ switch (a.element.type) {
135
+ case 'array':
136
+ case 'literal':
137
+ case 'type-reference':
138
+ case 'object':
139
+ case 'union':
140
+ return naturalCompare(a.code, b.code);
141
+
142
+ case 'tuple': {
143
+ // natural compare will sort longer tuples before shorter ones
144
+ // which is the opposite of what we want, so we sort first by length THEN
145
+ // by code to ensure shorter tuples come first
146
+ const aElement = a.element;
147
+ const bElement = b.element as TupleAST;
148
+ if (aElement.elements.length !== bElement.elements.length) {
149
+ return aElement.elements.length - bElement.elements.length;
150
+ }
151
+ return naturalCompare(a.code, b.code);
152
+ }
153
+ }
154
+ }
155
+
156
+ function printAndMaybeParenthesise(ast: SchemaAST): CodeWithComments {
157
+ const printed = printAST(ast);
158
+ if (ast.type === 'union') {
159
+ return {
160
+ code: `(${printed.code})`,
161
+ commentLines: printed.commentLines,
162
+ };
163
+ }
164
+ return {
165
+ code: printed.code,
166
+ commentLines: printed.commentLines,
167
+ };
168
+ }
package/src/types.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Maps ref paths to generated type names.
3
+ */
4
+ export type RefMap = ReadonlyMap<
5
+ // ref path
6
+ string,
7
+ // type name
8
+ string
9
+ >;
10
+
11
+ /**
12
+ * Minimal representation of the nodes in a schema being compiled to types.
13
+ */
14
+ export type SchemaAST =
15
+ | ArrayAST
16
+ | LiteralAST
17
+ | ObjectAST
18
+ | TupleAST
19
+ | TypeReferenceAST
20
+ | UnionAST;
21
+
22
+ export interface BaseSchemaASTNode {
23
+ readonly commentLines: string[];
24
+ }
25
+
26
+ export interface ArrayAST extends BaseSchemaASTNode {
27
+ readonly elementType: SchemaAST;
28
+ readonly type: 'array';
29
+ }
30
+ export interface LiteralAST extends BaseSchemaASTNode {
31
+ readonly code: string;
32
+ readonly type: 'literal';
33
+ }
34
+ export interface ObjectAST extends BaseSchemaASTNode {
35
+ readonly indexSignature: SchemaAST | null;
36
+ readonly properties: {
37
+ readonly name: string;
38
+ readonly optional: boolean;
39
+ readonly type: SchemaAST;
40
+ }[];
41
+ readonly type: 'object';
42
+ }
43
+ export interface TupleAST extends BaseSchemaASTNode {
44
+ readonly elements: SchemaAST[];
45
+ readonly spreadType: SchemaAST | null;
46
+ readonly type: 'tuple';
47
+ }
48
+ export interface TypeReferenceAST extends BaseSchemaASTNode {
49
+ readonly type: 'type-reference';
50
+ readonly typeName: string;
51
+ }
52
+ export interface UnionAST extends BaseSchemaASTNode {
53
+ readonly elements: SchemaAST[];
54
+ readonly type: 'union';
55
+ }
@@ -0,0 +1,148 @@
1
+ import { schemaToTypes } from '../src/index.js';
2
+
3
+ describe(schemaToTypes, () => {
4
+ it('returns a [] type when the schema is an empty array', () => {
5
+ const actual = schemaToTypes([]);
6
+
7
+ expect(actual).toMatchInlineSnapshot(`
8
+ "/** No options declared */
9
+ type Options = [];"
10
+ `);
11
+ });
12
+
13
+ it('returns a single Options type when the schema is not an array', () => {
14
+ const actual = schemaToTypes({ type: 'string' });
15
+
16
+ expect(actual).toMatchInlineSnapshot(`"type Options = string"`);
17
+ });
18
+
19
+ it('returns an array Options type when the schema is an array', () => {
20
+ const actual = schemaToTypes([{ type: 'string' }]);
21
+
22
+ expect(actual).toMatchInlineSnapshot(`"type Options = [string]"`);
23
+ });
24
+
25
+ it('returns a complex Options type when the schema contains an array of items', () => {
26
+ const actual = schemaToTypes([
27
+ {
28
+ items: [{ type: 'string' }],
29
+ type: 'array',
30
+ },
31
+ ]);
32
+
33
+ expect(actual).toMatchInlineSnapshot(`
34
+ "type Options = [ | []
35
+ | [string]]"
36
+ `);
37
+ });
38
+
39
+ it('returns a complex Options type when the schema is nested', () => {
40
+ const actual = schemaToTypes([
41
+ {
42
+ description: 'My schema items.',
43
+ items: { type: 'string' },
44
+ type: 'array',
45
+ },
46
+ ]);
47
+
48
+ expect(actual).toMatchInlineSnapshot(`
49
+ "type Options = [/** My schema items. */
50
+ string[]]"
51
+ `);
52
+ });
53
+
54
+ it('factors in one $ref property when a $defs property exists at the top level', () => {
55
+ const actual = schemaToTypes([
56
+ {
57
+ $defs: {
58
+ defOption: {
59
+ enum: ['a', 'b'],
60
+ type: 'string',
61
+ },
62
+ },
63
+ additionalProperties: false,
64
+ properties: {
65
+ one: {
66
+ $ref: '#/items/0/$defs/defOption',
67
+ },
68
+ },
69
+ type: 'object',
70
+ },
71
+ ]);
72
+
73
+ expect(actual).toMatchInlineSnapshot(`
74
+ "type DefOption = | 'a'
75
+ | 'b'
76
+
77
+ type Options = [{
78
+ one?: DefOption}]"
79
+ `);
80
+ });
81
+
82
+ it('factors in one $ref property when a definitions property exists at the top level', () => {
83
+ const actual = schemaToTypes([
84
+ {
85
+ additionalProperties: false,
86
+ definitions: {
87
+ defOption: {
88
+ enum: ['a', 'b'],
89
+ type: 'string',
90
+ },
91
+ },
92
+ properties: {
93
+ one: {
94
+ $ref: '#/items/0/$defs/defOption',
95
+ },
96
+ },
97
+ type: 'object',
98
+ },
99
+ ]);
100
+
101
+ expect(actual).toMatchInlineSnapshot(`
102
+ "type DefOption = | 'a'
103
+ | 'b'
104
+
105
+ type Options = [{
106
+ one?: DefOption}]"
107
+ `);
108
+ });
109
+
110
+ it('factors in two $ref properties when two $defs properties exist at the top level', () => {
111
+ const actual = schemaToTypes([
112
+ {
113
+ $defs: {
114
+ defOptionOne: {
115
+ enum: ['a', 'b'],
116
+ type: 'string',
117
+ },
118
+ defOptionTwo: {
119
+ enum: ['c', 'd'],
120
+ type: 'string',
121
+ },
122
+ },
123
+ additionalProperties: false,
124
+ properties: {
125
+ one: {
126
+ $ref: '#/items/0/$defs/defOptionOne',
127
+ },
128
+ two: {
129
+ $ref: '#/items/0/$defs/defOptionTwo',
130
+ },
131
+ },
132
+ type: 'object',
133
+ },
134
+ ]);
135
+
136
+ expect(actual).toMatchInlineSnapshot(`
137
+ "type DefOptionOne = | 'a'
138
+ | 'b'
139
+
140
+ type DefOptionTwo = | 'c'
141
+ | 'd'
142
+
143
+ type Options = [{
144
+ one?: DefOptionOne;
145
+ two?: DefOptionTwo}]"
146
+ `);
147
+ });
148
+ });