kea-typegen 3.6.6 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.ts CHANGED
@@ -82,6 +82,8 @@ export interface AppOptions {
82
82
  showTsErrors?: boolean
83
83
  /** Cache generated logic files into .typegen, use them if generating a logic type for the first time */
84
84
  useCache?: boolean
85
+ /** Skip Prettier formatting while generating logic type files */
86
+ prettier?: boolean
85
87
 
86
88
  log: (message: string) => void
87
89
  }
@@ -25,7 +25,7 @@ import { visitEvents } from './visitEvents'
25
25
  import { visitDefaults } from './visitDefaults'
26
26
  import { visitSharedListeners } from './visitSharedListeners'
27
27
  import { cloneNode } from 'ts-clone-node'
28
- import {NodeBuilderFlags} from "typescript";
28
+ import { NodeBuilderFlags } from 'typescript'
29
29
 
30
30
  const visitFunctions = {
31
31
  actions: visitActions,
@@ -44,20 +44,52 @@ const visitFunctions = {
44
44
  windowValues: visitWindowValues,
45
45
  }
46
46
 
47
+ // Cheap text prefilters so we only do expensive AST + type-checker work on files
48
+ // that can actually contain the thing we're looking for. On large projects most
49
+ // source files contain no Kea logic at all, and walking every node (and resolving
50
+ // a symbol for every identifier via the checker) for them dominates runtime.
51
+ //
52
+ // `kea` is never aliased on import in practice, so a `kea(` / `kea<` text match is a
53
+ // safe-enough gate for the Kea-call pass. If a project does alias `kea`, set
54
+ // KEA_TYPEGEN_NO_PREFILTER=1 to fall back to scanning every file.
55
+ const KEA_CALL_REGEX = /\bkea\s*[<(]/
56
+ const prefilterDisabled = process.env.KEA_TYPEGEN_NO_PREFILTER === '1'
57
+
58
+ function fileMightContainKeaCall(sourceFile: ts.SourceFile): boolean {
59
+ return prefilterDisabled || KEA_CALL_REGEX.test(sourceFile.text)
60
+ }
61
+
62
+ function fileMightContainResetContext(sourceFile: ts.SourceFile): boolean {
63
+ return prefilterDisabled || sourceFile.text.includes('resetContext')
64
+ }
65
+
47
66
  export function visitProgram(program: ts.Program, appOptions?: AppOptions): ParsedLogic[] {
48
67
  const checker = program.getTypeChecker()
49
68
  const parsedLogics: ParsedLogic[] = []
50
69
  const pluginModules: PluginModule[] = []
51
70
  const typeBuilderModules: TypeBuilderModule[] = []
71
+ const sourceFilePath = appOptions?.sourceFilePath ? path.resolve(appOptions.sourceFilePath) : undefined
52
72
 
53
73
  for (const sourceFile of program.getSourceFiles()) {
54
- if (!sourceFile.isDeclarationFile && !sourceFile.fileName.endsWith('Type.ts')) {
74
+ if (
75
+ !sourceFile.isDeclarationFile &&
76
+ !sourceFile.fileName.endsWith('Type.ts') &&
77
+ fileMightContainResetContext(sourceFile)
78
+ ) {
55
79
  ts.forEachChild(sourceFile, visitResetContext(checker, pluginModules))
56
80
  }
57
81
  }
58
82
 
59
83
  for (const sourceFile of program.getSourceFiles()) {
60
- if (!sourceFile.isDeclarationFile && !sourceFile.fileName.endsWith('Type.ts')) {
84
+ if (sourceFilePath && path.resolve(sourceFile.fileName) !== sourceFilePath) {
85
+ continue
86
+ }
87
+
88
+ if (
89
+ !sourceFile.isDeclarationFile &&
90
+ !sourceFile.fileName.endsWith('Type.ts') &&
91
+ fileMightContainKeaCall(sourceFile)
92
+ ) {
61
93
  if (appOptions?.verbose) {
62
94
  appOptions.log(`👀 Visiting: ${path.relative(process.cwd(), sourceFile.fileName)}`)
63
95
  }
@@ -245,7 +277,6 @@ export function visitKeaCalls(
245
277
  const calls: {
246
278
  name: string
247
279
  type: ts.Type
248
- typeNode: ts.TypeNode | null
249
280
  expression: ts.Expression
250
281
  typeBuilders: PluginModule[]
251
282
  }[] = []
@@ -261,8 +292,7 @@ export function visitKeaCalls(
261
292
  }
262
293
  const name = symbol.getName()
263
294
  const type = checker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration)
264
- const typeNode = type ? checker.typeToTypeNode(type, undefined, NodeBuilderFlags.NoTruncation) : null
265
- calls.push({ name, type, typeNode, expression: inputProperty.initializer, typeBuilders: [] })
295
+ calls.push({ name, type, expression: inputProperty.initializer, typeBuilders: [] })
266
296
  }
267
297
  } else if (ts.isArrayLiteralExpression(input)) {
268
298
  for (const callExpression of input.elements) {
@@ -273,7 +303,6 @@ export function visitKeaCalls(
273
303
  const builderName = callExpression.expression.getText()
274
304
  const argument = callExpression.arguments[0]
275
305
  const type = checker.getTypeAtLocation(argument)
276
- const typeNode = type ? checker.typeToTypeNode(type, undefined, NodeBuilderFlags.NoTruncation) : null
277
306
 
278
307
  const identifier = callExpression.expression
279
308
  const symbol = checker.getSymbolAtLocation(identifier)
@@ -348,14 +377,13 @@ export function visitKeaCalls(
348
377
  calls.push({
349
378
  name: builderName,
350
379
  type,
351
- typeNode,
352
380
  expression: callExpression.arguments[0],
353
381
  typeBuilders: typeBuilders,
354
382
  })
355
383
  }
356
384
  }
357
385
 
358
- for (let { name, type, typeNode, expression, typeBuilders } of calls) {
386
+ for (let { name, type, expression, typeBuilders } of calls) {
359
387
  if (name === 'path' || name === 'logicPath') {
360
388
  parsedLogic.hasPathInLogic = true
361
389
  }
@@ -363,18 +391,31 @@ export function visitKeaCalls(
363
391
  parsedLogic.hasKeyInLogic = true
364
392
  }
365
393
 
366
- if (typeNode && ts.isFunctionTypeNode(typeNode)) {
367
- type = type.getCallSignatures()[0].getReturnType()
368
- typeNode = type ? checker.typeToTypeNode(type, undefined, NodeBuilderFlags.NoTruncation) : null
394
+ // Equivalent to the previous `ts.isFunctionTypeNode(typeNode)` check, but without
395
+ // eagerly building a typeNode: a pure function type has call signatures and no
396
+ // members, so we unwrap it to its return type before visiting/printing.
397
+ const callSignatures = type.getCallSignatures()
398
+ if (callSignatures.length > 0 && type.getProperties().length === 0) {
399
+ type = callSignatures[0].getReturnType()
369
400
  }
370
401
 
371
402
  visitFunctions[name]?.(parsedLogic, type, expression)
372
403
 
404
+ let typeNode: ts.TypeNode | null | undefined = undefined
405
+ const getTypeNode = (): ts.TypeNode | null => {
406
+ if (typeNode === undefined) {
407
+ typeNode = type ? checker.typeToTypeNode(type, undefined, NodeBuilderFlags.NoTruncation) : null
408
+ }
409
+ return typeNode
410
+ }
411
+
373
412
  const visitKeaPropertyArguments: VisitKeaPropertyArguments = {
374
413
  name,
375
414
  appOptions,
376
415
  type,
377
- typeNode,
416
+ get typeNode() {
417
+ return getTypeNode()
418
+ },
378
419
  parsedLogic,
379
420
  node: expression,
380
421
  checker,
@@ -1,10 +1,41 @@
1
1
  import { AppOptions, ParsedLogic } from '../types'
2
2
  import * as ts from 'typescript'
3
- import { print, visit } from 'recast'
4
3
  import * as osPath from 'path'
5
4
  import { runThroughPrettier } from '../print/print'
6
5
  import * as fs from 'fs'
7
- import { t, b, visitAllKeaCalls, getAst } from './utils'
6
+ interface TextEdit {
7
+ start: number
8
+ end: number
9
+ text: string
10
+ }
11
+
12
+ function applyTextEdits(source: string, edits: TextEdit[]): string {
13
+ return edits
14
+ .sort((a, b) => b.start - a.start || b.end - a.end)
15
+ .reduce((output, { start, end, text }) => output.slice(0, start) + text + output.slice(end), source)
16
+ }
17
+
18
+ function getImportPath(importDeclaration: ts.ImportDeclaration): string | null {
19
+ return ts.isStringLiteralLike(importDeclaration.moduleSpecifier) ? importDeclaration.moduleSpecifier.text : null
20
+ }
21
+
22
+ function getImportInsertPosition(sourceFile: ts.SourceFile, rawCode: string): number {
23
+ const importDeclarations = sourceFile.statements.filter(ts.isImportDeclaration)
24
+ if (importDeclarations.length > 0) {
25
+ return importDeclarations[importDeclarations.length - 1].getEnd()
26
+ }
27
+
28
+ const shebangMatch = rawCode.match(/^#!.*(?:\r?\n|$)/)
29
+ return shebangMatch ? shebangMatch[0].length : 0
30
+ }
31
+
32
+ function getTypeArgumentInsertEnd(callExpression: ts.CallExpression, sourceFile: ts.SourceFile): number {
33
+ const openParenToken = callExpression
34
+ .getChildren(sourceFile)
35
+ .find((child) => child.kind === ts.SyntaxKind.OpenParenToken)
36
+
37
+ return openParenToken ? openParenToken.getStart(sourceFile) : callExpression.expression.getEnd()
38
+ }
8
39
 
9
40
  export async function writeTypeImports(
10
41
  appOptions: AppOptions,
@@ -15,9 +46,10 @@ export async function writeTypeImports(
15
46
  ) {
16
47
  const { log } = appOptions
17
48
  const sourceFile = program.getSourceFile(filename)
18
- const rawCode = sourceFile.getText()
19
-
20
- const ast = getAst(filename, rawCode)
49
+ if (!sourceFile) {
50
+ throw new Error(`Could not find source file: ${filename}`)
51
+ }
52
+ const rawCode = fs.readFileSync(filename, 'utf8')
21
53
 
22
54
  let importLocation = osPath
23
55
  .relative(osPath.dirname(filename), logicsNeedingImports[0].typeFileName)
@@ -26,56 +58,57 @@ export async function writeTypeImports(
26
58
  importLocation = `./${importLocation}`
27
59
  }
28
60
 
29
- // add import if missing
30
- let foundImport = false
31
- visit(ast, {
32
- visitImportDeclaration(path) {
33
- const importPath =
34
- path.value.source && t.StringLiteral.check(path.value.source) ? path.value.source.value : null
35
-
36
- if (
37
- t.ImportDeclaration.check(path.value) &&
38
- importPath &&
39
- osPath.resolve(osPath.dirname(filename), importPath) ===
40
- osPath.resolve(osPath.dirname(filename), importLocation)
41
- ) {
42
- foundImport = true
43
- path.value.importKind = 'type'
44
- path.value.specifiers = parsedLogics.map((l) =>
45
- b.importSpecifier(b.identifier(l.logicTypeName), b.identifier(l.logicTypeName)),
46
- )
47
- }
48
- return false
49
- },
61
+ const desiredImport = `import type { ${parsedLogics.map((l) => l.logicTypeName).join(', ')} } from '${importLocation}'`
62
+ const importDeclarations = sourceFile.statements.filter(ts.isImportDeclaration)
63
+ const matchingImport = importDeclarations.find((importDeclaration) => {
64
+ const importPath = getImportPath(importDeclaration)
65
+ return (
66
+ importPath !== null &&
67
+ osPath.resolve(osPath.dirname(filename), importPath) ===
68
+ osPath.resolve(osPath.dirname(filename), importLocation)
69
+ )
50
70
  })
51
71
 
52
- if (!foundImport) {
53
- visit(ast, {
54
- visitProgram(path) {
55
- path.value.body = [
56
- ...path.value.body.filter((n) => t.ImportDeclaration.check(n)),
57
- b.importDeclaration(
58
- parsedLogics.map((l) =>
59
- b.importSpecifier(b.identifier(l.logicTypeName), b.identifier(l.logicTypeName)),
60
- ),
61
- b.stringLiteral(importLocation),
62
- 'type',
63
- ),
64
- ...path.value.body.filter((n) => !t.ImportDeclaration.check(n)),
65
- ]
66
- return false
67
- },
72
+ const edits: TextEdit[] = []
73
+ if (matchingImport) {
74
+ edits.push({
75
+ start: matchingImport.getStart(sourceFile),
76
+ end: matchingImport.getEnd(),
77
+ text: desiredImport,
78
+ })
79
+ } else {
80
+ const insertPos = getImportInsertPosition(sourceFile, rawCode)
81
+ const hasExistingImports = importDeclarations.length > 0
82
+ const importText = hasExistingImports
83
+ ? `\n${desiredImport}${rawCode.slice(insertPos, insertPos + 1) === '\n' ? '' : '\n'}`
84
+ : `${desiredImport}\n`
85
+
86
+ edits.push({
87
+ start: insertPos,
88
+ end: insertPos,
89
+ text: importText,
68
90
  })
69
91
  }
70
92
 
71
- // find all kea calls, add `<logicType>` type parameters if needed
72
- visitAllKeaCalls(ast, logicsNeedingImports, filename, ({ path, parsedLogic }) => {
73
- path.node.typeParameters = b.tsTypeParameterInstantiation([
74
- b.tsTypeReference(b.identifier(parsedLogic.logicTypeName)),
75
- ])
76
- })
93
+ for (const parsedLogic of logicsNeedingImports) {
94
+ const callExpression = parsedLogic.node.parent
95
+ if (!ts.isCallExpression(callExpression)) {
96
+ continue
97
+ }
98
+
99
+ const typeArgumentStart = callExpression.expression.getEnd()
100
+ const typeArgumentEnd = callExpression.typeArguments
101
+ ? getTypeArgumentInsertEnd(callExpression, sourceFile)
102
+ : typeArgumentStart
103
+
104
+ edits.push({
105
+ start: typeArgumentStart,
106
+ end: typeArgumentEnd,
107
+ text: `<${parsedLogic.logicTypeName}>`,
108
+ })
109
+ }
77
110
 
78
- const newText = await runThroughPrettier(print(ast).code, filename)
111
+ const newText = await runThroughPrettier(applyTextEdits(rawCode, edits), filename)
79
112
  fs.writeFileSync(filename, newText)
80
113
 
81
114
  log(`🔥 Import added: ${osPath.relative(process.cwd(), filename)}`)