Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 105 additions & 12 deletions core/packages/gapic-node-processing/src/combine-libraries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
);
}
Comment thread
quirogas marked this conversation as resolved.

export function mergeVersionIndexExports(
contentA: string,
contentB: string,
): string {
const exportsMap = new Map<string, string>();

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.
*
Expand Down Expand Up @@ -157,23 +241,32 @@ export async function generateFinalDirectoryPath(
);
}

// Now we need to clean out duplicates
const uniquePaths = new Set<string>();
const uniquefullPathAndContent = [];
// Now we need to clean out duplicates and merge version index files
const uniquePathsMap = new Map<string, FilePathsAndContents>();

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;
}

/**
Expand Down Expand Up @@ -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);

Expand Down
49 changes: 29 additions & 20 deletions core/packages/gapic-node-processing/src/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, Set<string>>();
const allVersionedLibraries = await getAllTopLevelDirectories(
this.sourcePath,
);
Expand All @@ -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,
Expand All @@ -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;
}

Expand Down Expand Up @@ -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}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
generateFinalDirectoryPath,
combineLibraries,
writeFilesToGivenLocation,
mergeVersionIndexExports,
isVersionIndexFile,
} from '../src/combine-libraries';
import {describe, it} from 'mocha';
import * as path from 'path';
Expand Down Expand Up @@ -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);
});
});
});
Loading