Skip to content

Commit 7f26494

Browse files
authored
fix: stop down-leveling files where only formatting has changed (#102)
Use a TypeScript-aware normalization on the declarations files to neutralize any formatting discrepancies that may exist between the output of `downlevel-dts` and the output of the raw TypeScript compiler. Also corrects the location of output files in case where `tsc.outDir` is configured (previously a spurious interim path was added). Finally, changes how the down-leveled declarations are created when necessary to avoid having to retain all their text in memory, as this can result in a very large amount of data being retained in working set. (Fixes #40) --- By submitting this pull request, I confirm that my contribution is made under the terms of the [Apache 2.0 license]. [Apache 2.0 license]: https://www.apache.org/licenses/LICENSE-2.0
1 parent db62ddd commit 7f26494

File tree

1 file changed

+60
-42
lines changed

1 file changed

+60
-42
lines changed

src/downlevel-dts.ts

+60-42
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
copyFileSync,
23
existsSync,
34
mkdirSync,
45
mkdtempSync,
@@ -48,7 +49,7 @@ export function emitDownleveledDeclarations({ packageJson, projectRoot, tsc }: P
4849
const compatRoot = join(projectRoot, ...(tsc?.outDir != null ? [tsc?.outDir] : []), TYPES_COMPAT);
4950
rmSync(compatRoot, { force: true, recursive: true });
5051

51-
const rewrites = new Map<`${number}.${number}`, Map<string, string>>();
52+
const rewrites = new Set<`${number}.${number}`>();
5253

5354
for (const breakpoint of DOWNLEVEL_BREAKPOINTS) {
5455
if (TS_VERSION.compare(breakpoint) <= 0) {
@@ -64,11 +65,14 @@ export function emitDownleveledDeclarations({ packageJson, projectRoot, tsc }: P
6465
const workdir = mkdtempSync(join(tmpdir(), `downlevel-dts-${breakpoint}-${basename(projectRoot)}-`));
6566
try {
6667
downlevel(projectRoot, workdir, breakpoint.version);
67-
for (const dts of walkDirectory(workdir)) {
68-
const original = readFileSync(join(projectRoot, dts), 'utf-8');
69-
const downleveled = readFileSync(join(workdir, dts), 'utf-8');
70-
needed ||= !equalWithNormalizedLineTerminator(original, downleveled);
71-
rewriteSet.set(dts, downleveled);
68+
const projectOutDir = tsc?.outDir != null ? join(projectRoot, tsc.outDir) : projectRoot;
69+
const workOutDir = tsc?.outDir != null ? join(workdir, tsc.outDir) : workdir;
70+
for (const dts of walkDirectory(workOutDir)) {
71+
const original = readFileSync(join(projectOutDir, dts), 'utf-8');
72+
const downleveledPath = join(workOutDir, dts);
73+
const downleveled = readFileSync(downleveledPath, 'utf-8');
74+
needed ||= !semanticallyEqualDeclarations(original, downleveled);
75+
rewriteSet.set(dts, downleveledPath);
7276
}
7377

7478
// If none of the declarations files changed during the down-level, then
@@ -78,7 +82,29 @@ export function emitDownleveledDeclarations({ packageJson, projectRoot, tsc }: P
7882
// actually does not allow most of the unsupported syntaxes to be used
7983
// anyway.
8084
if (needed) {
81-
rewrites.set(`${breakpoint.major}.${breakpoint.minor}`, rewriteSet);
85+
rewrites.add(`${breakpoint.major}.${breakpoint.minor}`);
86+
87+
const versionSuffix = `ts${breakpoint.major}.${breakpoint.minor}`;
88+
const compatDir = join(compatRoot, versionSuffix);
89+
if (!existsSync(compatDir)) {
90+
mkdirSync(compatDir, { recursive: true });
91+
try {
92+
// Write an empty .npmignore file so that npm pack doesn't use the .gitignore file...
93+
writeFileSync(join(compatRoot, '.npmignore'), '\n', 'utf-8');
94+
// Make sure all of this is gitignored, out of courtesy...
95+
writeFileSync(join(compatRoot, '.gitignore'), '*\n', 'utf-8');
96+
} catch {
97+
// Ignore any error here... This is inconsequential.
98+
}
99+
}
100+
101+
for (const [dts, downleveledPath] of rewriteSet) {
102+
const rewritten = join(compatDir, dts);
103+
// Make sure the parent directory exists (dts might be nested)
104+
mkdirSync(dirname(rewritten), { recursive: true });
105+
// Write the re-written declarations file there...
106+
copyFileSync(downleveledPath, rewritten);
107+
}
82108
}
83109
} finally {
84110
// Clean up after outselves...
@@ -88,33 +114,11 @@ export function emitDownleveledDeclarations({ packageJson, projectRoot, tsc }: P
88114

89115
let typesVersions: Mutable<PackageJson['typesVersions']>;
90116

91-
for (const [version, rewriteSet] of rewrites) {
92-
const versionSuffix = `ts${version}`;
93-
const compatDir = join(compatRoot, versionSuffix);
94-
if (!existsSync(compatDir)) {
95-
mkdirSync(compatDir, { recursive: true });
96-
try {
97-
// Write an empty .npmignore file so that npm pack doesn't use the .gitignore file...
98-
writeFileSync(join(compatRoot, '.npmignore'), '\n', 'utf-8');
99-
// Make sure all of this is gitignored, out of courtesy...
100-
writeFileSync(join(compatRoot, '.gitignore'), '*\n', 'utf-8');
101-
} catch {
102-
// Ignore any error here... This is inconsequential.
103-
}
104-
}
105-
106-
for (const [dts, downleveled] of rewriteSet) {
107-
const rewritten = join(compatDir, dts);
108-
// Make sure the parent directory exists (dts might be nested)
109-
mkdirSync(dirname(rewritten), { recursive: true });
110-
// Write the re-written declarations file there...
111-
writeFileSync(rewritten, downleveled, 'utf-8');
112-
}
113-
117+
for (const version of rewrites) {
114118
// Register the type redirect in the typesVersions configuration
115119
typesVersions ??= {};
116120
const from = [...(tsc?.outDir != null ? [tsc?.outDir] : []), '*'].join('/');
117-
const to = [...(tsc?.outDir != null ? [tsc?.outDir] : []), TYPES_COMPAT, versionSuffix, '*'].join('/');
121+
const to = [...(tsc?.outDir != null ? [tsc?.outDir] : []), TYPES_COMPAT, `ts${version}`, '*'].join('/');
118122
// We put 2 candidate redirects (first match wins), so that it works for nested imports, too (see: https://github.com/microsoft/TypeScript/issues/43133)
119123
typesVersions[`<=${version}`] = { [from]: [to, `${to}/index.d.ts`] };
120124
}
@@ -159,23 +163,37 @@ export function emitDownleveledDeclarations({ packageJson, projectRoot, tsc }: P
159163
}
160164

161165
/**
162-
* Compares two strings ignoring new line differences. This is necessary because
163-
* `downlevel-dts` may use a different line-ending convention (at time of
164-
* writing, it always uses CRLF) than what `jsii` itself emits, but that should
165-
* not affect the comparison of declarations files.
166+
* Compares the contents of two declaration files semantically.
166167
*
167168
* @param left the first string.
168169
* @param right the second string.
169170
*
170-
* @returns `true` if `left` and `right` contain the same text, modulo line
171-
* terminator tokens.
171+
* @returns `true` if `left` and `right` contain the same declarations.
172172
*/
173-
function equalWithNormalizedLineTerminator(left: string, right: string): boolean {
174-
// ECMA262 12.3: https://tc39.es/ecma262/#sec-line-terminators
175-
const JS_LINE_TERMINATOR_REGEX = /(\n|\r\n?|\u{2028}|\u{2029})/gmu;
173+
function semanticallyEqualDeclarations(left: string, right: string): boolean {
174+
// We normalize declarations largely by parsing & re-printing them.
175+
const normalizeDeclarations = (code: string): string => {
176+
const sourceFile = ts.createSourceFile('index.d.ts', code, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
177+
const printer = ts.createPrinter({
178+
newLine: ts.NewLineKind.LineFeed,
179+
noEmitHelpers: true,
180+
omitTrailingSemicolon: false,
181+
removeComments: true,
182+
});
183+
let normalized = printer.printFile(sourceFile);
184+
185+
// TypeScript may emit duplicated reference declarations... which are absent from Downlevel-DTS' output...
186+
// https://github.com/microsoft/TypeScript/issues/48143
187+
const REFERENCES_TYPES_NODE = '/// <reference types="node" />';
188+
if (normalized.startsWith(`${REFERENCES_TYPES_NODE}\n${REFERENCES_TYPES_NODE}`)) {
189+
normalized = normalized.slice(REFERENCES_TYPES_NODE.length + 1);
190+
}
191+
192+
return normalized;
193+
};
176194

177-
left = left.replace(JS_LINE_TERMINATOR_REGEX, '\n').trim();
178-
right = right.replace(JS_LINE_TERMINATOR_REGEX, '\n').trim();
195+
left = normalizeDeclarations(left);
196+
right = normalizeDeclarations(right);
179197

180198
return left === right;
181199
}

0 commit comments

Comments
 (0)