ts-project.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  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. /**
  12. * A compiler face: the two aggregates a repository-wide program may seed from.
  13. * The root solution is never one of them.
  14. */
  15. export type CompilerFace = 'host' | 'client'
  16. /** TypeScript config host shared by repository scripts. */
  17. export const repositoryConfigHost: ts.ParseConfigFileHost = {
  18. useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
  19. readDirectory: (...args) => ts.sys.readDirectory(...args),
  20. fileExists: fileName => ts.sys.fileExists(fileName),
  21. readFile: fileName => ts.sys.readFile(fileName),
  22. getCurrentDirectory: () => ts.sys.getCurrentDirectory(),
  23. onUnRecoverableConfigFileDiagnostic(diagnostic) {
  24. throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
  25. },
  26. }
  27. /**
  28. * Parse one face aggregate tsconfig and flatten all referenced projects into one
  29. * semantic graph. Never seed the root solution: flattening host+client into one
  30. * program collides the cordis Context merges.
  31. */
  32. function loadProjectGraph(projectRoot: string, face: CompilerFace): ProjectGraph {
  33. const rootConfigPath = resolve(projectRoot, `tsconfig.${face}.json`)
  34. const rootConfig = parseConfig(rootConfigPath)
  35. const rootNames = new Set<string>()
  36. const visited = new Set<string>()
  37. const collect = (configPath: string, parsed: ts.ParsedCommandLine): void => {
  38. if (visited.has(configPath)) return
  39. visited.add(configPath)
  40. for (const fileName of parsed.fileNames) rootNames.add(fileName)
  41. for (const reference of parsed.projectReferences ?? []) {
  42. const referencePath = ts.resolveProjectReferencePath(reference)
  43. collect(referencePath, parseConfig(referencePath))
  44. }
  45. }
  46. collect(rootConfigPath, rootConfig)
  47. return {
  48. rootNames: [...rootNames],
  49. options: rootConfig.options,
  50. }
  51. }
  52. /** Parse one config file and fail loud on any config diagnostic. */
  53. function parseConfig(configPath: string): ts.ParsedCommandLine {
  54. const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, repositoryConfigHost)
  55. if (!parsed) throw new Error(`cannot parse TypeScript config ${configPath}`)
  56. if (parsed.errors.length > 0) {
  57. throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'))
  58. }
  59. return parsed
  60. }
  61. /** Disable emit-only options after loading the root solution config. */
  62. function semanticCompilerOptions(options: ts.CompilerOptions): ts.CompilerOptions {
  63. return {
  64. ...options,
  65. noEmit: true,
  66. composite: false,
  67. declaration: false,
  68. declarationMap: false,
  69. sourceMap: false,
  70. incremental: false,
  71. }
  72. }
  73. /** A repository-scoped TypeScript Program and its shared TypeChecker. */
  74. export class TypeScriptProject {
  75. /** The bound cross-file TypeScript program. */
  76. readonly program: ts.Program
  77. /** The checker shared by every semantic query in this project. */
  78. readonly checker: ts.TypeChecker
  79. /**
  80. * @param projectRoot - repository root the program is seeded and reported from.
  81. * @param face - which compiler face aggregate to flatten.
  82. */
  83. constructor(readonly projectRoot: string, face: CompilerFace = 'host') {
  84. const graph = loadProjectGraph(projectRoot, face)
  85. this.program = ts.createProgram(graph.rootNames, semanticCompilerOptions(graph.options))
  86. this.checker = this.program.getTypeChecker()
  87. }
  88. /**
  89. * Return every source file loaded into the flattened root project graph.
  90. * @returns program source files, including libraries and external dependencies.
  91. */
  92. sourceFiles(): readonly ts.SourceFile[] {
  93. return this.program.getSourceFiles()
  94. }
  95. /**
  96. * Render a loaded source file relative to the project root.
  97. * @param sourceFile - a source file from this project.
  98. * @returns a slash-separated repository-relative path.
  99. */
  100. relativePath(sourceFile: ts.SourceFile): string {
  101. return relative(this.projectRoot, sourceFile.fileName).replaceAll('\\', '/')
  102. }
  103. /**
  104. * Return one program source file by repository-relative path.
  105. * @param relativePath - path relative to the project root.
  106. * @returns the source file bound into this project.
  107. * @throws if a requested root or imported source was not loaded.
  108. */
  109. sourceFile(relativePath: string): ts.SourceFile {
  110. const sourceFile = this.program.getSourceFile(resolve(this.projectRoot, relativePath))
  111. if (!sourceFile) throw new Error(`TypeScript project did not load ${relativePath}`)
  112. return sourceFile
  113. }
  114. }