Skip to content

Commit

Permalink
chore: wip
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisbbreuer committed Oct 17, 2024
1 parent a92e32b commit caedf34
Show file tree
Hide file tree
Showing 3 changed files with 152 additions and 134 deletions.
Binary file modified bun.lockb
Binary file not shown.
254 changes: 120 additions & 134 deletions src/generate.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,110 @@
import type { Result } from 'neverthrow'
import type { DtsGenerationConfig, DtsGenerationOption } from './types'
import { readdir, readFile, rm, mkdir } from 'node:fs/promises'
import { extname, join, relative, dirname } from 'node:path'
import { readFile, rm, mkdir } from 'node:fs/promises'
import { join, relative, dirname } from 'node:path'
import { err, ok } from 'neverthrow'
import { config } from './config'
import { writeToFile, getAllTypeScriptFiles, checkIsolatedDeclarations } from './utils'

function validateOptions(options: unknown): Result<DtsGenerationOption, Error> {
if (typeof options === 'object' && options !== null) {
return ok(options as DtsGenerationOption)
export async function generateDeclarationsFromFiles(options: DtsGenerationConfig = config): Promise<void> {
// Check for isolatedModules setting
const isIsolatedDeclarations = await checkIsolatedDeclarations(options)
if (!isIsolatedDeclarations) {
console.error('Error: isolatedModules must be set to true in your tsconfig.json. Ensure `tsc --noEmit` does not output any errors.')
return
}

return err(new Error('Invalid options'))
if (options.clean) {
console.log('Cleaning output directory...')
await rm(options.outdir, { recursive: true, force: true })
}

const validationResult = validateOptions(options)

if (validationResult.isErr()) {
console.error(validationResult.error.message)
return
}

const files = await getAllTypeScriptFiles(options.root)
console.log('Found the following TypeScript files:', files)

for (const file of files) {
console.log(`Processing file: ${file}`)
let fileDeclarations
const isConfigFile = file.endsWith('config.ts')
const isIndexFile = file.endsWith('index.ts')
if (isConfigFile) {
fileDeclarations = await extractConfigTypeFromSource(file)
} else if (isIndexFile) {
fileDeclarations = await extractIndexTypeFromSource(file)
} else {
fileDeclarations = await extractTypeFromSource(file)
}

if (fileDeclarations) {
const relativePath = relative(options.root, file)
const outputPath = join(options.outdir, relativePath.replace(/\.ts$/, '.d.ts'))

// Ensure the directory exists
await mkdir(dirname(outputPath), { recursive: true })

// Format and write the declarations
const formattedDeclarations = formatDeclarations(fileDeclarations, isConfigFile)
await writeToFile(outputPath, formattedDeclarations)

console.log(`Generated ${outputPath}`)
}
}


console.log('Declaration file generation complete')
}

export async function generate(options?: DtsGenerationOption): Promise<void> {
await generateDeclarationsFromFiles({ ...config, ...options })
}

async function extractTypeFromSource(filePath: string): Promise<string> {
const fileContent = await readFile(filePath, 'utf-8')
let declarations = ''
let imports = new Set()
let imports = new Set<string>()

// Handle exports
const exportRegex = /export\s+((?:interface|type|const|function|async function)\s+\w+(?:\s*=\s*[^;]+|\s*\{[^}]*\}|\s*\([^)]*\)[^;]*));?/gs
// Handle imported types
const importRegex = /import\s+type\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g
let importMatch
while ((importMatch = importRegex.exec(fileContent)) !== null) {
const types = importMatch[1].split(',').map(t => t.trim())
const from = importMatch[2]
types.forEach(type => imports.add(`${type}:${from}`))
}

// Handle exported functions with comments
const exportedFunctionRegex = /(\/\*\*[\s\S]*?\*\/\s*)?(export\s+(async\s+)?function\s+(\w+)\s*\(([^)]*)\)\s*:\s*([^{]+))/g
let match
while ((match = exportRegex.exec(fileContent)) !== null) {
const declaration = match[1].trim()
while ((match = exportedFunctionRegex.exec(fileContent)) !== null) {
const [, comment, , isAsync, name, params, returnType] = match
const cleanParams = params.replace(/\s*=\s*[^,)]+/g, '')
const declaration = `${comment || ''}export declare ${isAsync || ''}function ${name}(${cleanParams}): ${returnType.trim()}`
declarations += `${declaration}\n\n`

// Check for types used in the declaration and add them to imports
const usedTypes = [...params.matchAll(/(\w+):\s*([A-Z]\w+)/g), ...returnType.matchAll(/\b([A-Z]\w+)\b/g)]
usedTypes.forEach(([, , type]) => {
if (type) imports.add(type)
})
}

// Handle other exports (interface, type, const)
const otherExportRegex = /(\/\*\*[\s\S]*?\*\/\s*)?(export\s+((?:interface|type|const)\s+\w+(?:\s*=\s*[^;]+|\s*\{[^}]*\})));?/gs
while ((match = otherExportRegex.exec(fileContent)) !== null) {
const [, comment, exportStatement, declaration] = match
if (declaration.startsWith('interface') || declaration.startsWith('type')) {
declarations += `export ${declaration}\n\n`
declarations += `${comment || ''}${exportStatement}\n\n`
} else if (declaration.startsWith('const')) {
const [, name, type] = declaration.match(/const\s+(\w+):\s*([^=]+)/) || []
if (name && type) {
declarations += `export declare const ${name}: ${type.trim()}\n\n`
}
} else if (declaration.startsWith('function') || declaration.startsWith('async function')) {
const funcMatch = declaration.match(/(async\s+)?function\s+(\w+)\s*\(([^)]*)\)\s*:\s*([^{]+)/)
if (funcMatch) {
const [, isAsync, name, params, returnType] = funcMatch
// Remove default values in parameters
const cleanParams = params.replace(/\s*=\s*[^,)]+/g, '')
declarations += `export declare ${isAsync || ''}function ${name}(${cleanParams}): ${returnType.trim()}\n\n`
declarations += `${comment || ''}export declare const ${name}: ${type.trim()}\n\n`
}
}

Expand All @@ -45,24 +113,25 @@ async function extractTypeFromSource(filePath: string): Promise<string> {
usedTypes.forEach(type => imports.add(type))
}

// Only include imports for types that are actually used
const importRegex = /import\s+type\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g
// Generate import statements for used types
let importDeclarations = ''
while ((match = importRegex.exec(fileContent)) !== null) {
const types = match[1].split(',').map(t => t.trim())
const from = match[2]
const usedTypes = types.filter(type => imports.has(type))
if (usedTypes.length > 0) {
importDeclarations += `import type { ${usedTypes.join(', ')} } from '${from}'\n`
const importMap = new Map()
imports.forEach(typeWithPath => {
const [type, path] = typeWithPath.split(':')
if (path) {
if (!importMap.has(path)) importMap.set(path, new Set())
importMap.get(path).add(type)
}
}
})
importMap.forEach((types, path) => {
importDeclarations += `import type { ${Array.from(types).join(', ')} } from '${path}'\n`
})

if (importDeclarations) {
declarations = importDeclarations + '\n\n' + declarations // Add two newlines here
declarations = importDeclarations + '\n' + declarations
}

// Add a special marker between imports and exports
return declarations.replace(/\n(export)/, '\n###LINEBREAK###$1').trim() + '\n'
return declarations.trim() + '\n'
}

async function extractConfigTypeFromSource(filePath: string): Promise<string> {
Expand Down Expand Up @@ -105,113 +174,30 @@ async function extractIndexTypeFromSource(filePath: string): Promise<string> {

function formatDeclarations(declarations: string, isConfigFile: boolean): string {
if (isConfigFile) {
// Special formatting for config.d.ts
return declarations
.replace(/\n{3,}/g, '\n\n') // Remove excess newlines, but keep doubles
.replace(/(\w+):\s+/g, '$1: ') // Ensure single space after colon
.trim() + '\n' // Ensure final newline
.replace(/\n{3,}/g, '\n\n')
.replace(/(\w+):\s+/g, '$1: ')
.trim() + '\n'
}

// Regular formatting for other files
return declarations
.replace(/\n{3,}/g, '\n\n') // Remove excess newlines, but keep doubles
.replace(/(\w+):\s+/g, '$1: ') // Ensure single space after colon
.replace(/\s*\n\s*/g, '\n') // Remove extra spaces around newlines
.replace(/\{\s*\n\s*\n/g, '{\n') // Remove extra newline after opening brace
.replace(/\n\s*\}/g, '\n}') // Remove extra space before closing brace
.replace(/;\s*\n/g, '\n') // Remove semicolons at end of lines
.replace(/export interface ([^\{]+)\{/g, 'export interface $1{ ') // Add space after opening brace for interface
.replace(/^(\s*\w+:.*(?:\n|$))/gm, ' $1') // Ensure all properties in interface are indented
.replace(/}\n\n(?=export (interface|type))/g, '}\n') // Remove extra newline between interface/type declarations
.replace(/^(import .*\n)+/m, match => match.trim() + '\n') // Ensure imports are grouped
.replace(/###LINEBREAK###/g, '\n') // Replace the special marker with a newline
.replace(/\n{3,}/g, '\n\n') // Final pass to remove any triple newlines
.trim() + '\n' // Ensure final newline
.replace(/\n{3,}/g, '\n\n')
.replace(/(\w+):\s+/g, '$1: ')
.replace(/\s*\n\s*/g, '\n')
.replace(/\{\s*\n\s*\n/g, '{\n')
.replace(/\n\s*\}/g, '\n}')
.replace(/;\s*\n/g, '\n')
.replace(/export interface ([^\{]+)\{/g, 'export interface $1{ ')
.replace(/^(\s*\w+:.*(?:\n|$))/gm, ' $1')
.replace(/}\n\n(?=\/\*\*|export (interface|type))/g, '}\n')
.replace(/^(import .*\n)+/m, match => match.trim() + '\n')
.trim() + '\n'
}

export async function generateDeclarationsFromFiles(options: DtsGenerationConfig = config): Promise<void> {
// Check for isolatedModules setting
const isIsolatedDeclarations = await checkIsolatedDeclarations(options)
if (!isIsolatedDeclarations) {
console.error('Error: isolatedModules must be set to true in your tsconfig.json. Ensure `tsc --noEmit` does not output any errors.')
return
}

if (options.clean) {
console.log('Cleaning output directory...')
await rm(options.outdir, { recursive: true, force: true })
}

const validationResult = validateOptions(options)

if (validationResult.isErr()) {
console.error(validationResult.error.message)
return
}

const files = await getAllTypeScriptFiles(options.root)
console.log('Found the following TypeScript files:', files)

for (const file of files) {
console.log(`Processing file: ${file}`)
let fileDeclarations
const isConfigFile = file.endsWith('config.ts')
const isIndexFile = file.endsWith('index.ts')
if (isConfigFile) {
fileDeclarations = await extractConfigTypeFromSource(file)
} else if (isIndexFile) {
fileDeclarations = await extractIndexTypeFromSource(file)
} else {
fileDeclarations = await extractTypeFromSource(file)
}

if (fileDeclarations) {
const relativePath = relative(options.root, file)
const outputPath = join(options.outdir, relativePath.replace(/\.ts$/, '.d.ts'))

// Ensure the directory exists
await mkdir(dirname(outputPath), { recursive: true })

// Format and write the declarations
const formattedDeclarations = formatDeclarations(fileDeclarations, isConfigFile)
await writeToFile(outputPath, formattedDeclarations)

console.log(`Generated ${outputPath}`)
}
function validateOptions(options: unknown): Result<DtsGenerationOption, Error> {
if (typeof options === 'object' && options !== null) {
return ok(options as DtsGenerationOption)
}


console.log('Declaration file generation complete')
}

async function getAllTypeScriptFiles(directory?: string): Promise<string[]> {
const dir = directory ?? config.root
const entries = await readdir(dir, { withFileTypes: true })

const files = await Promise.all(entries.map((entry) => {
const res = join(dir, entry.name)
return entry.isDirectory() ? getAllTypeScriptFiles(res) : res
}))

return Array.prototype.concat(...files).filter(file => extname(file) === '.ts')
}

export async function generate(options?: DtsGenerationOption): Promise<void> {
await generateDeclarationsFromFiles({ ...config, ...options })
}

async function writeToFile(filePath: string, content: string): Promise<void> {
await Bun.write(filePath, content)
}

async function checkIsolatedDeclarations(options: DtsGenerationConfig): Promise<boolean> {
try {
const tsconfigPath = options.tsconfigPath || join(options.root, 'tsconfig.json')
const tsconfigContent = await readFile(tsconfigPath, 'utf-8')
const tsconfig = JSON.parse(tsconfigContent)

return tsconfig.compilerOptions?.isolatedDeclarations === true
} catch (error) {
return false
}
return err(new Error('Invalid options'))
}
32 changes: 32 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { readdir, readFile } from 'node:fs/promises'
import { extname, join } from 'node:path'
import { config } from './config'
import { type DtsGenerationConfig } from './types'

export async function writeToFile(filePath: string, content: string): Promise<void> {
await Bun.write(filePath, content)
}

export async function getAllTypeScriptFiles(directory?: string): Promise<string[]> {
const dir = directory ?? config.root
const entries = await readdir(dir, { withFileTypes: true })

const files = await Promise.all(entries.map((entry) => {
const res = join(dir, entry.name)
return entry.isDirectory() ? getAllTypeScriptFiles(res) : res
}))

return Array.prototype.concat(...files).filter(file => extname(file) === '.ts')
}

export async function checkIsolatedDeclarations(options: DtsGenerationConfig): Promise<boolean> {
try {
const tsconfigPath = options.tsconfigPath || join(options.root, 'tsconfig.json')
const tsconfigContent = await readFile(tsconfigPath, 'utf-8')
const tsconfig = JSON.parse(tsconfigContent)

return tsconfig.compilerOptions?.isolatedDeclarations === true
} catch (error) {
return false
}
}

0 comments on commit caedf34

Please sign in to comment.