@plugjs/tsgo 0.0.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/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { install } from '@plugjs/plug/pipe'
2
+
3
+ import { TypeScript } from './typescript.ts'
4
+
5
+ /** Options for the {@link Pipe.typescript TypeScript} plug. */
6
+ export interface TypeScriptOptions {
7
+ /** Whether referenced projects are built too. Defaults to `false`. */
8
+ projectReferences?: boolean
9
+
10
+ /**
11
+ * Controls how deprecation diagnostics are reported. Use `'notice'` or
12
+ * `true` for notices, `'warn'` for warnings, and `'off'` or `false` to
13
+ * silence them. Defaults to `'notice'`.
14
+ */
15
+ reportDeprecations?: 'warn' | 'notice' | 'off' | boolean
16
+
17
+ /**
18
+ * Controls which files TypeScript emits. Use `'all'` or `true` for
19
+ * JavaScript and declaration files, `'js'` for JavaScript files, `'dts'`
20
+ * for declaration files, and `'none'` or `false` to type-check without
21
+ * emitting files. Defaults to `'all'`.
22
+ */
23
+ emitOnly?: 'js' | 'dts' | 'all' | 'none' | boolean
24
+ }
25
+
26
+ /** Options for the {@link tsc} and {@link tsbuild} helpers. */
27
+ export interface TscOptions extends Omit<TypeScriptOptions, 'projectReferences'> {
28
+ /** Base directory used to resolve the configuration path. */
29
+ directory?: string
30
+ }
31
+
32
+ declare module '@plugjs/plug' {
33
+ export interface Pipe {
34
+ /**
35
+ * Run the {@link https://www.typescriptlang.org/ TypeScript Compiler}
36
+ * version 7 (native) over the `tsconfig.json` configuration files
37
+ * given as input to this {@link Pipe} instance.
38
+ */
39
+ typescript(): Pipe
40
+
41
+ /**
42
+ * Run the {@link https://www.typescriptlang.org/ TypeScript Compiler}
43
+ * version 7 (native) over the `tsconfig.json` configuration files
44
+ * given as input to this {@link Pipe} instance.
45
+ *
46
+ * @param options {@link TypeScriptOptions} to use for the build.
47
+ */
48
+ typescript(options: TypeScriptOptions): Pipe
49
+ }
50
+ }
51
+
52
+ install('typescript', TypeScript)
53
+
54
+ export { tsbuild, tsc } from './typescript.ts'
@@ -0,0 +1,271 @@
1
+ import { assert } from '@plugjs/plug/asserts'
2
+ import { $gry, $p, $ylw, ERROR } from '@plugjs/plug/logging'
3
+ import { assertAbsolutePath, getAbsoluteParent, resolveFile } from '@plugjs/plug/paths'
4
+
5
+ import { convertProjectDiagnostics } from './diagnostics.ts'
6
+
7
+ import type { ReportRecord } from '@plugjs/plug/logging'
8
+ import type { AbsolutePath } from '@plugjs/plug/paths'
9
+ import type { API, ParsedCommandLine } from 'typescript/unstable/async'
10
+
11
+ /** Interface describing the actual (resolved) config file and its references */
12
+ interface ProjectReferences {
13
+ /** The actual path of the configuration file containing the references */
14
+ path: AbsolutePath
15
+ /** The actual configuration parsed from the configuration file */
16
+ config: ParsedCommandLine
17
+ /** The actual paths of all configuration files referenced by this project */
18
+ references: AbsolutePath[]
19
+ /** Any errors encountered while reading the configuration file */
20
+ errors: ReportRecord[]
21
+ }
22
+
23
+ /** Interface describing a group of cross-referencing projects */
24
+ interface Projects {
25
+ /** All projects to build, each associated with its referencing projects */
26
+ projects: Map<AbsolutePath, Set<AbsolutePath>>
27
+ /** Any errors encountered while reading the configuration files */
28
+ errors: ReportRecord[]
29
+ }
30
+
31
+ /** Interface describing the order of projects, including any cycles and unresolved projects */
32
+ interface ProjectOrder {
33
+ /** The order in which projects should be built */
34
+ order: AbsolutePath[]
35
+ /** Array of project reference cycles */
36
+ cycles: AbsolutePath[][]
37
+ /** Array containing all projects that could not be ordered because of cyclical dependencies */
38
+ unresolved: AbsolutePath[]
39
+ /** Any errors encountered while resolving the project order */
40
+ errors: ReportRecord[]
41
+ }
42
+
43
+ /* ========================================================================== */
44
+
45
+ /**
46
+ * Find the TypeScript project configuration file for a given path.
47
+ *
48
+ * The path specified can be a `tsconfig.json` file name or a directory
49
+ * containing a `tsconfig.json` file.
50
+ *
51
+ * The returned path is the absolute path of the actual `tsconfig.json` file.
52
+ */
53
+ export function findProjectConfig(path: AbsolutePath): AbsolutePath {
54
+ // The specified path can be either a file or a directory containing a
55
+ // "tsconfig.json" file: resolve as a file, then assume it's a directory
56
+ let file = resolveFile(path)
57
+ if (!file) file = resolveFile(path, 'tsconfig.json')
58
+ assert(file, `TypeScript configuration file not found in "${path}"`)
59
+ return file
60
+ }
61
+
62
+ /**
63
+ * Read the TypeScript project configuration file for a given path.
64
+ *
65
+ * The path specified can be a `tsconfig.json` file name or a directory
66
+ * containing a `tsconfig.json` file.
67
+ *
68
+ * The returned object contains the resolved configuration file name (in `path`)
69
+ * and the array of resolved project references (the actual resolved files on
70
+ * disk in `references`).
71
+ *
72
+ * Any errors encountered while reading the configuration file or resolving its
73
+ * references are returned in the `errors` array.
74
+ */
75
+ export async function readProjectConfig(api: API, path: AbsolutePath): Promise<ProjectReferences> {
76
+ const file = findProjectConfig(path)
77
+
78
+ // Use the TypeScript API to load up this project... We already update the
79
+ // snapshot here, so we don't need to do it again once we end up compiling
80
+ // the whole project...
81
+ const snapshot = await api.updateSnapshot({ openProjects: [file] })
82
+ const project = snapshot.getProject(file)
83
+ assert(project, `Failed to open TypeScript project from "${file}"`)
84
+ assertAbsolutePath(project.configFileName)
85
+ path = project.configFileName // rewrite the path to the actual
86
+
87
+ // Get any diagnostics encountered while parsing the configuration file
88
+ // and any "global" diagnostics (not associated with a specific file)...
89
+ const diagnostics = await Promise.all([
90
+ project.program.getGlobalDiagnostics(),
91
+ project.program.getProgramDiagnostics(),
92
+ project.program.getConfigFileParsingDiagnostics(),
93
+ ]).then((results) => results.flat())
94
+
95
+ // Convert the diagnostics to our simplified format
96
+ const errors = await convertProjectDiagnostics(diagnostics, project.program, path)
97
+
98
+ // Prepare our simplified result object
99
+ const config = project.parsedCommandLine
100
+ const result: ProjectReferences = { path, errors, config, references: [] }
101
+
102
+ // Resolve the references to absolute paths
103
+ const directory = getAbsoluteParent(path)
104
+ for (const reference of config.projectReferences ?? []) {
105
+ let resolved = resolveFile(directory, reference.path)
106
+ if (!resolved) resolved = resolveFile(directory, reference.path, 'tsconfig.json')
107
+ if (resolved) result.references.push(resolved)
108
+ }
109
+
110
+ // All done
111
+ return result
112
+ }
113
+
114
+ /**
115
+ * Find _all_ project references given a set of initial project paths.
116
+ *
117
+ * The returned map contains all the projects (resolved configuration file
118
+ * names) and their referring projects as values.
119
+ */
120
+ export async function findProjectReferences(api: API, ...paths: AbsolutePath[]): Promise<Projects> {
121
+ // The set of *all* projects, whether we have resolved them yet or not.
122
+ const remainingProjects = new Set<AbsolutePath>(paths)
123
+ // The set of all *resolved* projects (that is, opened and parsed)
124
+ const resolvedProjects = new Set<AbsolutePath>()
125
+
126
+ // The map of all our projects, keyed by their resolved configuration file
127
+ // name and having the set of projects that refer to it as a value
128
+ const projects = new Map<AbsolutePath, Set<AbsolutePath>>()
129
+
130
+ // An array of all errors encountered while reading the configuration files
131
+ const errors: ReportRecord[] = []
132
+
133
+ // Add a referring project to the set of referring projects for a referred project
134
+ function addProject(referredProject: AbsolutePath, referringProject?: AbsolutePath): void {
135
+ let referringProjects = projects.get(referredProject)
136
+ if (!referringProjects) {
137
+ referringProjects = new Set()
138
+ projects.set(referredProject, referringProjects)
139
+ }
140
+ if (referringProject) referringProjects.add(referringProject)
141
+ }
142
+
143
+ while (remainingProjects.size > 0) {
144
+ // Take and remove the next project from the remaining set
145
+ const project = remainingProjects.values().next().value!
146
+ remainingProjects.delete(project)
147
+
148
+ // Read the project configuration file and its references
149
+ const { path, references, errors: e } = await readProjectConfig(api, project)
150
+ // If we already resolved this project, skip it
151
+ if (resolvedProjects.has(path)) continue
152
+ // Push any errors encountered while reading the configuration file
153
+ errors.push(...e)
154
+ // Add the project (resolved filename) to the resolved set
155
+ resolvedProjects.add(path)
156
+ // Record the project and its references in our map of projects
157
+ addProject(path)
158
+
159
+ // Look at all the project references
160
+ for (const reference of references) {
161
+ // If we haven't resolved this reference yet, add it to the remaining
162
+ if (!resolvedProjects.has(reference)) remainingProjects.add(reference)
163
+ // Then make sure that we record the referring project for this reference
164
+ addProject(reference, path)
165
+ }
166
+ }
167
+
168
+ // Return our projects map and all errors
169
+ return { projects, errors }
170
+ }
171
+
172
+ /**
173
+ * Use Kahn's topological-sort algorithm on a map of projects and their
174
+ * references to determine the order in which they should be built.
175
+ *
176
+ * @param projects A map of projects and their referring projects
177
+ * @returns The order of projects, any unresolved projects, and cycles found
178
+ */
179
+ export function resolveProjectOrder(projects: ReadonlyMap<AbsolutePath, Set<AbsolutePath>>): ProjectOrder {
180
+ // The number of unresolved dependencies for each project
181
+ const counts: Record<AbsolutePath, number> = Object.create(null)
182
+
183
+ // Iterate through all the projects (and their referring projects), counting
184
+ // the number of unresolved dependencies for each project.
185
+ for (const [referred, referring] of projects.entries()) {
186
+ // Ensure the referred project has a dependencies count (even if it is zero)
187
+ counts[referred] = counts[referred] ?? 0
188
+ // For each referring project, increment its count of unresolved dependencies
189
+ for (const ref of referring) counts[ref] = (counts[ref] ?? 0) + 1
190
+ }
191
+
192
+ // The unique set of all known projects (referred and referring)
193
+ const uniques = Object.keys(counts) as AbsolutePath[]
194
+
195
+ // Projects are "ready" when all dependencies have been resolved (zero count)
196
+ const ready = uniques.filter((project) => counts[project] === 0)
197
+
198
+ // The final order of projects (in dependency order)
199
+ const order: AbsolutePath[] = []
200
+
201
+ // While there are still projects ready to be processed, take the first one
202
+ for (let project = ready.shift(); project; project = ready.shift()) {
203
+ // Add the project to the final order
204
+ order.push(project)
205
+
206
+ // For each project that depends on this project, decrement its unresolved
207
+ // dependency count. If it reaches zero, add it to the ready queue.
208
+ for (const dependent of projects.get(project) ?? /* coverage ignore next */ []) {
209
+ const remaining = (counts[dependent] = counts[dependent]! - 1)
210
+ if (remaining === 0) ready.push(dependent)
211
+ }
212
+ }
213
+
214
+ // When no more projects are "ready", the remaining projects are unresolved
215
+ const unresolved = uniques.filter((project) => counts[project])
216
+
217
+ // If we have no unresolved projects, we can return the order (no cycles)
218
+ if (unresolved.length === 0) return { order, unresolved, cycles: [], errors: [] }
219
+
220
+ // ===== FIND CYCLES =========================================================
221
+
222
+ // Projects we _already_ visited (and thus don't need to visit again)
223
+ const visited = new Set<AbsolutePath>()
224
+
225
+ // The list of cycles we have found (each cycle is a list of projects)
226
+ const cycles: AbsolutePath[][] = []
227
+
228
+ // Recursively visit a project's dependents, looking for cycles
229
+ function visit(project: AbsolutePath, stack: AbsolutePath[] = []): void {
230
+ // If this project is already in the current path, we found a cycle
231
+ const index = stack.indexOf(project)
232
+ if (index >= 0) {
233
+ cycles.push([...stack.slice(index), project])
234
+ return
235
+ }
236
+
237
+ // Don't revisit projects whose dependents have all been processed
238
+ if (visited.has(project)) return
239
+
240
+ // Add this project to the current path and visit its dependents
241
+ const next = [...stack, project]
242
+ for (const dependent of projects.get(project) ?? /* coverage ignore next */ []) {
243
+ if (unresolved.includes(dependent)) visit(dependent, next)
244
+ }
245
+
246
+ // Mark the project as visited after all its dependents have been processed
247
+ visited.add(project)
248
+ }
249
+
250
+ // Visit all the unresolved projects, looking for cycles
251
+ for (const project of unresolved) visit(project)
252
+
253
+ // Prep our errors
254
+ const errors: ReportRecord[] = []
255
+ errors.push({
256
+ level: ERROR,
257
+ message:
258
+ `Found ${$ylw(unresolved.length)} unresolved projects (${$ylw(cycles.length)} cycles)` +
259
+ `\n${$gry('-')} ${unresolved.map($p).join(`\n${$gry('-')} `)}`,
260
+ })
261
+
262
+ for (const cycle of cycles) {
263
+ errors.push({
264
+ level: ERROR,
265
+ message: `Project reference cycle:\n${$gry('-')} ${cycle.map($p).join(`\n${$gry('-')} `)}`,
266
+ })
267
+ }
268
+
269
+ // Return the final order, unresolved projects, and cycles found
270
+ return { order, unresolved, cycles, errors }
271
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ // Inherit options from the root configuration file
3
+ "extends": "../tsconfig.json",
4
+ // Make sure we actually emit the transpiled files from here
5
+ "compilerOptions": {
6
+ "rootDir": ".",
7
+ "noEmit": false,
8
+ "declaration": true,
9
+ "sourceMap": true,
10
+ "declarationMap": true,
11
+ "outDir": "../dist"
12
+ },
13
+ // Only include what's in the sources here
14
+ "include": ["."]
15
+ }
@@ -0,0 +1,202 @@
1
+ import { using } from '@plugjs/plug'
2
+ import { assert } from '@plugjs/plug/asserts'
3
+ import { Files } from '@plugjs/plug/files'
4
+ import { ERROR } from '@plugjs/plug/logging'
5
+ import { commonPath, getAbsoluteParent, resolveAbsolutePath } from '@plugjs/plug/paths'
6
+ import { API, EmitOnly } from 'typescript/unstable/async'
7
+
8
+ import { convertDiagnostics } from './diagnostics.ts'
9
+ import { findProjectConfig, findProjectReferences, readProjectConfig, resolveProjectOrder } from './projects.ts'
10
+
11
+ import type { TscOptions, TypeScriptOptions } from './index.ts'
12
+ import type { Pipe } from '@plugjs/plug'
13
+ import type { AbsolutePath } from '@plugjs/plug/paths'
14
+ import type { Context, Plug } from '@plugjs/plug/pipe'
15
+ import type { Snapshot } from 'typescript/unstable/async'
16
+
17
+ /* ========================================================================== *
18
+ * TYPESCRIPT 7 PLUG IMPLEMENTATION *
19
+ * ========================================================================== */
20
+
21
+ export class TypeScript implements Plug<Files> {
22
+ private _emitOnly: EmitOnly | null
23
+ private _reportDeprecations: 'warn' | 'notice' | 'off'
24
+ private _projectReferences: boolean
25
+
26
+ constructor(options: TypeScriptOptions = {}) {
27
+ const { projectReferences, reportDeprecations, emitOnly } = options
28
+ this._projectReferences = !!projectReferences
29
+
30
+ switch (emitOnly) {
31
+ // If *none* or *false* we don't emit any files at all
32
+ case 'none':
33
+ case false:
34
+ this._emitOnly = null
35
+ break
36
+ // If *js* we only emit JavaScript files
37
+ case 'js':
38
+ this._emitOnly = EmitOnly.OnlyJs
39
+ break
40
+ // If *dts* we only emit declaration files
41
+ case 'dts':
42
+ this._emitOnly = EmitOnly.OnlyDts
43
+ break
44
+ // If *all* or *true* (default) we emit all files
45
+ case 'all':
46
+ case true:
47
+ default:
48
+ this._emitOnly = EmitOnly.All
49
+ break
50
+ }
51
+
52
+ switch (reportDeprecations) {
53
+ // If *warn* we report deprecations as warnings
54
+ case 'warn':
55
+ this._reportDeprecations = 'warn'
56
+ break
57
+ // If *off* or *false* we don't report deprecations at all
58
+ case 'off':
59
+ case false:
60
+ this._reportDeprecations = 'off'
61
+ break
62
+ // If *notice* or *true* (default) we report deprecations as notices
63
+ case 'notice':
64
+ case true:
65
+ default:
66
+ this._reportDeprecations = 'notice'
67
+ break
68
+ }
69
+ }
70
+
71
+ async pipe(files: Files, context: Context): Promise<Files> {
72
+ // Create our TypeScript API and report for this plug
73
+ const api = new API({ cwd: files.directory })
74
+ const report = context.log.report('TypeScript Report')
75
+
76
+ // This is the list of emitted files to return to the pipe...
77
+ const emittedFiles: AbsolutePath[] = []
78
+
79
+ try {
80
+ // The list of projects to build (in order) and the snapshot used
81
+ // to build them.
82
+ let projects: AbsolutePath[]
83
+ let snapshot: Snapshot
84
+
85
+ // First of all figure out whether we have to build project references
86
+ // too or not... This will determine whether we have to resolve them....
87
+ if (this._projectReferences) {
88
+ // Find all project references for the given configuration files
89
+ const projectReferences = await findProjectReferences(api, ...files.absolutePaths())
90
+ report.add(...projectReferences.errors)
91
+ if (report.errors) report.done()
92
+
93
+ // Then determine the build order for all projects and their references
94
+ const projectOrder = resolveProjectOrder(projectReferences.projects)
95
+ report.add(...projectOrder.errors)
96
+ if (report.errors) report.done()
97
+
98
+ // At this point, our list of projects to build is the "order"
99
+ snapshot = await api.updateSnapshot({ openProjects: projectOrder.order })
100
+ projects = [...projectOrder.order]
101
+ } else {
102
+ // If we're not building references, then we just build our files
103
+ const configs = [...files.absolutePaths()].map(findProjectConfig)
104
+ snapshot = await api.updateSnapshot({ openProjects: configs })
105
+ projects = [...configs]
106
+
107
+ // When resolving the project references above, we already processed
108
+ // any configuration files and added any errors to the report...
109
+ // In this case, we have to do it ourselves by reading each config!
110
+ for (const config of configs) {
111
+ const { errors } = await readProjectConfig(api, config)
112
+ report.add(...errors)
113
+ }
114
+
115
+ // If we have any errors at this point, we can't continue...
116
+ if (report.errors) report.done()
117
+ }
118
+
119
+ // Now build all projects in the order we determined earlier
120
+ for (const project of projects) {
121
+ // Get the actual TypeScript program for this project...
122
+ const program = snapshot.getProject(project)?.program
123
+ assert(program, `Project "${project}" not found in snapshot`)
124
+
125
+ // Collect compilation diagnostics for this project (has files)
126
+ const diagnostics = await Promise.all([
127
+ program.getBindDiagnostics(),
128
+ program.getSemanticDiagnostics(),
129
+ program.getSyntacticDiagnostics(),
130
+ program.getSuggestionDiagnostics(),
131
+ program.getDeclarationDiagnostics(),
132
+ ]).then((results) => results.flat())
133
+
134
+ // When emitting, let's emit the files
135
+ if (this._emitOnly !== null) {
136
+ // Emit the files for this program and collect diagnostics
137
+ const emitted = await program.emit(this._emitOnly)
138
+ diagnostics.push(...emitted.diagnostics)
139
+
140
+ // Make sure that we add any emitted file to the list to be returned
141
+ if (emitted.emittedFiles?.length) {
142
+ const directory = getAbsoluteParent(project)
143
+ const mapper = resolveAbsolutePath.bind(null, directory)
144
+ emittedFiles.push(...emitted.emittedFiles.map((file) => mapper(file)))
145
+ }
146
+
147
+ // Report an error if emitting was skipped for some reason (we should
148
+ // have some diagnostics associated with this, but still...)
149
+ // coverage ignore if // don't know how to test this...
150
+ if (emitted.emitSkipped) {
151
+ report.add({
152
+ level: ERROR,
153
+ message: `Unable to emit files for this project`,
154
+ file: project,
155
+ })
156
+ }
157
+ }
158
+
159
+ // Add all diagnostics to the report
160
+ report.add(...(await convertDiagnostics(diagnostics, program, this._reportDeprecations)))
161
+ }
162
+ } finally {
163
+ await api.close()
164
+ }
165
+
166
+ report.done()
167
+
168
+ const outputPath = commonPath(files.directory, ...emittedFiles)
169
+ return Files.builder(outputPath)
170
+ .add(...emittedFiles)
171
+ .build()
172
+ }
173
+ }
174
+
175
+ /* ========================================================================== *
176
+ * TYPESCRIPT 7 HELPERS *
177
+ * ========================================================================== */
178
+
179
+ /**
180
+ * Compile the project identified by the specified configuration file
181
+ * (either a `tsconfig.json` or a directory containing one).
182
+ *
183
+ * This is roughly equivalent to the command line `tsc --project <config>`.
184
+ */
185
+ export function tsc(config: string, options: TscOptions = {}): Pipe {
186
+ const { directory, ...opts } = options
187
+ return using(config, { directory }).plug(new TypeScript({ ...opts, projectReferences: false }))
188
+ }
189
+
190
+ /* ========================================================================== */
191
+
192
+ /**
193
+ * Compile the project identified by the specified configuration file
194
+ * (either a `tsconfig.json` or a directory containing one) and all of its
195
+ * references.
196
+ *
197
+ * This is roughly equivalent to the command line `tsc --build <config>`.
198
+ */
199
+ export function tsbuild(config: string, options: TscOptions = {}): Pipe {
200
+ const { directory, ...opts } = options
201
+ return using(config, { directory }).plug(new TypeScript({ ...opts, projectReferences: true }))
202
+ }