@napi-rs/cli 3.9.1 → 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.
@@ -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,
@@ -1136,10 +1138,19 @@ function typeImportReferenceMeaning(
1136
1138
  }
1137
1139
  }
1138
1140
 
1139
- function createDeclarationProgram(source: string): {
1140
- program: import('typescript').Program
1141
- sourceFile: SourceFile
1142
- } {
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 {
1143
1154
  const typeScript = loadTypeScript()
1144
1155
  const options: CompilerOptions = {
1145
1156
  module: typeScript.ModuleKind.ESNext,
@@ -1149,13 +1160,6 @@ function createDeclarationProgram(source: string): {
1149
1160
  target: typeScript.ScriptTarget.Latest,
1150
1161
  types: [],
1151
1162
  }
1152
- const sourceFile = typeScript.createSourceFile(
1153
- IN_MEMORY_DECLARATION_FILE,
1154
- source,
1155
- options.target!,
1156
- true,
1157
- typeScript.ScriptKind.TS,
1158
- )
1159
1163
  const host: CompilerHost = {
1160
1164
  fileExists: (fileName) => fileName === IN_MEMORY_DECLARATION_FILE,
1161
1165
  getCanonicalFileName: (fileName) => fileName,
@@ -1170,11 +1174,20 @@ function createDeclarationProgram(source: string): {
1170
1174
  useCaseSensitiveFileNames: () => true,
1171
1175
  writeFile: () => {},
1172
1176
  }
1173
- const program = typeScript.createProgram({
1177
+ return typeScript.createProgram({
1174
1178
  rootNames: [IN_MEMORY_DECLARATION_FILE],
1175
1179
  options,
1176
1180
  host,
1177
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)
1178
1191
  const diagnostics = program.getSyntacticDiagnostics(sourceFile)
1179
1192
  if (diagnostics.length > 0) {
1180
1193
  throwDeclarationDiagnostics(typeScript, sourceFile, diagnostics)
@@ -1182,6 +1195,24 @@ function createDeclarationProgram(source: string): {
1182
1195
  return { program, sourceFile }
1183
1196
  }
1184
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
+
1185
1216
  function throwDeclarationDiagnostics(
1186
1217
  typeScript: TypeScriptModule,
1187
1218
  sourceFile: SourceFile,
@@ -1403,6 +1434,330 @@ export function rewriteUnboundNodeGlobalTypeQueries(source: string): string {
1403
1434
  return rewritten
1404
1435
  }
1405
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
+
1406
1761
  function parseDeclarationSource(source: string) {
1407
1762
  const typeScript = loadTypeScript()
1408
1763
  const parsed = typeScript.createSourceFile(