feat!: complete rework
- Use Prettier for formatting - Use composite TypeScript projects - Use generics in custom AST and resolve them for outputs that don’t support generics - Add tests - Export AST types - Export single renderers - JSON Schema renderer option to control unresolvedProperties/additionalProperties - Tests The Markdown generation may still be improved.
This commit is contained in:
@@ -9,18 +9,18 @@ on:
|
||||
- v[0-9]+.[0-9]+.[0-9]+*
|
||||
|
||||
jobs:
|
||||
# build:
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v2
|
||||
# - uses: actions/setup-node@v2
|
||||
# with:
|
||||
# node-version: 16
|
||||
# - run: npm ci
|
||||
# - run: npm test
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
|
||||
publish-npm:
|
||||
# needs: build
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
*.ast.json
|
||||
*.ast.resolved.json
|
||||
*.tsbuildinfo
|
||||
node_modules
|
||||
lib
|
||||
libtest
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
.github
|
||||
*.tsbuildinfo
|
||||
.versionrc.json
|
||||
.vscode
|
||||
CODEOWNERS
|
||||
libtest
|
||||
src
|
||||
test
|
||||
tsconfig.json
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
semi: false
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"cwd": "${workspaceFolder}/../optolith-database-schema",
|
||||
"program": "${workspaceFolder}/src/bin/cli.ts",
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/lib/**/*.js"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+1360
-303
File diff suppressed because it is too large
Load Diff
+10
-8
@@ -12,27 +12,29 @@
|
||||
"main": "./lib/main.js",
|
||||
"exports": {
|
||||
".": "./lib/main.js",
|
||||
"./config": "./lib/config.js",
|
||||
"./renderers": "./lib/renderers.js"
|
||||
"./ast": "./lib/ast.js",
|
||||
"./renderers": "./lib/renderers.js",
|
||||
"./renderers/*": "./lib/renderers/*.js"
|
||||
},
|
||||
"bin": {
|
||||
"otjsmd": "./lib/bin/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc -w",
|
||||
"build": "tsc -b",
|
||||
"watch": "tsc -b -w",
|
||||
"release": "standard-version",
|
||||
"test": "node --test"
|
||||
"test": "glob -c \"node --import tsx --test\" \"./test/**/*.test.ts\""
|
||||
},
|
||||
"author": "Lukas Obermann",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"typescript": "^5.1.6"
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.2.5",
|
||||
"@types/node": "^20.9.0",
|
||||
"glob": "^10.3.10",
|
||||
"standard-version": "^9.5.0",
|
||||
"ts-node": "^10.9.1"
|
||||
"tsx": "^4.0.0"
|
||||
},
|
||||
"repository": "github:elyukai/optolith-tsjsonschemamd",
|
||||
"bugs": {
|
||||
|
||||
+455
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* The possible discriminator values to differenciate the different nodes.
|
||||
*/
|
||||
export enum NodeKind {
|
||||
Root,
|
||||
Group,
|
||||
Record,
|
||||
Dictionary,
|
||||
Token,
|
||||
Reference,
|
||||
Enumeration,
|
||||
EnumerationCase,
|
||||
Array,
|
||||
Union,
|
||||
Literal,
|
||||
Tuple,
|
||||
ExportAssignment,
|
||||
TypeDefinition,
|
||||
TypeParameter,
|
||||
DefaultImport,
|
||||
NamedImport,
|
||||
NamespaceImport,
|
||||
Intersection,
|
||||
}
|
||||
|
||||
/**
|
||||
* The parsed JSDoc annotations for a node.
|
||||
*/
|
||||
export type Doc = {
|
||||
/**
|
||||
* The initial description text.
|
||||
*/
|
||||
comment?: string
|
||||
|
||||
/**
|
||||
* A dictionary of supported tags (`@tag`) with parsed values, if present.
|
||||
*/
|
||||
tags: DocTags
|
||||
}
|
||||
|
||||
/**
|
||||
* A dictionary from all supported tag names to their JSON Schema data types.
|
||||
*/
|
||||
export type DocTagTypes = {
|
||||
// General
|
||||
main: "string"
|
||||
title: "string"
|
||||
default: "unknown"
|
||||
|
||||
// String
|
||||
minLength: "integer"
|
||||
maxLength: "integer"
|
||||
pattern: "string"
|
||||
format: "string"
|
||||
markdown: "boolean"
|
||||
|
||||
// Numeric
|
||||
integer: "boolean"
|
||||
minimum: "number"
|
||||
maximum: "number"
|
||||
multipleOf: "number"
|
||||
exclusiveMinimum: "number"
|
||||
exclusiveMaximum: "number"
|
||||
|
||||
// Object
|
||||
minProperties: "integer"
|
||||
maxProperties: "integer"
|
||||
patternProperties: "string"
|
||||
|
||||
// Array
|
||||
minItems: "integer"
|
||||
maxItems: "integer"
|
||||
uniqueItems: "boolean"
|
||||
}
|
||||
|
||||
/**
|
||||
* A dictionary from all supported data types in JSON Schema to their
|
||||
* corresponding TypeScript data types.
|
||||
*/
|
||||
type JSONSchemaTypeToTypeScriptType = {
|
||||
number: number
|
||||
integer: number
|
||||
boolean: boolean
|
||||
string: string
|
||||
unknown: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* A dictionary from all supported tags to their values.
|
||||
*/
|
||||
export type DocTags = {
|
||||
-readonly [K in keyof DocTagTypes]?: JSONSchemaTypeToTypeScriptType[DocTagTypes[K]]
|
||||
}
|
||||
|
||||
/**
|
||||
* An object with a fixed set of keys, which may have different value types.
|
||||
*/
|
||||
export type RecordNode = {
|
||||
kind: NodeKind.Record
|
||||
fileName: string
|
||||
jsDoc?: Doc
|
||||
|
||||
/**
|
||||
* All properties, keyed by the property name.
|
||||
*/
|
||||
children: {
|
||||
[identifier: string]: {
|
||||
jsDoc?: Doc
|
||||
|
||||
/**
|
||||
* Is the property required?
|
||||
*/
|
||||
isRequired: boolean
|
||||
|
||||
/**
|
||||
* The property value.
|
||||
*/
|
||||
value: ChildNode
|
||||
|
||||
/**
|
||||
* Is the property read-only?
|
||||
*/
|
||||
isReadOnly: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const isRecordNode = (node: Node): node is RecordNode =>
|
||||
node.kind === NodeKind.Record
|
||||
|
||||
/**
|
||||
* An object with a variable set of keys with the same value type. The keys may
|
||||
* be restricted to match a certain regular expression.
|
||||
*/
|
||||
export type DictionaryNode = {
|
||||
kind: NodeKind.Dictionary
|
||||
fileName: string
|
||||
jsDoc?: Doc
|
||||
|
||||
/**
|
||||
* The value type at all defined keys.
|
||||
*/
|
||||
children: ChildNode
|
||||
|
||||
/**
|
||||
* An optional pattern in regular expression syntax all keys must match.
|
||||
*/
|
||||
pattern?: string
|
||||
}
|
||||
|
||||
export const isDictionaryNode = (node: Node): node is DictionaryNode =>
|
||||
node.kind === NodeKind.Dictionary
|
||||
|
||||
export type IntersectionNode = {
|
||||
kind: NodeKind.Intersection
|
||||
fileName: string
|
||||
jsDoc?: Doc
|
||||
|
||||
/**
|
||||
* The types to intersect.
|
||||
*/
|
||||
children: ChildNode[]
|
||||
}
|
||||
|
||||
export const isIntersectionNode = (node: Node): node is IntersectionNode =>
|
||||
node.kind === NodeKind.Intersection
|
||||
|
||||
/**
|
||||
* The possible discriminator values to differenciate the different tokens.
|
||||
*/
|
||||
export enum TokenKind {
|
||||
String,
|
||||
Number,
|
||||
Boolean,
|
||||
}
|
||||
|
||||
/**
|
||||
* A primitive type.
|
||||
*/
|
||||
export type TokenNode = {
|
||||
kind: NodeKind.Token
|
||||
fileName: string
|
||||
jsDoc?: Doc
|
||||
|
||||
/**
|
||||
* The specific primitive type.
|
||||
*/
|
||||
token: TokenKind
|
||||
}
|
||||
|
||||
export const isTokenNode = (node: Node): node is TokenNode =>
|
||||
node.kind === NodeKind.Token
|
||||
|
||||
export type QualifiedName = {
|
||||
segment: string
|
||||
right?: QualifiedName
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to another type.
|
||||
*/
|
||||
export type ReferenceNode = {
|
||||
kind: NodeKind.Reference
|
||||
fileName: string
|
||||
jsDoc?: Doc
|
||||
/**
|
||||
* Only used if type parameters are resolved, since this involves copying
|
||||
* nodes in different files.
|
||||
*/
|
||||
resolvedFileName?: string
|
||||
name: QualifiedName
|
||||
typeArguments?: ChildNode[]
|
||||
}
|
||||
|
||||
export const isReferenceNode = (node: Node): node is ReferenceNode =>
|
||||
node.kind === NodeKind.Reference
|
||||
|
||||
/**
|
||||
* An array of elements of the same type.
|
||||
*/
|
||||
export type ArrayNode = {
|
||||
kind: NodeKind.Array
|
||||
fileName: string
|
||||
jsDoc?: Doc
|
||||
|
||||
/**
|
||||
* The type of all elements.
|
||||
*/
|
||||
children: ChildNode
|
||||
}
|
||||
|
||||
export const isArrayNode = (node: Node): node is ArrayNode =>
|
||||
node.kind === NodeKind.Array
|
||||
|
||||
/**
|
||||
* A set of possible types.
|
||||
*/
|
||||
export type UnionNode = {
|
||||
kind: NodeKind.Union
|
||||
fileName: string
|
||||
jsDoc?: Doc
|
||||
|
||||
/**
|
||||
* The list of all possible types.
|
||||
*/
|
||||
children: ChildNode[]
|
||||
}
|
||||
|
||||
export const isUnionNode = (node: Node): node is UnionNode =>
|
||||
node.kind === NodeKind.Union
|
||||
|
||||
/**
|
||||
* A constant value.
|
||||
*/
|
||||
export type LiteralNode = {
|
||||
kind: NodeKind.Literal
|
||||
fileName: string
|
||||
jsDoc?: Doc
|
||||
|
||||
/**
|
||||
* The constant value.
|
||||
*/
|
||||
value: string | number | boolean
|
||||
}
|
||||
|
||||
export const isLiteralNode = (node: Node): node is LiteralNode =>
|
||||
node.kind === NodeKind.Literal
|
||||
|
||||
/**
|
||||
* A tuple of elements that may have different types.
|
||||
*/
|
||||
export type TupleNode = {
|
||||
kind: NodeKind.Tuple
|
||||
fileName: string
|
||||
jsDoc?: Doc
|
||||
|
||||
/**
|
||||
* The types of the different elements of the tuple. Each node index
|
||||
* corresponds with the index in the tuple.
|
||||
*/
|
||||
children: ChildNode[]
|
||||
}
|
||||
|
||||
export const isTupleNode = (node: Node): node is TupleNode =>
|
||||
node.kind === NodeKind.Tuple
|
||||
|
||||
/**
|
||||
* A fixed set of possible string or numeric values.
|
||||
*/
|
||||
export type EnumerationNode = {
|
||||
kind: NodeKind.Enumeration
|
||||
fileName: string
|
||||
name: string
|
||||
jsDoc?: Doc
|
||||
|
||||
/**
|
||||
* All possible cases.
|
||||
*/
|
||||
children: EnumerationCase[]
|
||||
}
|
||||
|
||||
export const isEnumerationNode = (node: Node): node is EnumerationNode =>
|
||||
node.kind === NodeKind.Enumeration
|
||||
|
||||
/**
|
||||
* A possible case from an enumeration.
|
||||
*/
|
||||
export type EnumerationCase = {
|
||||
kind: NodeKind.EnumerationCase
|
||||
jsDoc?: Doc
|
||||
fileName: string
|
||||
|
||||
/**
|
||||
* The case name.
|
||||
*/
|
||||
name: string
|
||||
|
||||
/**
|
||||
* The value the case represents.
|
||||
*/
|
||||
value: string | number
|
||||
}
|
||||
|
||||
export const isEnumerationCase = (node: Node): node is EnumerationCase =>
|
||||
node.kind === NodeKind.EnumerationCase
|
||||
|
||||
/**
|
||||
* A grouped/namespaced set of declarations.
|
||||
*/
|
||||
export type GroupNode = {
|
||||
kind: NodeKind.Group
|
||||
fileName: string
|
||||
name: string
|
||||
jsDoc?: Doc
|
||||
|
||||
/**
|
||||
* All elements within, keyed by their identifier.
|
||||
*/
|
||||
children: StatementNode[]
|
||||
}
|
||||
|
||||
export const isGroupNode = (node: Node): node is GroupNode =>
|
||||
node.kind === NodeKind.Group
|
||||
|
||||
export type TypeParameterNode = {
|
||||
kind: NodeKind.TypeParameter
|
||||
name: string
|
||||
fileName: string
|
||||
constraint?: ChildNode
|
||||
default?: ChildNode
|
||||
}
|
||||
|
||||
export const isTypeParameterNode = (node: Node): node is TypeParameterNode =>
|
||||
node.kind === NodeKind.TypeParameter
|
||||
|
||||
/**
|
||||
* A type definition (alias or interface).
|
||||
*/
|
||||
export type TypeDefinitionNode = {
|
||||
kind: NodeKind.TypeDefinition
|
||||
fileName: string
|
||||
name: string
|
||||
jsDoc?: Doc
|
||||
typeParameters?: TypeParameterNode[]
|
||||
definition: ChildNode
|
||||
}
|
||||
|
||||
export const isTypeDefinitionNode = (node: Node): node is TypeDefinitionNode =>
|
||||
node.kind === NodeKind.TypeDefinition
|
||||
|
||||
export type ExportAssignmentNode = {
|
||||
kind: NodeKind.ExportAssignment
|
||||
fileName: string
|
||||
name: string
|
||||
jsDoc?: Doc
|
||||
expression: ChildNode
|
||||
}
|
||||
|
||||
export const isExportAssignmentNode = (
|
||||
node: Node
|
||||
): node is ExportAssignmentNode => node.kind === NodeKind.ExportAssignment
|
||||
|
||||
/**
|
||||
* A supported nested type node in a TypeScript file.
|
||||
*/
|
||||
export type ChildNode =
|
||||
| RecordNode
|
||||
| DictionaryNode
|
||||
| TokenNode
|
||||
| ReferenceNode
|
||||
| ArrayNode
|
||||
| UnionNode
|
||||
| LiteralNode
|
||||
| TupleNode
|
||||
| IntersectionNode
|
||||
|
||||
/**
|
||||
* A supported top-level node in a TypeScript file.
|
||||
*/
|
||||
export type StatementNode =
|
||||
| GroupNode
|
||||
| EnumerationNode
|
||||
| TypeDefinitionNode
|
||||
| ExportAssignmentNode
|
||||
|
||||
export type NamespaceImport = {
|
||||
kind: NodeKind.NamespaceImport
|
||||
name: string
|
||||
fileName: string
|
||||
}
|
||||
|
||||
export const isNamespaceImport = (node: Node): node is NamespaceImport =>
|
||||
node.kind === NodeKind.NamespaceImport
|
||||
|
||||
export type NamedImport = {
|
||||
kind: NodeKind.NamedImport
|
||||
name: string
|
||||
alias?: string
|
||||
fileName: string
|
||||
}
|
||||
|
||||
export const isNamedImport = (node: Node): node is NamedImport =>
|
||||
node.kind === NodeKind.NamedImport
|
||||
|
||||
export type DefaultImport = {
|
||||
kind: NodeKind.DefaultImport
|
||||
name: string
|
||||
fileName: string
|
||||
}
|
||||
|
||||
export const isDefaultImport = (node: Node): node is DefaultImport =>
|
||||
node.kind === NodeKind.DefaultImport
|
||||
|
||||
export type ImportNode = NamespaceImport | NamedImport | DefaultImport
|
||||
|
||||
/**
|
||||
* The file root node.
|
||||
*/
|
||||
export type RootNode = {
|
||||
kind: NodeKind.Root
|
||||
jsDoc?: Doc
|
||||
fileName: string
|
||||
imports: ImportNode[]
|
||||
|
||||
/**
|
||||
* All top-level type declarations.
|
||||
*/
|
||||
children: StatementNode[]
|
||||
}
|
||||
|
||||
export type ContentNode = RootNode | StatementNode | ChildNode
|
||||
|
||||
export type SupportNode = TypeParameterNode | EnumerationCase
|
||||
|
||||
export type Node = ContentNode | ImportNode | SupportNode
|
||||
+21
-12
@@ -4,7 +4,8 @@ import { resolve } from "node:path"
|
||||
import { argv, cwd } from "node:process"
|
||||
import { GeneratorOptions, generate } from "../main.js"
|
||||
|
||||
const cliOptions = argv.slice(2)
|
||||
const cliOptions = argv
|
||||
.slice(2)
|
||||
.reduce<[lastOption: string | undefined, options: Map<string, string[]>]>(
|
||||
([lastOption, map], arg) => {
|
||||
if (/^-{1,2}/.test(arg)) {
|
||||
@@ -18,17 +19,21 @@ const cliOptions = argv.slice(2)
|
||||
[undefined, new Map()]
|
||||
)[1]
|
||||
|
||||
const optionsPath = resolve(cwd(), cliOptions.get("-c")?.[0] ?? cliOptions.get("--config")?.[0] ?? "otjsmd.config.js")
|
||||
const optionsPath = resolve(
|
||||
cwd(),
|
||||
cliOptions.get("-c")?.[0] ??
|
||||
cliOptions.get("--config")?.[0] ??
|
||||
"otjsmd.config.js"
|
||||
)
|
||||
|
||||
const options = (await import(optionsPath)).default as GeneratorOptions
|
||||
|
||||
if (cliOptions.has("-w") || cliOptions.has("--watch")) {
|
||||
try {
|
||||
generate(options)
|
||||
}
|
||||
catch (err) {
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
console.log("Watching for changes ...")
|
||||
|
||||
const generate$ = debounce(generate, 300)
|
||||
@@ -36,21 +41,25 @@ if (cliOptions.has("-w") || cliOptions.has("--watch")) {
|
||||
for await (const _ of watch(options.sourceDir, { recursive: true })) {
|
||||
try {
|
||||
generate$(options)
|
||||
}
|
||||
catch (err) {
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
generate(options)
|
||||
}
|
||||
|
||||
function debounce<T extends any[]>(this: any, f: (...args: T) => void, timeout: number): (...args: T) => void {
|
||||
function debounce<T extends any[]>(
|
||||
this: any,
|
||||
f: (...args: T) => void,
|
||||
timeout: number
|
||||
): (...args: T) => void {
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
return (...args) => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => { f.apply(this, args) }, timeout)
|
||||
timer = setTimeout(() => {
|
||||
f.apply(this, args)
|
||||
}, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export type JsonSchemaSpec =
|
||||
| "Draft_07"
|
||||
| "Draft_2019_09"
|
||||
| "Draft_2020_12"
|
||||
+129
-63
@@ -1,7 +1,19 @@
|
||||
import { Dirent, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { basename, dirname, extname, format, join, relative, sep } from "node:path"
|
||||
import { Dirent, existsSync } from "node:fs"
|
||||
import { mkdir, readdir, rm, writeFile } from "node:fs/promises"
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
extname,
|
||||
format,
|
||||
join,
|
||||
relative,
|
||||
sep,
|
||||
} from "node:path"
|
||||
import ts from "typescript"
|
||||
import { RootNode, fileToAst } from "./parser/ast.js"
|
||||
import { RootNode } from "./ast.js"
|
||||
import { fileToAst } from "./parser/ast.js"
|
||||
import { resolveTypeArgumentsForFile } from "./parser/resolvetypeargs.js"
|
||||
import { tsextPattern } from "./utils/path.js"
|
||||
|
||||
export type MetaInformation = {
|
||||
/**
|
||||
@@ -35,6 +47,12 @@ export type Renderer = {
|
||||
* The file extension the output files should use, e.g. `.md`.
|
||||
*/
|
||||
fileExtension: string
|
||||
|
||||
/**
|
||||
* If `true`, the root node will have its type parameters resolved and thus
|
||||
* removed.
|
||||
*/
|
||||
resolveTypeParameters?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,122 +124,170 @@ export type GeneratorOptions = {
|
||||
*
|
||||
* @param options - The generator options.
|
||||
*/
|
||||
export const generate = (options: GeneratorOptions): void => {
|
||||
export const generate = async (options: GeneratorOptions): Promise<void> => {
|
||||
const {
|
||||
sourceDir,
|
||||
outputs,
|
||||
dumpAst = false,
|
||||
clean = false,
|
||||
fileNamePredicate,
|
||||
verbose = false
|
||||
verbose = false,
|
||||
} = options
|
||||
|
||||
const flattenTypeScriptFileNamesFromDir = (dirPath: string): string[] => {
|
||||
const flattenTypeScriptFileNamesFromDir = async (
|
||||
dirPath: string
|
||||
): Promise<string[]> => {
|
||||
const dirEntryToFilePath = (dirEntry: Dirent) =>
|
||||
join(dirPath, dirEntry.name).split(sep).join("/")
|
||||
|
||||
return readdirSync(dirPath, { withFileTypes: true })
|
||||
.flatMap(dirEntry => {
|
||||
if (dirEntry.isDirectory()) {
|
||||
return flattenTypeScriptFileNamesFromDir(dirEntryToFilePath(dirEntry))
|
||||
}
|
||||
else if (dirEntry.isFile() && extname(dirEntry.name) === ".ts") {
|
||||
return [dirEntryToFilePath(dirEntry)]
|
||||
}
|
||||
else {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const entries = await readdir(dirPath, { withFileTypes: true })
|
||||
|
||||
return (
|
||||
await Promise.all(
|
||||
entries.map(async (dirEntry) => {
|
||||
if (dirEntry.isDirectory()) {
|
||||
return flattenTypeScriptFileNamesFromDir(
|
||||
dirEntryToFilePath(dirEntry)
|
||||
)
|
||||
} else if (dirEntry.isFile() && extname(dirEntry.name) === ".ts") {
|
||||
return [dirEntryToFilePath(dirEntry)]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
})
|
||||
)
|
||||
).flat()
|
||||
}
|
||||
|
||||
const tsFiles = flattenTypeScriptFileNamesFromDir(sourceDir)
|
||||
const tsFiles = await flattenTypeScriptFileNamesFromDir(sourceDir)
|
||||
|
||||
const program = ts.createProgram(tsFiles, { strict: true })
|
||||
|
||||
// KEEP ALWAYS, SIDE EFFECT: it fills the parent references of nodes
|
||||
const checker = program.getTypeChecker()
|
||||
|
||||
outputs.forEach(({ targetDir, clean: cleanSingle }) => {
|
||||
for (const { targetDir, clean: cleanSingle } of outputs) {
|
||||
if ((cleanSingle ?? clean) && existsSync(targetDir)) {
|
||||
rmSync(targetDir, { recursive: true })
|
||||
await rm(targetDir, { recursive: true })
|
||||
}
|
||||
|
||||
mkdirSync(targetDir, { recursive: true })
|
||||
})
|
||||
await mkdir(targetDir, { recursive: true })
|
||||
}
|
||||
|
||||
const rootFiles = program
|
||||
.getSourceFiles()
|
||||
.filter(file => tsFiles.includes(file.fileName))
|
||||
.filter((file) => tsFiles.includes(file.fileName))
|
||||
|
||||
const filteredFiles =
|
||||
fileNamePredicate
|
||||
? rootFiles.filter(file => fileNamePredicate(file.fileName))
|
||||
const rootFilesAsts = Object.fromEntries(
|
||||
rootFiles.map((file) => [file.fileName, fileToAst(file, checker, program)])
|
||||
)
|
||||
|
||||
const filteredFiles = fileNamePredicate
|
||||
? rootFiles.filter((file) => fileNamePredicate(file.fileName))
|
||||
: rootFiles
|
||||
|
||||
console.log(`Generating files for ${filteredFiles.length} input file(s) and ${outputs.length} output format(s) ...`)
|
||||
console.log(
|
||||
`Generating files for ${filteredFiles.length} input file(s) and ${outputs.length} output format(s) ...`
|
||||
)
|
||||
|
||||
filteredFiles.forEach(file => {
|
||||
let outputFilesCount = 0
|
||||
|
||||
for (const file of filteredFiles) {
|
||||
const relativePath = relative(sourceDir, file.fileName)
|
||||
const dir = dirname(relativePath)
|
||||
const name = basename(relativePath).replace(/(?:\.d)?\.ts$/, "")
|
||||
const dir = dirname(relativePath)
|
||||
const name = basename(relativePath).replace(tsextPattern, "")
|
||||
|
||||
try {
|
||||
if (verbose) {
|
||||
console.log(`Generating output for "${relativePath}" ...`)
|
||||
}
|
||||
|
||||
const ast = fileToAst(file, checker, program)
|
||||
const ast = rootFilesAsts[file.fileName]!
|
||||
const resolvedAst = resolveTypeArgumentsForFile(rootFilesAsts, ast)
|
||||
|
||||
if (Object.keys(ast.elements).length > 0) {
|
||||
if (Object.keys(ast.children).length > 0) {
|
||||
if (dumpAst) {
|
||||
writeFileSync(`${file.fileName}.ast.json`, JSON.stringify(ast, undefined, 2))
|
||||
}
|
||||
|
||||
outputs.forEach(({ targetDir, renderer: { transformer, fileExtension } }) => {
|
||||
const outputDir = join(targetDir, dir)
|
||||
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
|
||||
const outputAbsoluteFilePath = format({ dir: outputDir, name, ext: fileExtension })
|
||||
const outputRelativeFilePath = relative(targetDir, outputAbsoluteFilePath)
|
||||
|
||||
const output = transformer(
|
||||
ast,
|
||||
{
|
||||
absolutePath: outputAbsoluteFilePath,
|
||||
relativePath: outputRelativeFilePath,
|
||||
}
|
||||
await writeFile(
|
||||
`${file.fileName}.ast.json`,
|
||||
JSON.stringify(ast, undefined, 2),
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
writeFileSync(outputAbsoluteFilePath, output)
|
||||
|
||||
if (verbose) {
|
||||
console.log(`-> ${outputAbsoluteFilePath}`)
|
||||
if (resolvedAst) {
|
||||
await writeFile(
|
||||
`${file.fileName}.ast.resolved.json`,
|
||||
JSON.stringify(ast, undefined, 2),
|
||||
"utf-8"
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
else {
|
||||
}
|
||||
|
||||
for (const { targetDir, renderer } of outputs) {
|
||||
const {
|
||||
transformer,
|
||||
fileExtension,
|
||||
resolveTypeParameters = false,
|
||||
} = renderer
|
||||
const outputDir = join(targetDir, dir)
|
||||
|
||||
const outputAbsoluteFilePath = format({
|
||||
dir: outputDir,
|
||||
name,
|
||||
ext: fileExtension,
|
||||
})
|
||||
|
||||
const outputRelativeFilePath = relative(
|
||||
targetDir,
|
||||
outputAbsoluteFilePath
|
||||
)
|
||||
|
||||
const meta: MetaInformation = {
|
||||
absolutePath: outputAbsoluteFilePath,
|
||||
relativePath: outputRelativeFilePath,
|
||||
}
|
||||
|
||||
const output = resolveTypeParameters
|
||||
? resolvedAst !== undefined
|
||||
? transformer(resolvedAst, meta)
|
||||
: undefined
|
||||
: transformer(ast, meta)
|
||||
|
||||
if (output === undefined) {
|
||||
if (verbose) {
|
||||
console.log(`-> empty output`)
|
||||
}
|
||||
} else {
|
||||
await mkdir(outputDir, { recursive: true })
|
||||
await writeFile(outputAbsoluteFilePath, output, "utf-8")
|
||||
outputFilesCount++
|
||||
|
||||
if (verbose) {
|
||||
console.log(`-> ${outputAbsoluteFilePath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (verbose) {
|
||||
console.log(`file does not contain renderable content`)
|
||||
} else {
|
||||
console.log(`"${relativePath}" does not contain renderable content`)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
error.message = `${error.message} in TS file "${file.fileName}"`
|
||||
throw error
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.log(`Generating ${filteredFiles.length * outputs.length} output file(s) finished successfully.`)
|
||||
console.log(
|
||||
`Generating ${outputFilesCount} output file(s) finished successfully.`
|
||||
)
|
||||
} else {
|
||||
console.log(`Generated ${filteredFiles.length * outputs.length} output file(s).`)
|
||||
console.log(`Generated ${outputFilesCount} output file(s).`)
|
||||
}
|
||||
}
|
||||
|
||||
+599
-705
File diff suppressed because it is too large
Load Diff
+3
-18
@@ -1,21 +1,7 @@
|
||||
import ts from "typescript"
|
||||
import { Doc } from "../ast.js"
|
||||
import { flattenComment } from "./doccomment.js"
|
||||
import { DocTags, parseDocTags } from "./doctags.js"
|
||||
|
||||
/**
|
||||
* The parsed JSDoc annotations for a node.
|
||||
*/
|
||||
export type Doc = {
|
||||
/**
|
||||
* The initial description text.
|
||||
*/
|
||||
comment?: string
|
||||
|
||||
/**
|
||||
* A dictionary of supported tags (`@tag`) with parsed values, if present.
|
||||
*/
|
||||
tags: DocTags
|
||||
}
|
||||
import { parseDocTags } from "./doctags.js"
|
||||
|
||||
const isDocEmpty = (doc: Doc): boolean =>
|
||||
doc.comment === undefined && Object.keys(doc.tags).length === 0
|
||||
@@ -54,8 +40,7 @@ export const parseModuleDoc = (file: ts.SourceFile): Doc | undefined => {
|
||||
if (firstNode) {
|
||||
if (ts.isImportDeclaration(firstNode)) {
|
||||
return parseNodeDoc(firstNode)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
const jsDocs = firstNode.getChildren().filter(ts.isJSDoc)
|
||||
|
||||
return jsDocs.length > 1 ? parseDoc(jsDocs[0]) : undefined
|
||||
|
||||
@@ -6,5 +6,7 @@ type JSDocComments = ts.NodeArray<ts.JSDocComment>
|
||||
/**
|
||||
* Flattens a TypeScript comment into a simple string.
|
||||
*/
|
||||
export const flattenComment = (comment: string | JSDocComments | undefined): string | undefined =>
|
||||
export const flattenComment = (
|
||||
comment: string | JSDocComments | undefined
|
||||
): string | undefined =>
|
||||
ts.getTextOfJSDocComment(comment)?.replaceAll(EOL, "\n")
|
||||
|
||||
+32
-35
@@ -1,10 +1,11 @@
|
||||
import ts from "typescript"
|
||||
import { DocTagTypes, DocTags } from "../ast.js"
|
||||
import { flattenComment } from "./doccomment.js"
|
||||
|
||||
/**
|
||||
* A dictionary from all supported tag names to their value types.
|
||||
*/
|
||||
const docTagTypes = {
|
||||
const docTagTypes: DocTagTypes = {
|
||||
// General
|
||||
main: "string",
|
||||
title: "string",
|
||||
@@ -34,49 +35,45 @@ const docTagTypes = {
|
||||
minItems: "integer",
|
||||
maxItems: "integer",
|
||||
uniqueItems: "boolean",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* A dictionary from all supported tag names to their JSON Schema data types.
|
||||
*/
|
||||
export type DocTagTypes = typeof docTagTypes
|
||||
|
||||
/**
|
||||
* A dictionary from all supported data types in JSON Schema to their
|
||||
* corresponding TypeScript data types.
|
||||
*/
|
||||
type JSONSchemaTypeToTypeScriptType = {
|
||||
number: number
|
||||
integer: number
|
||||
boolean: boolean
|
||||
string: string
|
||||
unknown: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* A dictionary from all supported tags to their values.
|
||||
*/
|
||||
export type DocTags = {
|
||||
-readonly [K in keyof DocTagTypes]?: JSONSchemaTypeToTypeScriptType[DocTagTypes[K]]
|
||||
}
|
||||
|
||||
const parseDocTagComment = <K extends keyof DocTags>(name: K, comment: string | undefined): DocTags[K] => {
|
||||
const parseDocTagComment = <K extends keyof DocTags>(
|
||||
name: K,
|
||||
comment: string | undefined
|
||||
): DocTags[K] => {
|
||||
switch (docTagTypes[name]) {
|
||||
case "boolean": return (comment === "true" || !comment) as DocTags[K]
|
||||
case "number": return (comment === undefined ? 0 : Number.parseFloat(comment)) as DocTags[K]
|
||||
case "integer": return (comment === undefined ? 0 : Number.parseInt(comment)) as DocTags[K]
|
||||
case "unknown": return (comment === undefined ? undefined : JSON.parse(comment)) as DocTags[K]
|
||||
default: return (comment ?? "") as DocTags[K]
|
||||
case "boolean":
|
||||
return (comment === "true" || !comment) as DocTags[K]
|
||||
case "number":
|
||||
return (
|
||||
comment === undefined ? 0 : Number.parseFloat(comment)
|
||||
) as DocTags[K]
|
||||
case "integer":
|
||||
return (
|
||||
comment === undefined ? 0 : Number.parseInt(comment)
|
||||
) as DocTags[K]
|
||||
case "unknown":
|
||||
return (
|
||||
comment === undefined ? undefined : JSON.parse(comment)
|
||||
) as DocTags[K]
|
||||
default:
|
||||
return (comment ?? "") as DocTags[K]
|
||||
}
|
||||
}
|
||||
|
||||
const parseDocTag = (tag: ts.JSDocTag): [keyof DocTags, DocTags[keyof DocTags]] => [
|
||||
const parseDocTag = (
|
||||
tag: ts.JSDocTag
|
||||
): [keyof DocTags, DocTags[keyof DocTags]] => [
|
||||
tag.tagName.text as keyof DocTags,
|
||||
parseDocTagComment(tag.tagName.text as keyof DocTags, flattenComment(tag.comment))
|
||||
parseDocTagComment(
|
||||
tag.tagName.text as keyof DocTags,
|
||||
flattenComment(tag.comment)
|
||||
),
|
||||
]
|
||||
|
||||
/**
|
||||
* Parses all JSON Schema tags into their proper types.
|
||||
*/
|
||||
export const parseDocTags = (tags: ts.NodeArray<ts.JSDocTag> | undefined): DocTags =>
|
||||
Object.fromEntries(tags?.map(parseDocTag) ?? [])
|
||||
export const parseDocTags = (
|
||||
tags: ts.NodeArray<ts.JSDocTag> | undefined
|
||||
): DocTags => Object.fromEntries(tags?.map(parseDocTag) ?? [])
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import {
|
||||
ContentNode,
|
||||
EnumerationNode,
|
||||
ExportAssignmentNode,
|
||||
NodeKind,
|
||||
QualifiedName,
|
||||
ReferenceNode,
|
||||
RootNode,
|
||||
StatementNode,
|
||||
TypeDefinitionNode,
|
||||
isExportAssignmentNode,
|
||||
} from "../ast.js"
|
||||
import { assertExhaustive } from "../utils/assertExhaustive.js"
|
||||
import { isNotNullish } from "../utils/nullable.js"
|
||||
|
||||
enum ScopeTypeKind {
|
||||
Default,
|
||||
TypeArgument,
|
||||
NamespaceImport,
|
||||
}
|
||||
|
||||
type TypeInScope<Node> = {
|
||||
node: Node
|
||||
kind: ScopeTypeKind
|
||||
}
|
||||
|
||||
type TypesInScope = {
|
||||
[key: string]: TypeInScope<RootNode | StatementNode>
|
||||
}
|
||||
|
||||
const resolveQualifiedName = (
|
||||
name: QualifiedName,
|
||||
node: RootNode | StatementNode
|
||||
): EnumerationNode | TypeDefinitionNode | ExportAssignmentNode | undefined => {
|
||||
switch (node.kind) {
|
||||
case NodeKind.Root:
|
||||
case NodeKind.Group: {
|
||||
const childNode = node.children.find(
|
||||
(child) => child.name === name.segment
|
||||
)
|
||||
|
||||
switch (childNode?.kind) {
|
||||
case NodeKind.Group:
|
||||
return name.right === undefined
|
||||
? undefined
|
||||
: resolveQualifiedName(name.right, childNode)
|
||||
case NodeKind.Enumeration:
|
||||
return name.right === undefined ? childNode : undefined
|
||||
case NodeKind.TypeDefinition:
|
||||
return name.right === undefined ? childNode : undefined
|
||||
case NodeKind.ExportAssignment:
|
||||
return name.right === undefined ? childNode : undefined
|
||||
case undefined:
|
||||
return undefined
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
case NodeKind.Enumeration:
|
||||
case NodeKind.TypeDefinition:
|
||||
case NodeKind.ExportAssignment:
|
||||
return undefined
|
||||
default:
|
||||
return assertExhaustive(node)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveQualifiedNameInScope = (
|
||||
name: QualifiedName,
|
||||
typesInScope: TypesInScope
|
||||
):
|
||||
| TypeInScope<EnumerationNode | TypeDefinitionNode | ExportAssignmentNode>
|
||||
| undefined => {
|
||||
const type = typesInScope[name.segment]
|
||||
if (name.right === undefined) {
|
||||
if (type === undefined) {
|
||||
return undefined
|
||||
}
|
||||
switch (type.node.kind) {
|
||||
case NodeKind.Group:
|
||||
return undefined
|
||||
case NodeKind.Enumeration:
|
||||
return { node: type.node, kind: type.kind }
|
||||
case NodeKind.TypeDefinition:
|
||||
return { node: type.node, kind: type.kind }
|
||||
case NodeKind.ExportAssignment:
|
||||
return { node: type.node, kind: type.kind }
|
||||
case NodeKind.Root:
|
||||
return undefined
|
||||
default:
|
||||
return assertExhaustive(type.node)
|
||||
}
|
||||
} else if (type !== undefined) {
|
||||
const node = resolveQualifiedName(name.right, type.node)
|
||||
if (node === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return { node, kind: type.kind }
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const mapTypeParametersToArgumentsInScope = (
|
||||
node: TypeDefinitionNode,
|
||||
typesInScope: TypesInScope,
|
||||
file: RootNode,
|
||||
files: Record<string, RootNode>,
|
||||
reference?: ReferenceNode
|
||||
) =>
|
||||
Object.fromEntries(
|
||||
node.typeParameters?.map(
|
||||
(child, index): [string, TypeInScope<RootNode | StatementNode>] => {
|
||||
const argument = reference?.typeArguments?.[index] ?? child.default
|
||||
|
||||
if (argument === undefined) {
|
||||
throw new Error(
|
||||
`Type argument ${index} is missing for type parameter "${child.name}" for type "${node.name}" and no default value is provided`
|
||||
)
|
||||
}
|
||||
|
||||
const resolvedArgument = resolveTypeArgumentsForNode(
|
||||
argument,
|
||||
typesInScope,
|
||||
file,
|
||||
files
|
||||
)
|
||||
|
||||
if (resolvedArgument === undefined) {
|
||||
throw new Error(
|
||||
`Type argument ${index} could not be resolved for type parameter "${child.name}" for type "${node.name}"`
|
||||
)
|
||||
}
|
||||
|
||||
return [
|
||||
child.name,
|
||||
{
|
||||
node: {
|
||||
kind: NodeKind.TypeDefinition,
|
||||
fileName: node.fileName,
|
||||
name: child.name,
|
||||
definition: resolvedArgument,
|
||||
},
|
||||
kind: ScopeTypeKind.TypeArgument,
|
||||
},
|
||||
]
|
||||
}
|
||||
) ?? []
|
||||
)
|
||||
|
||||
const resolveTypeArgumentsForNode = <T extends ContentNode>(
|
||||
node: T,
|
||||
typesInScope: TypesInScope,
|
||||
file: RootNode,
|
||||
files: Record<string, RootNode>
|
||||
): T | undefined => {
|
||||
switch (node.kind) {
|
||||
case NodeKind.Root: {
|
||||
const children = node.children
|
||||
.map((child) =>
|
||||
resolveTypeArgumentsForNode(child, typesInScope, file, files)
|
||||
)
|
||||
.filter(isNotNullish)
|
||||
|
||||
if (children.length > 0) {
|
||||
return {
|
||||
...node,
|
||||
children,
|
||||
}
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
case NodeKind.Group: {
|
||||
const children = node.children
|
||||
.map((child) =>
|
||||
resolveTypeArgumentsForNode(child, typesInScope, file, files)
|
||||
)
|
||||
.filter(isNotNullish)
|
||||
|
||||
if (children.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
children,
|
||||
}
|
||||
}
|
||||
case NodeKind.Record: {
|
||||
const children = Object.entries(node.children).map(([key, value]) => {
|
||||
const child = resolveTypeArgumentsForNode(
|
||||
value.value,
|
||||
typesInScope,
|
||||
file,
|
||||
files
|
||||
)
|
||||
if (child === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return [key, { ...value, value: child }]
|
||||
})
|
||||
|
||||
if (children.every(isNotNullish)) {
|
||||
return {
|
||||
...node,
|
||||
children: Object.fromEntries(children),
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
case NodeKind.Dictionary: {
|
||||
const children = resolveTypeArgumentsForNode(
|
||||
node.children,
|
||||
typesInScope,
|
||||
file,
|
||||
files
|
||||
)
|
||||
|
||||
if (children === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
children,
|
||||
}
|
||||
}
|
||||
case NodeKind.Token:
|
||||
return node
|
||||
case NodeKind.Reference: {
|
||||
const actualTypesInScope = {
|
||||
...rootTypesInScope(files, files[node.fileName]!),
|
||||
...Object.fromEntries(
|
||||
Object.entries(typesInScope).filter(
|
||||
([_key, value]) => value.kind === ScopeTypeKind.TypeArgument
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
const { node: referencedType, kind = ScopeTypeKind.Default } =
|
||||
resolveQualifiedNameInScope(node.name, actualTypesInScope) ?? {}
|
||||
|
||||
if (referencedType === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const nodeWithTargetFileName: ReferenceNode = {
|
||||
...node,
|
||||
resolvedFileName: referencedType.fileName,
|
||||
}
|
||||
|
||||
if (
|
||||
referencedType.kind !== NodeKind.TypeDefinition ||
|
||||
node.typeArguments === undefined
|
||||
) {
|
||||
if (kind === ScopeTypeKind.TypeArgument) {
|
||||
switch (referencedType.kind) {
|
||||
case NodeKind.Enumeration:
|
||||
return nodeWithTargetFileName as T
|
||||
case NodeKind.TypeDefinition:
|
||||
return referencedType.definition as T
|
||||
case NodeKind.ExportAssignment:
|
||||
return nodeWithTargetFileName as T
|
||||
default:
|
||||
return nodeWithTargetFileName as T
|
||||
}
|
||||
} else {
|
||||
return nodeWithTargetFileName as T
|
||||
}
|
||||
}
|
||||
|
||||
const newTypeArguments = mapTypeParametersToArgumentsInScope(
|
||||
referencedType,
|
||||
typesInScope,
|
||||
file,
|
||||
files,
|
||||
node
|
||||
)
|
||||
|
||||
return resolveTypeArgumentsForNode(
|
||||
referencedType.definition,
|
||||
{
|
||||
...typesInScope,
|
||||
...newTypeArguments,
|
||||
},
|
||||
file,
|
||||
files
|
||||
) as T | undefined
|
||||
}
|
||||
case NodeKind.Enumeration:
|
||||
return node
|
||||
case NodeKind.Array: {
|
||||
const children = resolveTypeArgumentsForNode(
|
||||
node.children,
|
||||
typesInScope,
|
||||
file,
|
||||
files
|
||||
)
|
||||
|
||||
if (children === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
children,
|
||||
}
|
||||
}
|
||||
case NodeKind.Union: {
|
||||
const children = node.children.map((child) =>
|
||||
resolveTypeArgumentsForNode(child, typesInScope, file, files)
|
||||
)
|
||||
|
||||
if (children.every(isNotNullish)) {
|
||||
return {
|
||||
...node,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
case NodeKind.Intersection: {
|
||||
const children = node.children.map((child) =>
|
||||
resolveTypeArgumentsForNode(child, typesInScope, file, files)
|
||||
)
|
||||
|
||||
if (children.every(isNotNullish)) {
|
||||
return {
|
||||
...node,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
case NodeKind.Literal:
|
||||
return node
|
||||
case NodeKind.Tuple: {
|
||||
const children = node.children.map((child) =>
|
||||
resolveTypeArgumentsForNode(child, typesInScope, file, files)
|
||||
)
|
||||
|
||||
if (children.every(isNotNullish)) {
|
||||
return {
|
||||
...node,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
case NodeKind.TypeDefinition: {
|
||||
if (
|
||||
(node.typeParameters?.filter((p) => p.default === undefined).length ??
|
||||
0) > 0
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const newTypeArguments = mapTypeParametersToArgumentsInScope(
|
||||
node,
|
||||
typesInScope,
|
||||
file,
|
||||
files
|
||||
)
|
||||
|
||||
const newTypesInScope = Object.entries(newTypeArguments).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (
|
||||
acc[key] === undefined ||
|
||||
acc[key]!.kind !== ScopeTypeKind.TypeArgument
|
||||
) {
|
||||
acc[key] = value
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ ...typesInScope }
|
||||
)
|
||||
|
||||
const definition = resolveTypeArgumentsForNode(
|
||||
node.definition,
|
||||
newTypesInScope,
|
||||
file,
|
||||
files
|
||||
)
|
||||
|
||||
if (definition === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
definition,
|
||||
}
|
||||
}
|
||||
case NodeKind.ExportAssignment:
|
||||
return undefined
|
||||
default:
|
||||
return assertExhaustive(node)
|
||||
}
|
||||
}
|
||||
|
||||
const rootTypesInScope = (
|
||||
files: Record<string, RootNode>,
|
||||
file: RootNode
|
||||
): TypesInScope => {
|
||||
const importedTypesInScope =
|
||||
file.imports?.reduce<TypesInScope>((acc, importNode) => {
|
||||
const importedFile = files[importNode.fileName]
|
||||
|
||||
switch (importNode.kind) {
|
||||
case NodeKind.DefaultImport: {
|
||||
const defaultImport = importedFile?.children.find(
|
||||
isExportAssignmentNode
|
||||
)
|
||||
if (defaultImport) {
|
||||
acc[importNode.name] = {
|
||||
node: defaultImport,
|
||||
kind: ScopeTypeKind.Default,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case NodeKind.NamedImport: {
|
||||
const namedImport = importedFile?.children.find(
|
||||
(statement) => statement.name === importNode.name
|
||||
)
|
||||
if (namedImport) {
|
||||
acc[importNode.alias ?? importNode.name] = {
|
||||
node: namedImport,
|
||||
kind: ScopeTypeKind.Default,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case NodeKind.NamespaceImport: {
|
||||
if (importedFile) {
|
||||
acc[importNode.name] = {
|
||||
node: importedFile,
|
||||
kind: ScopeTypeKind.NamespaceImport,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
return assertExhaustive(importNode)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {}) ?? {}
|
||||
|
||||
const localTypesInScope = file.children.reduce<TypesInScope>((acc, child) => {
|
||||
switch (child.kind) {
|
||||
case NodeKind.Enumeration:
|
||||
acc[child.name] = { node: child, kind: ScopeTypeKind.Default }
|
||||
break
|
||||
case NodeKind.TypeDefinition:
|
||||
acc[child.name] = { node: child, kind: ScopeTypeKind.Default }
|
||||
break
|
||||
case NodeKind.ExportAssignment:
|
||||
acc[child.name] = { node: child, kind: ScopeTypeKind.Default }
|
||||
break
|
||||
case NodeKind.Group:
|
||||
acc[child.name] = { node: child, kind: ScopeTypeKind.Default }
|
||||
break
|
||||
default:
|
||||
return assertExhaustive(child)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return { ...importedTypesInScope, ...localTypesInScope }
|
||||
}
|
||||
|
||||
export const resolveTypeArgumentsForFile = (
|
||||
files: Record<string, RootNode>,
|
||||
file: RootNode
|
||||
): RootNode | undefined =>
|
||||
resolveTypeArgumentsForNode(file, rootTypesInScope(files, file), file, files)
|
||||
+264
-130
@@ -1,10 +1,20 @@
|
||||
import { EOL } from "node:os"
|
||||
import { sep } from "node:path"
|
||||
import { JsonSchemaSpec } from "../config.js"
|
||||
import { sep } from "node:path/posix"
|
||||
import {
|
||||
ChildNode,
|
||||
Doc,
|
||||
DocTagTypes,
|
||||
NodeKind,
|
||||
RootNode,
|
||||
StatementNode,
|
||||
TokenKind,
|
||||
} from "../ast.js"
|
||||
import { AstTransformer, Renderer } from "../main.js"
|
||||
import { ChildNode, NodeKind, TokenKind, parentGroupToArray } from "../parser/ast.js"
|
||||
import { Doc } from "../parser/doc.js"
|
||||
import { DocTagTypes } from "../parser/doctags.js"
|
||||
import { assertExhaustive } from "../utils/assertExhaustive.js"
|
||||
import {
|
||||
getFullyQualifiedNameAsPath,
|
||||
getRelativeExternalPath,
|
||||
} from "../utils/references.js"
|
||||
|
||||
/**
|
||||
* Descriptive annotations of the JSON type definition
|
||||
@@ -30,14 +40,17 @@ interface StrictObject extends ObjectBase {
|
||||
[key: string]: Definition
|
||||
}
|
||||
required: string[]
|
||||
additionalProperties: false
|
||||
additionalProperties?: boolean
|
||||
}
|
||||
|
||||
const isStrictObject = (def: Definition): def is StrictObject =>
|
||||
typeof def === "object" && "properties" in def
|
||||
|
||||
interface PatternDictionary extends ObjectBase {
|
||||
patternProperties: {
|
||||
[pattern: string]: Definition
|
||||
}
|
||||
additionalProperties: false
|
||||
additionalProperties?: boolean
|
||||
}
|
||||
|
||||
interface Dictionary extends ObjectBase {
|
||||
@@ -62,7 +75,7 @@ interface Tuple07 extends Annotated {
|
||||
items: Definition[]
|
||||
minItems: number
|
||||
maxItems: number
|
||||
additionalItems: false
|
||||
additionalItems: boolean
|
||||
}
|
||||
|
||||
interface Tuple202012 extends Annotated {
|
||||
@@ -104,6 +117,11 @@ interface Union extends Annotated {
|
||||
oneOf: Definition[]
|
||||
}
|
||||
|
||||
interface Intersection extends Annotated {
|
||||
allOf: Definition[]
|
||||
unresolvedProperties?: boolean
|
||||
}
|
||||
|
||||
interface Constant extends Annotated {
|
||||
const: string | number | boolean
|
||||
}
|
||||
@@ -116,8 +134,11 @@ interface Reference extends Annotated {
|
||||
$ref: string
|
||||
}
|
||||
|
||||
const isReference = (def: Definition): def is Reference =>
|
||||
typeof def === "object" && "$ref" in def
|
||||
|
||||
interface Group {
|
||||
"guard Group": any
|
||||
_groupBrand: any
|
||||
[identifier: string]: Definition
|
||||
}
|
||||
|
||||
@@ -131,43 +152,29 @@ type Definition =
|
||||
| Boolean
|
||||
| Reference
|
||||
| Union
|
||||
| Intersection
|
||||
| Constant
|
||||
| Enum
|
||||
| Tuple
|
||||
| Group
|
||||
|
||||
export interface JsonSchema_07 extends Annotated {
|
||||
$schema: string
|
||||
$id: string
|
||||
$ref?: string
|
||||
definitions: {
|
||||
[id: string]: Definition
|
||||
}
|
||||
}
|
||||
|
||||
export interface JsonSchema_2019_09 extends Annotated {
|
||||
$schema: string
|
||||
$id: string
|
||||
$ref?: string
|
||||
$defs: {
|
||||
[id: string]: Definition
|
||||
}
|
||||
}
|
||||
|
||||
const toAnnotations = (jsDoc: Doc | undefined) => ({
|
||||
title: jsDoc?.tags.title,
|
||||
description: jsDoc?.comment,
|
||||
})
|
||||
|
||||
const toDefault = (jsDoc: Doc | undefined) => jsDoc?.tags.default !== undefined ? {
|
||||
default: jsDoc?.tags.default,
|
||||
} : undefined
|
||||
const toDefault = (jsDoc: Doc | undefined) =>
|
||||
jsDoc?.tags.default !== undefined
|
||||
? {
|
||||
default: jsDoc?.tags.default,
|
||||
}
|
||||
: undefined
|
||||
|
||||
type ConstraintsByType = {
|
||||
number: NumberConstraints,
|
||||
string: StringConstraints,
|
||||
object: ObjectConstraints,
|
||||
array: ArrayConstraints,
|
||||
number: NumberConstraints
|
||||
string: StringConstraints
|
||||
object: ObjectConstraints
|
||||
array: ArrayConstraints
|
||||
}
|
||||
|
||||
type IgnoreValue<T> = { [K in keyof T]-?: 0 }
|
||||
@@ -176,31 +183,46 @@ type IgnoreValueEach<T> = { [K in keyof T]: IgnoreValue<T[K]> }
|
||||
|
||||
// ensures that each key is present in a runtime object
|
||||
const constraintsByType: IgnoreValueEach<ConstraintsByType> = {
|
||||
number: { maximum: 0, minimum: 0, exclusiveMinimum: 0, exclusiveMaximum: 0, multipleOf: 0 },
|
||||
number: {
|
||||
maximum: 0,
|
||||
minimum: 0,
|
||||
exclusiveMinimum: 0,
|
||||
exclusiveMaximum: 0,
|
||||
multipleOf: 0,
|
||||
},
|
||||
string: { minLength: 0, maxLength: 0, format: 0, pattern: 0 },
|
||||
object: { minProperties: 0, maxProperties: 0 },
|
||||
array: { minItems: 0, maxItems: 0, uniqueItems: 0 },
|
||||
}
|
||||
|
||||
const toConstraints = <T extends keyof ConstraintsByType>(jsDoc: Doc | undefined, type: T): ConstraintsByType[T] =>
|
||||
const toConstraints = <T extends keyof ConstraintsByType>(
|
||||
jsDoc: Doc | undefined,
|
||||
type: T
|
||||
): ConstraintsByType[T] =>
|
||||
Object.fromEntries(
|
||||
jsDoc
|
||||
? (Object.keys(constraintsByType[type]) as (keyof ConstraintsByType[T])[])
|
||||
.flatMap(
|
||||
(key) => {
|
||||
if (jsDoc.tags[key as keyof DocTagTypes] !== undefined) {
|
||||
return [[key, jsDoc.tags[key as keyof DocTagTypes]]]
|
||||
}
|
||||
else {
|
||||
return []
|
||||
}
|
||||
? (
|
||||
Object.keys(constraintsByType[type]) as (keyof ConstraintsByType[T])[]
|
||||
).flatMap((key) => {
|
||||
if (jsDoc.tags[key as keyof DocTagTypes] !== undefined) {
|
||||
return [[key, jsDoc.tags[key as keyof DocTagTypes]]]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
)
|
||||
})
|
||||
: []
|
||||
)
|
||||
|
||||
const nodeToDefinition = (spec: JsonSchemaSpec, node: ChildNode, options: { isReadOnly?: boolean } = {}): Definition => {
|
||||
const { isReadOnly } = options
|
||||
const nodeToDefinition = (
|
||||
node: ChildNode,
|
||||
file: RootNode,
|
||||
options: Required<JsonSchemaRendererOptions>,
|
||||
shallowOptions: {
|
||||
isReadOnly?: boolean
|
||||
} = {}
|
||||
): Definition => {
|
||||
const { spec, allowAdditionalProperties } = options
|
||||
const { isReadOnly } = shallowOptions
|
||||
|
||||
switch (node.kind) {
|
||||
case NodeKind.Record: {
|
||||
@@ -209,14 +231,19 @@ const nodeToDefinition = (spec: JsonSchemaSpec, node: ChildNode, options: { isRe
|
||||
type: "object",
|
||||
...toDefault(node.jsDoc),
|
||||
properties: Object.fromEntries(
|
||||
Object.entries(node.elements)
|
||||
.map(([key, config]) => [key, nodeToDefinition(spec, config.value, { isReadOnly: config.isReadOnly })])),
|
||||
required: Object.entries(node.elements)
|
||||
Object.entries(node.children).map(([key, config]) => [
|
||||
key,
|
||||
nodeToDefinition(config.value, file, options, {
|
||||
isReadOnly: config.isReadOnly,
|
||||
}),
|
||||
])
|
||||
),
|
||||
required: Object.entries(node.children)
|
||||
.filter(([_, config]) => config.isRequired)
|
||||
.map(([key]) => key),
|
||||
...toConstraints(node.jsDoc, "object"),
|
||||
...(isReadOnly ? { readOnly: false } : {}),
|
||||
additionalProperties: false
|
||||
...(isReadOnly ? { readOnly: true } : undefined),
|
||||
additionalProperties: allowAdditionalProperties,
|
||||
}
|
||||
}
|
||||
case NodeKind.Dictionary: {
|
||||
@@ -226,21 +253,20 @@ const nodeToDefinition = (spec: JsonSchemaSpec, node: ChildNode, options: { isRe
|
||||
type: "object",
|
||||
...toDefault(node.jsDoc),
|
||||
patternProperties: {
|
||||
[node.pattern]: nodeToDefinition(spec, node.elements)
|
||||
[node.pattern]: nodeToDefinition(node.children, file, options),
|
||||
},
|
||||
...toConstraints(node.jsDoc, "object"),
|
||||
...(isReadOnly ? { readOnly: false } : {}),
|
||||
additionalProperties: false
|
||||
...(isReadOnly ? { readOnly: true } : undefined),
|
||||
additionalProperties: allowAdditionalProperties,
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
return {
|
||||
...toAnnotations(node.jsDoc),
|
||||
type: "object",
|
||||
...toDefault(node.jsDoc),
|
||||
additionalProperties: nodeToDefinition(spec, node.elements),
|
||||
additionalProperties: nodeToDefinition(node.children, file, options),
|
||||
...toConstraints(node.jsDoc, "object"),
|
||||
...(isReadOnly ? { readOnly: false } : {}),
|
||||
...(isReadOnly ? { readOnly: true } : undefined),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,76 +275,108 @@ const nodeToDefinition = (spec: JsonSchemaSpec, node: ChildNode, options: { isRe
|
||||
...toAnnotations(node.jsDoc),
|
||||
type: "array",
|
||||
...toDefault(node.jsDoc),
|
||||
items: nodeToDefinition(spec, node.elements),
|
||||
items: nodeToDefinition(node.children, file, options),
|
||||
...toConstraints(node.jsDoc, "array"),
|
||||
...(isReadOnly ? { readOnly: false } : {}),
|
||||
}
|
||||
}
|
||||
case NodeKind.Enumeration: {
|
||||
return {
|
||||
...toAnnotations(node.jsDoc),
|
||||
enum: node.cases.map(({ value }) => value),
|
||||
...toDefault(node.jsDoc),
|
||||
...(isReadOnly ? { readOnly: false } : {}),
|
||||
...(isReadOnly ? { readOnly: true } : undefined),
|
||||
}
|
||||
}
|
||||
case NodeKind.Tuple: {
|
||||
switch (spec) {
|
||||
case "Draft_07":
|
||||
case "Draft_2019_09": return {
|
||||
...toAnnotations(node.jsDoc),
|
||||
type: "array",
|
||||
items: node.elements.map(element => nodeToDefinition(spec, element)),
|
||||
...toDefault(node.jsDoc),
|
||||
minItems: node.elements.length,
|
||||
maxItems: node.elements.length,
|
||||
additionalItems: false,
|
||||
...(isReadOnly ? { readOnly: false } : {}),
|
||||
}
|
||||
case "Draft_2020_12": return {
|
||||
...toAnnotations(node.jsDoc),
|
||||
type: "array",
|
||||
prefixItems: node.elements.map(element => nodeToDefinition(spec, element)),
|
||||
...toDefault(node.jsDoc),
|
||||
minItems: node.elements.length,
|
||||
maxItems: node.elements.length,
|
||||
items: false,
|
||||
...(isReadOnly ? { readOnly: false } : {}),
|
||||
}
|
||||
default: throw TypeError("invalid spec")
|
||||
case JsonSchemaSpec.Draft_07:
|
||||
case JsonSchemaSpec.Draft_2019_09:
|
||||
return {
|
||||
...toAnnotations(node.jsDoc),
|
||||
type: "array",
|
||||
items: node.children.map((child) =>
|
||||
nodeToDefinition(child, file, options)
|
||||
),
|
||||
...toDefault(node.jsDoc),
|
||||
minItems: node.children.length,
|
||||
maxItems: node.children.length,
|
||||
additionalItems: false,
|
||||
...(isReadOnly ? { readOnly: true } : undefined),
|
||||
}
|
||||
case JsonSchemaSpec.Draft_2020_12:
|
||||
return {
|
||||
...toAnnotations(node.jsDoc),
|
||||
type: "array",
|
||||
prefixItems: node.children.map((child) =>
|
||||
nodeToDefinition(child, file, options)
|
||||
),
|
||||
...toDefault(node.jsDoc),
|
||||
minItems: node.children.length,
|
||||
maxItems: node.children.length,
|
||||
items: false,
|
||||
...(isReadOnly ? { readOnly: true } : undefined),
|
||||
}
|
||||
default:
|
||||
return assertExhaustive(spec, "invalid spec")
|
||||
}
|
||||
}
|
||||
case NodeKind.Union: {
|
||||
return {
|
||||
...toAnnotations(node.jsDoc),
|
||||
oneOf: node.cases.map(element => nodeToDefinition(spec, element)),
|
||||
oneOf: node.children.map((child) =>
|
||||
nodeToDefinition(child, file, options)
|
||||
),
|
||||
...toDefault(node.jsDoc),
|
||||
...(isReadOnly ? { readOnly: false } : {}),
|
||||
...(isReadOnly ? { readOnly: true } : undefined),
|
||||
}
|
||||
}
|
||||
case NodeKind.Group: {
|
||||
return Object.fromEntries(
|
||||
Object.entries(node.elements)
|
||||
.map(([key, node]) => [key, nodeToDefinition(spec, node)])
|
||||
) as Group
|
||||
case NodeKind.Intersection: {
|
||||
const allOf = node.children.map((child) =>
|
||||
nodeToDefinition(child, file, options)
|
||||
)
|
||||
|
||||
const base = {
|
||||
...toAnnotations(node.jsDoc),
|
||||
allOf,
|
||||
...toDefault(node.jsDoc),
|
||||
...(isReadOnly ? { readOnly: true } : undefined),
|
||||
}
|
||||
|
||||
if (allOf.every((e) => isStrictObject(e) || isReference(e))) {
|
||||
if (isUnresolvedPropertiesSupported(spec)) {
|
||||
allOf.forEach(
|
||||
(e) =>
|
||||
"additionalProperties" in e &&
|
||||
typeof e.additionalProperties === "boolean" &&
|
||||
delete e.additionalProperties
|
||||
)
|
||||
return {
|
||||
...base,
|
||||
unresolvedProperties: allowAdditionalProperties,
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
'The requested JSON Schema spec does not support intersecting record types with "additionalProperties" set to false, which will likely result in unexpected validation errors. Consider switching to a newer JSON Schema spec or do not use intersection types.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
case NodeKind.Literal: {
|
||||
return {
|
||||
...toAnnotations(node.jsDoc),
|
||||
const: node.value,
|
||||
...toDefault(node.jsDoc),
|
||||
...(isReadOnly ? { readOnly: false } : {}),
|
||||
...(isReadOnly ? { readOnly: true } : undefined),
|
||||
}
|
||||
}
|
||||
case NodeKind.Reference: {
|
||||
const externalFilePath = node.externalFilePath ? `${node.externalFilePath}.schema.json` : ""
|
||||
const qualifiedName = [...parentGroupToArray(node.parentGroup), node.name].join("/")
|
||||
const externalFilePath = getRelativeExternalPath(
|
||||
node,
|
||||
file,
|
||||
".schema.json"
|
||||
)
|
||||
const qualifiedName = getFullyQualifiedNameAsPath(node, file)
|
||||
|
||||
return {
|
||||
...toAnnotations(node.jsDoc),
|
||||
$ref: `${externalFilePath}#/${defsKey(spec)}/${qualifiedName}`,
|
||||
...toDefault(node.jsDoc),
|
||||
...(isReadOnly ? { readOnly: false } : {}),
|
||||
...(isReadOnly ? { readOnly: true } : undefined),
|
||||
}
|
||||
}
|
||||
case NodeKind.Token: {
|
||||
@@ -329,7 +387,7 @@ const nodeToDefinition = (spec: JsonSchemaSpec, node: ChildNode, options: { isRe
|
||||
type: node.jsDoc?.tags.integer ? "integer" : "number",
|
||||
...toDefault(node.jsDoc),
|
||||
...toConstraints(node.jsDoc, "number"),
|
||||
...(isReadOnly ? { readOnly: false } : {}),
|
||||
...(isReadOnly ? { readOnly: true } : undefined),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,7 +397,7 @@ const nodeToDefinition = (spec: JsonSchemaSpec, node: ChildNode, options: { isRe
|
||||
type: "string",
|
||||
...toDefault(node.jsDoc),
|
||||
...toConstraints(node.jsDoc, "string"),
|
||||
...(isReadOnly ? { readOnly: false } : {}),
|
||||
...(isReadOnly ? { readOnly: true } : undefined),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,22 +406,59 @@ const nodeToDefinition = (spec: JsonSchemaSpec, node: ChildNode, options: { isRe
|
||||
...toAnnotations(node.jsDoc),
|
||||
type: "boolean",
|
||||
...toDefault(node.jsDoc),
|
||||
...(isReadOnly ? { readOnly: false } : {}),
|
||||
...(isReadOnly ? { readOnly: true } : undefined),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return assertExhaustive(node)
|
||||
}
|
||||
}
|
||||
|
||||
const toForwardSlashAbsolutePath = (path: string) => "/" + path.split(sep).join("/")
|
||||
const statementToDefinition = (
|
||||
node: StatementNode,
|
||||
file: RootNode,
|
||||
options: Required<JsonSchemaRendererOptions>,
|
||||
shallowOptions: { isReadOnly?: boolean } = {}
|
||||
): Definition => {
|
||||
const { isReadOnly } = shallowOptions
|
||||
|
||||
type TransformerOptions = {
|
||||
spec: JsonSchemaSpec
|
||||
switch (node.kind) {
|
||||
case NodeKind.TypeDefinition: {
|
||||
return nodeToDefinition(node.definition, file, options)
|
||||
}
|
||||
case NodeKind.ExportAssignment: {
|
||||
return nodeToDefinition(node.expression, file, options)
|
||||
}
|
||||
case NodeKind.Enumeration: {
|
||||
return {
|
||||
...toAnnotations(node.jsDoc),
|
||||
enum: node.children.map(({ value }) => value),
|
||||
...toDefault(node.jsDoc),
|
||||
...(isReadOnly ? { readOnly: true } : undefined),
|
||||
}
|
||||
}
|
||||
case NodeKind.Group: {
|
||||
return Object.fromEntries(
|
||||
Object.entries(node.children).map(([key, node]) => [
|
||||
key,
|
||||
statementToDefinition(node, file, options),
|
||||
])
|
||||
) as Group
|
||||
}
|
||||
default:
|
||||
return assertExhaustive(node, "invalid statement")
|
||||
}
|
||||
}
|
||||
|
||||
const astToJsonSchema = ({ spec }: TransformerOptions): AstTransformer =>
|
||||
const toForwardSlashAbsolutePath = (path: string) =>
|
||||
"/" + path.split(sep).join("/")
|
||||
|
||||
const astToJsonSchema =
|
||||
(options: Required<JsonSchemaRendererOptions>): AstTransformer =>
|
||||
(file, { relativePath }): string => {
|
||||
const { spec } = options
|
||||
const mainType = file.jsDoc?.tags.main
|
||||
|
||||
const jsonSchema = {
|
||||
@@ -371,43 +466,82 @@ const astToJsonSchema = ({ spec }: TransformerOptions): AstTransformer =>
|
||||
$id: toForwardSlashAbsolutePath(relativePath),
|
||||
$ref: mainType ? `#/${defsKey(spec)}/${mainType}` : mainType,
|
||||
[defsKey(spec)]: Object.fromEntries(
|
||||
Object.entries(file.elements)
|
||||
.map(([key, node]) => [key, nodeToDefinition(spec, node)])
|
||||
)
|
||||
file.children.map((node) => [
|
||||
node.name,
|
||||
statementToDefinition(node, file, options),
|
||||
])
|
||||
),
|
||||
}
|
||||
|
||||
return `${JSON.stringify(jsonSchema, undefined, 2).replace(/\n/g, EOL)}${EOL}`
|
||||
return `${JSON.stringify(jsonSchema, undefined, 2).replace(
|
||||
/\n/g,
|
||||
EOL
|
||||
)}${EOL}`
|
||||
}
|
||||
|
||||
const defsKey = (spec: JsonSchemaSpec): string => {
|
||||
const defsKey = (spec: JsonSchemaSpec) => {
|
||||
switch (spec) {
|
||||
case "Draft_07": return "definitions"
|
||||
case "Draft_2019_09":
|
||||
case "Draft_2020_12": return "$defs"
|
||||
default: throw TypeError("invalid spec")
|
||||
case JsonSchemaSpec.Draft_07:
|
||||
return "definitions"
|
||||
case JsonSchemaSpec.Draft_2019_09:
|
||||
case JsonSchemaSpec.Draft_2020_12:
|
||||
return "$defs"
|
||||
default:
|
||||
return assertExhaustive(spec, "invalid spec")
|
||||
}
|
||||
}
|
||||
|
||||
const schemaUri = (spec: JsonSchemaSpec): string => {
|
||||
switch (spec) {
|
||||
case "Draft_07": return "https://json-schema.org/draft-07/schema"
|
||||
case "Draft_2019_09": return "https://json-schema.org/draft/2019-09/schema"
|
||||
case "Draft_2020_12": return "https://json-schema.org/draft/2020-12/schema"
|
||||
default: throw TypeError("invalid spec")
|
||||
case JsonSchemaSpec.Draft_07:
|
||||
return "https://json-schema.org/draft-07/schema"
|
||||
case JsonSchemaSpec.Draft_2019_09:
|
||||
return "https://json-schema.org/draft/2019-09/schema"
|
||||
case JsonSchemaSpec.Draft_2020_12:
|
||||
return "https://json-schema.org/draft/2020-12/schema"
|
||||
default:
|
||||
return assertExhaustive(spec, "invalid spec")
|
||||
}
|
||||
}
|
||||
|
||||
type RendererOptions = {
|
||||
const isUnresolvedPropertiesSupported = (spec: JsonSchemaSpec): boolean => {
|
||||
switch (spec) {
|
||||
case JsonSchemaSpec.Draft_07:
|
||||
return false
|
||||
case JsonSchemaSpec.Draft_2019_09:
|
||||
case JsonSchemaSpec.Draft_2020_12:
|
||||
return true
|
||||
default:
|
||||
return assertExhaustive(spec, "invalid spec")
|
||||
}
|
||||
}
|
||||
|
||||
export enum JsonSchemaSpec {
|
||||
Draft_07 = "Draft_07",
|
||||
Draft_2019_09 = "Draft_2019_09",
|
||||
Draft_2020_12 = "Draft_2020_12",
|
||||
}
|
||||
|
||||
export type JsonSchemaRendererOptions = {
|
||||
/**
|
||||
* The used JSON Schema specification.
|
||||
* @default "Draft_2020_12"
|
||||
* @default JsonSchemaSpec.Draft_2020_12
|
||||
*/
|
||||
spec?: JsonSchemaSpec
|
||||
|
||||
/**
|
||||
* Whether to allow unresolved additional keys in object definitions.
|
||||
* @default false
|
||||
*/
|
||||
allowAdditionalProperties?: boolean
|
||||
}
|
||||
|
||||
export const jsonSchemaRenderer = ({
|
||||
spec = "Draft_2020_12"
|
||||
}: RendererOptions = {}): Renderer => Object.freeze({
|
||||
transformer: astToJsonSchema({ spec }),
|
||||
fileExtension: ".schema.json",
|
||||
})
|
||||
spec = JsonSchemaSpec.Draft_2020_12,
|
||||
allowAdditionalProperties = false,
|
||||
}: JsonSchemaRendererOptions = {}): Renderer =>
|
||||
Object.freeze({
|
||||
transformer: astToJsonSchema({ spec, allowAdditionalProperties }),
|
||||
fileExtension: ".schema.json",
|
||||
resolveTypeParameters: true,
|
||||
})
|
||||
|
||||
+418
-191
@@ -1,10 +1,41 @@
|
||||
import { EOL } from "node:os"
|
||||
import {
|
||||
ArrayNode,
|
||||
ChildNode,
|
||||
DictionaryNode,
|
||||
Doc,
|
||||
EnumerationNode,
|
||||
LiteralNode,
|
||||
NodeKind,
|
||||
RecordNode,
|
||||
ReferenceNode,
|
||||
RootNode,
|
||||
StatementNode,
|
||||
TokenKind,
|
||||
TokenNode,
|
||||
TupleNode,
|
||||
TypeParameterNode,
|
||||
UnionNode,
|
||||
isReferenceNode,
|
||||
isTokenNode,
|
||||
} from "../ast.js"
|
||||
import { AstTransformer, Renderer } from "../main.js"
|
||||
import { ArrayNode, ChildNode, DictionaryNode, EnumerationNode, LiteralNode, NodeKind, RecordNode, ReferenceNode, RootNode, TokenKind, TokenNode, TupleNode, UnionNode, parentGroupToArray } from "../parser/ast.js"
|
||||
import { Doc } from "../parser/doc.js"
|
||||
import { assertExhaustive } from "../utils/assertExhaustive.js"
|
||||
import { isNotNullish } from "../utils/nullable.js"
|
||||
import {
|
||||
getRightmostQualifiedNameSegment,
|
||||
qualifiedNameToArray,
|
||||
} from "../utils/qualifiedName.js"
|
||||
import {
|
||||
getFullyQualifiedNameAsPath,
|
||||
getRelativeExternalPath,
|
||||
} from "../utils/references.js"
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max)
|
||||
|
||||
const h = (level: number, text: string, anchor?: string) => {
|
||||
const safeLevel = level < 1 ? 1 : level > 6 ? 6 : level
|
||||
const safeLevel = clamp(level, 1, 6)
|
||||
const anchorElement = anchor === undefined ? "" : ` <a name="${anchor}"></a>`
|
||||
|
||||
return `${"#".repeat(safeLevel)}${anchorElement} ${text}`
|
||||
@@ -12,29 +43,85 @@ const h = (level: number, text: string, anchor?: string) => {
|
||||
|
||||
const a = (text: string, href: string) => `<a href="${href}">${text}</a>`
|
||||
|
||||
const namedLink = (anchorName: string, fileUrl = "") => `${fileUrl}#${anchorName}`
|
||||
const anchorUrl = (anchorName: string, fileUrl = "") =>
|
||||
`${fileUrl}#${anchorName}`
|
||||
|
||||
const icode = (code: string | number | boolean) => `\`${code}\``
|
||||
|
||||
const icodejson = (code: unknown | string | number | boolean) => `\`${JSON.stringify(code)}\``
|
||||
const icodejson = (code: unknown | string | number | boolean) =>
|
||||
`\`${JSON.stringify(code)}\``
|
||||
|
||||
const boolean = (boolean: boolean) => boolean ? "Yes" : "No"
|
||||
const boolean = (boolean: boolean) => (boolean ? "Yes" : "No")
|
||||
|
||||
const docHeader = (schema: RootNode, jsDoc: Doc | undefined) => {
|
||||
const title = jsDoc?.tags.title ?? schema.jsDoc?.tags.title ?? "[TITLE MISSING]"
|
||||
const title =
|
||||
jsDoc?.tags.title ?? schema.jsDoc?.tags.title ?? "[TITLE MISSING]"
|
||||
const description = jsDoc?.comment ?? schema.jsDoc?.comment
|
||||
|
||||
return headerWithDescription(h(1, title), description)
|
||||
}
|
||||
|
||||
const definitionHeader = (id: string, jsDoc: Doc | undefined) => {
|
||||
const definitionHeader = (
|
||||
id: string,
|
||||
jsDoc: Doc | undefined,
|
||||
typeParameters?: TypeParameterNode[]
|
||||
) => {
|
||||
const fullName = typeParameters
|
||||
? `${id}<${typeParameters
|
||||
.map((param) => {
|
||||
const constraint =
|
||||
param.constraint?.kind === NodeKind.Token
|
||||
? param.constraint.token === TokenKind.Number
|
||||
? "Number"
|
||||
: param.constraint.token === TokenKind.String
|
||||
? "String"
|
||||
: param.constraint.token === TokenKind.Boolean
|
||||
? "Boolean"
|
||||
: "..."
|
||||
: param.constraint?.kind === NodeKind.Reference
|
||||
? qualifiedNameToArray(param.constraint.name).join("/")
|
||||
: param.constraint !== undefined
|
||||
? "..."
|
||||
: undefined
|
||||
|
||||
const defaultValue =
|
||||
param.default?.kind === NodeKind.Token
|
||||
? param.default.token === TokenKind.Number
|
||||
? "Number"
|
||||
: param.default.token === TokenKind.String
|
||||
? "String"
|
||||
: param.default.token === TokenKind.Boolean
|
||||
? "Boolean"
|
||||
: "..."
|
||||
: param.default?.kind === NodeKind.Reference
|
||||
? qualifiedNameToArray(param.default.name).join("/")
|
||||
: param.default !== undefined
|
||||
? "..."
|
||||
: undefined
|
||||
|
||||
return `${param.name}${
|
||||
constraint !== undefined ? ` extends ${constraint}` : ""
|
||||
}${defaultValue !== undefined ? ` = ${defaultValue}` : ""}`
|
||||
})
|
||||
.join(", ")}>`
|
||||
: id
|
||||
|
||||
return headerWithDescription(
|
||||
h(3, jsDoc?.tags.title ? `${jsDoc?.tags.title} (\`${id}\`)` : `\`${id}\``, id),
|
||||
h(
|
||||
3,
|
||||
jsDoc?.tags.title
|
||||
? `${jsDoc?.tags.title} (\`${fullName}\`)`
|
||||
: `\`${fullName}\``,
|
||||
id
|
||||
),
|
||||
jsDoc?.comment
|
||||
)
|
||||
}
|
||||
|
||||
const headerWithDescription = (title: string, description: string | undefined) => {
|
||||
const headerWithDescription = (
|
||||
title: string,
|
||||
description: string | undefined
|
||||
) => {
|
||||
if (description === undefined) {
|
||||
return title
|
||||
}
|
||||
@@ -50,51 +137,73 @@ namespace LabelledList {
|
||||
const linemd = (label: string, value: any, indent = 0) =>
|
||||
`${" ".repeat(indent)}- **${label}:** ${value}`
|
||||
|
||||
const isNonNullable = <T>(x: T): x is NonNullable<T> => x != null
|
||||
|
||||
export const line = <T>(
|
||||
label: string,
|
||||
value: T | undefined,
|
||||
transform?: (value: NonNullable<T>) => string | number | boolean,
|
||||
config: Config = {}
|
||||
) =>
|
||||
isNonNullable (value)
|
||||
? transform
|
||||
? linemd(label, transform(value), config.indent)
|
||||
: linemd(label, value, config.indent)
|
||||
: undefined
|
||||
isNotNullish(value)
|
||||
? transform
|
||||
? linemd(label, transform(value), config.indent)
|
||||
: linemd(label, value, config.indent)
|
||||
: undefined
|
||||
|
||||
export const create = (items: (string | undefined)[]) =>
|
||||
items.filter(item => item !== undefined).join(EOL)
|
||||
items.filter((item) => item !== undefined).join(EOL)
|
||||
}
|
||||
|
||||
type SimpleNode =
|
||||
| TokenNode
|
||||
| LiteralNode
|
||||
| ReferenceNode
|
||||
| EnumerationNode
|
||||
type SimpleNode = TokenNode | LiteralNode | ReferenceNode | EnumerationNode
|
||||
|
||||
const simpleBody = (node: SimpleNode): string => {
|
||||
const simpleBody = (node: SimpleNode, file: RootNode): string => {
|
||||
switch (node.kind) {
|
||||
case NodeKind.Token: {
|
||||
switch (node.token) {
|
||||
case TokenKind.Number: {
|
||||
return LabelledList.create([
|
||||
LabelledList.line("Type", node.jsDoc?.tags.integer ?? false, value => value ? "Integer" : "Number"),
|
||||
LabelledList.line(
|
||||
"Type",
|
||||
node.jsDoc?.tags.integer ?? false,
|
||||
(value) => (value ? "Integer" : "Number")
|
||||
),
|
||||
LabelledList.line("Default", node.jsDoc?.tags.default, icodejson),
|
||||
LabelledList.line("Minimum", node.jsDoc?.tags.minimum, icode),
|
||||
LabelledList.line("Exclusive Minimum", node.jsDoc?.tags.exclusiveMinimum, icode),
|
||||
LabelledList.line(
|
||||
"Exclusive Minimum",
|
||||
node.jsDoc?.tags.exclusiveMinimum,
|
||||
icode
|
||||
),
|
||||
LabelledList.line("Maximum", node.jsDoc?.tags.maximum, icode),
|
||||
LabelledList.line("Exclusive Maximum", node.jsDoc?.tags.exclusiveMaximum, icode),
|
||||
LabelledList.line("Multiple of", node.jsDoc?.tags.multipleOf, icode),
|
||||
LabelledList.line(
|
||||
"Exclusive Maximum",
|
||||
node.jsDoc?.tags.exclusiveMaximum,
|
||||
icode
|
||||
),
|
||||
LabelledList.line(
|
||||
"Multiple of",
|
||||
node.jsDoc?.tags.multipleOf,
|
||||
icode
|
||||
),
|
||||
])
|
||||
}
|
||||
case TokenKind.String: {
|
||||
return LabelledList.create([
|
||||
LabelledList.line("Type", node.jsDoc?.tags.markdown ?? false, value => value ? "Markdown-formatted text" : "String"),
|
||||
LabelledList.line(
|
||||
"Type",
|
||||
node.jsDoc?.tags.markdown ?? false,
|
||||
(value) => (value ? "Markdown-formatted text" : "String")
|
||||
),
|
||||
LabelledList.line("Default", node.jsDoc?.tags.default, icodejson),
|
||||
LabelledList.line("Minimum Length", node.jsDoc?.tags.minLength, icode),
|
||||
LabelledList.line("Maximum Length", node.jsDoc?.tags.maxLength, icode),
|
||||
LabelledList.line(
|
||||
"Minimum Length",
|
||||
node.jsDoc?.tags.minLength,
|
||||
icode
|
||||
),
|
||||
LabelledList.line(
|
||||
"Maximum Length",
|
||||
node.jsDoc?.tags.maxLength,
|
||||
icode
|
||||
),
|
||||
LabelledList.line("Format", node.jsDoc?.tags.format, icode),
|
||||
LabelledList.line("Pattern", node.jsDoc?.tags.pattern, icode),
|
||||
])
|
||||
@@ -105,6 +214,8 @@ const simpleBody = (node: SimpleNode): string => {
|
||||
LabelledList.line("Default", node.jsDoc?.tags.default, icodejson),
|
||||
])
|
||||
}
|
||||
default:
|
||||
return assertExhaustive(node.token)
|
||||
}
|
||||
}
|
||||
case NodeKind.Literal: {
|
||||
@@ -114,32 +225,71 @@ const simpleBody = (node: SimpleNode): string => {
|
||||
}
|
||||
case NodeKind.Reference: {
|
||||
return LabelledList.create([
|
||||
LabelledList.line(
|
||||
"Type",
|
||||
node,
|
||||
({ name, parentGroup, externalFilePath }) => {
|
||||
const fullQualifiedName = [...parentGroupToArray(parentGroup), name].join("/")
|
||||
LabelledList.line("Type", node, () => {
|
||||
const externalFilePath = getRelativeExternalPath(node, file, ".md")
|
||||
const fullQualifiedName = getFullyQualifiedNameAsPath(node, file)
|
||||
|
||||
return a(
|
||||
fullQualifiedName,
|
||||
`${externalFilePath ? `${externalFilePath}.md` : ""}#${fullQualifiedName}`
|
||||
)
|
||||
const mainType = a(
|
||||
fullQualifiedName,
|
||||
`${externalFilePath ?? ""}#${fullQualifiedName}`
|
||||
)
|
||||
|
||||
if (node.typeArguments) {
|
||||
const parameters = node.typeArguments
|
||||
.map((arg) => {
|
||||
if (isReferenceNode(arg)) {
|
||||
const externalFilePath = getRelativeExternalPath(
|
||||
arg,
|
||||
file,
|
||||
".md"
|
||||
)
|
||||
const fullQualifiedName = getFullyQualifiedNameAsPath(
|
||||
arg,
|
||||
file
|
||||
)
|
||||
return a(
|
||||
fullQualifiedName,
|
||||
`${externalFilePath ?? ""}#${fullQualifiedName}`
|
||||
)
|
||||
} else if (isTokenNode(arg)) {
|
||||
switch (arg.token) {
|
||||
case TokenKind.Boolean:
|
||||
return "Boolean"
|
||||
|
||||
case TokenKind.Number:
|
||||
return "Number"
|
||||
|
||||
case TokenKind.String:
|
||||
return "String"
|
||||
|
||||
default:
|
||||
return assertExhaustive(arg.token)
|
||||
}
|
||||
} else {
|
||||
return "..."
|
||||
}
|
||||
})
|
||||
.join(", ")
|
||||
|
||||
return `${mainType}<${parameters}>`
|
||||
}
|
||||
),
|
||||
|
||||
return mainType
|
||||
}),
|
||||
])
|
||||
}
|
||||
case NodeKind.Enumeration: {
|
||||
return LabelledList.create([
|
||||
LabelledList.line(
|
||||
"Possible values",
|
||||
node.cases,
|
||||
cases => cases
|
||||
.map(value => value.value)
|
||||
LabelledList.line("Possible values", node.children, (cases) =>
|
||||
cases
|
||||
.map((value) => value.value)
|
||||
.map(icodejson)
|
||||
.join(", ")
|
||||
),
|
||||
])
|
||||
}
|
||||
default:
|
||||
return assertExhaustive(node)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,12 +298,15 @@ type SectionNode = {
|
||||
append: SectionNode[]
|
||||
}
|
||||
|
||||
const mergeParagraphs = ({ inline, append }: SectionNode): string[] =>
|
||||
[ ...inline, ...append.flatMap(mergeParagraphs) ]
|
||||
const mergeParagraphs = ({ inline, append }: SectionNode): string[] => [
|
||||
...inline,
|
||||
...append.flatMap(mergeParagraphs),
|
||||
]
|
||||
|
||||
const arrayBody = (
|
||||
node: ArrayNode,
|
||||
propertyPath: string
|
||||
propertyPath: string,
|
||||
file: RootNode
|
||||
): SectionNode => {
|
||||
const itemsPropertyPath = `${propertyPath}[]`
|
||||
|
||||
@@ -161,22 +314,27 @@ const arrayBody = (
|
||||
inline: [
|
||||
LabelledList.create([
|
||||
LabelledList.line("Type", "List"),
|
||||
LabelledList.line("Items", itemsPropertyPath, anchor => a(anchor, namedLink(anchor))),
|
||||
LabelledList.line("Items", itemsPropertyPath, (anchor) =>
|
||||
a(anchor, anchorUrl(anchor))
|
||||
),
|
||||
LabelledList.line("Default", node.jsDoc?.tags.default, icodejson),
|
||||
LabelledList.line("Minimum Items", node.jsDoc?.tags.minItems, icode),
|
||||
LabelledList.line("Maximum Items", node.jsDoc?.tags.maxItems, icode),
|
||||
LabelledList.line("Unique Items", node.jsDoc?.tags.uniqueItems, boolean),
|
||||
])
|
||||
LabelledList.line(
|
||||
"Unique Items",
|
||||
node.jsDoc?.tags.uniqueItems,
|
||||
boolean
|
||||
),
|
||||
]),
|
||||
],
|
||||
append: [
|
||||
definitionToMarkdown(itemsPropertyPath, node.elements),
|
||||
]
|
||||
append: [definitionToMarkdown(itemsPropertyPath, node.children, file)],
|
||||
}
|
||||
}
|
||||
|
||||
const tupleBody = (
|
||||
node: TupleNode,
|
||||
propertyPath: string
|
||||
propertyPath: string,
|
||||
file: RootNode
|
||||
): SectionNode => {
|
||||
const indexedPropertyPath = (index: number) => `${propertyPath}[${index}]`
|
||||
|
||||
@@ -186,32 +344,32 @@ const tupleBody = (
|
||||
LabelledList.line("Type", "Tuple"),
|
||||
LabelledList.line(
|
||||
"Items",
|
||||
node.elements,
|
||||
childNodes => `[${
|
||||
childNodes
|
||||
node.children,
|
||||
(childNodes) =>
|
||||
`[${childNodes
|
||||
.map((_, index) =>
|
||||
a(indexedPropertyPath(index), indexedPropertyPath(index)))
|
||||
.join(", ")
|
||||
}]`
|
||||
a(indexedPropertyPath(index), indexedPropertyPath(index))
|
||||
)
|
||||
.join(", ")}]`
|
||||
),
|
||||
LabelledList.line("Default", node.jsDoc?.tags.default, icodejson),
|
||||
]),
|
||||
],
|
||||
append: node.elements.map(
|
||||
(childNode, index) =>
|
||||
definitionToMarkdown(indexedPropertyPath(index), childNode)
|
||||
)
|
||||
append: node.children.map((childNode, index) =>
|
||||
definitionToMarkdown(indexedPropertyPath(index), childNode, file)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const unionBody = (
|
||||
node: UnionNode,
|
||||
propertyPath: string
|
||||
propertyPath: string,
|
||||
file: RootNode
|
||||
): SectionNode => {
|
||||
const id = (childNode: ChildNode, index: number) => {
|
||||
switch (childNode.kind) {
|
||||
case NodeKind.Record: {
|
||||
const tagProperty = childNode.elements["tag"]
|
||||
const tagProperty = childNode.children["tag"]
|
||||
|
||||
return tagProperty && tagProperty.value.kind === NodeKind.Literal
|
||||
? tagProperty.value.value.toString()
|
||||
@@ -219,12 +377,22 @@ const unionBody = (
|
||||
}
|
||||
|
||||
case NodeKind.Reference: {
|
||||
return childNode.name
|
||||
return getRightmostQualifiedNameSegment(childNode.name)
|
||||
}
|
||||
|
||||
default: {
|
||||
case NodeKind.Record:
|
||||
case NodeKind.Dictionary:
|
||||
case NodeKind.Token:
|
||||
case NodeKind.Reference:
|
||||
case NodeKind.Array:
|
||||
case NodeKind.Union:
|
||||
case NodeKind.Literal:
|
||||
case NodeKind.Tuple:
|
||||
case NodeKind.Intersection: {
|
||||
return index.toFixed(0)
|
||||
}
|
||||
default:
|
||||
return assertExhaustive(childNode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,30 +402,31 @@ const unionBody = (
|
||||
inline: [
|
||||
LabelledList.create([
|
||||
LabelledList.line("Type", "Union"),
|
||||
LabelledList.line(
|
||||
"Cases",
|
||||
node.cases,
|
||||
cases =>
|
||||
cases
|
||||
.map((childNode, index) => {
|
||||
const caseId = casePropertyPath(id(childNode, index))
|
||||
LabelledList.line("Cases", node.children, (cases) =>
|
||||
cases
|
||||
.map((childNode, index) => {
|
||||
const caseId = casePropertyPath(id(childNode, index))
|
||||
|
||||
return a(caseId, namedLink(caseId))
|
||||
})
|
||||
.join(" | ")
|
||||
return a(caseId, anchorUrl(caseId))
|
||||
})
|
||||
.join(" | ")
|
||||
),
|
||||
])
|
||||
]),
|
||||
],
|
||||
append: node.cases.map(
|
||||
(childNode, index) =>
|
||||
definitionToMarkdown(casePropertyPath(id(childNode, index)), childNode)
|
||||
)
|
||||
append: node.children.map((childNode, index) =>
|
||||
definitionToMarkdown(
|
||||
casePropertyPath(id(childNode, index)),
|
||||
childNode,
|
||||
file
|
||||
)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const dictionaryBody = (
|
||||
node: DictionaryNode,
|
||||
propertyPath: string
|
||||
propertyPath: string,
|
||||
file: RootNode
|
||||
): SectionNode => {
|
||||
const itemsPropertyPath = `${propertyPath}[key]`
|
||||
|
||||
@@ -265,139 +434,144 @@ const dictionaryBody = (
|
||||
inline: [
|
||||
LabelledList.create([
|
||||
LabelledList.line("Type", "Dictionary"),
|
||||
LabelledList.line("Property Values", itemsPropertyPath, anchor => a(anchor, namedLink(anchor))),
|
||||
LabelledList.line("Property Values", itemsPropertyPath, (anchor) =>
|
||||
a(anchor, anchorUrl(anchor))
|
||||
),
|
||||
LabelledList.line("Default", node.jsDoc?.tags.default, icodejson),
|
||||
LabelledList.line("Pattern", node.pattern, icode),
|
||||
LabelledList.line("Minimum Properties", node.jsDoc?.tags.minProperties, icode),
|
||||
LabelledList.line(
|
||||
"Minimum Properties",
|
||||
node.jsDoc?.tags.minProperties,
|
||||
icode
|
||||
),
|
||||
]),
|
||||
],
|
||||
append: [
|
||||
definitionToMarkdown(itemsPropertyPath, node.elements),
|
||||
]
|
||||
append: [definitionToMarkdown(itemsPropertyPath, node.children, file)],
|
||||
}
|
||||
}
|
||||
|
||||
const strictObjectBody = (
|
||||
node: RecordNode,
|
||||
propertyPath: string
|
||||
propertyPath: string,
|
||||
file: RootNode
|
||||
): SectionNode => {
|
||||
const nodeElements = Object.entries(node.elements)
|
||||
const nodeElements = Object.entries(node.children)
|
||||
|
||||
if (nodeElements.length === 0) {
|
||||
return {
|
||||
inline: [
|
||||
LabelledList.create([
|
||||
LabelledList.line("Type", "Empty Object"),
|
||||
])
|
||||
LabelledList.create([LabelledList.line("Type", "Empty Object")]),
|
||||
],
|
||||
append: []
|
||||
append: [],
|
||||
}
|
||||
}
|
||||
else {
|
||||
const propertiesOverview = Object.entries(node.elements)
|
||||
} else {
|
||||
const propertiesOverview = Object.entries(node.children)
|
||||
.map(([key, config]) => {
|
||||
const propertyPropertyPath = `${propertyPath}/${key}`
|
||||
const title = `\`${key}${config.isRequired ? "" : "?"}\``
|
||||
|
||||
return [
|
||||
title,
|
||||
config.jsDoc?.comment
|
||||
?.split("\n\n")[0]
|
||||
?.replaceAll("\n", " ")
|
||||
?? "",
|
||||
a("See details", namedLink(propertyPropertyPath))
|
||||
config.jsDoc?.comment?.split("\n\n")[0]?.replaceAll("\n", " ") ?? "",
|
||||
a("See details", anchorUrl(propertyPropertyPath)),
|
||||
].join(" | ")
|
||||
})
|
||||
.join(EOL)
|
||||
|
||||
const properties = nodeElements
|
||||
.reduce<SectionNode>(
|
||||
(
|
||||
{
|
||||
inline,
|
||||
append,
|
||||
},
|
||||
[key, propertyNode]
|
||||
) => {
|
||||
const propertyPropertyPath = `${propertyPath}/${key}`
|
||||
const title = h(4, `\`${key}${propertyNode.isRequired ? "" : "?"}\``, propertyPropertyPath)
|
||||
const properties = nodeElements.reduce<SectionNode>(
|
||||
({ inline, append }, [key, propertyNode]) => {
|
||||
const propertyPropertyPath = `${propertyPath}/${key}`
|
||||
const title = h(
|
||||
4,
|
||||
`\`${key}${propertyNode.isRequired ? "" : "?"}\``,
|
||||
propertyPropertyPath
|
||||
)
|
||||
|
||||
if (propertyNode.value.kind === NodeKind.Record) {
|
||||
return {
|
||||
inline: [
|
||||
...inline,
|
||||
headerWithDescription(title, propertyNode.jsDoc?.comment),
|
||||
LabelledList.create([
|
||||
LabelledList.line("Type", propertyPropertyPath, anchor => a("Object", namedLink(anchor))),
|
||||
LabelledList.line("Default", node.jsDoc?.tags.default, icodejson),
|
||||
]),
|
||||
],
|
||||
append: [
|
||||
...append,
|
||||
definitionToMarkdown(propertyPropertyPath, propertyNode.value)
|
||||
],
|
||||
}
|
||||
if (propertyNode.value.kind === NodeKind.Record) {
|
||||
return {
|
||||
inline: [
|
||||
...inline,
|
||||
headerWithDescription(title, propertyNode.jsDoc?.comment),
|
||||
LabelledList.create([
|
||||
LabelledList.line("Type", propertyPropertyPath, (anchor) =>
|
||||
a("Object", anchorUrl(anchor))
|
||||
),
|
||||
LabelledList.line(
|
||||
"Default",
|
||||
node.jsDoc?.tags.default,
|
||||
icodejson
|
||||
),
|
||||
]),
|
||||
],
|
||||
append: [
|
||||
...append,
|
||||
definitionToMarkdown(
|
||||
propertyPropertyPath,
|
||||
propertyNode.value,
|
||||
file
|
||||
),
|
||||
],
|
||||
}
|
||||
else {
|
||||
const { inline: inlineCurrent, append: appendCurrent } = definitionToMarkdown(
|
||||
} else {
|
||||
const { inline: inlineCurrent, append: appendCurrent } =
|
||||
definitionToMarkdown(
|
||||
propertyPropertyPath,
|
||||
propertyNode.value,
|
||||
file,
|
||||
true,
|
||||
headerWithDescription(title, propertyNode.jsDoc?.comment)
|
||||
)
|
||||
|
||||
return {
|
||||
inline: [
|
||||
...inline,
|
||||
...inlineCurrent,
|
||||
],
|
||||
append: [
|
||||
...append,
|
||||
...appendCurrent
|
||||
],
|
||||
}
|
||||
return {
|
||||
inline: [...inline, ...inlineCurrent],
|
||||
append: [...append, ...appendCurrent],
|
||||
}
|
||||
},
|
||||
{
|
||||
inline: [],
|
||||
append: [],
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
inline: [],
|
||||
append: [],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
inline: [
|
||||
LabelledList.create([
|
||||
LabelledList.line("Type", "Object"),
|
||||
LabelledList.line("Default", node.jsDoc?.tags.default, icodejson),
|
||||
LabelledList.line("Minimum Properties", node.jsDoc?.tags.minProperties, icode),
|
||||
LabelledList.line(
|
||||
"Minimum Properties",
|
||||
node.jsDoc?.tags.minProperties,
|
||||
icode
|
||||
),
|
||||
]),
|
||||
`Key | Description | Details${EOL}:-- | :-- | :--${EOL}${propertiesOverview}`,
|
||||
...properties.inline,
|
||||
],
|
||||
append: properties.append
|
||||
append: properties.append,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prependHeader = (
|
||||
propertyPath: string,
|
||||
node: ChildNode,
|
||||
node: StatementNode | ChildNode,
|
||||
skipLine: boolean,
|
||||
header: string | undefined,
|
||||
{ inline, append }: SectionNode,
|
||||
) =>
|
||||
({
|
||||
inline: [
|
||||
...(!skipLine ? ["---"] : []),
|
||||
header ?? definitionHeader(propertyPath, node.jsDoc),
|
||||
...inline
|
||||
],
|
||||
append
|
||||
})
|
||||
{ inline, append }: SectionNode
|
||||
) => ({
|
||||
inline: [
|
||||
...(!skipLine ? ["---"] : []),
|
||||
header ?? definitionHeader(propertyPath, node.jsDoc),
|
||||
...inline,
|
||||
],
|
||||
append,
|
||||
})
|
||||
|
||||
const definitionToMarkdown = (
|
||||
propertyPath: string,
|
||||
node: ChildNode,
|
||||
node: StatementNode | ChildNode,
|
||||
file: RootNode,
|
||||
skipLine = false,
|
||||
header?: string
|
||||
): SectionNode => {
|
||||
@@ -407,59 +581,112 @@ const definitionToMarkdown = (
|
||||
case NodeKind.Reference:
|
||||
case NodeKind.Enumeration: {
|
||||
return prependHeader(propertyPath, node, skipLine, header, {
|
||||
inline: [simpleBody(node)],
|
||||
append: []
|
||||
inline: [simpleBody(node, file)],
|
||||
append: [],
|
||||
})
|
||||
}
|
||||
case NodeKind.Record: {
|
||||
return prependHeader(propertyPath, node, skipLine, header,
|
||||
strictObjectBody(node, propertyPath))
|
||||
return prependHeader(
|
||||
propertyPath,
|
||||
node,
|
||||
skipLine,
|
||||
header,
|
||||
strictObjectBody(node, propertyPath, file)
|
||||
)
|
||||
}
|
||||
case NodeKind.Array: {
|
||||
return prependHeader(propertyPath, node, skipLine, header,
|
||||
arrayBody(node, propertyPath))
|
||||
return prependHeader(
|
||||
propertyPath,
|
||||
node,
|
||||
skipLine,
|
||||
header,
|
||||
arrayBody(node, propertyPath, file)
|
||||
)
|
||||
}
|
||||
case NodeKind.Union: {
|
||||
return prependHeader(propertyPath, node, skipLine, header,
|
||||
unionBody(node, propertyPath))
|
||||
return prependHeader(
|
||||
propertyPath,
|
||||
node,
|
||||
skipLine,
|
||||
header,
|
||||
unionBody(node, propertyPath, file)
|
||||
)
|
||||
}
|
||||
case NodeKind.Dictionary: {
|
||||
return prependHeader(propertyPath, node, skipLine, header,
|
||||
dictionaryBody(node, propertyPath))
|
||||
return prependHeader(
|
||||
propertyPath,
|
||||
node,
|
||||
skipLine,
|
||||
header,
|
||||
dictionaryBody(node, propertyPath, file)
|
||||
)
|
||||
}
|
||||
case NodeKind.Tuple: {
|
||||
return prependHeader(propertyPath, node, skipLine, header,
|
||||
tupleBody(node, propertyPath))
|
||||
return prependHeader(
|
||||
propertyPath,
|
||||
node,
|
||||
skipLine,
|
||||
header,
|
||||
tupleBody(node, propertyPath, file)
|
||||
)
|
||||
}
|
||||
case NodeKind.Group: {
|
||||
return {
|
||||
inline: [],
|
||||
append: Object.entries(node.elements)
|
||||
.map(([key, childNode], i) =>
|
||||
definitionToMarkdown(`${propertyPath}/${key}`, childNode, skipLine && i === 0)
|
||||
append: Object.entries(node.children).map(([key, childNode], i) =>
|
||||
definitionToMarkdown(
|
||||
`${propertyPath}/${key}`,
|
||||
childNode,
|
||||
file,
|
||||
skipLine && i === 0
|
||||
)
|
||||
),
|
||||
}
|
||||
}
|
||||
case NodeKind.TypeDefinition: {
|
||||
return definitionToMarkdown(
|
||||
propertyPath,
|
||||
node.definition,
|
||||
file,
|
||||
skipLine,
|
||||
definitionHeader(propertyPath, node.jsDoc, node.typeParameters)
|
||||
)
|
||||
}
|
||||
case NodeKind.ExportAssignment: {
|
||||
// ignore export assignment
|
||||
return { append: [], inline: [] }
|
||||
}
|
||||
case NodeKind.Intersection: {
|
||||
// TODO: implement intersection
|
||||
return { append: [], inline: [] }
|
||||
}
|
||||
default:
|
||||
return assertExhaustive(node)
|
||||
}
|
||||
}
|
||||
|
||||
const astToMarkdown: AstTransformer = file => {
|
||||
const ref = file.jsDoc?.tags.main !== undefined ? file.elements[file.jsDoc?.tags.main] : undefined
|
||||
const astToMarkdown: AstTransformer = (file) => {
|
||||
const ref =
|
||||
file.jsDoc?.tags.main !== undefined
|
||||
? file.children.find(({ name }) => name === file.jsDoc?.tags.main)
|
||||
: undefined
|
||||
|
||||
const definitions = Object.entries(file.elements)
|
||||
.map(([id, definition], i) =>
|
||||
definitionToMarkdown(id, definition, i === 0)
|
||||
const definitions = file.children
|
||||
.map((definition, i) =>
|
||||
definitionToMarkdown(definition.name, definition, file, i === 0)
|
||||
)
|
||||
.flatMap(mergeParagraphs)
|
||||
|
||||
return [
|
||||
docHeader(file, ref?.jsDoc),
|
||||
h(2, "Definitions"),
|
||||
...definitions
|
||||
].join(EOL + EOL) + EOL
|
||||
return (
|
||||
[docHeader(file, ref?.jsDoc), h(2, "Definitions"), ...definitions].join(
|
||||
EOL + EOL
|
||||
) + EOL
|
||||
)
|
||||
}
|
||||
|
||||
export const markdownRenderer = (): Renderer => Object.freeze({
|
||||
transformer: astToMarkdown,
|
||||
fileExtension: ".md",
|
||||
})
|
||||
export const markdownRenderer = (): Renderer =>
|
||||
Object.freeze({
|
||||
transformer: astToMarkdown,
|
||||
fileExtension: ".md",
|
||||
resolveTypeParameters: false,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"lib": ["ESNext"],
|
||||
"module": "NodeNext",
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"outDir": "../lib",
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"target": "ESNext",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function assertExhaustive(
|
||||
_x: never,
|
||||
msg: string = "The switch is not exhaustive."
|
||||
): never {
|
||||
throw new Error(msg)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const isNotNullish = <T>(value: T): value is NonNullable<T> =>
|
||||
value !== null && value !== undefined
|
||||
@@ -0,0 +1,7 @@
|
||||
export const tsextPattern = /(?:\.d)?\.ts$/
|
||||
|
||||
export const changeExtension = (
|
||||
fileName: string,
|
||||
oldExt: string,
|
||||
newExt: string
|
||||
) => fileName.slice(0, -oldExt.length) + newExt
|
||||
@@ -0,0 +1,17 @@
|
||||
import { QualifiedName } from "../ast.js"
|
||||
|
||||
export const qualifiedNameToArray = (name: QualifiedName): string[] => {
|
||||
if (name.right) {
|
||||
return [name.segment, ...qualifiedNameToArray(name.right)]
|
||||
}
|
||||
return [name.segment]
|
||||
}
|
||||
|
||||
export const getRightmostQualifiedNameSegment = (
|
||||
name: QualifiedName
|
||||
): string => {
|
||||
if (name.right) {
|
||||
return getRightmostQualifiedNameSegment(name.right)
|
||||
}
|
||||
return name.segment
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { dirname, format, parse, relative } from "node:path/posix"
|
||||
import { NodeKind, ReferenceNode, RootNode } from "../ast.js"
|
||||
import { qualifiedNameToArray } from "./qualifiedName.js"
|
||||
|
||||
export const getRelativeExternalPath = (
|
||||
node: ReferenceNode,
|
||||
file: RootNode,
|
||||
ext: string
|
||||
) => {
|
||||
const externalSourceFilePath =
|
||||
node.resolvedFileName !== file.fileName ? node.resolvedFileName : undefined
|
||||
|
||||
const externalSourceFilePathParts = externalSourceFilePath
|
||||
? parse(relative(dirname(file.fileName), externalSourceFilePath))
|
||||
: undefined
|
||||
|
||||
if (externalSourceFilePathParts) {
|
||||
// @ts-expect-error Allowed by Node.js documentation
|
||||
externalSourceFilePathParts.base = undefined
|
||||
externalSourceFilePathParts.ext = ext
|
||||
}
|
||||
|
||||
let externalFilePath = externalSourceFilePathParts
|
||||
? format(externalSourceFilePathParts)
|
||||
: ""
|
||||
|
||||
if (!externalFilePath.startsWith(".") && externalFilePath !== "") {
|
||||
externalFilePath = "./" + externalFilePath
|
||||
}
|
||||
|
||||
return externalFilePath
|
||||
}
|
||||
|
||||
export const getFullyQualifiedNameAsPath = (
|
||||
node: ReferenceNode,
|
||||
file: RootNode
|
||||
) => {
|
||||
const isNamespaceImport = file.imports.some(
|
||||
(importNode) =>
|
||||
importNode.kind === NodeKind.NamespaceImport &&
|
||||
importNode.name === node.name.segment
|
||||
)
|
||||
|
||||
return qualifiedNameToArray(node.name)
|
||||
.slice(isNamespaceImport ? 1 : 0)
|
||||
.join("/")
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// @ts-check
|
||||
import * as assert from "node:assert/strict"
|
||||
import { sep } from "node:path"
|
||||
import { dirname, join } from "node:path/posix"
|
||||
import { describe, it } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import ts from "typescript"
|
||||
import { fileToAst, NodeKind, TokenKind } from "../../lib/parser/ast.js"
|
||||
|
||||
describe("ast", () => {
|
||||
it("should treat a record with defaulted type parameters as record without type parameters for output", () => {
|
||||
const { checker, program } = prepareTypeScriptInstance(["typeParameterWithDefaults.ts"])
|
||||
const file = /** @type {import("typescript").SourceFile} */ (program.getSourceFiles().find(file => file.fileName.includes("typeParameterWithDefaults")))
|
||||
const actual = fileToAst(file, checker, program)
|
||||
|
||||
/** @type {import("../../src/parser/ast.js").RootNode} */
|
||||
const expected = {
|
||||
kind: NodeKind.Main,
|
||||
jsDoc: undefined,
|
||||
elements: {
|
||||
Record: {
|
||||
kind: NodeKind.Record,
|
||||
jsDoc: undefined,
|
||||
elements: {
|
||||
id: {
|
||||
isRequired: true,
|
||||
jsDoc: undefined,
|
||||
value: {
|
||||
kind: NodeKind.Token,
|
||||
jsDoc: undefined,
|
||||
token: TokenKind.Number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(actual, expected)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {string[]} filePaths
|
||||
* @return {{ checker: ts.TypeChecker; program: ts.Program }}
|
||||
*/
|
||||
const prepareTypeScriptInstance = filePaths => {
|
||||
const root = join(dirname(fileURLToPath(import.meta.url).split(sep).join("/")), "files")
|
||||
const program = ts.createProgram(filePaths.map(file => join(root, file)), { strict: true })
|
||||
const checker = program.getTypeChecker()
|
||||
return { program, checker }
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import * as assert from "node:assert/strict"
|
||||
import { normalize, sep } from "node:path"
|
||||
import { dirname, join } from "node:path/posix"
|
||||
import { describe, it } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import ts from "typescript"
|
||||
import { NodeKind, RootNode, TokenKind } from "../../src/ast.js"
|
||||
import { fileToAst } from "../../src/parser/ast.js"
|
||||
|
||||
const prepareTypeScriptInstance = (
|
||||
filePaths: string[][]
|
||||
): { checker: ts.TypeChecker; program: ts.Program; root: string } => {
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "files")
|
||||
const program = ts.createProgram(
|
||||
filePaths.map((file) => join(root, ...file)),
|
||||
{ strict: true }
|
||||
)
|
||||
const checker = program.getTypeChecker()
|
||||
return { program, checker, root }
|
||||
}
|
||||
|
||||
describe("fileToAst", () => {
|
||||
const { checker, program, root } = prepareTypeScriptInstance([
|
||||
["a.ts"],
|
||||
["b.ts"],
|
||||
])
|
||||
const a = program
|
||||
.getSourceFiles()
|
||||
.find((file) => normalize(file.fileName).endsWith(sep + "a.ts"))!
|
||||
const b = program
|
||||
.getSourceFiles()
|
||||
.find((file) => normalize(file.fileName).endsWith(sep + "b.ts"))!
|
||||
|
||||
it("transforms the content into a custom ast (a.ts)", () => {
|
||||
const actual = fileToAst(a, checker, program)
|
||||
|
||||
assert.deepEqual<RootNode>(actual, {
|
||||
kind: NodeKind.Root,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
imports: [],
|
||||
children: [
|
||||
{
|
||||
kind: NodeKind.TypeDefinition,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
name: "A",
|
||||
typeParameters: [
|
||||
{
|
||||
kind: NodeKind.TypeParameter,
|
||||
fileName: a.fileName,
|
||||
name: "T",
|
||||
constraint: {
|
||||
kind: NodeKind.Token,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
token: TokenKind.Number,
|
||||
},
|
||||
default: {
|
||||
kind: NodeKind.Token,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
token: TokenKind.Number,
|
||||
},
|
||||
},
|
||||
],
|
||||
definition: {
|
||||
kind: NodeKind.Record,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
children: {
|
||||
id: {
|
||||
isReadOnly: false,
|
||||
isRequired: true,
|
||||
jsDoc: undefined,
|
||||
value: {
|
||||
kind: NodeKind.Reference,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
name: { segment: "T", right: undefined },
|
||||
typeArguments: undefined,
|
||||
resolvedFileName: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: NodeKind.TypeDefinition,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
name: "Def",
|
||||
typeParameters: undefined,
|
||||
definition: {
|
||||
kind: NodeKind.Token,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
token: TokenKind.Number,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: NodeKind.ExportAssignment,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
name: "default",
|
||||
expression: {
|
||||
kind: NodeKind.Reference,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
name: { segment: "Def" },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("transforms the content into a custom ast (b.ts)", () => {
|
||||
const actual = fileToAst(b, checker, program)
|
||||
|
||||
assert.deepEqual<RootNode>(actual, {
|
||||
kind: NodeKind.Root,
|
||||
fileName: b.fileName,
|
||||
jsDoc: undefined,
|
||||
imports: [
|
||||
{
|
||||
kind: NodeKind.DefaultImport,
|
||||
name: "D",
|
||||
fileName: join(root, "a.ts"),
|
||||
},
|
||||
{
|
||||
kind: NodeKind.NamedImport,
|
||||
name: "A",
|
||||
alias: "TypeA",
|
||||
fileName: join(root, "a.ts"),
|
||||
},
|
||||
],
|
||||
children: [
|
||||
{
|
||||
kind: NodeKind.TypeDefinition,
|
||||
fileName: b.fileName,
|
||||
jsDoc: undefined,
|
||||
name: "B",
|
||||
typeParameters: undefined,
|
||||
definition: {
|
||||
kind: NodeKind.Reference,
|
||||
fileName: b.fileName,
|
||||
resolvedFileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
name: { segment: "TypeA", right: undefined },
|
||||
typeArguments: [
|
||||
{
|
||||
kind: NodeKind.Reference,
|
||||
fileName: b.fileName,
|
||||
resolvedFileName: undefined,
|
||||
jsDoc: undefined,
|
||||
name: { segment: "D", right: undefined },
|
||||
typeArguments: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: NodeKind.TypeDefinition,
|
||||
fileName: b.fileName,
|
||||
jsDoc: undefined,
|
||||
name: "C",
|
||||
typeParameters: undefined,
|
||||
definition: {
|
||||
kind: NodeKind.Token,
|
||||
fileName: b.fileName,
|
||||
jsDoc: undefined,
|
||||
token: TokenKind.String,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
export type A<T extends number = number> = {
|
||||
id: T
|
||||
}
|
||||
|
||||
type Def = number
|
||||
|
||||
export default Def
|
||||
@@ -0,0 +1,5 @@
|
||||
import D, { A as TypeA } from "./a.js"
|
||||
|
||||
export type B = TypeA<D>
|
||||
|
||||
export type C = string
|
||||
@@ -1,3 +0,0 @@
|
||||
export type Record<T extends number = number> = {
|
||||
id: T
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import * as assert from "node:assert/strict"
|
||||
import { normalize, sep } from "node:path"
|
||||
import { dirname, join } from "node:path/posix"
|
||||
import { describe, it } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import ts from "typescript"
|
||||
import { NodeKind, RootNode, TokenKind } from "../../src/ast.js"
|
||||
import { fileToAst } from "../../src/parser/ast.js"
|
||||
import { resolveTypeArgumentsForFile } from "../../src/parser/resolvetypeargs.js"
|
||||
|
||||
const prepareTypeScriptInstance = (
|
||||
suite: string,
|
||||
filePaths: string[][]
|
||||
): { checker: ts.TypeChecker; program: ts.Program; root: string } => {
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), suite)
|
||||
const program = ts.createProgram(
|
||||
filePaths.map((file) => join(root, ...file)),
|
||||
{ strict: true }
|
||||
)
|
||||
const checker = program.getTypeChecker()
|
||||
return { program, checker, root }
|
||||
}
|
||||
|
||||
describe("resolveTypeArgumentsForFile", () => {
|
||||
const { checker, program, root } = prepareTypeScriptInstance("files", [
|
||||
["a.ts"],
|
||||
["b.ts"],
|
||||
])
|
||||
const a = program
|
||||
.getSourceFiles()
|
||||
.find((file) => normalize(file.fileName).endsWith(sep + "a.ts"))!
|
||||
const b = program
|
||||
.getSourceFiles()
|
||||
.find((file) => normalize(file.fileName).endsWith(sep + "b.ts"))!
|
||||
const asta = fileToAst(a, checker, program)
|
||||
const astb = fileToAst(b, checker, program)
|
||||
|
||||
it("keeps generic types with default arguments", () => {
|
||||
const actual = resolveTypeArgumentsForFile(
|
||||
{ [a.fileName]: asta, [b.fileName]: astb },
|
||||
asta
|
||||
)
|
||||
|
||||
assert.deepEqual<RootNode>(actual, {
|
||||
kind: NodeKind.Root,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
imports: [],
|
||||
children: [
|
||||
{
|
||||
kind: NodeKind.TypeDefinition,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
name: "A",
|
||||
typeParameters: [
|
||||
{
|
||||
kind: NodeKind.TypeParameter,
|
||||
fileName: a.fileName,
|
||||
name: "T",
|
||||
constraint: {
|
||||
kind: NodeKind.Token,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
token: TokenKind.Number,
|
||||
},
|
||||
default: {
|
||||
kind: NodeKind.Token,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
token: TokenKind.Number,
|
||||
},
|
||||
},
|
||||
],
|
||||
definition: {
|
||||
kind: NodeKind.Record,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
children: {
|
||||
id: {
|
||||
isReadOnly: false,
|
||||
isRequired: true,
|
||||
jsDoc: undefined,
|
||||
value: {
|
||||
kind: NodeKind.Token,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
token: TokenKind.Number,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: NodeKind.TypeDefinition,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
name: "Def",
|
||||
typeParameters: undefined,
|
||||
definition: {
|
||||
kind: NodeKind.Token,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
token: TokenKind.Number,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("resolves generic types only as deep as required", () => {
|
||||
const actual = resolveTypeArgumentsForFile(
|
||||
{ [a.fileName]: asta, [b.fileName]: astb },
|
||||
astb
|
||||
)
|
||||
|
||||
assert.deepEqual<RootNode>(actual, {
|
||||
kind: NodeKind.Root,
|
||||
fileName: b.fileName,
|
||||
jsDoc: undefined,
|
||||
imports: [
|
||||
{
|
||||
kind: NodeKind.DefaultImport,
|
||||
fileName: a.fileName,
|
||||
name: "D",
|
||||
},
|
||||
{
|
||||
kind: NodeKind.NamedImport,
|
||||
fileName: a.fileName,
|
||||
name: "A",
|
||||
alias: "TypeA",
|
||||
},
|
||||
],
|
||||
children: [
|
||||
{
|
||||
kind: NodeKind.TypeDefinition,
|
||||
fileName: b.fileName,
|
||||
jsDoc: undefined,
|
||||
name: "B",
|
||||
typeParameters: undefined,
|
||||
definition: {
|
||||
kind: NodeKind.Record,
|
||||
fileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
children: {
|
||||
id: {
|
||||
isReadOnly: false,
|
||||
isRequired: true,
|
||||
jsDoc: undefined,
|
||||
value: {
|
||||
kind: NodeKind.Reference,
|
||||
fileName: b.fileName,
|
||||
jsDoc: undefined,
|
||||
name: { segment: "D", right: undefined },
|
||||
typeArguments: undefined,
|
||||
resolvedFileName: a.fileName,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: NodeKind.TypeDefinition,
|
||||
fileName: b.fileName,
|
||||
jsDoc: undefined,
|
||||
name: "C",
|
||||
typeParameters: undefined,
|
||||
definition: {
|
||||
kind: NodeKind.Token,
|
||||
fileName: b.fileName,
|
||||
jsDoc: undefined,
|
||||
token: TokenKind.String,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("maintains cross-file references", () => {
|
||||
const { checker, program, root } = prepareTypeScriptInstance(
|
||||
"resolvetypeargsfiles",
|
||||
[["a.ts"], ["b.ts"], ["c.ts"]]
|
||||
)
|
||||
const a = program
|
||||
.getSourceFiles()
|
||||
.find((file) => normalize(file.fileName).endsWith(sep + "a.ts"))!
|
||||
const b = program
|
||||
.getSourceFiles()
|
||||
.find((file) => normalize(file.fileName).endsWith(sep + "b.ts"))!
|
||||
const c = program
|
||||
.getSourceFiles()
|
||||
.find((file) => normalize(file.fileName).endsWith(sep + "c.ts"))!
|
||||
const asta = fileToAst(a, checker, program)
|
||||
const astb = fileToAst(b, checker, program)
|
||||
const astc = fileToAst(c, checker, program)
|
||||
const files = {
|
||||
[a.fileName]: asta,
|
||||
[b.fileName]: astb,
|
||||
[c.fileName]: astc,
|
||||
}
|
||||
const actual = resolveTypeArgumentsForFile(files, astc)
|
||||
|
||||
assert.deepEqual<RootNode>(actual, {
|
||||
kind: NodeKind.Root,
|
||||
fileName: c.fileName,
|
||||
jsDoc: undefined,
|
||||
imports: [
|
||||
{
|
||||
kind: NodeKind.NamedImport,
|
||||
fileName: b.fileName,
|
||||
name: "B",
|
||||
},
|
||||
],
|
||||
children: [
|
||||
{
|
||||
kind: NodeKind.TypeDefinition,
|
||||
fileName: c.fileName,
|
||||
jsDoc: undefined,
|
||||
name: "C",
|
||||
typeParameters: undefined,
|
||||
definition: {
|
||||
kind: NodeKind.Record,
|
||||
fileName: b.fileName,
|
||||
jsDoc: undefined,
|
||||
children: {
|
||||
object: {
|
||||
isReadOnly: false,
|
||||
isRequired: true,
|
||||
jsDoc: undefined,
|
||||
value: {
|
||||
kind: NodeKind.Reference,
|
||||
fileName: b.fileName,
|
||||
resolvedFileName: a.fileName,
|
||||
jsDoc: undefined,
|
||||
name: { segment: "A", right: undefined },
|
||||
typeArguments: undefined,
|
||||
},
|
||||
},
|
||||
value: {
|
||||
isReadOnly: false,
|
||||
isRequired: true,
|
||||
jsDoc: undefined,
|
||||
value: {
|
||||
kind: NodeKind.Token,
|
||||
fileName: c.fileName,
|
||||
jsDoc: undefined,
|
||||
token: TokenKind.Number,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export type A = {
|
||||
label: string
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { A } from "./a.js"
|
||||
|
||||
export type B<T> = {
|
||||
object: A
|
||||
value: T
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { B } from "./b.js"
|
||||
|
||||
export type C = B<number>
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"lib": ["ESNext"],
|
||||
"module": "NodeNext",
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"outDir": "../libtest",
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"target": "ESNext",
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../src" },
|
||||
]
|
||||
}
|
||||
+5
-14
@@ -1,16 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": true,
|
||||
"lib": ["ESNext"],
|
||||
"module": "NodeNext",
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"outDir": "lib",
|
||||
"strict": true,
|
||||
"target": "ESNext",
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "src" },
|
||||
{ "path": "test" },
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user