ReviewOS

also looking at this

stacks/dtsx

fix(bundle): leave template literal text alone when renaming identifiers

#3131
Open mkantautas wants to merge fix/bundle-template-literal-rename into main
3 files +108 -3
packages/dtsx/src/bundle-linker.tsmodified+42-3
Changes to packages/dtsx/src/bundle-linker.ts
@@ -332,9 +332,9 @@ function parseModule(file: string, dts: string): ModuleInfo {
332332}
333333
334334/**
335 * Rename identifier references in declaration text. Strings, comments,
336 * qualified-name members (`a.b`) and property or parameter names (`{ b: T }`,
337 * `(b: T)`) are left alone.
335 * Rename identifier references in declaration text. Strings, template literal
336 * text (not its `${}` placeholders), comments, qualified-name members (`a.b`)
337 * and property or parameter names (`{ b: T }`, `(b: T)`) are left alone.
338338 */
339339export function renameIdentifiers(text: string, renames: Map<string, string>): string {
340340 if (renames.size === 0) return text
@@ -342,8 +342,47 @@ export function renameIdentifiers(text: string, renames: Map<string, string>): s
342342 let i = 0
343343 const n = text.length
344344 let lastSignificant = '\n'
345 // One entry per template literal or placeholder the scan is in: -1 for
346 // template text, otherwise the braces open inside that placeholder.
347 const templates: number[] = []
345348 while (i < n) {
346349 const c = text[i]
350 if (templates.length > 0 && templates[templates.length - 1] === -1) {
351 if (c === '\\') {
352 out += text.slice(i, i + 2)
353 i += 2
354 }
355 else if (c === '`') {
356 templates.pop()
357 out += c
358 lastSignificant = c
359 i++
360 }
361 else if (c === '$' && text[i + 1] === '{') {
362 templates.push(0)
363 out += '${'
364 lastSignificant = '{'
365 i += 2
366 }
367 else {
368 out += c
369 i++
370 }
371 continue
372 }
373 if (c === '`') {
374 templates.push(-1)
375 out += c
376 i++
377 continue
378 }
379 if (templates.length > 0 && c === '{') {
380 templates[templates.length - 1]++
381 }
382 else if (templates.length > 0 && c === '}') {
383 if (templates[templates.length - 1] === 0) templates.pop()
384 else templates[templates.length - 1]--
385 }
347386 if (c === '/' && text[i + 1] === '*') {
348387 const end = text.indexOf('*/', i + 2)
349388 const stop = end === -1 ? n : end + 2
packages/dtsx/test/bundle-linker.test.tsmodified+14-0
Changes to packages/dtsx/test/bundle-linker.test.ts
@@ -31,4 +31,18 @@ describe('renameIdentifiers', () => {
3131 'declare function f(options: Options_1, o: { Options: string, x?: Options_1 }): Ns.Options | \'Options\';',
3232 )
3333 })
34
35 it('leaves template literal text alone and renames inside its placeholders', () => {
36 const renames = new Map([['Options', 'Options_1']])
37 const text = [
38 'type A = `Options` | `// Options` | `\\`Options` | `\\${Options}` | Options;',
39 'type B = `${Options}-Options` | `a${`Options-${Options}`}b` | `${{ Options: 1 } extends Options ? \'x\' : \'y\'}`;',
40 'type C = Options;',
41 ].join('\n')
42 expect(renameIdentifiers(text, renames)).toBe([
43 'type A = `Options` | `// Options` | `\\`Options` | `\\${Options}` | Options_1;',
44 'type B = `${Options_1}-Options` | `a${`Options-${Options_1}`}b` | `${{ Options: 1 } extends Options_1 ? \'x\' : \'y\'}`;',
45 'type C = Options_1;',
46 ].join('\n'))
47 })
3448})
packages/dtsx/test/module-graph.test.tsmodified+52-0
Changes to packages/dtsx/test/module-graph.test.ts
@@ -461,6 +461,58 @@ describe('generate with bundle: true', () => {
461461 await rm(dir, { recursive: true, force: true })
462462 })
463463
464 // Renaming a linked name (`enabled` to the default export's helper, a
465 // colliding `Options` to `Options_1`) also rewrote the same word inside
466 // template literal types, changing what they accept.
467 it('keeps template literal text when renaming', async () => {
468 const dir = await mkdtemp(join(tmpdir(), 'dtsx-bundle-template-'))
469 await writeFiles(dir, {
470 'src/index.ts': [
471 `import enabled from './flag'`,
472 `export * from './labels'`,
473 `export type Options = 'x' | 'y'`,
474 'export type Status = `enabled` | `Options`',
475 `export function isEnabled(): number { return Number(enabled) }`,
476 ].join('\n'),
477 'src/flag.ts': `export default 1`,
478 'src/labels.ts': [
479 `type Options = 'a' | 'b'`,
480 'export type Label = `Options-${Options}`',
481 ].join('\n'),
482 'consumer.ts': [
483 `import { isEnabled, type Label, type Options, type Status } from './dist/index'`,
484 `const status: Status = 'enabled'`,
485 `const other: Status = 'Options'`,
486 `const label: Label = 'Options-a'`,
487 `const option: Options = 'x'`,
488 `export { status, other, label, option, isEnabled }`,
489 ].join('\n'),
490 })
491
492 await generate({
493 cwd: dir,
494 root: 'src',
495 entrypoints: ['index.ts'],
496 outdir: join(dir, 'dist'),
497 clean: false,
498 keepComments: false,
499 tsconfigPath: '',
500 verbose: false,
501 bundle: true,
502 })
503 const bundled = await Bun.file(join(dir, 'dist', 'index.d.ts')).text()
504
505 expect(bundled).toContain('type Status = `enabled` | `Options`;')
506 expect(bundled).toContain('type Label = `Options-${Options_1}`;')
507
508 if (tscAvailable) {
509 const result = runTsc(dir, ['consumer.ts'])
510 expect(result.output).toBe('')
511 expect(result.ok).toBe(true)
512 }
513 await rm(dir, { recursive: true, force: true })
514 })
515
464516 it('writes one bundled file per entrypoint', async () => {
465517 await writeFiles(TMP, {
466518 'src/index.ts': `export * from './router';`,