diff --git a/core/packages/gapic-node-processing/src/combine-libraries.ts b/core/packages/gapic-node-processing/src/combine-libraries.ts index 3b4ce47074c4..927884fd49ee 100644 --- a/core/packages/gapic-node-processing/src/combine-libraries.ts +++ b/core/packages/gapic-node-processing/src/combine-libraries.ts @@ -13,6 +13,7 @@ // limitations under the License. import {Dirent} from 'fs'; +import * as ts from 'typescript'; import {LibraryConfig} from './library'; const fs = require('fs/promises'); // For async file system operations @@ -26,6 +27,89 @@ export interface FilePathsAndContents { filePath: string; content: string; } + +function isVersionDirectory(dirName: string): boolean { + return ( + dirName.length >= 2 && + dirName.startsWith('v') && + dirName[1] >= '0' && + dirName[1] <= '9' + ); +} + +export function isVersionIndexFile(filePath: string): boolean { + const parts = filePath.replace(/\\/g, '/').split('/'); + if (parts.length < 3) return false; + const fileName = parts[parts.length - 1]; + const versionDir = parts[parts.length - 2]; + const srcDir = parts[parts.length - 3]; + + return ( + fileName === 'index.ts' && + srcDir === 'src' && + isVersionDirectory(versionDir) + ); +} + +export function mergeVersionIndexExports( + contentA: string, + contentB: string, +): string { + const exportsMap = new Map(); + + function parseExports(content: string) { + if (!content) return; + const sourceFile = ts.createSourceFile( + 'index.ts', + content, + ts.ScriptTarget.Latest, + true, + ); + + for (const statement of sourceFile.statements) { + if (ts.isExportDeclaration(statement)) { + const moduleSpecifier = + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) + ? statement.moduleSpecifier.text + : ''; + if ( + statement.exportClause && + ts.isNamedExports(statement.exportClause) + ) { + for (const element of statement.exportClause.elements) { + const exportSpecifier = element.propertyName + ? `${element.propertyName.text} as ${element.name.text}` + : element.name.text; + if (moduleSpecifier) { + exportsMap.set(exportSpecifier, moduleSpecifier); + } + } + } + } + } + } + + parseExports(contentA); + parseExports(contentB); + + const licenseMatchA = contentA.match(/^(\/\*\*?[\s\S]*?\*\/|\/\/[^\n]*\n)+/); + const licenseMatchB = contentB.match(/^(\/\*\*?[\s\S]*?\*\/|\/\/[^\n]*\n)+/); + const licenseHeader = ( + licenseMatchA ? licenseMatchA[0] : licenseMatchB ? licenseMatchB[0] : '' + ).trim(); + + const exportLines: string[] = []; + const sortedExportKeys = Array.from(exportsMap.keys()).sort(); + for (const exportKey of sortedExportKeys) { + const moduleSpecifier = exportsMap.get(exportKey); + exportLines.push(`export {${exportKey}} from '${moduleSpecifier}';`); + } + + return ( + (licenseHeader ? licenseHeader + '\n' : '') + exportLines.join('\n') + '\n' + ); +} /** * Recursively removes a regex pattern from a specified property in an array of objects. * @@ -157,23 +241,32 @@ export async function generateFinalDirectoryPath( ); } - // Now we need to clean out duplicates - const uniquePaths = new Set(); - const uniquefullPathAndContent = []; + // Now we need to clean out duplicates and merge version index files + const uniquePathsMap = new Map(); for (const fullPathAndContent of fullPathsAndContents) { const normalizedPath = fullPathAndContent.filePath.replace(/\\/g, '/'); fullPathAndContent.filePath = normalizedPath; - if (!uniquePaths.has(normalizedPath)) { - uniquePaths.add(normalizedPath); - uniquefullPathAndContent.push(fullPathAndContent); + + if (isVersionIndexFile(normalizedPath)) { + if (uniquePathsMap.has(normalizedPath)) { + const existing = uniquePathsMap.get(normalizedPath)!; + existing.content = mergeVersionIndexExports( + existing.content, + fullPathAndContent.content, + ); + } else { + uniquePathsMap.set(normalizedPath, fullPathAndContent); + } + } else { + if (!uniquePathsMap.has(normalizedPath)) { + uniquePathsMap.set(normalizedPath, fullPathAndContent); + } } } - uniquefullPathAndContent.forEach(x => console.log(x)); - return uniquefullPathAndContent as unknown as { - filePath: string; - content: string; - }[]; + + const uniquefullPathAndContent = Array.from(uniquePathsMap.values()); + return uniquefullPathAndContent; } /** @@ -268,7 +361,7 @@ export async function writeFilesToGivenLocation( try { await fs.rm(dirToWrite, {recursive: true, force: true}); } catch (err) { - `${dirToWrite} not found; could not be deleted`; + // ignore if directory does not exist } await ensureDirectoryExists(dirToWrite); diff --git a/core/packages/gapic-node-processing/src/library.ts b/core/packages/gapic-node-processing/src/library.ts index 6c0471c289d2..a6f89588c18e 100644 --- a/core/packages/gapic-node-processing/src/library.ts +++ b/core/packages/gapic-node-processing/src/library.ts @@ -36,6 +36,10 @@ export {{ '{' + service.name.toPascalCase() + 'Client}' }} from './{{ service.na */ const CLIENT_EXTRACTION_REGEX = /export\s*{\s*(\w+Client)\s*}/g; +function isNodeError(err: unknown): err is NodeJS.ErrnoException { + return err instanceof Error && 'code' in err; +} + /** * Represents a parsed version of a library, breaking it down into components * that can be used for comparison to determine release precedence. @@ -119,7 +123,7 @@ export class LibraryConfig { * A getter to provide a list of clients and their corresponding versions. */ public async getClientsAndVersions() { - const clientsAndVersions: VersionsAndClients[] = []; + const versionMap = new Map>(); const allVersionedLibraries = await getAllTopLevelDirectories( this.sourcePath, ); @@ -130,16 +134,10 @@ export class LibraryConfig { throw new Error( 'Unexpected library format. Expected *only* top-level directories containing fully formed libraries for each verison.', ); - // If this fails, it means that the library is not - // in the format we expect. This could happen if we - // are rerunning the command on a well-formed library } const versions = await getAllTopLevelDirectories( path.join(this.sourcePath, directory, this.srcPath), ); - // even though this looks nested, it ends up being o(1) since - // we only have one directory per versioned library (the single - // version of the library) for (const version of versions) { const indexFile = path.join( this.sourcePath, @@ -148,23 +146,37 @@ export class LibraryConfig { version, INDEX_PATH, ); - if (await fs.stat(indexFile)) { + try { const fileContent = await fs.readFile(indexFile, 'utf8'); const clientsRegexMatch = Array.from( fileContent.matchAll(CLIENT_EXTRACTION_REGEX), ); - // ensures we don't have any duplicates in the regex matching - // creates an array from the set which is what the client type is - clientsAndVersions.push({ - version, - clients: Array.from( - new Set(clientsRegexMatch.map((x: any) => x[1])), - ), - }); + if (!versionMap.has(version)) { + versionMap.set(version, new Set()); + } + const set = versionMap.get(version)!; + for (const match of clientsRegexMatch) { + set.add(match[1]); + } + } catch (err) { + if (isNodeError(err) && err.code === 'ENOENT') { + // ignore if file does not exist + } else { + const details = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to read version index file at ${indexFile}: ${details}`, + ); + } } } } - console.log('Found the following clients and versions', clientsAndVersions); + const clientsAndVersions: VersionsAndClients[] = []; + for (const [version, clientSet] of versionMap.entries()) { + clientsAndVersions.push({ + version, + clients: Array.from(clientSet).sort(), + }); + } return clientsAndVersions; } @@ -247,12 +259,9 @@ export class LibraryConfig { } function alphaOrBetaPrecedence(preRelease: string): '' | 'beta' | 'alpha' { - console.log(preRelease); if (preRelease.startsWith('beta')) { - console.log('beta'); return 'beta'; } else if (preRelease.startsWith('alpha')) { - console.log('alpha'); return 'alpha'; } else { throw new Error(`Unknown pre-release type: ${preRelease}`); diff --git a/core/packages/gapic-node-processing/test/combine-libraries.test.ts b/core/packages/gapic-node-processing/test/combine-libraries.test.ts index 0236997d4308..77e793cc5f07 100644 --- a/core/packages/gapic-node-processing/test/combine-libraries.test.ts +++ b/core/packages/gapic-node-processing/test/combine-libraries.test.ts @@ -16,6 +16,8 @@ import { generateFinalDirectoryPath, combineLibraries, writeFilesToGivenLocation, + mergeVersionIndexExports, + isVersionIndexFile, } from '../src/combine-libraries'; import {describe, it} from 'mocha'; import * as path from 'path'; @@ -282,4 +284,40 @@ describe('combine libraries', () => { ); } }); + + it('should merge export statements across version index files', () => { + const contentA = `// Copyright 2026 Google LLC +export { BigtableClient } from './bigtable_client'; +`; + const contentB = `// Copyright 2026 Google LLC +export { BigtableInstanceAdminClient } from './bigtable_instance_admin_client'; +export { BigtableTableAdminClient } from './bigtable_table_admin_client'; +`; + + const merged = mergeVersionIndexExports(contentA, contentB); + assert.strictEqual( + merged, + `// Copyright 2026 Google LLC +export {BigtableClient} from './bigtable_client'; +export {BigtableInstanceAdminClient} from './bigtable_instance_admin_client'; +export {BigtableTableAdminClient} from './bigtable_table_admin_client'; +` + ); + }); + + describe('isVersionIndexFile', () => { + it('should return true for valid version index files', () => { + assert.strictEqual(isVersionIndexFile('src/v1/index.ts'), true); + assert.strictEqual(isVersionIndexFile('src/v2/index.ts'), true); + assert.strictEqual(isVersionIndexFile('src/v1beta1/index.ts'), true); + assert.strictEqual(isVersionIndexFile('packages/foo/src/v1/index.ts'), true); + }); + + it('should return false for non-version or non-index files', () => { + assert.strictEqual(isVersionIndexFile('src/index.ts'), false); + assert.strictEqual(isVersionIndexFile('src/validators/index.ts'), false); + assert.strictEqual(isVersionIndexFile('src/views/index.ts'), false); + assert.strictEqual(isVersionIndexFile('src/v1/helpers.ts'), false); + }); + }); });