UNPKG

10.6 kBPlain TextView Raw
1// TODO: parser would be ~2x faster if we reused the underlying ts program from TS in TJS
2
3import doctrine from 'doctrine'
4import fs from 'fs-extra'
5import { version } from 'fts-core'
6import path from 'path'
7import tempy from 'tempy'
8import * as TS from 'ts-morph'
9import * as TJS from 'typescript-json-schema'
10import * as FTS from './types'
11
12const FTSReturns = 'FTSReturns'
13const FTSParams = 'FTSParams'
14
15const promiseTypeRe = /^Promise<(.*)>$/
16
17const supportedExtensions = {
18 js: 'javascript',
19 jsx: 'javascript',
20 ts: 'typescript',
21 tsx: 'typescript'
22}
23
24export async function generateDefinition(
25 file: string,
26 options: FTS.PartialDefinitionOptions = {}
27): Promise<FTS.Definition> {
28 file = path.resolve(file)
29 const fileInfo = path.parse(file)
30 const language = supportedExtensions[fileInfo.ext.substr(1)]
31
32 if (!language) {
33 throw new Error(`File type "${fileInfo.ext}" not supported. "${file}"`)
34 }
35
36 const outDir = tempy.directory()
37
38 // initialize and compile TS program
39 const compilerOptions = {
40 allowJs: true,
41 ignoreCompilerErrors: true,
42 // TODO: why do we need to specify the full filename for these lib definition files?
43 lib: ['lib.es2018.d.ts', 'lib.dom.d.ts'],
44 target: TS.ScriptTarget.ES5,
45 outDir,
46 ...(options.compilerOptions || {})
47 }
48
49 const jsonSchemaOptions = {
50 noExtraProps: true,
51 required: true,
52 validationKeywords: ['coerceTo', 'coerceFrom'],
53 ...(options.jsonSchemaOptions || {})
54 }
55
56 const definition: Partial<FTS.Definition> = {
57 config: {
58 defaultExport: true,
59 language
60 },
61 params: {
62 context: false,
63 order: [],
64 schema: null
65 },
66 returns: {
67 async: false,
68 http: false,
69 schema: null
70 },
71 version
72 }
73
74 const project = new TS.Project({ compilerOptions })
75
76 project.addExistingSourceFile(file)
77 project.resolveSourceFileDependencies()
78
79 const diagnostics = project.getPreEmitDiagnostics()
80 if (diagnostics.length > 0) {
81 console.log(project.formatDiagnosticsWithColorAndContext(diagnostics))
82
83 // TODO: throw error?
84 }
85
86 const sourceFile = project.getSourceFileOrThrow(file)
87 const main = extractMainFunction(sourceFile, definition)
88
89 if (!main) {
90 throw new Error(`Unable to infer a main function export "${file}"`)
91 }
92
93 // extract main function type and documentation info
94 const title = main.getName ? main.getName() : path.parse(file).name
95 const mainTypeParams = main.getTypeParameters()
96 definition.title = title
97
98 if (mainTypeParams.length > 0) {
99 throw new Error(
100 `Generic Type Parameters are not supported for function "${title}"`
101 )
102 }
103
104 const doc = main.getJsDocs()[0]
105 let docs: doctrine.Annotation
106
107 if (doc) {
108 const { description } = doc.getStructure()
109 docs = doctrine.parse(description as string)
110 if (docs.description) {
111 definition.description = docs.description
112 }
113 }
114
115 const builder: FTS.DefinitionBuilder = {
116 definition,
117 docs,
118 main,
119 sourceFile,
120 title
121 }
122
123 if (options.emit) {
124 const result = project.emit(options.emitOptions)
125 if (result.getEmitSkipped()) {
126 throw new Error('emit skipped')
127 }
128 }
129
130 addParamsDeclaration(builder)
131 addReturnTypeDeclaration(builder)
132
133 // TODO: figure out a better workaround than mutating the source file directly
134 // TODO: fix support for JS files since you can't save TS in JS
135 const tempSourceFilePath = path.format({
136 dir: fileInfo.dir,
137 ext: '.ts',
138 name: `.${fileInfo.name}-fts`
139 })
140 const tempSourceFile = sourceFile.copy(tempSourceFilePath, {
141 overwrite: true
142 })
143 await tempSourceFile.save()
144
145 try {
146 extractJSONSchemas(builder, tempSourceFilePath, jsonSchemaOptions)
147 } finally {
148 await fs.remove(tempSourceFilePath)
149 }
150
151 return builder.definition as FTS.Definition
152}
153
154/** Find main exported function declaration */
155function extractMainFunction(
156 sourceFile: TS.SourceFile,
157 definition: Partial<FTS.Definition>
158): TS.FunctionDeclaration | undefined {
159 const functionDefaultExports = sourceFile
160 .getFunctions()
161 .filter((f) => f.isDefaultExport())
162
163 if (functionDefaultExports.length === 1) {
164 definition.config.defaultExport = true
165 return functionDefaultExports[0]
166 } else {
167 definition.config.defaultExport = false
168 }
169
170 const functionExports = sourceFile
171 .getFunctions()
172 .filter((f) => f.isExported())
173
174 if (functionExports.length === 1) {
175 const func = functionExports[0]
176 definition.config.namedExport = func.getName()
177 return func
178 }
179
180 if (functionExports.length > 1) {
181 const externalFunctions = functionExports.filter((f) => {
182 const docs = f.getJsDocs()[0]
183
184 return (
185 docs &&
186 docs.getTags().find((tag) => {
187 const tagName = tag.getTagName()
188 return tagName === 'external' || tagName === 'public'
189 })
190 )
191 })
192
193 if (externalFunctions.length === 1) {
194 const func = externalFunctions[0]
195 definition.config.namedExport = func.getName()
196 return func
197 }
198 }
199
200 // TODO: arrow function exports are a lil hacky
201 const arrowFunctionExports = sourceFile
202 .getDescendantsOfKind(TS.SyntaxKind.ArrowFunction)
203 .filter((f) => TS.TypeGuards.isExportAssignment(f.getParent()))
204
205 if (arrowFunctionExports.length === 1) {
206 const func = arrowFunctionExports[0]
207 const exportAssignment = func.getParent() as TS.ExportAssignment
208 const exportSymbol = sourceFile.getDefaultExportSymbol()
209
210 // TODO: handle named exports `export const foo = () => 'bar'`
211 if (exportSymbol) {
212 const defaultExportPos = exportSymbol
213 .getValueDeclarationOrThrow()
214 .getPos()
215 const exportAssignmentPos = exportAssignment.getPos()
216
217 // TODO: better way of comparing nodes
218 const isDefaultExport = defaultExportPos === exportAssignmentPos
219
220 if (isDefaultExport) {
221 definition.config.defaultExport = true
222 return (func as unknown) as TS.FunctionDeclaration
223 }
224 }
225 }
226
227 return undefined
228}
229
230function addParamsDeclaration(
231 builder: FTS.DefinitionBuilder
232): TS.ClassDeclaration {
233 const mainParams = builder.main.getParameters()
234
235 const paramsDeclaration = builder.sourceFile.addClass({
236 name: FTSParams
237 })
238
239 const paramComments = {}
240
241 if (builder.docs) {
242 const paramTags = builder.docs.tags.filter((tag) => tag.title === 'param')
243 for (const tag of paramTags) {
244 paramComments[tag.name] = tag.description
245 }
246 }
247
248 for (let i = 0; i < mainParams.length; ++i) {
249 const param = mainParams[i]
250 const name = param.getName()
251 const structure = param.getStructure()
252
253 // TODO: this handles alias type resolution i think...
254 // need to test multiple levels of aliasing
255 structure.type = param.getType().getText()
256
257 if (name === 'context') {
258 if (i !== mainParams.length - 1) {
259 throw new Error(
260 `Function parameter "context" must be last parameter to main function "${
261 builder.title
262 }"`
263 )
264 }
265
266 builder.definition.params.context = true
267 // TODO: ensure context has valid type `FTS.Context`
268 // ignore context in parameter aggregation
269 continue
270 } else {
271 // TODO: ensure that type is valid:
272 // not `FTS.Context`
273 // not Promise<T>
274 // not Function or ArrowFunction
275 // not RegExp
276 }
277
278 const promiseReMatch = structure.type.match(promiseTypeRe)
279 if (promiseReMatch) {
280 throw new Error(
281 `Parameter "${name}" has unsupported type "${structure.type}"`
282 )
283 }
284
285 addPropertyToDeclaration(
286 paramsDeclaration,
287 structure as TS.PropertyDeclarationStructure,
288 paramComments[name]
289 )
290 builder.definition.params.order.push(name)
291 }
292
293 return paramsDeclaration
294}
295
296function addReturnTypeDeclaration(builder: FTS.DefinitionBuilder) {
297 const mainReturnType = builder.main.getReturnType()
298 let type = mainReturnType.getText()
299
300 const promiseReMatch = type.match(promiseTypeRe)
301 const isAsync = !!promiseReMatch
302
303 builder.definition.returns.async = builder.main.isAsync()
304
305 if (isAsync) {
306 type = promiseReMatch[1]
307 builder.definition.returns.async = true
308 }
309
310 if (type === 'void') {
311 type = 'any'
312 }
313
314 if (
315 type.endsWith('HttpResponse') &&
316 (isAsync || mainReturnType.isInterface())
317 ) {
318 builder.definition.returns.http = true
319 }
320
321 const declaration = builder.sourceFile.addInterface({
322 name: FTSReturns
323 })
324
325 const jsdoc =
326 builder.docs &&
327 builder.docs.tags.find(
328 (tag) => tag.title === 'returns' || tag.title === 'return'
329 )
330 addPropertyToDeclaration(
331 declaration,
332 { name: 'result', type },
333 jsdoc && jsdoc.description
334 )
335}
336
337function addPropertyToDeclaration(
338 declaration: TS.ClassDeclaration | TS.InterfaceDeclaration,
339 structure: TS.PropertyDeclarationStructure,
340 jsdoc?: string
341): TS.PropertyDeclaration | TS.PropertySignature {
342 const isDate = structure.type === 'Date'
343 const isBuffer = structure.type === 'Buffer'
344
345 // Type coercion for non-JSON primitives like Date and Buffer
346 if (isDate || isBuffer) {
347 const coercionType = structure.type
348
349 if (isDate) {
350 structure.type = 'Date'
351 } else {
352 structure.type = 'string'
353 }
354
355 jsdoc = `${jsdoc ? jsdoc + '\n' : ''}@coerceTo ${coercionType}`
356 }
357
358 const property = declaration.addProperty(structure)
359
360 if (jsdoc) {
361 property.addJsDoc(jsdoc)
362 }
363
364 return property
365}
366
367function extractJSONSchemas(
368 builder: FTS.DefinitionBuilder,
369 file: string,
370 jsonSchemaOptions: TJS.PartialArgs = {},
371 jsonCompilerOptions: any = {}
372) {
373 const compilerOptions = {
374 allowJs: true,
375 lib: ['es2018', 'dom'],
376 target: 'es5',
377 ...jsonCompilerOptions
378 }
379
380 const program = TJS.getProgramFromFiles(
381 [file],
382 compilerOptions,
383 process.cwd()
384 )
385
386 builder.definition.params.schema = TJS.generateSchema(
387 program,
388 FTSParams,
389 jsonSchemaOptions
390 )
391
392 if (!builder.definition.params.schema) {
393 throw new Error(`Error generating params JSON schema for TS file "${file}"`)
394 }
395
396 builder.definition.returns.schema = TJS.generateSchema(program, FTSReturns, {
397 ...jsonSchemaOptions,
398 required: false
399 })
400
401 if (!builder.definition.returns.schema) {
402 throw new Error(
403 `Error generating returns JSON schema for TS file "${file}"`
404 )
405 }
406}
407
408/*
409// useful for quick testing purposes
410if (!module.parent) {
411 generateDefinition('./fixtures/http-response.ts')
412 .then((definition) => {
413 console.log(JSON.stringify(definition, null, 2))
414 })
415 .catch((err) => {
416 console.error(err)
417 process.exit(1)
418 })
419}
420*/