@napi-rs/cli 3.9.0 → 3.10.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.
Files changed (39) hide show
  1. package/README.md +17 -4
  2. package/dist/cli.js +10359 -8729
  3. package/dist/index.cjs +10381 -8751
  4. package/dist/index.d.cts +83 -16
  5. package/dist/index.d.ts +83 -16
  6. package/dist/index.js +10359 -8729
  7. package/docs/wasi.md +318 -4
  8. package/package.json +5 -6
  9. package/src/api/__tests__/__snapshots__/templates.spec.ts.md +4003 -53
  10. package/src/api/__tests__/__snapshots__/templates.spec.ts.snap +0 -0
  11. package/src/api/__tests__/build-regressions.spec.ts +210 -3
  12. package/src/api/__tests__/build.spec.ts +2333 -3
  13. package/src/api/__tests__/create-npm-dirs.spec.ts +105 -3
  14. package/src/api/__tests__/pre-publish.spec.ts +153 -1
  15. package/src/api/__tests__/templates.spec.ts +1309 -1
  16. package/src/api/build.ts +756 -30
  17. package/src/api/create-npm-dirs.ts +23 -10
  18. package/src/api/new.ts +13 -18
  19. package/src/api/pre-publish.ts +34 -11
  20. package/src/api/rename.ts +10 -21
  21. package/src/api/templates/binding-target.ts +176 -0
  22. package/src/api/templates/index.ts +1 -0
  23. package/src/api/templates/js-binding.ts +54 -10
  24. package/src/api/templates/load-wasi-template.ts +661 -58
  25. package/src/api/templates/wasi-worker-template.ts +36 -31
  26. package/src/commands/build.ts +1 -1
  27. package/src/utils/__tests__/__snapshots__/typegen.spec.ts.md +18 -28
  28. package/src/utils/__tests__/__snapshots__/typegen.spec.ts.snap +0 -0
  29. package/src/utils/__tests__/misc.spec.ts +4 -0
  30. package/src/utils/__tests__/reconciliation.spec.ts +676 -0
  31. package/src/utils/__tests__/serialize.spec.ts +55 -0
  32. package/src/utils/__tests__/target.spec.ts +221 -0
  33. package/src/utils/__tests__/typegen.spec.ts +115 -0
  34. package/src/utils/config.ts +50 -0
  35. package/src/utils/index.ts +1 -0
  36. package/src/utils/misc.ts +351 -79
  37. package/src/utils/serialize.ts +47 -0
  38. package/src/utils/target.ts +150 -1
  39. package/src/utils/typegen.ts +608 -42
@@ -5,9 +5,11 @@ import { sortBy } from 'es-toolkit'
5
5
  import type {
6
6
  CompilerHost,
7
7
  CompilerOptions,
8
+ Declaration,
8
9
  Diagnostic,
9
10
  EntityName,
10
11
  Identifier,
12
+ Node,
11
13
  NodeArray,
12
14
  SourceFile,
13
15
  Statement,
@@ -206,23 +208,28 @@ function prettyPrint(
206
208
  s += `export interface ${line.name} {\n${line.def}\n}`
207
209
  break
208
210
 
209
- case TypeDefKind.Type:
210
- s += `export type ${line.name} = \n${line.def}`
211
+ case TypeDefKind.Type: {
212
+ const def = line.def.trim()
213
+ s +=
214
+ def.startsWith('|') || def.includes('\n')
215
+ ? `export type ${line.name} =\n${line.def}`
216
+ : `export type ${line.name} = ${def}`
211
217
  break
218
+ }
212
219
 
213
220
  case TypeDefKind.Enum: {
214
221
  const enumName = constEnum ? 'const enum' : 'enum'
215
- s += `${exportDeclare(ambient)} ${enumName} ${line.name} {\n${line.def}\n}`
222
+ s += `${exportDeclare(ambient)} ${enumName} ${line.name} ${enumBody(line.def)}`
216
223
  break
217
224
  }
218
225
 
219
226
  case TypeDefKind.StringEnum: {
220
227
  if (constEnum) {
221
- s += `${exportDeclare(ambient)} const enum ${line.name} {\n${line.def}\n}`
228
+ s += `${exportDeclare(ambient)} const enum ${line.name} ${enumBody(line.def)}`
222
229
  } else if (runtimeStringEnum) {
223
- s += `${exportDeclare(ambient)} enum ${line.name} {\n${line.def}\n}`
230
+ s += `${exportDeclare(ambient)} enum ${line.name} ${enumBody(line.def)}`
224
231
  } else {
225
- s += `export type ${line.name} = ${line.def.replaceAll(/.*=/g, '').replaceAll(',', '|')};`
232
+ s += `export type ${line.name} = ${stringEnumToUnion(line.def)}`
226
233
  }
227
234
  break
228
235
  }
@@ -238,7 +245,7 @@ function prettyPrint(
238
245
  // Runtime instances inherit from Iterator.prototype when it exists,
239
246
  // but the generated constructor does not extend the global Iterator.
240
247
  const [T, TResult, TNext] = iteratorTypes
241
- const resultType = `(${TResult}) | undefined`
248
+ const resultType = `${unionPart(TResult)} | undefined`
242
249
  classDef +=
243
250
  `\n[globalThis.Symbol.iterator](): this` +
244
251
  `\nnext(...[value]: [] | [${TNext}]): globalThis.IteratorResult<${T}, ${resultType}>` +
@@ -256,7 +263,7 @@ function prettyPrint(
256
263
  const [T, TResult, TNext] = line.asyncIterator
257
264
  classDef += `\n[globalThis.Symbol.asyncIterator](): globalThis.${asyncGeneratorHelperName}<${line.name}, ${T}, ${TResult}, ${TNext}>`
258
265
  }
259
- s += `${exportDeclare(ambient)} class ${line.name}${extendsDef} {\n${classDef}\n}`
266
+ s += `${exportDeclare(ambient)} class ${line.name}${extendsDef} ${blockBody(classDef)}`
260
267
  s += iteratorInterface
261
268
  if (line.original_name && line.original_name !== line.name) {
262
269
  s += `\nexport type ${line.original_name} = ${line.name}`
@@ -268,6 +275,10 @@ function prettyPrint(
268
275
  s += `${exportDeclare(ambient)} ${line.def}`
269
276
  break
270
277
 
278
+ case TypeDefKind.Const:
279
+ s += ambient ? line.def : declareExportedConst(line.def)
280
+ break
281
+
271
282
  default:
272
283
  s += line.def
273
284
  }
@@ -283,6 +294,185 @@ function exportDeclare(ambient: boolean): string {
283
294
  return 'export declare'
284
295
  }
285
296
 
297
+ function blockBody(def: string): string {
298
+ const body = def.trim()
299
+ return body ? `{\n${def}\n}` : '{}'
300
+ }
301
+
302
+ function enumBody(def: string): string {
303
+ const body = def.trim()
304
+ if (!body) {
305
+ return '{}'
306
+ }
307
+ const withTrailingComma = body.endsWith(',') ? def : `${def},`
308
+ return `{\n${withTrailingComma}\n}`
309
+ }
310
+
311
+ function unionPart(type: string): string {
312
+ return type.includes('|') || type.includes('&') || type.includes('=>')
313
+ ? `(${type})`
314
+ : type
315
+ }
316
+
317
+ const EXPORT_CONST_PREFIX = 'export const '
318
+
319
+ function declareExportedConst(def: string): string {
320
+ return def.startsWith(EXPORT_CONST_PREFIX)
321
+ ? `export declare const ${def.slice(EXPORT_CONST_PREFIX.length)}`
322
+ : def
323
+ }
324
+
325
+ function isWhitespace(character: string): boolean {
326
+ return (
327
+ character === ' ' ||
328
+ character === '\t' ||
329
+ character === '\n' ||
330
+ character === '\r'
331
+ )
332
+ }
333
+
334
+ /**
335
+ * Walk a rust-emitted string-enum body (`Name = 'value', ...`) and collect
336
+ * the initializer literals. Comments and commas inside quotes are skipped
337
+ * by scanning rather than by splitting on `,`.
338
+ */
339
+ function stringEnumToUnion(def: string): string {
340
+ const values: string[] = []
341
+ let index = 0
342
+
343
+ const eof = () => index >= def.length
344
+ const peek = (offset = 0) => def[index + offset]
345
+
346
+ const skipTrivia = () => {
347
+ while (!eof()) {
348
+ const character = peek()
349
+ if (character !== undefined && isWhitespace(character)) {
350
+ index += 1
351
+ continue
352
+ }
353
+ if (character === '/' && peek(1) === '/') {
354
+ index += 2
355
+ while (!eof() && peek() !== '\n') {
356
+ index += 1
357
+ }
358
+ continue
359
+ }
360
+ if (character === '/' && peek(1) === '*') {
361
+ index += 2
362
+ while (!eof() && !(peek() === '*' && peek(1) === '/')) {
363
+ index += 1
364
+ }
365
+ if (!eof()) {
366
+ index += 2
367
+ }
368
+ continue
369
+ }
370
+ return
371
+ }
372
+ }
373
+
374
+ const isNameTerminator = (character: string) =>
375
+ isWhitespace(character) ||
376
+ character === '=' ||
377
+ character === ',' ||
378
+ character === "'" ||
379
+ character === '"' ||
380
+ character === '/'
381
+
382
+ const parseName = () => {
383
+ if (peek() === "'" || peek() === '"') {
384
+ return parseString() !== undefined
385
+ }
386
+ const character = peek()
387
+ if (
388
+ character === undefined ||
389
+ isNameTerminator(character) ||
390
+ (character >= '0' && character <= '9')
391
+ ) {
392
+ return false
393
+ }
394
+ index += 1
395
+ while (!eof()) {
396
+ const next = peek()
397
+ if (next === undefined || isNameTerminator(next)) {
398
+ break
399
+ }
400
+ index += 1
401
+ }
402
+ return true
403
+ }
404
+
405
+ const parseString = () => {
406
+ const quote = peek()
407
+ if (quote !== "'" && quote !== '"') {
408
+ return
409
+ }
410
+ let end = index + 1
411
+ while (end < def.length) {
412
+ if (def[end] === '\\') {
413
+ end += 2
414
+ continue
415
+ }
416
+ if (def[end] === quote) {
417
+ const literal = def.slice(index, end + 1)
418
+ index = end + 1
419
+ return literal
420
+ }
421
+ end += 1
422
+ }
423
+ return
424
+ }
425
+
426
+ const parseNumber = () => {
427
+ let end = index
428
+ if (def[end] === '-') {
429
+ end += 1
430
+ }
431
+ const firstDigit = def[end]
432
+ if (firstDigit === undefined || firstDigit < '0' || firstDigit > '9') {
433
+ return
434
+ }
435
+ end += 1
436
+ while (end < def.length) {
437
+ const digit = def[end]
438
+ if (digit === undefined || digit < '0' || digit > '9') {
439
+ break
440
+ }
441
+ end += 1
442
+ }
443
+ const literal = def.slice(index, end)
444
+ index = end
445
+ return literal
446
+ }
447
+
448
+ while (!eof()) {
449
+ skipTrivia()
450
+ if (eof()) {
451
+ break
452
+ }
453
+ if (peek() === ',') {
454
+ index += 1
455
+ continue
456
+ }
457
+ if (!parseName()) {
458
+ index += 1
459
+ continue
460
+ }
461
+ skipTrivia()
462
+ if (peek() !== '=') {
463
+ continue
464
+ }
465
+ index += 1
466
+ skipTrivia()
467
+ const value = parseString() ?? parseNumber()
468
+ if (value !== undefined) {
469
+ values.push(value)
470
+ }
471
+ }
472
+
473
+ return values.join(' | ')
474
+ }
475
+
286
476
  /**
287
477
  * Read the napi-derive-emitted intermediate type-def file and render its
288
478
  * entries into the `index.d.ts` source string plus the list of names to
@@ -452,7 +642,7 @@ function renderTypeDefs(
452
642
  })
453
643
  .join('\n\n'),
454
644
  )
455
- .join('\n')
645
+ .join('\n\n')
456
646
  const globalDeclarations = []
457
647
  if (hasIteratorClass) {
458
648
  globalDeclarations.push(ITERATOR_OBJECT_COMPATIBILITY_DECLARATION)
@@ -948,10 +1138,19 @@ function typeImportReferenceMeaning(
948
1138
  }
949
1139
  }
950
1140
 
951
- function createDeclarationProgram(source: string): {
952
- program: import('typescript').Program
953
- sourceFile: SourceFile
954
- } {
1141
+ /**
1142
+ * A program over one already-parsed declaration file and nothing else.
1143
+ *
1144
+ * `noLib` and `noResolve` are the point, not an optimization: the answers a
1145
+ * caller takes from this program have to depend on the source alone, never on
1146
+ * what happens to sit next to the output directory. Nothing outside the file
1147
+ * is loaded, so an unresolved import stays unresolved rather than resolving
1148
+ * differently from one machine to the next.
1149
+ */
1150
+ function programOverDeclarationSource(
1151
+ sourceFile: SourceFile,
1152
+ source: string,
1153
+ ): import('typescript').Program {
955
1154
  const typeScript = loadTypeScript()
956
1155
  const options: CompilerOptions = {
957
1156
  module: typeScript.ModuleKind.ESNext,
@@ -961,13 +1160,6 @@ function createDeclarationProgram(source: string): {
961
1160
  target: typeScript.ScriptTarget.Latest,
962
1161
  types: [],
963
1162
  }
964
- const sourceFile = typeScript.createSourceFile(
965
- IN_MEMORY_DECLARATION_FILE,
966
- source,
967
- options.target!,
968
- true,
969
- typeScript.ScriptKind.TS,
970
- )
971
1163
  const host: CompilerHost = {
972
1164
  fileExists: (fileName) => fileName === IN_MEMORY_DECLARATION_FILE,
973
1165
  getCanonicalFileName: (fileName) => fileName,
@@ -982,11 +1174,20 @@ function createDeclarationProgram(source: string): {
982
1174
  useCaseSensitiveFileNames: () => true,
983
1175
  writeFile: () => {},
984
1176
  }
985
- const program = typeScript.createProgram({
1177
+ return typeScript.createProgram({
986
1178
  rootNames: [IN_MEMORY_DECLARATION_FILE],
987
1179
  options,
988
1180
  host,
989
1181
  })
1182
+ }
1183
+
1184
+ function createDeclarationProgram(source: string): {
1185
+ program: import('typescript').Program
1186
+ sourceFile: SourceFile
1187
+ } {
1188
+ const typeScript = loadTypeScript()
1189
+ const sourceFile = parseDeclarationFile(source)
1190
+ const program = programOverDeclarationSource(sourceFile, source)
990
1191
  const diagnostics = program.getSyntacticDiagnostics(sourceFile)
991
1192
  if (diagnostics.length > 0) {
992
1193
  throwDeclarationDiagnostics(typeScript, sourceFile, diagnostics)
@@ -994,6 +1195,24 @@ function createDeclarationProgram(source: string): {
994
1195
  return { program, sourceFile }
995
1196
  }
996
1197
 
1198
+ /**
1199
+ * Parse a declaration source without judging it. Unlike
1200
+ * {@link parseDeclarationSource}, a syntax error somewhere else in the file is
1201
+ * no reason to refuse to answer a question about it: this runs over whatever a
1202
+ * project put in its `--dts-header` and over declaration files kept from
1203
+ * earlier builds.
1204
+ */
1205
+ function parseDeclarationFile(source: string): SourceFile {
1206
+ const typeScript = loadTypeScript()
1207
+ return typeScript.createSourceFile(
1208
+ IN_MEMORY_DECLARATION_FILE,
1209
+ source,
1210
+ typeScript.ScriptTarget.Latest,
1211
+ true,
1212
+ typeScript.ScriptKind.TS,
1213
+ )
1214
+ }
1215
+
997
1216
  function throwDeclarationDiagnostics(
998
1217
  typeScript: TypeScriptModule,
999
1218
  sourceFile: SourceFile,
@@ -1215,6 +1434,330 @@ export function rewriteUnboundNodeGlobalTypeQueries(source: string): string {
1215
1434
  return rewritten
1216
1435
  }
1217
1436
 
1437
+ /**
1438
+ * One top-level `export … const|let|var <name>` found by
1439
+ * {@link scanExportedName}.
1440
+ */
1441
+ export interface ExportedVariableDeclaration {
1442
+ /**
1443
+ * Where the declaration block starts: the doc comment written directly above
1444
+ * the statement when there is one, so replacing the span swaps the comment
1445
+ * with it instead of stranding it.
1446
+ */
1447
+ start: number
1448
+ /**
1449
+ * Just past the statement, the statement's own `;` included and trailing
1450
+ * trivia excluded. A caller that replaces `[start, end)` therefore has to
1451
+ * put that terminator back, or whatever followed on the same line runs
1452
+ * straight into the replacement.
1453
+ */
1454
+ end: number
1455
+ /**
1456
+ * Where this declarator starts: the name, without the `export declare const`
1457
+ * in front of it.
1458
+ */
1459
+ declaratorStart: number
1460
+ /** Just past this declarator, before any `,` that separates it from a sibling. */
1461
+ declaratorEnd: number
1462
+ /**
1463
+ * How many names the statement declares in all. More than one and
1464
+ * `[start, end)` covers names besides this one, so replacing that span would
1465
+ * delete them.
1466
+ */
1467
+ declaratorCount: number
1468
+ /** The type annotation as written, or `undefined` when there is none. */
1469
+ type?: string
1470
+ }
1471
+
1472
+ /** What {@link scanExportedName} reads out of one declaration source. */
1473
+ export interface ExportedNameScan {
1474
+ /**
1475
+ * Whether the file exports by assignment (`export = x`) at the top level,
1476
+ * which is what a build without `napi-derive`'s `type-def` feature emits.
1477
+ * Such a file cannot carry a named export at all.
1478
+ *
1479
+ * `export default x` parses as the same node and does not count: only
1480
+ * `isExportEquals` does.
1481
+ */
1482
+ exportsByAssignment: boolean
1483
+ /**
1484
+ * Whether this source already binds `name` in a way that leaves no room for
1485
+ * an added `export declare const name` — as an export, or as a top-level
1486
+ * declaration the const would redeclare.
1487
+ *
1488
+ * Not simply "is it exported": TypeScript merges a value with a declaration
1489
+ * that lives only in type space, and a caller that backed off there would
1490
+ * take away typed access to a real runtime export (TS2693 at every value
1491
+ * use) while preventing no collision at all. So an exported `type`,
1492
+ * `interface` or non-instantiated namespace is *not* an owner.
1493
+ *
1494
+ * Which leaves the question of what "value space" means for every shape a
1495
+ * declaration file can take, and that is TypeScript's question to answer,
1496
+ * not this CLI's — see {@link scanExportedName}. An owner is an export the
1497
+ * checker gives `SymbolFlags.Value` (a variable, `function`, `class`,
1498
+ * `enum`, an instantiated namespace — instantiated by the binder's rules,
1499
+ * which count an aliased member such as `export import x = …` that hand-
1500
+ * written recursion over the body kept missing) or `SymbolFlags.Alias`
1501
+ * (every `export { … }` clause, its `export type { … }` and
1502
+ * `export * as name from '…'` forms, and `export import name = …`; an alias
1503
+ * collides with a local declaration of the name however it is spelled —
1504
+ * TS2323, TS2440).
1505
+ *
1506
+ * The same two flags settle a binding that is not exported at all: a
1507
+ * declaration file that is not a module has no export table, and its
1508
+ * top-level `declare const name` is a global the generated export would
1509
+ * redeclare; inside a module an `import name = …` the file keeps to itself
1510
+ * conflicts too. A top-level `type` or `interface` does not, in either
1511
+ * place — and neither does a name the file puts in the *global* scope
1512
+ * rather than its own, through a `declare global { … }` member or the UMD
1513
+ * name of `export as namespace name`: the added export shadows it instead
1514
+ * of colliding with it.
1515
+ *
1516
+ * `export default` binds `default` rather than a name, and
1517
+ * `export * from '…'` is left unresolved on purpose, so it names nothing
1518
+ * here; neither counts.
1519
+ */
1520
+ ownsName: boolean
1521
+ /**
1522
+ * Whether the file was bound to answer {@link ownsName}, or whether the
1523
+ * source was settled without it.
1524
+ *
1525
+ * Binding is skipped only for a source that can spell no such name at all —
1526
+ * see {@link scanExportedName}. Nothing in this CLI branches on it: it is
1527
+ * here so a test can hold that shortcut in place without timing anything,
1528
+ * which is the only way to notice it has quietly stopped applying.
1529
+ */
1530
+ checked: boolean
1531
+ /**
1532
+ * The exported variable statements that declare `name`, in source order.
1533
+ * Only these carry a span a caller can rewrite; the other export forms above
1534
+ * have no declaration here to replace.
1535
+ */
1536
+ declarations: ExportedVariableDeclaration[]
1537
+ }
1538
+
1539
+ /**
1540
+ * How a declaration source exports `name`, and whether it exports by
1541
+ * assignment instead.
1542
+ *
1543
+ * Parsed rather than pattern-matched, because a declaration file spells both
1544
+ * in places that are not an export of it. `napi-derive` copies a crate's
1545
+ * `js_doc` through verbatim, so a doc comment can name either; a
1546
+ * `--dts-header` may carry a commented-out example of the declaration or of
1547
+ * `export = binding`, or a member of a `declare namespace` / `declare module`
1548
+ * block, which is an export of that block and not of the file. Importing any
1549
+ * of those yields TS2305, and a commented-out `export =` is not an export
1550
+ * assignment at all.
1551
+ *
1552
+ * Ownership is decided by TypeScript's own binder rather than by a list of
1553
+ * shapes this CLI enumerates. Every attempt at the list missed something —
1554
+ * a namespace export clause, an `export import` member, an alias nested one
1555
+ * level deeper — because the question is really "what does this file export,
1556
+ * and in which declaration space", and only the checker knows. So the file is
1557
+ * bound and {@link ExportedNameScan.ownsName} is read off the export symbol's
1558
+ * flags.
1559
+ *
1560
+ * The syntax is still what finds the rewritable declaration and its span, and
1561
+ * one parse serves both: the program is built over the source file parsed
1562
+ * here, so binding costs no second parse.
1563
+ */
1564
+ export function scanExportedName(
1565
+ source: string,
1566
+ name: string,
1567
+ ): ExportedNameScan {
1568
+ const typeScript = loadTypeScript()
1569
+ const sourceFile = parseDeclarationFile(source)
1570
+ const declarations: ExportedVariableDeclaration[] = []
1571
+ let exportsByAssignment = false
1572
+ for (const statement of sourceFile.statements) {
1573
+ if (typeScript.isExportAssignment(statement)) {
1574
+ // `export = x`. `export default x` is the same node without the flag,
1575
+ // and it binds `default`, never `name`.
1576
+ exportsByAssignment ||= statement.isExportEquals === true
1577
+ continue
1578
+ }
1579
+ // Only an exported variable statement leaves a declaration a caller can
1580
+ // rewrite in place. Every other owner is found through the checker below,
1581
+ // which has no span to offer and needs none.
1582
+ if (
1583
+ !typeScript.isVariableStatement(statement) ||
1584
+ !statement.modifiers?.some(
1585
+ (modifier) => modifier.kind === typeScript.SyntaxKind.ExportKeyword,
1586
+ )
1587
+ ) {
1588
+ continue
1589
+ }
1590
+ for (const declaration of statement.declarationList.declarations) {
1591
+ if (
1592
+ !typeScript.isIdentifier(declaration.name) ||
1593
+ declaration.name.text !== name
1594
+ ) {
1595
+ continue
1596
+ }
1597
+ declarations.push({
1598
+ start: declarationBlockStart(source, sourceFile, statement),
1599
+ end: statement.end,
1600
+ declaratorStart: declaration.getStart(sourceFile),
1601
+ declaratorEnd: declaration.end,
1602
+ declaratorCount: statement.declarationList.declarations.length,
1603
+ type: declaration.type?.getText(sourceFile),
1604
+ })
1605
+ break
1606
+ }
1607
+ }
1608
+ const { ownsName, checked } = sourceBindsName(sourceFile, source, name)
1609
+ return { exportsByAssignment, ownsName, checked, declarations }
1610
+ }
1611
+
1612
+ /**
1613
+ * Whether anything in this source already binds `name` where a generated
1614
+ * `export declare const name` would land — as an export of the file, or as a
1615
+ * declaration at its top level that the const would redeclare — and whether
1616
+ * the file had to be bound to find out.
1617
+ *
1618
+ * Both questions are the checker's, and both are answered off the same
1619
+ * program. See {@link ExportedNameScan.ownsName}.
1620
+ */
1621
+ function sourceBindsName(
1622
+ sourceFile: SourceFile,
1623
+ source: string,
1624
+ name: string,
1625
+ ): { ownsName: boolean; checked: boolean } {
1626
+ const typeScript = loadTypeScript()
1627
+ // A binding of `name` in this file has to spell it here — but it need not
1628
+ // spell it in the characters the name is made of. An identifier may write
1629
+ // any of them as a `\uXXXX` or `\u{…}` escape; a string-literal export name
1630
+ // (`export { x as "…" }`) may use `\xXX` as well, and may be broken across
1631
+ // lines with a backslash-newline continuation. TypeScript resolves every one
1632
+ // of those to the same name where a text search sees nothing. Enumerating
1633
+ // the escapes is the game that was already lost once, so the shortcut asks
1634
+ // for less: a source carrying neither the name nor a backslash *anywhere*
1635
+ // cannot spell it, and that is what almost every header is. Everything else
1636
+ // goes to the checker, which has always been able to say. The other form a
1637
+ // text search would miss is `export * from '…'`, which the program below
1638
+ // leaves unresolved by design, so nothing is skipped there either.
1639
+ if (!source.includes(name) && !source.includes('\\')) {
1640
+ return { ownsName: false, checked: false }
1641
+ }
1642
+ // A value or an alias is what a `const` of the same name cannot be written
1643
+ // beside; a type-only declaration is what it merges with.
1644
+ const meaning = typeScript.SymbolFlags.Alias | typeScript.SymbolFlags.Value
1645
+ const checker = programOverDeclarationSource(
1646
+ sourceFile,
1647
+ source,
1648
+ ).getTypeChecker()
1649
+ const moduleSymbol = checker.getSymbolAtLocation(sourceFile)
1650
+ const exported =
1651
+ moduleSymbol === undefined
1652
+ ? undefined
1653
+ : checker
1654
+ .getExportsOfModule(moduleSymbol)
1655
+ .find((symbol) => symbol.name === name)
1656
+ if (exported !== undefined && (exported.flags & meaning) !== 0) {
1657
+ return { ownsName: true, checked: true }
1658
+ }
1659
+ // Not every binding that collides is an export. A declaration file that is
1660
+ // not a module has no export table at all, and its top-level `declare const`
1661
+ // is a global the generated export would redeclare (TS2451, TS2395). Inside
1662
+ // a module, an `import name = …` kept to the file is likewise no export and
1663
+ // still conflicts (TS2440). Ambient declaration files put most top-level
1664
+ // declarations in the export table by themselves, so these are the leftovers
1665
+ // rather than the common case — but they are the ones a rule written around
1666
+ // exports alone would miss.
1667
+ //
1668
+ // Scoped at the source file, so a binding nested inside a namespace or a
1669
+ // function body is correctly none of this file's business, and filtered to
1670
+ // the declarations that bind in this file's own scope — see
1671
+ // {@link declaresInFileScope}.
1672
+ return {
1673
+ ownsName: checker
1674
+ .getSymbolsInScope(sourceFile, meaning)
1675
+ .some(
1676
+ (symbol) =>
1677
+ symbol.name === name &&
1678
+ symbol.declarations?.some((declaration) =>
1679
+ declaresInFileScope(typeScript, declaration, sourceFile),
1680
+ ) === true,
1681
+ ),
1682
+ checked: true,
1683
+ }
1684
+ }
1685
+
1686
+ /**
1687
+ * Whether `declaration` binds its name in `sourceFile`'s own scope — the scope
1688
+ * a generated `export declare const` would land in.
1689
+ *
1690
+ * `getSymbolsInScope` answers what is *visible* at a location, and the global
1691
+ * scope is visible everywhere, so a name a file declares into that scope comes
1692
+ * back from it while colliding with nothing the file itself adds. Written here
1693
+ * is therefore not enough; written here *and at this file's top level* is the
1694
+ * question:
1695
+ *
1696
+ * - A `declare global { … }` member is a global, reached through a
1697
+ * `ModuleDeclaration`. A module-scoped `export declare const` of the same
1698
+ * name shadows it rather than redeclaring it. The members of an ambient
1699
+ * `declare module '…'` block and of a `declare namespace` belong to that
1700
+ * module or namespace instead of to the file; the checker already keeps
1701
+ * those out of scope here, and the same walk covers them without depending
1702
+ * on that.
1703
+ * - `export as namespace name` declares a UMD global for script consumers,
1704
+ * not a binding in the file. Its `NamespaceExportDeclaration` is a child of
1705
+ * the source file and so passes the walk, and it too sits happily beside an
1706
+ * export of the same name.
1707
+ *
1708
+ * Everything else that reaches here does bind at the top level: a script
1709
+ * file's own `declare const` (appending the export makes the file a module and
1710
+ * puts both in the same scope), and a module's unexported `import name = …`.
1711
+ */
1712
+ function declaresInFileScope(
1713
+ typeScript: TypeScriptModule,
1714
+ declaration: Declaration,
1715
+ sourceFile: SourceFile,
1716
+ ): boolean {
1717
+ if (
1718
+ declaration.getSourceFile() !== sourceFile ||
1719
+ typeScript.isNamespaceExportDeclaration(declaration)
1720
+ ) {
1721
+ return false
1722
+ }
1723
+ for (
1724
+ let node: Node | undefined = declaration.parent;
1725
+ node !== undefined && !typeScript.isSourceFile(node);
1726
+ node = node.parent
1727
+ ) {
1728
+ if (typeScript.isModuleDeclaration(node)) {
1729
+ return false
1730
+ }
1731
+ }
1732
+ return true
1733
+ }
1734
+
1735
+ /**
1736
+ * Where a statement's block starts for replacement purposes: the doc comment
1737
+ * directly above it when one is there, otherwise the statement itself. Only a
1738
+ * `/** … *\/` comment separated from the statement by nothing but whitespace
1739
+ * counts, so a license banner further up is never swallowed.
1740
+ */
1741
+ function declarationBlockStart(
1742
+ source: string,
1743
+ sourceFile: SourceFile,
1744
+ statement: Statement,
1745
+ ): number {
1746
+ const typeScript = loadTypeScript()
1747
+ const start = statement.getStart(sourceFile)
1748
+ const comments =
1749
+ typeScript.getLeadingCommentRanges(source, statement.getFullStart()) ?? []
1750
+ for (const comment of comments) {
1751
+ if (
1752
+ source.startsWith('/**', comment.pos) &&
1753
+ source.slice(comment.end, start).trim() === ''
1754
+ ) {
1755
+ return comment.pos
1756
+ }
1757
+ }
1758
+ return start
1759
+ }
1760
+
1218
1761
  function parseDeclarationSource(source: string) {
1219
1762
  const typeScript = loadTypeScript()
1220
1763
  const parsed = typeScript.createSourceFile(
@@ -1424,43 +1967,66 @@ export function correctStringIdent(src: string, ident: number): string {
1424
1967
  let bracketDepth = 0
1425
1968
  const result = src
1426
1969
  .split('\n')
1427
- .map((line) => {
1428
- line = line.trim()
1970
+ .map((rawLine) => {
1971
+ const line = rawLine.trim()
1429
1972
  if (line === '') {
1430
1973
  return ''
1431
1974
  }
1432
1975
 
1433
1976
  const isInMultilineComment = line.startsWith('*')
1434
- const isClosingBracket = line.endsWith('}')
1435
- const isOpeningBracket = line.endsWith('{')
1436
1977
  const isTypeDeclaration = line.endsWith('=')
1437
- const isTypeVariant = line.startsWith('|')
1978
+ const netBraces = isInMultilineComment ? 0 : netUnquotedBraces(line)
1438
1979
 
1439
- let rightIndent = ident
1440
- if ((isOpeningBracket || isTypeDeclaration) && !isInMultilineComment) {
1441
- bracketDepth += 1
1442
- rightIndent += (bracketDepth - 1) * 2
1443
- } else {
1444
- if (
1445
- isClosingBracket &&
1446
- bracketDepth > 0 &&
1447
- !isInMultilineComment &&
1448
- !isTypeVariant
1449
- ) {
1450
- bracketDepth -= 1
1451
- }
1452
- rightIndent += bracketDepth * 2
1980
+ if (netBraces < 0 && !line.startsWith('|')) {
1981
+ bracketDepth = Math.max(0, bracketDepth + netBraces)
1453
1982
  }
1454
1983
 
1984
+ let rightIndent = ident + bracketDepth * 2
1455
1985
  if (isInMultilineComment) {
1456
1986
  rightIndent += 1
1457
1987
  }
1458
1988
 
1459
- const s = `${' '.repeat(rightIndent)}${line}`
1989
+ if (netBraces > 0) {
1990
+ bracketDepth += netBraces
1991
+ } else if (
1992
+ isTypeDeclaration &&
1993
+ !isInMultilineComment &&
1994
+ netBraces === 0
1995
+ ) {
1996
+ bracketDepth += 1
1997
+ }
1460
1998
 
1461
- return s
1999
+ return `${' '.repeat(rightIndent)}${line}`
1462
2000
  })
1463
2001
  .join('\n')
1464
2002
 
1465
2003
  return result
1466
2004
  }
2005
+
2006
+ function netUnquotedBraces(line: string): number {
2007
+ let net = 0
2008
+ let quote: "'" | '"' | '`' | undefined
2009
+ let escaped = false
2010
+ for (const character of line) {
2011
+ if (quote) {
2012
+ if (escaped) {
2013
+ escaped = false
2014
+ } else if (character === '\\') {
2015
+ escaped = true
2016
+ } else if (character === quote) {
2017
+ quote = undefined
2018
+ }
2019
+ continue
2020
+ }
2021
+ if (character === "'" || character === '"' || character === '`') {
2022
+ quote = character
2023
+ continue
2024
+ }
2025
+ if (character === '{') {
2026
+ net += 1
2027
+ } else if (character === '}') {
2028
+ net -= 1
2029
+ }
2030
+ }
2031
+ return net
2032
+ }