/* * Copyright 2025 the original author or authors. *

* Licensed under the Moderne Source Available License (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* https://docs.moderne.io/licensing/moderne-source-available-license *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import {ExecutionContext} from "./execution"; import {OptionDescriptor} from "./recipe"; export const DATA_TABLE_STORE = Symbol.for("org.openrewrite.dataTables.store"); const COLUMNS_KEY = Symbol.for("org.openrewrite.dataTables.columns"); export function Column(descriptor: ColumnDescriptor) { return function (target: any, propertyKey: string) { if (!target.constructor.hasOwnProperty(COLUMNS_KEY)) { Object.defineProperty(target.constructor, COLUMNS_KEY, { value: {}, writable: true, configurable: true, }); } target.constructor[COLUMNS_KEY][propertyKey] = descriptor; } } export interface DataTableStore { insertRow(dataTable: DataTable, ctx: ExecutionContext, row: Row): void getRows(dataTableName: string, scope: string): Iterable getDataTables(): DataTable[] } export class InMemoryDataTableStore implements DataTableStore { private readonly _buckets = new Map, rows: any[] }>() insertRow(dataTable: DataTable, _ctx: ExecutionContext, row: Row): void { const suffix = dataTable.group ?? dataTable.instanceName; const key = `${dataTable.descriptor.name}\0${suffix}`; let bucket = this._buckets.get(key); if (!bucket) { bucket = {dataTable, rows: []}; this._buckets.set(key, bucket); } bucket.rows.push(row); } getRows(dataTableName: string, group?: string): any[] { if (group !== undefined) { const key = `${dataTableName}\0${group}`; const bucket = this._buckets.get(key); return bucket ? [...bucket.rows] : []; } // For ungrouped, find by name with no group for (const bucket of this._buckets.values()) { if (bucket.dataTable.descriptor.name === dataTableName && bucket.dataTable.group === undefined) { return [...bucket.rows]; } } return []; } getDataTables(): DataTable[] { return Array.from(this._buckets.values()).map(b => b.dataTable); } } function sha256Prefix(input: string, hexChars: number): string { const hash = crypto.createHash('sha256').update(input, 'utf8').digest('hex'); return hash.substring(0, hexChars); } /** * Sanitize a value for a filename, byte-identical to the Java host's * {@code CsvDataTableStore.sanitize} so a shared data table resolves to the same file. */ export function sanitizeScope(scope: string): string { // 1. lowercase let s = scope.toLowerCase(); // \p{L}\p{Nd} mirrors Java's Character.isLetterOrDigit, for cross-runtime parity s = s.replace(/[^\p{L}\p{Nd}]/gu, '-'); s = s.replace(/-+/g, '-').replace(/^-|-$/g, ''); if (s.length > 30) { s = s.substring(0, 30); const lastDash = s.lastIndexOf('-'); if (lastDash > 0) { s = s.substring(0, lastDash); } } const hash = sha256Prefix(scope, 4); return `${s}-${hash}`; } export class DataTable { private readonly _descriptor: DataTableDescriptor private _group?: string private _instanceName?: string public constructor(name: string, displayName: string, description: string, private rowConstructor: { [key: string | symbol]: any }) { this._descriptor = { name: name, displayName: displayName, description: description, columns: [] } } get descriptor(): DataTableDescriptor { const columnsRecord: Record = this.rowConstructor[COLUMNS_KEY] || {}; return { ...this._descriptor, columns: Object.entries(columnsRecord).map(([name, descriptor]) => ({name, ...descriptor})), } } get group(): string | undefined { return this._group; } set group(value: string) { this._group = value; } get instanceName(): string { return this._instanceName ?? this._descriptor.displayName; } set instanceName(value: string) { this._instanceName = value; } insertRow(ctx: ExecutionContext, row: Row): void { if (!ctx.messages[DATA_TABLE_STORE]) { ctx.messages[DATA_TABLE_STORE] = new InMemoryDataTableStore(); } const dataTableStore: DataTableStore = ctx.messages[DATA_TABLE_STORE]; dataTableStore.insertRow(this, ctx, row); } } export interface DataTableDescriptor { name: string, displayName: string, description: string, columns: ({ name: string } & ColumnDescriptor)[] } export interface ColumnDescriptor { displayName: string, description: string } /** * Escape a value for CSV output following RFC 4180. */ function escapeCsv(value: unknown): string { if (value === null || value === undefined) { return '""'; } const str = String(value); if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { return '"' + str.replace(/"/g, '""') + '"'; } return str; } /** * A DataTableStore that writes rows directly to CSV files as they are inserted. * Uses the data table's file-safe key for filenames. */ export class CsvDataTableStore implements DataTableStore { private readonly _rowCounts: { [key: string]: number } = {}; private readonly _dataTables = new Map>(); constructor(private readonly outputDir: string, private readonly prefixColumns: { [key: string]: string } = {}, private readonly suffixColumns: { [key: string]: string } = {}) { fs.mkdirSync(outputDir, {recursive: true}); } insertRow(dataTable: DataTable, _ctx: ExecutionContext, row: Row): void { const fileKey = CsvDataTableStore.fileKey(dataTable); const csvPath = path.join(this.outputDir, fileKey + '.csv'); this._dataTables.set(fileKey, dataTable); const columns = dataTable.descriptor.columns; // Key the header on file existence, not in-memory state, so writers sharing one file // (Java host + RPC runtimes) emit it once; column names (not display names) match the Java writer. if (!fs.existsSync(csvPath)) { const header = [ ...Object.keys(this.prefixColumns), ...columns.map(col => col.name), ...Object.keys(this.suffixColumns), ].map(escapeCsv).join(','); const comments = [ `# @name ${dataTable.descriptor.name}`, `# @instanceName ${dataTable.instanceName}`, `# @group ${dataTable.group ?? ''}`, ].join('\n'); fs.writeFileSync(csvPath, comments + '\n' + header + '\n'); } const rowValues = [ ...Object.values(this.prefixColumns), ...columns.map(col => (row as any)[col.name]), ...Object.values(this.suffixColumns), ].map(escapeCsv); fs.appendFileSync(csvPath, rowValues.join(',') + '\n'); this._rowCounts[fileKey] = (this._rowCounts[fileKey] ?? 0) + 1; } getRows(_dataTableName: string, _group?: string): any[] { // CSV store writes to disk; reading back is not supported return []; } getDataTables(): DataTable[] { return Array.from(this._dataTables.values()); } get rowCounts(): { [key: string]: number } { return {...this._rowCounts}; } get tableKeys(): string[] { return Array.from(this._dataTables.keys()); } /** * Filesystem-safe key for a data table; mirrors the Java host's * {@code CsvDataTableStore.fileKey} so a shared table resolves to the same filename. */ static fileKey(dataTable: DataTable): string { const name = dataTable.descriptor.name; const group = dataTable.group; if (group != null) { if (group === name) { return name; } return `${name}--${sanitizeScope(group)}`; } // Suffix only when instanceName was customized (differs from the display name). const instanceName = dataTable.instanceName; if (instanceName === dataTable.descriptor.displayName) { return name; } return `${name}--${sanitizeScope(instanceName)}`; } }