ts-project.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. /**
  2. * Shared TypeScript Program construction for repository gates that need real
  3. * cross-file symbols and types instead of isolated syntax trees.
  4. */
  5. import { relative, resolve } from 'node:path'
  6. import ts from 'typescript'
  7. interface ProjectGraph {
  8. rootNames: string[]
  9. options: ts.CompilerOptions
  10. }
  11. const configHost: ts.ParseConfigFileHost = {
  12. useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
  13. readDirectory: (...args) => ts.sys.readDirectory(...args),
  14. fileExists: fileName => ts.sys.fileExists(fileName),
  15. readFile: fileName => ts.sys.readFile(fileName),
  16. getCurrentDirectory: () => ts.sys.getCurrentDirectory(),
  17. onUnRecoverableConfigFileDiagnostic(diagnostic) {
  18. throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
  19. },
  20. }
  21. /** Parse a root tsconfig and flatten all referenced projects into one semantic graph. */
  22. function loadProjectGraph(projectRoot: string): ProjectGraph {
  23. const rootConfigPath = resolve(projectRoot, 'tsconfig.json')
  24. const rootConfig = parseConfig(rootConfigPath)
  25. const rootNames = new Set<string>()
  26. const visited = new Set<string>()
  27. const collect = (configPath: string, parsed: ts.ParsedCommandLine): void => {
  28. if (visited.has(configPath)) return
  29. visited.add(configPath)
  30. for (const fileName of parsed.fileNames) rootNames.add(fileName)
  31. for (const reference of parsed.projectReferences ?? []) {
  32. const referencePath = ts.resolveProjectReferencePath(reference)
  33. collect(referencePath, parseConfig(referencePath))
  34. }
  35. }
  36. collect(rootConfigPath, rootConfig)
  37. return {
  38. rootNames: [...rootNames],
  39. options: rootConfig.options,
  40. }
  41. }
  42. /** Parse one config file and fail loud on any config diagnostic. */
  43. function parseConfig(configPath: string): ts.ParsedCommandLine {
  44. const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost)
  45. if (!parsed) throw new Error(`cannot parse TypeScript config ${configPath}`)
  46. if (parsed.errors.length > 0) {
  47. throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'))
  48. }
  49. return parsed
  50. }
  51. /** Disable emit-only options after loading the root solution config. */
  52. function semanticCompilerOptions(options: ts.CompilerOptions): ts.CompilerOptions {
  53. return {
  54. ...options,
  55. noEmit: true,
  56. composite: false,
  57. declaration: false,
  58. declarationMap: false,
  59. sourceMap: false,
  60. incremental: false,
  61. }
  62. }
  63. /** A repository-scoped TypeScript Program and its shared TypeChecker. */
  64. export class TypeScriptProject {
  65. /** The bound cross-file TypeScript program. */
  66. readonly program: ts.Program
  67. /** The checker shared by every semantic query in this project. */
  68. readonly checker: ts.TypeChecker
  69. constructor(private readonly projectRoot: string) {
  70. const graph = loadProjectGraph(projectRoot)
  71. this.program = ts.createProgram(graph.rootNames, semanticCompilerOptions(graph.options))
  72. this.checker = this.program.getTypeChecker()
  73. }
  74. /**
  75. * Return every source file loaded into the flattened root project graph.
  76. * @returns program source files, including libraries and external dependencies.
  77. */
  78. sourceFiles(): readonly ts.SourceFile[] {
  79. return this.program.getSourceFiles()
  80. }
  81. /**
  82. * Render a loaded source file relative to the project root.
  83. * @param sourceFile - a source file from this project.
  84. * @returns a slash-separated repository-relative path.
  85. */
  86. relativePath(sourceFile: ts.SourceFile): string {
  87. return relative(this.projectRoot, sourceFile.fileName).replaceAll('\\', '/')
  88. }
  89. /**
  90. * Return one program source file by repository-relative path.
  91. * @param relativePath - path relative to the project root.
  92. * @returns the source file bound into this project.
  93. * @throws if a requested root or imported source was not loaded.
  94. */
  95. sourceFile(relativePath: string): ts.SourceFile {
  96. const sourceFile = this.program.getSourceFile(resolve(this.projectRoot, relativePath))
  97. if (!sourceFile) throw new Error(`TypeScript project did not load ${relativePath}`)
  98. return sourceFile
  99. }
  100. }