Skip to content

Commit a0cccac

Browse files
chore: wip
1 parent ba310da commit a0cccac

File tree

5 files changed

+148
-10
lines changed

5 files changed

+148
-10
lines changed

packages/dtsx/src/extractor.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable no-case-declarations, regexp/no-contradiction-with-assertion */
22
import type { ClassDeclaration, EnumDeclaration, ExportAssignment, ExportDeclaration, FunctionDeclaration, ImportDeclaration, InterfaceDeclaration, Modifier, ModuleDeclaration, Node, ParameterDeclaration, SourceFile, TypeAliasDeclaration, VariableStatement } from 'typescript'
33
import type { Declaration } from './types'
4-
import { createSourceFile, forEachChild, isArrayBindingPattern, isBindingElement, isCallSignatureDeclaration, isConstructorDeclaration, isConstructSignatureDeclaration, isEnumDeclaration, isEnumMember, isExportAssignment, isFunctionDeclaration, isIdentifier, isInterfaceDeclaration, isMethodDeclaration, isMethodSignature, isModuleBlock, isModuleDeclaration, isObjectBindingPattern, isPropertyDeclaration, isPropertySignature, isStringLiteral, isTypeAliasDeclaration, isVariableStatement, NodeFlags, ScriptKind, ScriptTarget, SyntaxKind } from 'typescript'
4+
import { createSourceFile, forEachChild, isArrayBindingPattern, isBindingElement, isCallSignatureDeclaration, isConstructorDeclaration, isConstructSignatureDeclaration, isEnumDeclaration, isEnumMember, isExportAssignment, isFunctionDeclaration, isGetAccessorDeclaration, isIdentifier, isIndexSignatureDeclaration, isInterfaceDeclaration, isMethodDeclaration, isMethodSignature, isModuleBlock, isModuleDeclaration, isObjectBindingPattern, isPropertyDeclaration, isPropertySignature, isSetAccessorDeclaration, isStringLiteral, isTypeAliasDeclaration, isVariableStatement, NodeFlags, ScriptKind, ScriptTarget, SyntaxKind } from 'typescript'
55

66
/**
77
* Cache for parsed SourceFile objects to avoid re-parsing
@@ -179,12 +179,16 @@ function extractImportDeclaration(node: ImportDeclaration, sourceCode: string):
179179
const text = getNodeText(node, sourceCode)
180180
const isTypeOnly = !!(node.importClause?.isTypeOnly)
181181

182+
// Detect side-effect imports (no import clause, e.g., `import 'module'`)
183+
const isSideEffectImport = !node.importClause
184+
182185
return {
183186
kind: 'import',
184187
name: '', // Imports don't have a single name
185188
text,
186189
isExported: false,
187190
isTypeOnly,
191+
isSideEffect: isSideEffectImport,
188192
source: node.moduleSpecifier.getText().slice(1, -1), // Remove quotes
189193
start: node.getStart(),
190194
end: node.getEnd(),
@@ -538,6 +542,16 @@ function getInterfaceBody(node: InterfaceDeclaration): string {
538542
const returnType = member.type?.getText() || 'any'
539543
members.push(` new (${params}): ${returnType}`)
540544
}
545+
else if (isIndexSignatureDeclaration(member)) {
546+
// Index signature: [key: string]: T or [index: number]: T
547+
const params = member.parameters.map((param) => {
548+
const paramName = param.name.getText()
549+
const paramType = param.type?.getText() || 'any'
550+
return `${paramName}: ${paramType}`
551+
}).join(', ')
552+
const returnType = member.type?.getText() || 'any'
553+
members.push(` [${params}]: ${returnType}`)
554+
}
541555
}
542556

543557
return `{\n${members.join('\n')}\n}`
@@ -794,6 +808,56 @@ function buildClassBody(node: ClassDeclaration): string {
794808
const type = member.type?.getText() || 'any'
795809
signature += `: ${type};`
796810

811+
members.push(signature)
812+
}
813+
else if (isGetAccessorDeclaration(member)) {
814+
// Get accessor declaration
815+
const name = member.name?.getText() || ''
816+
const isStatic = member.modifiers?.some(mod => mod.kind === SyntaxKind.StaticKeyword)
817+
const isPrivate = member.modifiers?.some(mod => mod.kind === SyntaxKind.PrivateKeyword)
818+
const isProtected = member.modifiers?.some(mod => mod.kind === SyntaxKind.ProtectedKeyword)
819+
const isAbstract = member.modifiers?.some(mod => mod.kind === SyntaxKind.AbstractKeyword)
820+
821+
let signature = ' '
822+
if (isStatic)
823+
signature += 'static '
824+
if (isAbstract)
825+
signature += 'abstract '
826+
if (isPrivate)
827+
signature += 'private '
828+
else if (isProtected)
829+
signature += 'protected '
830+
831+
const returnType = member.type?.getText() || 'any'
832+
signature += `get ${name}(): ${returnType};`
833+
834+
members.push(signature)
835+
}
836+
else if (isSetAccessorDeclaration(member)) {
837+
// Set accessor declaration
838+
const name = member.name?.getText() || ''
839+
const isStatic = member.modifiers?.some(mod => mod.kind === SyntaxKind.StaticKeyword)
840+
const isPrivate = member.modifiers?.some(mod => mod.kind === SyntaxKind.PrivateKeyword)
841+
const isProtected = member.modifiers?.some(mod => mod.kind === SyntaxKind.ProtectedKeyword)
842+
const isAbstract = member.modifiers?.some(mod => mod.kind === SyntaxKind.AbstractKeyword)
843+
844+
let signature = ' '
845+
if (isStatic)
846+
signature += 'static '
847+
if (isAbstract)
848+
signature += 'abstract '
849+
if (isPrivate)
850+
signature += 'private '
851+
else if (isProtected)
852+
signature += 'protected '
853+
854+
// Get parameter type from the setter's parameter
855+
const param = member.parameters[0]
856+
const paramType = param?.type?.getText() || 'any'
857+
const paramName = param?.name?.getText() || 'value'
858+
859+
signature += `set ${name}(${paramName}: ${paramType});`
860+
797861
members.push(signature)
798862
}
799863
}

packages/dtsx/src/processor.ts

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,46 @@ function formatComments(comments: string[] | undefined, keepComments: boolean =
6464
return `${formattedComments}\n`
6565
}
6666

67+
/**
68+
* Find the start of interface body, accounting for nested braces in generics
69+
* Returns the index of the opening brace of the body, or -1 if not found
70+
*/
71+
function findInterfaceBodyStart(text: string): number {
72+
let angleDepth = 0
73+
let inString = false
74+
let stringChar = ''
75+
76+
for (let i = 0; i < text.length; i++) {
77+
const char = text[i]
78+
const prevChar = i > 0 ? text[i - 1] : ''
79+
80+
// Handle string literals
81+
if (!inString && (char === '"' || char === '\'' || char === '`')) {
82+
inString = true
83+
stringChar = char
84+
}
85+
else if (inString && char === stringChar && prevChar !== '\\') {
86+
inString = false
87+
}
88+
89+
if (!inString) {
90+
// Track angle brackets for generics
91+
if (char === '<') {
92+
angleDepth++
93+
}
94+
else if (char === '>') {
95+
angleDepth--
96+
}
97+
// The body starts with { after all generics are closed
98+
else if (char === '{' && angleDepth === 0) {
99+
return i
100+
}
101+
}
102+
}
103+
104+
return -1
105+
}
106+
67107
/**
68108
* Replace unresolved types with 'any' in the DTS output
69109
*/
@@ -216,10 +256,20 @@ function extractAllImportedItems(importText: string): string[] {
216256

217257
const items: string[] = []
218258

219-
// Helper to clean import item names (trim first, then remove 'type ' prefix)
259+
// Helper to clean import item names and extract alias if present
260+
// For 'SomeType as AliasedType', returns 'AliasedType' (the local name used in code)
220261
const cleanImportItem = (item: string): string => {
221-
const trimmed = item.trim()
222-
return trimmed.startsWith('type ') ? trimmed.slice(5).trim() : trimmed
262+
let trimmed = item.trim()
263+
// Remove 'type ' prefix
264+
if (trimmed.startsWith('type ')) {
265+
trimmed = trimmed.slice(5).trim()
266+
}
267+
// Handle aliases: 'OriginalName as AliasName' -> 'AliasName'
268+
const asIndex = trimmed.indexOf(' as ')
269+
if (asIndex !== -1) {
270+
return trimmed.slice(asIndex + 4).trim()
271+
}
272+
return trimmed
223273
}
224274

225275
// Find 'from' keyword position
@@ -479,6 +529,13 @@ export function processDeclarations(
479529
// Create filtered imports based on actually used items
480530
const processedImports: string[] = []
481531
for (const imp of imports) {
532+
// Preserve side-effect imports unconditionally (they may have type effects like reflect-metadata)
533+
if (imp.isSideEffect) {
534+
const sideEffectImport = imp.text.trim().endsWith(';') ? imp.text.trim() : `${imp.text.trim()};`
535+
processedImports.push(sideEffectImport)
536+
continue
537+
}
538+
482539
// Parse import using string operations to avoid regex backtracking
483540
const parsed = parseImportStatement(imp.text)
484541
if (!parsed) continue
@@ -488,7 +545,12 @@ export function processDeclarations(
488545
// Filter to only used items
489546
const usedDefault = defaultName ? usedImportItems.has(defaultName) : false
490547
const usedNamed = namedItems.filter((item) => {
491-
const cleanItem = item.startsWith('type ') ? item.slice(5).trim() : item.trim()
548+
let cleanItem = item.startsWith('type ') ? item.slice(5).trim() : item.trim()
549+
// For aliases 'OriginalName as AliasName', check if AliasName is used
550+
const asIndex = cleanItem.indexOf(' as ')
551+
if (asIndex !== -1) {
552+
cleanItem = cleanItem.slice(asIndex + 4).trim()
553+
}
492554
return usedImportItems.has(cleanItem)
493555
})
494556

@@ -704,6 +766,16 @@ export function processInterfaceDeclaration(decl: Declaration, keepComments: boo
704766
// Add comments if present
705767
const comments = formatComments(decl.leadingComments, keepComments)
706768

769+
// The extractor already produces properly formatted interface declarations
770+
// We just need to ensure proper export and declare keywords
771+
let text = decl.text
772+
773+
// If the extractor's text already starts with proper keywords, use it
774+
if (text.startsWith('export declare interface') || text.startsWith('declare interface')) {
775+
return comments + text
776+
}
777+
778+
// Otherwise build from components
707779
let result = ''
708780

709781
// Add export if needed
@@ -727,10 +799,10 @@ export function processInterfaceDeclaration(decl: Declaration, keepComments: boo
727799
result += ` extends ${decl.extends}`
728800
}
729801

730-
// Extract the body from the original text
731-
const bodyMatch = decl.text.match(/\{[\s\S]*\}/)
732-
if (bodyMatch) {
733-
result += ` ${bodyMatch[0]}`
802+
// Find the body using balanced brace matching to handle nested braces in generics
803+
const bodyStart = findInterfaceBodyStart(decl.text)
804+
if (bodyStart !== -1) {
805+
result += ` ${decl.text.slice(bodyStart)}`
734806
}
735807
else {
736808
result += ' {}'

packages/dtsx/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface Declaration {
6363
source?: string // for imports
6464
specifiers?: ImportSpecifier[] // for imports
6565
isTypeOnly?: boolean // for imports/exports
66+
isSideEffect?: boolean // for side-effect imports like `import 'module'`
6667
isAsync?: boolean
6768
isGenerator?: boolean
6869
overloads?: string[] // for function overloads

packages/dtsx/test/fixtures/output/exports.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { BunPlugin } from 'bun';
22
import { dtsConfig } from './config';
3-
import { generate } from './generate';
3+
import { generate, something as dts } from './generate';
44
import type { SomeOtherType } from '@stacksjs/types';
55
export type { SomeOtherType };
66
export type { BunRegisterPlugin } from 'bun';

packages/dtsx/test/fixtures/output/imports.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { basename, delimiter, dirname, extname, isAbsolute, join, normalize, type ParsedPath, relative, resolve, sep, toNamespacedPath } from 'node:path';
22
import { generate } from '@stacksjs/dtsx';
3+
import { something as dts } from './generate';
34
import forge, { pki, tls } from 'node-forge';
45
/**
56
* Returns the path to the `actions` directory. The `actions` directory

0 commit comments

Comments
 (0)