diff --git a/package.json b/package.json index edd3d33..a7ccf60 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,22 @@ "command": "slingr.runInfraUpdate", "title": "Run infrastructure update" }, + { + "command": "slingr-vscode-extension.changeReferenceToComposition", + "title": "Change Reference to Composition" + }, + { + "command": "slingr-vscode-extension.changeCompositionToReference", + "title": "Change Composition to Reference" + }, + { + "command": "slingr-vscode-extension.changeReferenceToComposition", + "title": "Change Reference to Composition" + }, + { + "command": "slingr-vscode-extension.changeCompositionToReference", + "title": "Change Composition to Reference" + }, { "command": "slingr-vscode-extension.newModel", "title": "New model" @@ -66,7 +82,23 @@ }, { "command": "slingr-vscode-extension.addField", - "title": "Add field" + "title": "Add Field" + }, + { + "command": "slingr-vscode-extension.addComposition", + "title": "Add Composition" + }, + { + "command": "slingr-vscode-extension.addReference", + "title": "Add Reference" + }, + { + "command": "slingr-vscode-extension.addComposition", + "title": "Add Composition" + }, + { + "command": "slingr-vscode-extension.addReference", + "title": "Add Reference" }, { "command": "slingr-vscode-extension.newFolder", @@ -96,6 +128,38 @@ "command": "slingr-vscode-extension.newDataSource", "title": "New data source" }, + { + "command": "slingr-vscode-extension.extractFieldsToComposition", + "title": "Extract Fields to Composition" + }, + { + "command": "slingr-vscode-extension.extractFieldsToEmbedded", + "title": "Extract Fields to Embedded" + }, + { + "command": "slingr-vscode-extension.extractFieldsToParent", + "title": "Extract Fields to Parent" + }, + { + "command": "slingr-vscode-extension.extractFieldsToReference", + "title": "Extract Fields to Reference" + }, + { + "command": "slingr-vscode-extension.extractFieldsToComposition", + "title": "Extract Fields to Composition" + }, + { + "command": "slingr-vscode-extension.extractFieldsToEmbedded", + "title": "Extract Fields to Embedded" + }, + { + "command": "slingr-vscode-extension.extractFieldsToParent", + "title": "Extract Fields to Parent" + }, + { + "command": "slingr-vscode-extension.extractFieldsToReference", + "title": "Extract Fields to Reference" + }, { "command": "slingr-vscode-extension.renameDataSource", "title": "Rename data source" @@ -150,44 +214,54 @@ } ], "view/item/context": [ + { + "command": "slingr-vscode-extension.newModel", + "when": "view == slingrExplorer && (viewItem == 'folder' || viewItem == 'dataRoot' || viewItem == 'model' || viewItem == 'compositionField')", + "group": "0_creation@2" + }, { "command": "slingr-vscode-extension.newFolder", "when": "view == slingrExplorer && viewItem == 'dataRoot'", "group": "0_creation@1" }, { - "command": "slingr-vscode-extension.newModel", - "when": "view == slingrExplorer && viewItem == 'dataRoot'", + "command": "slingr-vscode-extension.defineFields", + "when": "view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')", "group": "0_creation@2" }, { "command": "slingr-vscode-extension.addField", - "when": "view == slingrExplorer && viewItem == 'model'", + "when": "view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')", + "group": "0_creation" + }, + { + "command": "slingr-vscode-extension.addComposition", + "when": "view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')", "group": "0_creation@1" }, { - "command": "slingr-vscode-extension.defineFields", - "when": "view == slingrExplorer && viewItem == 'model'", - "group": "0_creation@2" + "command": "slingr-vscode-extension.addReference", + "when": "view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')", + "group": "0_creation@1" }, { - "command": "slingr-vscode-extension.renameModel", - "when": "view == slingrExplorer && viewItem == 'model'", - "group": "1_modification@1" + "command": "slingr-vscode-extension.createTest", + "when": "view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')", + "group": "0_creation" }, { - "command": "slingr-vscode-extension.modifyModel", + "command": "slingr-vscode-extension.renameModel", "when": "view == slingrExplorer && viewItem == 'model'", "group": "1_modification@2" }, { "command": "slingr-vscode-extension.deleteModel", - "when": "view == slingrExplorer && viewItem == 'model'", + "when": "view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')", "group": "1_modification@3" }, { "command": "slingr-vscode-extension.renameField", - "when": "view == slingrExplorer && viewItem == 'field'", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", "group": "0_modification@1" }, { @@ -197,32 +271,47 @@ }, { "command": "slingr-vscode-extension.deleteField", - "when": "view == slingrExplorer && viewItem == 'field'", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", "group": "0_modification@3" }, { "command": "slingr-vscode-extension.newFolder", - "when": "view == slingrExplorer && viewItem == 'folder'", - "group": "0_creation@1" + "when": "view == slingrExplorer && (viewItem == 'folder' || viewItem == 'referenceField')", + "group": "3_modification" }, { - "command": "slingr-vscode-extension.newModel", - "when": "view == slingrExplorer && viewItem == 'folder'", - "group": "0_creation@2" + "command": "slingr-vscode-extension.extractFieldsToComposition", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", + "group": "4_extract" }, { - "command": "slingr-vscode-extension.renameFolder", - "when": "view == slingrExplorer && viewItem == 'folder'", - "group": "1_modification@1" + "command": "slingr-vscode-extension.extractFieldsToEmbedded", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", + "group": "4_extract" }, { - "command": "slingr-vscode-extension.deleteFolder", - "when": "view == slingrExplorer && viewItem == 'folder'", - "group": "1_modification@2" + "command": "slingr-vscode-extension.extractFieldsToParent", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", + "group": "4_extract" }, { - "command": "slingr-vscode-extension.newDataSource", - "when": "view == slingrExplorer && viewItem == 'dataSourcesRoot'", + "command": "slingr-vscode-extension.extractFieldsToReference", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", + "group": "4_extract" + }, + { + "command": "slingr-vscode-extension.changeReferenceToComposition", + "when": "view == slingrExplorer && viewItem == 'referenceField'", + "group": "3_modification" + }, + { + "command": "slingr-vscode-extension.changeCompositionToReference", + "when": "view == slingrExplorer && viewItem == 'compositionField'", + "group": "0_creation@1" + }, + { + "command": "slingr-vscode-extension.createModelFromDescription", + "when": "view == slingrExplorer && (viewItem == 'folder' || viewItem == 'dataRoot' || viewItem == 'model' || viewItem == 'compositionField')", "group": "0_creation" }, { @@ -232,7 +321,7 @@ }, { "command": "slingr-vscode-extension.deleteDataSource", - "when": "view == slingrExplorer && viewItem == 'dataSource'", + "when": "view == slingrExplorer && (viewItem == 'dataSource' || viewItem == 'compositionField')", "group": "2_modification" } ], @@ -247,6 +336,11 @@ } ], "slingr-vscode-extension.creation": [ + { + "command": "slingr-vscode-extension.newModel", + "when": "(view == slingrExplorer && (viewItem == 'folder' || viewItem == 'dataRoot' || viewItem == 'model' || viewItem == 'compositionField')) || !viewItem", + "group": "0_model" + }, { "command": "slingr-vscode-extension.newFolder", "when": "(view == slingrExplorer && (viewItem == 'folder' || viewItem == 'dataRoot')) || !viewItem", @@ -259,12 +353,22 @@ }, { "command": "slingr-vscode-extension.addField", - "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", + "when": "(view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')) || !viewItem", "group": "1_field@1" }, { "command": "slingr-vscode-extension.defineFields", - "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", + "when": "(view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')) || !viewItem", + "group": "1_field" + }, + { + "command": "slingr-vscode-extension.addComposition", + "when": "(view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')) || !viewItem", + "group": "1_field" + }, + { + "command": "slingr-vscode-extension.addReference", + "when": "(view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')) || !viewItem", "group": "1_field@2" }, { @@ -276,7 +380,7 @@ "slingr-vscode-extension.refactorings": [ { "command": "slingr-vscode-extension.renameModel", - "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", + "when": "(view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')) || !viewItem", "group": "1_modification@1" }, { @@ -286,7 +390,7 @@ }, { "command": "slingr-vscode-extension.deleteModel", - "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", + "when": "(view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')) || !viewItem", "group": "2_modification@1" }, { @@ -301,18 +405,38 @@ }, { "command": "slingr-vscode-extension.renameField", - "when": "view == slingrExplorer && viewItem == 'field'", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", "group": "1_modification@4" }, { "command": "slingr-vscode-extension.changeFieldType", - "when": "view == slingrExplorer && viewItem == 'field'", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", "group": "1_modification@5" }, { "command": "slingr-vscode-extension.deleteField", - "when": "view == slingrExplorer && viewItem == 'field'", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", "group": "2_modification@3" + }, + { + "command": "slingr-vscode-extension.extractFieldsToComposition", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", + "group": "4_extract" + }, + { + "command": "slingr-vscode-extension.extractFieldsToEmbedded", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", + "group": "4_extract" + }, + { + "command": "slingr-vscode-extension.extractFieldsToParent", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", + "group": "4_extract" + }, + { + "command": "slingr-vscode-extension.extractFieldsToReference", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", + "group": "4_extract" } ] }, diff --git a/src/cache/cache.ts b/src/cache/cache.ts index f553dab..830ba21 100644 --- a/src/cache/cache.ts +++ b/src/cache/cache.ts @@ -124,6 +124,7 @@ export class MetadataCache { public readonly onInfrastructureStatusChange: vscode.Event = this._onInfrastructureStatusChange.event; public isInfrastructureUpdateNeeded: boolean = false; private outOfSyncDataSources: Set = new Set(); + private isBuildingReferences = false; /** * Initializes the cache and the ts-morph project. @@ -221,13 +222,13 @@ export class MetadataCache { * Sets up file system watchers to detect changes, creations, and deletions * of TypeScript files and folder structure changes in src/data. */ - private setupFileWatcher(): void { + private async setupFileWatcher(): Promise { // Watch for TypeScript file changes this.fileWatcher = vscode.workspace.createFileSystemWatcher('**/*.ts'); - this.fileWatcher.onDidCreate(uri => this.queueFileChange(uri, 'create')); - this.fileWatcher.onDidChange(uri => this.queueFileChange(uri, 'change')); - this.fileWatcher.onDidDelete(uri => this.queueFileChange(uri, 'delete')); + this.fileWatcher.onDidCreate(async uri => await this.queueFileChange(uri, 'create')); + this.fileWatcher.onDidChange(async uri => await this.queueFileChange(uri, 'change')); + this.fileWatcher.onDidDelete(async uri => await this.queueFileChange(uri, 'delete')); // Watch for folder structure changes in src/data directory // ignoreCreateEvents: false, ignoreChangeEvents: true, ignoreDeleteEvents: false @@ -243,13 +244,13 @@ export class MetadataCache { * @param uri The URI of the file that changed. * @param type The type of change (create, change, delete). */ - private queueFileChange(uri: vscode.Uri, type: FileChangeType): void { + private async queueFileChange(uri: vscode.Uri, type: FileChangeType): Promise { if (uri.path.includes('/node_modules/')) { return; } this.fileChangeQueue.push({ uri, type }); - this.processQueue(); + await this.processQueue(); } /** @@ -317,7 +318,6 @@ export class MetadataCache { return; } - this.isProcessingQueue = true; const { uri, type } = this.fileChangeQueue.shift()!; const filePath = uri.fsPath.replace(/\\/g, '/'); @@ -380,7 +380,8 @@ export class MetadataCache { this.cache[filePath] = newFileMeta; } - this.buildAllReferences(); + // Rebuild all references since this file change might affect references in other files + await this.buildAllReferences(); let fileType: CacheFileUpdateType = 'unknown'; if (filePath.includes('/src/dataSources/')) { fileType = 'dataSource'; @@ -393,7 +394,7 @@ export class MetadataCache { console.error(`Error processing file change for ${uri.fsPath}:`, error); } finally { this.isProcessingQueue = false; - this.processQueue(); + await this.processQueue(); } } @@ -484,7 +485,8 @@ export class MetadataCache { } // Build references and fire update event (same as processQueue) - this.buildAllReferences(); + // Rebuild all references since this file change might affect references in other files + await this.buildAllReferences(); this._onDidUpdate.fire({ type: 'dataSource', uri: uri }); // Fire infrastructure status change event AFTER cache has been updated @@ -833,32 +835,42 @@ export class MetadataCache { * @param targetFilePath Optional file path to rebuild references for. If not provided, rebuilds all. */ private async buildAllReferences(targetFilePath?: string): Promise { - - // If targetFilePath is provided, only rebuild references for that specific file - if (targetFilePath) { - this.buildReferencesForFile(targetFilePath); + // Prevent concurrent reference building operations + if (this.isBuildingReferences) { return; } + + this.isBuildingReferences = true; + + try { + // If targetFilePath is provided, only rebuild references for that specific file + if (targetFilePath) { + this.buildReferencesForFile(targetFilePath); + return; + } - // Full rebuild - clear all references first - let totalItems = 0; - for (const file of Object.values(this.cache)) { - for (const cls of Object.values(file.classes)) { - cls.references = []; - totalItems++; - for (const prop of Object.values(cls.properties)) { - prop.references = []; + // Full rebuild - clear all references first + let totalItems = 0; + for (const file of Object.values(this.cache)) { + for (const cls of Object.values(file.classes)) { + cls.references = []; + totalItems++; + for (const prop of Object.values(cls.properties)) { + prop.references = []; + totalItems++; + } + } + for (const ds of Object.values(file.dataSources)) { + ds.references = []; totalItems++; } } - for (const ds of Object.values(file.dataSources)) { - ds.references = []; - totalItems++; - } - } - // single pass through all source files - await this.buildReferencesOptimized(); + // single pass through all source files + await this.buildReferencesOptimized(); + } finally { + this.isBuildingReferences = false; + } } /** @@ -886,7 +898,9 @@ export class MetadataCache { for (const file of Object.values(this.cache)) { const sourceFile = this.tsMorphProject.getSourceFile(file.uri.fsPath); - if (!sourceFile) continue; + if (!sourceFile) { + continue; + } // Collect classes and their properties for (const cls of Object.values(file.classes)) { @@ -934,6 +948,17 @@ export class MetadataCache { return; } + // Clear existing references for this file before rebuilding + for (const cls of Object.values(file.classes)) { + cls.references = []; + for (const prop of Object.values(cls.properties)) { + prop.references = []; + } + } + for (const ds of Object.values(file.dataSources)) { + ds.references = []; + } + for (const cls of Object.values(file.classes)) { const classNode = sourceFile.getClass(cls.name); if (!classNode) { @@ -990,7 +1015,18 @@ export class MetadataCache { preciseRange ); - metadataObject.references.push(refLocation); + // Check for duplicates before adding + const isDuplicate = metadataObject.references.some(existing => + existing.uri.fsPath === refLocation.uri.fsPath && + existing.range.start.line === refLocation.range.start.line && + existing.range.start.character === refLocation.range.start.character && + existing.range.end.line === refLocation.range.end.line && + existing.range.end.character === refLocation.range.end.character + ); + + if (!isDuplicate) { + metadataObject.references.push(refLocation); + } } } } catch (error) { @@ -1042,6 +1078,12 @@ export class MetadataCache { return dataModels; } + public getModelByName(name: string): DecoratedClass | null { + const models = this.getDataModels(); + return models.find(m => m.name === name) || null; + } + + /** * Returns all @Model decorated classes that are stored in the src/data folder. * This is a more specific version of getDataModels() that only returns @@ -1054,6 +1096,30 @@ export class MetadataCache { ); } + public getModelDecoratorByName(name: string, model: DecoratedClass): DecoratorMetadata | null { + return model.decorators.find(decorator => decorator.name === name) || null; + } + + /** + * Returns all models that have the same datasource as the specified model. + * @param sourceModel - The model to compare datasources with + * @returns An array of DecoratedClass objects with the same datasource + */ + public getModelsByDataSource(sourceModel: DecoratedClass): DecoratedClass[] { + const sourceModelDecorator = this.getModelDecoratorByName("Model", sourceModel); + const sourceDataSource = sourceModelDecorator?.arguments?.[0]?.dataSource; + + return this.getDataModelClasses().filter(model => { + if (model.name === sourceModel.name) { + return false; // Don't include the source model itself + } + + const modelDecorator = this.getModelDecoratorByName("Model", model); + const modelDataSource = modelDecorator?.arguments?.[0]?.dataSource; + return modelDataSource === sourceDataSource; + }); + } + /** * Returns all data sources found in the cache. * @returns An array of DataSourceMetadata objects. diff --git a/src/commands/commandHelpers.ts b/src/commands/commandHelpers.ts new file mode 100644 index 0000000..4ae7bb4 --- /dev/null +++ b/src/commands/commandHelpers.ts @@ -0,0 +1,313 @@ +import * as vscode from 'vscode'; +import { AppTreeItem } from '../explorer/appTreeItem'; + +/** + * Interface for tree view multi-selection context + */ +export interface TreeViewContext { + /** The tree item that was clicked */ + clickedItem: AppTreeItem; + /** All selected tree items */ + selectedItems: AppTreeItem[]; + /** Filtered field items from the selection */ + fieldItems: AppTreeItem[]; + /** Model name from the field items */ + modelName: string; + /** Model file path */ + modelPath: string; +} + +/** + * Interface for URI resolution options + */ +export interface UriResolutionOptions { + /** Whether to require a TypeScript file */ + requireTypeScript?: boolean; + /** Whether to require a model file (contains @Model) */ + requireModel?: boolean; + /** Whether to allow fallback to active editor */ + allowActiveEditorFallback?: boolean; + /** Whether to use workspace folder as fallback when no URI provided */ + useWorkspaceFolderFallback?: boolean; + /** Error message when no URI is provided */ + noUriErrorMessage?: string; + /** Error message when file is not TypeScript */ + notTypeScriptErrorMessage?: string; + /** Error message when file is not a model */ + notModelErrorMessage?: string; +} + +/** + * Result of URI resolution + */ +export interface UriResolutionResult { + /** Resolved target URI */ + targetUri: vscode.Uri; + /** Model name if available from AppTreeItem */ + modelName?: string; + /** The document for the resolved URI */ + document?: vscode.TextDocument; +} + +/** + * Default options for URI resolution + */ +const DEFAULT_URI_OPTIONS: UriResolutionOptions = { + requireTypeScript: true, + requireModel: false, + allowActiveEditorFallback: true, + useWorkspaceFolderFallback: false, + noUriErrorMessage: 'Please select a file or open one in the editor.', + notTypeScriptErrorMessage: 'Please select a TypeScript file (.ts).', + notModelErrorMessage: 'The selected file does not appear to be a model file.' +}; + +/** + * Detects if command arguments represent a tree view multi-selection context + */ +export function isTreeViewContext(firstArg?: any, secondArg?: any): boolean { + return firstArg && + typeof firstArg === 'object' && + 'itemType' in firstArg && + secondArg && + Array.isArray(secondArg); +} + +/** + * Validates and extracts tree view context from command arguments + */ +export function validateTreeViewContext(firstArg: any, secondArg: any): TreeViewContext { + if (!isTreeViewContext(firstArg, secondArg)) { + throw new Error('Invalid tree view context arguments'); + } + + const clickedItem = firstArg as AppTreeItem; + const selectedItems = secondArg as AppTreeItem[]; + + // Filter for field items with valid parent metadata + const fieldItems = selectedItems.filter(item => + (item.itemType === "field" || item.itemType === "referenceField") && + item.parent?.metadata && 'name' in item.parent.metadata + ); + + if (fieldItems.length === 0) { + throw new Error("Please select one or more fields to extract."); + } + + const modelName = fieldItems[0].parent!.metadata!.name; + const modelPath = fieldItems[0].parent!.metadata!.declaration.uri.fsPath; + + // Verify all fields are from the same model + const allFromSameModel = fieldItems.every(item => + item.parent?.metadata?.name === modelName + ); + + if (!allFromSameModel) { + throw new Error("All selected fields must be from the same model."); + } + + return { + clickedItem, + selectedItems, + fieldItems, + modelName, + modelPath + }; +} + +/** + * Creates a command handler that supports both tree view context and standard URI resolution + */ +export function createTreeViewAwareCommandHandler( + treeViewHandler: (context: TreeViewContext) => Promise, + standardHandler: (result: UriResolutionResult) => Promise, + uriOptions?: UriResolutionOptions +) { + return async (firstArg?: any, secondArg?: any) => { + try { + if (isTreeViewContext(firstArg, secondArg)) { + // Handle tree view multi-selection context + const context = validateTreeViewContext(firstArg, secondArg); + await treeViewHandler(context); + } else { + // Handle standard URI resolution context + const result = await resolveTargetUri(firstArg, uriOptions); + await standardHandler(result); + } + } catch (error) { + vscode.window.showErrorMessage(`${error}`); + } + }; +} + +/** + * Registers a command that supports both tree view context and standard URI resolution + */ +export function registerTreeViewAwareCommand( + disposables: vscode.Disposable[], + commandId: string, + treeViewHandler: (context: TreeViewContext) => Promise, + standardHandler: (result: UriResolutionResult) => Promise, + uriOptions?: UriResolutionOptions +): void { + const handler = createTreeViewAwareCommandHandler(treeViewHandler, standardHandler, uriOptions); + const command = vscode.commands.registerCommand(commandId, handler); + disposables.push(command); +} + +/** + * Resolves a URI from various input sources with validation + */ +export async function resolveTargetUri( + uri?: vscode.Uri | AppTreeItem, + options: UriResolutionOptions = {} +): Promise { + const opts = { ...DEFAULT_URI_OPTIONS, ...options }; + let targetUri: vscode.Uri; + let modelName: string | undefined; + + // Step 1: Resolve the URI from different sources + if (uri) { + if (uri instanceof vscode.Uri) { + targetUri = uri; + } else { + // AppTreeItem case + if ((uri.itemType === 'model' || uri.itemType === 'compositionField') && uri.metadata?.declaration?.uri) { + targetUri = uri.metadata.declaration.uri; + modelName = uri.metadata?.name; + } else if (uri.itemType === 'folder' && uri.folderPath) { + // Handle folder context - create a URI for the folder + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + throw new Error('No workspace folder found'); + } + const basePath = vscode.Uri.joinPath(workspaceFolder.uri, 'src', 'data'); + targetUri = vscode.Uri.joinPath(basePath, ...uri.folderPath.split(/[\/\\]/)); + } else if (uri.itemType === 'dataRoot') { + // Handle data root context - use the data folder + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + throw new Error('No workspace folder found'); + } + targetUri = vscode.Uri.joinPath(workspaceFolder.uri, 'src', 'data'); + } else { + throw new Error(opts.noUriErrorMessage!); + } + } + } else { + // Fallback to active editor if allowed + if (opts.allowActiveEditorFallback) { + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + // Use workspace folder as final fallback if allowed + if (opts.useWorkspaceFolderFallback) { + targetUri = vscode.workspace.workspaceFolders?.[0]?.uri ?? vscode.Uri.file(''); + } else { + throw new Error(opts.noUriErrorMessage!); + } + } else { + targetUri = activeEditor.document.uri; + } + } else { + throw new Error(opts.noUriErrorMessage!); + } + } + + // Step 2: Validate TypeScript file if required + if (opts.requireTypeScript && !targetUri.fsPath.endsWith('.ts')) { + throw new Error(opts.notTypeScriptErrorMessage!); + } + + // Step 3: Load document and validate model if required + let document: vscode.TextDocument | undefined; + if (opts.requireModel) { + document = await vscode.workspace.openTextDocument(targetUri); + const content = document.getText(); + + if (!content.includes('@Model')) { + throw new Error(opts.notModelErrorMessage!); + } + } + + return { + targetUri, + modelName, + document + }; +} + +/** + * Creates a standardized command handler that includes error handling + */ +export function createCommandHandler( + commandFn: (result: UriResolutionResult, ...args: any[]) => Promise, + uriOptions?: UriResolutionOptions +) { + return async (uri?: vscode.Uri | AppTreeItem, ...additionalArgs: any[]) => { + try { + const result = await resolveTargetUri(uri, uriOptions); + await commandFn(result, ...additionalArgs); + } catch (error) { + vscode.window.showErrorMessage(`${error}`); + } + }; +} + +/** + * Creates a command registration helper + */ +export function registerCommand( + disposables: vscode.Disposable[], + commandId: string, + commandFn: (result: UriResolutionResult, ...args: any[]) => Promise, + uriOptions?: UriResolutionOptions +): void { + const handler = createCommandHandler(commandFn, uriOptions); + const command = vscode.commands.registerCommand(commandId, handler); + disposables.push(command); +} + +/** + * Pre-configured URI resolution options for common scenarios + */ +export const URI_OPTIONS = { + /** For commands that work with any TypeScript file */ + TYPESCRIPT_FILE: { + requireTypeScript: true, + requireModel: false, + allowActiveEditorFallback: true, + useWorkspaceFolderFallback: false, + noUriErrorMessage: 'Please select a TypeScript file or open one in the editor.', + notTypeScriptErrorMessage: 'Please select a TypeScript file (.ts).' + } as UriResolutionOptions, + + /** For commands that specifically need model files */ + MODEL_FILE: { + requireTypeScript: true, + requireModel: true, + allowActiveEditorFallback: true, + useWorkspaceFolderFallback: false, + noUriErrorMessage: 'Please select a model file or open one in the editor.', + notTypeScriptErrorMessage: 'Please select a TypeScript model file (.ts).', + notModelErrorMessage: 'The selected file does not appear to be a model file.' + } as UriResolutionOptions, + + /** For commands that require explicit file selection (no active editor fallback) */ + EXPLICIT_MODEL_SELECTION: { + requireTypeScript: true, + requireModel: false, + allowActiveEditorFallback: false, + useWorkspaceFolderFallback: false, + noUriErrorMessage: 'Please select a model file.', + notTypeScriptErrorMessage: 'Please select a TypeScript model file (.ts).' + } as UriResolutionOptions, + + /** For commands that work with any file type */ + ANY_FILE: { + requireTypeScript: false, + requireModel: false, + allowActiveEditorFallback: true, + useWorkspaceFolderFallback: true, + noUriErrorMessage: 'Please select a file or open one in the editor.' + } as UriResolutionOptions +}; diff --git a/src/commands/commandRegistration.ts b/src/commands/commandRegistration.ts index c12f944..701930b 100644 --- a/src/commands/commandRegistration.ts +++ b/src/commands/commandRegistration.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { MetadataCache } from '../cache/cache'; +import { DecoratedClass, MetadataCache } from '../cache/cache'; import { ExplorerProvider } from '../explorer/explorerProvider'; import { NewModelTool } from './models/newModel'; import { DefineFieldsTool } from './fields/defineFields'; @@ -11,7 +11,15 @@ import { CreateTestTool } from './createTest'; import { AppTreeItem } from '../explorer/appTreeItem'; import { CreateModelFromDescriptionTool } from './models/createModelFromDesc'; import { ModifyModelTool } from './models/modifyModel'; +import { AddCompositionTool } from './models/addComposition'; +import { AddReferenceTool } from './models/addReference'; import { AIService } from '../services/aiService'; +import { ProjectAnalysisService } from '../services/projectAnalysisService'; +import { registerCommand, URI_OPTIONS, UriResolutionResult, resolveTargetUri, registerTreeViewAwareCommand, TreeViewContext } from './commandHelpers'; +import { ExtractFieldsToCompositionTool } from './fields/extractFieldsToComposition'; +import { ExtractFieldsToReferenceTool } from './fields/extractFieldsToReference'; +import { ExtractFieldsToEmbeddedTool } from './fields/extractFieldsToEmbedded'; +import { ExtractFieldsToParentTool } from './fields/extractFieldsToParent'; import { NewDataSourceTool } from './newDataSource'; import { createLaunchConfiguration } from './setupLaunchConfig'; import { createTasksConfiguration } from './setupTaskConfig'; @@ -23,6 +31,9 @@ export function registerGeneralCommands( ): vscode.Disposable[] { const disposables: vscode.Disposable[] = []; const aiService = new AIService(); + const projectAnalysisService = new ProjectAnalysisService(); + + // Navigation command const navigateToCodeCommand = vscode.commands.registerCommand('slingr-vscode-extension.navigateToCode', (location: vscode.Location) => { @@ -58,89 +69,91 @@ export function registerGeneralCommands( // New Model Tool const newModelTool = new NewModelTool(); - const newModelCommand = vscode.commands.registerCommand('slingr-vscode-extension.newModel', (uri?: vscode.Uri | AppTreeItem) => { - // If no URI provided, use the current workspace folder - const targetUri = uri || (vscode.workspace.workspaceFolders?.[0]?.uri ?? vscode.Uri.file('')); - return newModelTool.createNewModel(targetUri, cache); - }); - disposables.push(newModelCommand); + registerCommand( + disposables, + 'slingr-vscode-extension.newModel', + async (result: UriResolutionResult) => { + await newModelTool.createNewModel(result.targetUri, cache); + }, + URI_OPTIONS.ANY_FILE + ); // Define Fields Tool const defineFieldsTool = new DefineFieldsTool(); - const defineFieldsCommand = vscode.commands.registerCommand('slingr-vscode-extension.defineFields', async () => { - const activeEditor = vscode.window.activeTextEditor; - if (!activeEditor) { - vscode.window.showErrorMessage('Please open a model file to define fields.'); - return; - } - - const document = activeEditor.document; - const content = document.getText(); - - // Check if this is a model file - if (!content.includes('@Model')) { - vscode.window.showErrorMessage('The current file does not appear to be a model file.'); - return; - } - - // Extract model name from class declaration - const classMatch = content.match(/export\s+class\s+(\w+)\s+extends\s+BaseModel/); - if (!classMatch) { - vscode.window.showErrorMessage('Could not find model class definition.'); - return; - } - - const modelName = classMatch[1]; - - // Get field descriptions from user - const fieldsDescription = await vscode.window.showInputBox({ - prompt: "Enter field descriptions to be processed by AI", - placeHolder: "e.g., title, description, project (relationship to Project), status (enum: todo, in-progress, done)", - ignoreFocusOut: true - }); - - if (!fieldsDescription) { - return; - } + registerCommand( + disposables, + 'slingr-vscode-extension.defineFields', + async (result: UriResolutionResult) => { + if(!result.modelName) { + throw new Error('Model name could not be determined.'); + } + const model = cache.getModelByName(result.modelName); + if (!model) { + throw new Error('Could not identify a model class in the selected file.'); + } + + // Get field descriptions from user + const fieldsDescription = await vscode.window.showInputBox({ + prompt: "Enter field descriptions to be processed by AI", + placeHolder: "e.g., title, description, project (relationship to Project), status (enum: todo, in-progress, done)", + ignoreFocusOut: true + }); + + if (!fieldsDescription) { + return; // User cancelled + } - try { await defineFieldsTool.processFieldDescriptions( fieldsDescription, - document.uri, + result.targetUri, cache, - modelName + model.name ); - } catch (error) { - vscode.window.showErrorMessage(`Failed to process field descriptions: ${error}`); - } - }); - disposables.push(defineFieldsCommand); + }, + URI_OPTIONS.MODEL_FILE + ); // Add Field Tool const addFieldTool = new AddFieldTool(); - const addFieldCommand = vscode.commands.registerCommand('slingr-vscode-extension.addField', async () => { - const activeEditor = vscode.window.activeTextEditor; - if (!activeEditor) { - vscode.window.showErrorMessage('Please open a model file to add a field.'); - return; - } - - const document = activeEditor.document; - const content = document.getText(); - - // Check if this is a model file - if (!content.includes('@Model')) { - vscode.window.showErrorMessage('The current file does not appear to be a model file.'); - return; - } - - try { - await addFieldTool.addField(document.uri, cache); - } catch (error) { - vscode.window.showErrorMessage(`Failed to add field: ${error}`); - } - }); - disposables.push(addFieldCommand); + registerCommand( + disposables, + 'slingr-vscode-extension.addField', + async (result: UriResolutionResult) => { + if (!result.modelName) { + throw new Error('Model name could not be determined.'); + } + await addFieldTool.addField(result.targetUri, result.modelName, cache); + }, + URI_OPTIONS.MODEL_FILE + ); + + // Add Composition Tool + const addCompositionTool = new AddCompositionTool(); + registerCommand( + disposables, + 'slingr-vscode-extension.addComposition', + async (result: UriResolutionResult) => { + if (!result.modelName) { + throw new Error('Model name could not be determined.'); + } + await addCompositionTool.addComposition(cache, result.modelName); + }, + URI_OPTIONS.EXPLICIT_MODEL_SELECTION + ); + + // Add Reference Tool + const addReferenceTool = new AddReferenceTool(explorerProvider); + registerCommand( + disposables, + 'slingr-vscode-extension.addReference', + async (result: UriResolutionResult) => { + if (!result.modelName) { + throw new Error('Model name could not be determined.'); + } + await addReferenceTool.addReference(cache, result.modelName); + }, + URI_OPTIONS.EXPLICIT_MODEL_SELECTION + ); // New Folder Tool const newFolderTool = new NewFolderTool(); @@ -165,25 +178,14 @@ export function registerGeneralCommands( // Create Test Tool const createTestTool = new CreateTestTool(aiService); - const createTestCommand = vscode.commands.registerCommand('slingr-vscode-extension.createTest', async (uri?: vscode.Uri) => { - let targetUri = uri; - - if (!targetUri) { - const activeEditor = vscode.window.activeTextEditor; - if (!activeEditor) { - vscode.window.showErrorMessage('Please open a model file or select a file to create a test.'); - return; - } - targetUri = activeEditor.document.uri; - } - - try { - await createTestTool.createTest(targetUri, cache); - } catch (error) { - vscode.window.showErrorMessage(`Failed to create test: ${error}`); - } - }); - disposables.push(createTestCommand); + registerCommand( + disposables, + 'slingr-vscode-extension.createTest', + async (result: UriResolutionResult) => { + await createTestTool.createTest(result.targetUri, cache); + }, + URI_OPTIONS.TYPESCRIPT_FILE + ); // General refactor command (placeholder for refactor controller integration) const refactorCommand = vscode.commands.registerCommand('slingr-vscode-extension.refactor', () => { diff --git a/src/commands/fields/addField.ts b/src/commands/fields/addField.ts index 8083aba..9929981 100644 --- a/src/commands/fields/addField.ts +++ b/src/commands/fields/addField.ts @@ -34,18 +34,18 @@ import { FileSystemService } from "../../services/fileSystemService"; * ``` */ export class AddFieldTool implements AIEnhancedTool { - private userInputService: UserInputService; - private projectAnalysisService: ProjectAnalysisService; - private sourceCodeService: SourceCodeService; - private fileSystemService: FileSystemService; - private defineFieldsTool: DefineFieldsTool; + private userInputService: UserInputService; + private projectAnalysisService: ProjectAnalysisService; + private sourceCodeService: SourceCodeService; + private fileSystemService: FileSystemService; + private defineFieldsTool: DefineFieldsTool; constructor() { - this.userInputService = new UserInputService(); - this.projectAnalysisService = new ProjectAnalysisService(); - this.sourceCodeService = new SourceCodeService(); - this.fileSystemService = new FileSystemService(); - this.defineFieldsTool = new DefineFieldsTool(); + this.userInputService = new UserInputService(); + this.projectAnalysisService = new ProjectAnalysisService(); + this.sourceCodeService = new SourceCodeService(); + this.fileSystemService = new FileSystemService(); + this.defineFieldsTool = new DefineFieldsTool(); } /** @@ -59,25 +59,31 @@ export class AddFieldTool implements AIEnhancedTool { async processWithAI( userInput: string, targetUri: vscode.Uri, + modelName: string, cache: MetadataCache, additionalContext?: any ): Promise { // The current addField method handles user interaction internally, // so we just call it with the provided parameters - await this.addField(targetUri, cache); + await this.addField(targetUri, modelName, cache); } /** * Adds a new field to an existing model file. * * @param targetUri - The URI of the model file where the field should be added + * @param modelName - The name of the model class to which the field will be added * @param cache - The metadata cache for context about existing models (optional) * @returns Promise that resolves when the field is added */ - public async addField(targetUri: vscode.Uri, cache?: MetadataCache): Promise { + public async addField(targetUri: vscode.Uri, modelName: string, cache?: MetadataCache): Promise { try { // Step 1: Validate target file - const { modelClass, document } = await this.validateAndPrepareTarget(targetUri, cache); + const { modelClass, document } = await this.validateAndPrepareTarget(targetUri, modelName, cache); + + if (!modelClass) { + throw new Error("No model class found in this file. Make sure the class has a @Model decorator."); + } // Step 2: Get field information from user const fieldInfo = await this.gatherFieldInformation(modelClass, cache); @@ -92,7 +98,7 @@ export class AddFieldTool implements AIEnhancedTool { const fieldCode = this.generateFieldCode(fieldInfo); // Step 5: Insert field into model class - await this.sourceCodeService.insertField(document, modelClass.name,fieldInfo, fieldCode, cache); + await this.sourceCodeService.insertField(document, modelClass.name, fieldInfo, fieldCode, cache); // Step 5.5: If it's a Choice field, also create the enum if (fieldInfo.type.decorator === "Choice") { @@ -133,6 +139,7 @@ export class AddFieldTool implements AIEnhancedTool { * * @param targetUri - The URI of the model file where the field should be added * @param fieldInfo - Predefined field information + * @param modelName - The name of the model class to which the field will be added * @param cache - The metadata cache for context about existing models * @param silent - If true, suppresses success/error messages (defaults to false) * @returns Promise that resolves when the field is added @@ -140,28 +147,31 @@ export class AddFieldTool implements AIEnhancedTool { public async addFieldProgrammatically( targetUri: vscode.Uri, fieldInfo: FieldInfo, + modelName: string, cache: MetadataCache, silent: boolean = false ): Promise { try { // Step 1: Validate target file - const { modelClass, document } = await this.validateAndPrepareTarget(targetUri, cache); + const { modelClass, document } = await this.validateAndPrepareTarget(targetUri, modelName, cache); // Step 2: Check if field already exists - const existingFields = Object.keys(modelClass.properties || {}); - if (existingFields.includes(fieldInfo.name)) { - const message = `Field '${fieldInfo.name}' already exists in model ${modelClass.name}`; - if (!silent) { - vscode.window.showWarningMessage(message); + if (modelClass) { + const existingFields = Object.keys(modelClass.properties || {}); + if (existingFields.includes(fieldInfo.name)) { + const message = `Field '${fieldInfo.name}' already exists in model ${modelClass.name}`; + if (!silent) { + vscode.window.showWarningMessage(message); + } + return; } - return; } // Step 3: Generate basic field structure const fieldCode = this.generateFieldCode(fieldInfo); // Step 4: Insert field into model class - await this.sourceCodeService.insertField(document, modelClass.name,fieldInfo, fieldCode, cache); + await this.sourceCodeService.insertField(document, modelName, fieldInfo, fieldCode, cache); // Step 5: If it's a Choice field, also create the enum if (fieldInfo.type.decorator === "Choice") { @@ -182,13 +192,165 @@ export class AddFieldTool implements AIEnhancedTool { } } + /** + * Creates a WorkspaceEdit for adding a field programmatically without applying it. + * This method prepares all the necessary changes (field insertion, imports, enums) + * and returns them as a WorkspaceEdit that can be applied later or combined with other edits. + * + * @param targetUri - The URI of the model file where the field should be added + * @param fieldInfo - Predefined field information + * @param modelName - The name of the model class to which the field will be added + * @param cache - The metadata cache for context about existing models + * @param enumValues - For Choice fields, the enum values to use (if not provided, default values will be used) + * @returns Promise that resolves to a WorkspaceEdit containing all necessary changes + * @throws Error if validation fails or field already exists + * + */ + public async createAddFieldWorkspaceEdit( + targetUri: vscode.Uri, + fieldInfo: FieldInfo, + modelName: string, + cache: MetadataCache, + enumValues?: string[] + ): Promise { + // Step 1: Validate target file + const { modelClass, document } = await this.validateAndPrepareTarget(targetUri, modelName, cache); + + // Step 2: Check if field already exists + if (modelClass) { + const existingFields = Object.keys(modelClass.properties || {}); + if (existingFields.includes(fieldInfo.name)) { + throw new Error(`Field '${fieldInfo.name}' already exists in model ${modelClass.name}`); + } + } + + // Step 3: Generate basic field structure + const fieldCode = this.generateFieldCode(fieldInfo); + + // Step 4: Create the workspace edit + const edit = new vscode.WorkspaceEdit(); + + // Step 5: Add field insertion edit (delegate to source code service but intercept the edit) + await this.addFieldEditToWorkspace(edit, document, modelName, fieldInfo, fieldCode, cache); + + // Step 6: If it's a Choice field, also add enum creation edit + if (fieldInfo.type.decorator === "Choice") { + await this.addEnumEditToWorkspace(edit, document, fieldInfo, enumValues); + } + + return edit; + } + + /** + * Adds field insertion edits to the provided WorkspaceEdit. + * This mirrors the logic from sourceCodeService.insertField but adds to the edit instead of applying. + */ + private async addFieldEditToWorkspace( + edit: vscode.WorkspaceEdit, + document: vscode.TextDocument, + modelClassName: string, + fieldInfo: FieldInfo, + fieldCode: string, + cache?: MetadataCache + ): Promise { + const lines = document.getText().split("\n"); + const newImports = new Set(["Field", fieldInfo.type.decorator]); + + // Add imports using source code service logic (we need to call a helper method) + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, newImports); + + // Add model import if needed + if (fieldInfo.additionalConfig?.targetModel) { + await this.sourceCodeService.addModelImport(document, fieldInfo.additionalConfig.targetModel, edit, cache); + } + + // Find class boundaries and add field + const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, modelClassName); + const indentation = detectIndentation(lines, 0, lines.length); + const indentedFieldCode = applyIndentation(fieldCode, indentation); + + edit.insert(document.uri, new vscode.Position(classEndLine, 0), `\n${indentedFieldCode}\n`); + } + + /** + * Adds enum creation edits to the provided WorkspaceEdit for Choice fields. + */ + private async addEnumEditToWorkspace( + edit: vscode.WorkspaceEdit, + document: vscode.TextDocument, + fieldInfo: FieldInfo, + enumValues?: string[] + ): Promise { + const enumName = this.generateEnumName(fieldInfo.name); + + // Use provided enum values or generate default ones + let values = enumValues; + if (!values || values.length === 0) { + values = this.generateDefaultEnumValues(fieldInfo.name); + } + + // Normalize the values + const normalizedValues = values.map(value => this.normalizeEnumValue(value)); + + // Generate enum code + const enumCode = this.generateEnumCode(enumName, normalizedValues); + + // Find insertion point at the end of the file + const content = document.getText(); + const lines = content.split("\n"); + + let insertionLine = lines.length; + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].trim()) { + insertionLine = i + 1; + break; + } + } + + const insertPosition = new vscode.Position(insertionLine, 0); + const codeToInsert = "\n" + enumCode + "\n"; + + edit.insert(document.uri, insertPosition, codeToInsert); + } + + /** + * Generates default enum values for a Choice field when none are provided. + */ + private generateDefaultEnumValues(fieldName: string): string[] { + const fieldLower = fieldName.toLowerCase(); + + // Generate context-appropriate default values + if (fieldLower.includes("status")) { + return ["Active", "Inactive", "Pending"]; + } + if (fieldLower.includes("type")) { + return ["TypeA", "TypeB", "TypeC"]; + } + if (fieldLower.includes("category")) { + return ["General", "Important", "Urgent"]; + } + if (fieldLower.includes("state")) { + return ["Open", "InProgress", "Closed"]; + } + if (fieldLower.includes("priority")) { + return ["Low", "Medium", "High"]; + } + if (fieldLower.includes("level")) { + return ["Basic", "Intermediate", "Advanced"]; + } + + // Default generic values + return ["Option1", "Option2", "Option3"]; + } + /** * Validates the target file and prepares it for field addition. */ private async validateAndPrepareTarget( targetUri: vscode.Uri, + modelName: string, cache?: MetadataCache - ): Promise<{ modelClass: DecoratedClass; document: vscode.TextDocument }> { + ): Promise<{ modelClass: DecoratedClass | null; document: vscode.TextDocument }> { // Ensure the file is a TypeScript file if (!targetUri.fsPath.endsWith(".ts")) { throw new Error("Target file must be a TypeScript file (.ts)"); @@ -202,10 +364,7 @@ export class AddFieldTool implements AIEnhancedTool { throw new Error("Metadata cache is required for field addition"); } - const modelClass = await this.projectAnalysisService.findModelClass(document, cache); - if (!modelClass) { - throw new Error("No model class found in this file. Make sure the class has a @Model decorator."); - } + const modelClass = cache.getModelByName(modelName); return { modelClass, document }; } diff --git a/src/commands/fields/changeCompositionToReference.ts b/src/commands/fields/changeCompositionToReference.ts new file mode 100644 index 0000000..4b188da --- /dev/null +++ b/src/commands/fields/changeCompositionToReference.ts @@ -0,0 +1,532 @@ +import * as vscode from "vscode"; +import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; +import { FieldInfo, FieldTypeOption } from "../interfaces"; +import { UserInputService } from "../../services/userInputService"; +import { ProjectAnalysisService } from "../../services/projectAnalysisService"; +import { SourceCodeService } from "../../services/sourceCodeService"; +import { FileSystemService } from "../../services/fileSystemService"; +import { ExplorerProvider } from "../../explorer/explorerProvider"; +import { DeleteFieldTool } from "../../refactor/tools/deleteField"; +import { detectIndentation, applyIndentation } from "../../utils/detectIndentation"; +import * as path from "path"; +import { ModelService } from "../../services/modelService"; + +/** + * Tool for converting composition relationships to reference relationships. + * + * This tool converts a @Composition field to a @Reference + */ +export class ChangeCompositionToReferenceTool { + private userInputService: UserInputService; + private projectAnalysisService: ProjectAnalysisService; + private sourceCodeService: SourceCodeService; + private modelService: ModelService; + private fileSystemService: FileSystemService; + private deleteFieldTool: DeleteFieldTool; + + constructor() { + this.userInputService = new UserInputService(); + this.projectAnalysisService = new ProjectAnalysisService(); + this.sourceCodeService = new SourceCodeService(); + this.modelService = new ModelService(); + this.fileSystemService = new FileSystemService(); + this.deleteFieldTool = new DeleteFieldTool(); + } + + /** + * Converts a composition field to a reference field. + * + * @param cache - The metadata cache for context about existing models + * @param sourceModelName - The name of the model containing the composition field + * @param fieldName - The name of the composition field to convert + * @returns Promise that resolves to a WorkspaceEdit containing all changes needed for the conversion + */ + public async changeCompositionToReference( + cache: MetadataCache, + sourceModelName: string, + fieldName: string + ): Promise { + + const edit = new vscode.WorkspaceEdit(); + + try { + // Step 1: Validate the source model and composition field + const { sourceModel, document, compositionField, componentModel } = await this.validateCompositionField( + cache, + sourceModelName, + fieldName + ); + + // Step 3: Determine the target file path for the new independent model + const targetFilePath = await this.determineTargetFilePath(sourceModel, componentModel.name); + + // Step 4: Generate and create the independent model using existing tools + await this.generateAndCreateIndependentModel(componentModel, sourceModel, targetFilePath, cache, edit); + + // Step 5: Extract related enums before removing the component model + const relatedEnums = await this.sourceCodeService.extractRelatedEnums(document, componentModel, + this.sourceCodeService.extractClassBody(document, componentModel.name)); + + // Step 6-8: Remove field, model, and enums in a single workspace edit to avoid coordinate issues + await this.removeFieldModelAndEnums(document, compositionField, componentModel, relatedEnums, cache, edit); + + // Step 9: Add the reference field to the source model + await this.addReferenceField(document, sourceModel.name, fieldName, componentModel.name, compositionField.type.endsWith('[]'), cache, edit); + + // Step 10: Add import for the new model in the source file + await this.sourceCodeService.addModelImport(document, componentModel.name, edit, cache); + + // Step 11: Focus on the newly modified field + await this.sourceCodeService.focusOnElement(document, fieldName); + + // Step 12: Show success message + vscode.window.showInformationMessage( + `Composition converted to reference! The component model '${componentModel.name}' is now an independent model in its own file.` + ); + + // Return the consolidated workspace edit containing all changes + return edit; + } catch (error) { + vscode.window.showErrorMessage(`Failed to change composition to reference: ${error}`); + console.error("Error changing composition to reference:", error); + return edit; // Return the edit even if there was an error + } + } + + /** + * Validates that the specified field is a valid composition field. + */ + private async validateCompositionField( + cache: MetadataCache, + sourceModelName: string, + fieldName: string + ): Promise<{ + sourceModel: DecoratedClass; + document: vscode.TextDocument; + compositionField: PropertyMetadata; + componentModel: DecoratedClass; + }> { + // Get source model + const sourceModel = cache.getModelByName(sourceModelName); + if (!sourceModel) { + throw new Error(`Source model '${sourceModelName}' not found in the project`); + } + + // Get field + const compositionField = sourceModel.properties[fieldName]; + if (!compositionField) { + throw new Error(`Field '${fieldName}' not found in model '${sourceModelName}'`); + } + + // Check if field has @Composition decorator + const hasCompositionDecorator = compositionField.decorators.some(d => d.name === "Composition"); + if (!hasCompositionDecorator) { + throw new Error(`Field '${fieldName}' is not a composition field`); + } + + // Extract component model name from the field type + const componentModelName = compositionField.type.replace('[]', ''); // Remove array suffix if present + const componentModel = cache.getModelByName(componentModelName); + if (!componentModel) { + throw new Error(`Component model '${componentModelName}' not found in the project`); + } + + // Verify that the component model is actually defined in the same file as the source model + if (componentModel.declaration.uri.fsPath !== sourceModel.declaration.uri.fsPath) { + throw new Error(`Component model '${componentModelName}' is not in the same file as the source model. This operation only works with embedded component models.`); + } + + // Open source document + const document = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + if (!document) { + throw new Error(`Could not open document for model '${sourceModelName}'`); + } + + return { sourceModel, document, compositionField, componentModel }; + } + + + /** + * Determines the target file path for the new independent model. + */ + private async determineTargetFilePath(sourceModel: DecoratedClass, componentModelName: string): Promise { + const sourceDir = path.dirname(sourceModel.declaration.uri.fsPath); + const fileName = `${componentModelName}.ts`; + return path.join(sourceDir, fileName); + } + + /** + * Creates the independent model by copying the component model's class body. + */ + private async generateAndCreateIndependentModel( + componentModel: DecoratedClass, + sourceModel: DecoratedClass, + targetFilePath: string, + cache: MetadataCache, + workspaceEdit: vscode.WorkspaceEdit + ): Promise { + + // Step 1: Get the source document to extract the class body + const sourceDocument = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + + // Step 2: Extract the complete class body from the component model + const classBody = this.sourceCodeService.extractClassBody(sourceDocument, componentModel.name); + + // Step 3: Extract related enums from the source file (for Choice fields) + const relatedEnums = await this.sourceCodeService.extractRelatedEnums(sourceDocument, componentModel, classBody); + + // Step 4: Get datasource from source model + const sourceModelDecorator = cache.getModelDecoratorByName("Model", sourceModel); + const dataSource = sourceModelDecorator?.arguments?.[0]?.dataSource; + + // Step 5: Convert the class body for independent model use + const convertedClassBody = this.convertComponentClassBody(classBody); + + // Step 6: Generate the complete model file content + const docs = cache.getModelDecoratorByName("Model", componentModel)?.arguments?.[0]?.docs; + const modelFileContent = await this.modelService.generateModelFileContent( + componentModel.name, + convertedClassBody, + dataSource, + undefined, + false, + targetFilePath, + cache, + docs + ); + + // Step 8: Add related enums to the file content + const finalFileContent = this.sourceCodeService.addEnumsToFileContent(modelFileContent, relatedEnums); + + // Step 9: Create the workspace edit to create the new model file + const modelFileUri = vscode.Uri.file(targetFilePath); + workspaceEdit.createFile(modelFileUri, { ignoreIfExists: true }, {label: 'Create independent model file', needsConfirmation: true}); + workspaceEdit.insert(modelFileUri, new vscode.Position(0, 0), finalFileContent, {label: 'Insert model content', needsConfirmation: true}); + + console.log(`Prepared workspace edit to create independent model file: ${targetFilePath}`); + } + + /** + * Converts a component model class body to work as an independent model. + * This mainly involves ensuring proper formatting and removing any component-specific elements. + */ + private convertComponentClassBody(classBody: string): string { + + // Future enhancements could include: + // - Removing component-specific decorators if any + // - Adjusting field configurations if needed + // - Updating comments that reference "component" + + return classBody; + } + + /** + * Adds model imports to the newly created model file. + */ + private async addModelImportsToNewFile(modelFileUri: vscode.Uri, importStatements: string[]): Promise { + if (importStatements.length === 0) { + return; + } + + try { + const document = await vscode.workspace.openTextDocument(modelFileUri); + const edit = new vscode.WorkspaceEdit(); + + // Find the position after the slingr-framework import + const content = document.getText(); + const lines = content.split("\n"); + + let insertPosition = 1; // Default to after first line + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes("from") && lines[i].includes("slingr-framework")) { + insertPosition = i + 1; + break; + } + } + + // Add each import statement + const importsText = importStatements.join("\n") + "\n"; + edit.insert(modelFileUri, new vscode.Position(insertPosition, 0), importsText); + + await vscode.workspace.applyEdit(edit); + } catch (error) { + console.warn("Could not add model imports to new file:", error); + } + } + + /** + * Removes the composition field, component model, and unused enums in a single workspace edit + * to avoid coordinate invalidation issues. + */ + private async removeFieldModelAndEnums( + document: vscode.TextDocument, + compositionField: PropertyMetadata, + componentModel: DecoratedClass, + relatedEnums: string[], + cache: MetadataCache, + workspaceEdit: vscode.WorkspaceEdit + ): Promise { + + // Step 1: Get field deletion range (using DeleteFieldTool logic) + const fileMetadata = cache.getMetadataForFile(document.uri.fsPath); + let modelName = 'Unknown'; + + if (fileMetadata) { + for (const [className, classData] of Object.entries(fileMetadata.classes)) { + const classInfo = classData as DecoratedClass; + if (classInfo.properties[compositionField.name] === compositionField) { + modelName = className; + break; + } + } + } + + // Get field deletion edit without applying it + const fieldDeletionEdit = await this.deleteFieldTool.deleteFieldProgrammatically( + compositionField, + modelName, + cache + ); + + // Step 2: Get model deletion range + await this.sourceCodeService.deleteModelClassFromFile(document.uri, componentModel, workspaceEdit); + + // Step 3: Get enum deletion ranges + await this.addEnumDeletionsToWorkspaceEdit(document, relatedEnums, workspaceEdit, componentModel); + + // Step 4: Merge field deletion edits into the main workspace edit + this.mergeWorkspaceEdits(fieldDeletionEdit, workspaceEdit); + + console.log("Prepared workspace edit to remove field, model, and unused enums"); + } + + /** + * Adds enum deletion ranges to the workspace edit if the enums are no longer used. + */ + private async addEnumDeletionsToWorkspaceEdit( + document: vscode.TextDocument, + extractedEnums: string[], + workspaceEdit: vscode.WorkspaceEdit, + componentModel: DecoratedClass + ): Promise { + if (extractedEnums.length === 0) { + return; + } + + const sourceContent = document.getText(); + + // Extract enum names from the enum definitions + const enumNames = extractedEnums.map(enumDef => { + const match = enumDef.match(/enum\s+(\w+)/); + return match ? match[1] : null; + }).filter(name => name !== null) as string[]; + + console.log(`Found ${enumNames.length} enums to check for deletion: ${enumNames.join(', ')}`); + + // Check each enum to see if it's still used in the source file + for (const enumName of enumNames) { + if (!this.isEnumStillUsedInFile(sourceContent, enumName, extractedEnums, componentModel)) { + console.log(`Enum "${enumName}" is not used anymore, scheduling for deletion`); + await this.addEnumDeletionToWorkspaceEdit(document, enumName, workspaceEdit); + } else { + console.log(`Enum "${enumName}" is still used, keeping it in source file`); + } + } + } + + /** + * Checks if an enum is still referenced in the source file (excluding the extracted enums and component model). + */ + private isEnumStillUsedInFile(sourceContent: string, enumName: string, extractedEnums: string[], componentModel: DecoratedClass): boolean { + // Create a version of the source content without the extracted enums + let contentWithoutExtractedEnums = sourceContent; + for (const enumDef of extractedEnums) { + contentWithoutExtractedEnums = contentWithoutExtractedEnums.replace(enumDef, ''); + } + + // Also remove the component model class from the content since we're extracting it + // This prevents false positives where the enum is only used in the component model + try { + const lines = contentWithoutExtractedEnums.split('\n'); + const { classStartLine, classEndLine } = this.sourceCodeService.findClassBoundaries(lines, componentModel.name); + + // Remove the component model class from the content + const linesWithoutComponentModel = [ + ...lines.slice(0, classStartLine), + ...lines.slice(classEndLine + 1) + ]; + contentWithoutExtractedEnums = linesWithoutComponentModel.join('\n'); + } catch (error) { + console.warn(`Could not remove component model "${componentModel.name}" from content for enum usage check:`, error); + } + + // Look for references to the enum name in the remaining content + const enumRefRegex = new RegExp(`\\b${enumName}\\b`, 'g'); + const matches = contentWithoutExtractedEnums.match(enumRefRegex); + + console.log(`Checking if enum "${enumName}" is still used: found ${matches ? matches.length : 0} references`); + + // If there are matches, the enum is still used + return matches !== null && matches.length > 0; + } + + /** + * Adds an enum deletion range to the workspace edit. + */ + private async addEnumDeletionToWorkspaceEdit( + document: vscode.TextDocument, + enumName: string, + workspaceEdit: vscode.WorkspaceEdit + ): Promise { + const sourceContent = document.getText(); + const lines = sourceContent.split('\n'); + + // Find the enum definition with better pattern matching + let enumStartLine = -1; + let enumEndLine = -1; + + // Look for the enum declaration line + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Match: "enum EnumName {" or "export enum EnumName {" + const enumMatch = line.match(new RegExp(`^(export\\s+)?enum\\s+${enumName}\\s*\\{`)); + if (enumMatch) { + enumStartLine = i; + + // Look for any preceding comments or empty lines that belong to this enum + for (let j = i - 1; j >= 0; j--) { + const prevLine = lines[j].trim(); + if (prevLine === '' || prevLine.startsWith('//') || prevLine.startsWith('/*') || prevLine.endsWith('*/')) { + enumStartLine = j; + } else { + break; + } + } + + // Find the closing brace + let braceCount = 0; + let foundOpenBrace = false; + + for (let j = i; j < lines.length; j++) { + const currentLine = lines[j]; + + for (const char of currentLine) { + if (char === '{') { + braceCount++; + foundOpenBrace = true; + } else if (char === '}') { + braceCount--; + if (foundOpenBrace && braceCount === 0) { + enumEndLine = j; + break; + } + } + } + + if (foundOpenBrace && braceCount === 0) { + break; + } + } + + break; // Found the enum, stop searching + } + } + + if (enumStartLine !== -1 && enumEndLine !== -1) { + // Include any trailing empty lines that belong to this enum + + // Create the range to delete (include the newline of the last line) + const rangeToDelete = new vscode.Range( + new vscode.Position(enumStartLine, 0), + new vscode.Position(enumEndLine + 1, 0) + ); + + workspaceEdit.delete(document.uri, rangeToDelete, {label: `Delete unused enum ${enumName}`, needsConfirmation: true}); + console.log(`Scheduled deletion of enum "${enumName}" from lines ${enumStartLine} to ${enumEndLine}`); + } else { + console.warn(`Could not find enum "${enumName}" for deletion`); + } + } + + /** + * Merges edits from one workspace edit into another. + */ + private mergeWorkspaceEdits(sourceEdit: vscode.WorkspaceEdit, targetEdit: vscode.WorkspaceEdit): void { + sourceEdit.entries().forEach(([uri, edits]) => { + edits.forEach(edit => { + if (edit instanceof vscode.TextEdit) { + targetEdit.replace(uri, edit.range, edit.newText, {label: 'Merge field deletion edits', needsConfirmation: true}); + } + }); + }); + } + + /** + * Adds the reference field to the source model. + */ + private async addReferenceField( + document: vscode.TextDocument, + sourceModelName: string, + fieldName: string, + targetModelName: string, + isArray: boolean, + cache: MetadataCache, + workspaceEdit: vscode.WorkspaceEdit + ): Promise { + // Create field info for the reference field + const fieldType: FieldTypeOption = { + label: "Relationship", + decorator: "Reference", + tsType: isArray ? `${targetModelName}[]` : targetModelName, + description: "Reference relationship", + }; + + const fieldInfo: FieldInfo = { + name: fieldName, + type: fieldType, + required: false, // References are typically optional + additionalConfig: { + relationshipType: "reference", + targetModel: targetModelName, + }, + }; + + // Generate the field code + const fieldCode = this.generateReferenceFieldCode(fieldInfo, targetModelName, isArray); + + // Create the field insertion edits manually and merge into main workspace edit + const lines = document.getText().split("\n"); + const { classStartLine, classEndLine } = this.sourceCodeService.findClassBoundaries(lines, sourceModelName); + + // Apply proper indentation + const indentation = detectIndentation(lines, classStartLine, classEndLine); + const indentedFieldCode = applyIndentation(fieldCode, indentation); + + // Insert the field + workspaceEdit.insert(document.uri, new vscode.Position(classEndLine, 0), `\n${indentedFieldCode}\n`, {label: `Add reference field ${fieldName}`, needsConfirmation: true}); + + // Add necessary imports to the workspace edit + const newImports = new Set(["Field", "Reference"]); + await this.sourceCodeService.ensureSlingrFrameworkImports(document, workspaceEdit, newImports); + } + + /** + * Generates the TypeScript code for the reference field. + */ + private generateReferenceFieldCode(fieldInfo: FieldInfo, targetModelName: string, isArray: boolean): string { + const lines: string[] = []; + + // Add Field decorator + lines.push("@Field({})"); + + // Add Reference decorator + lines.push("@Reference()"); + + // Add property declaration + const typeDeclaration = isArray ? `${targetModelName}[]` : targetModelName; + lines.push(`${fieldInfo.name}!: ${typeDeclaration};`); + + return lines.join("\n"); + } +} diff --git a/src/commands/fields/changeReferenceToComposition.ts b/src/commands/fields/changeReferenceToComposition.ts new file mode 100644 index 0000000..f3fa2cc --- /dev/null +++ b/src/commands/fields/changeReferenceToComposition.ts @@ -0,0 +1,603 @@ +import * as vscode from "vscode"; +import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; +import { FieldInfo, FieldTypeOption } from "../interfaces"; +import { UserInputService } from "../../services/userInputService"; +import { ProjectAnalysisService } from "../../services/projectAnalysisService"; +import { SourceCodeService } from "../../services/sourceCodeService"; +import { FileSystemService } from "../../services/fileSystemService"; +import { ExplorerProvider } from "../../explorer/explorerProvider"; +import { DeleteFieldTool } from "../../refactor/tools/deleteField"; +import { detectIndentation, applyIndentation } from "../../utils/detectIndentation"; +import * as path from "path"; +import { ModelService } from "../../services/modelService"; + +/** + * Tool for converting reference relationships to composition relationships. + * + * This tool converts a @Reference field to a @Composition field by: + * 1. Checking if the referenced model is used elsewhere + * 2. Optionally deleting the referenced model file if not used elsewhere + * 3. Creating a new component model in the same file as the owner + * 4. Converting the field from @Reference to @Composition + */ +export class ChangeReferenceToCompositionTool { + private userInputService: UserInputService; + private projectAnalysisService: ProjectAnalysisService; + private sourceCodeService: SourceCodeService; + private modelService: ModelService; + private fileSystemService: FileSystemService; + private deleteFieldTool: DeleteFieldTool; + + constructor() { + this.userInputService = new UserInputService(); + this.projectAnalysisService = new ProjectAnalysisService(); + this.sourceCodeService = new SourceCodeService(); + this.modelService = new ModelService(); + this.fileSystemService = new FileSystemService(); + this.deleteFieldTool = new DeleteFieldTool(); + } + + /** + * Converts a reference field to a composition field. + * + * @param cache - The metadata cache for context about existing models + * @param sourceModelName - The name of the model containing the reference field + * @param fieldName - The name of the reference field to convert + * @returns Promise that resolves to a WorkspaceEdit containing all changes needed for the conversion + */ + public async changeReferenceToComposition( + cache: MetadataCache, + sourceModelName: string, + fieldName: string + ): Promise { + + const edit = new vscode.WorkspaceEdit(); + try { + // Step 1: Validate the source model and reference field + const { sourceModel, document, referenceField, targetModel } = await this.validateReferenceField( + cache, + sourceModelName, + fieldName + ); + + // Step 2: Check if target model is referenced by other fields + const isReferencedElsewhere = this.isModelReferencedElsewhere(cache, targetModel.name, sourceModelName, fieldName); + + + // Step 4: Create the component model content based on the target model + const componentModelCode = await this.generateComponentModelCode(targetModel, sourceModel, cache); + + // Step 5: Remove the reference field decorators + await this.removeReferenceField(document, referenceField, cache, edit); + + // Step 6: Add the component model to the source file + await this.addComponentModel(document, componentModelCode, sourceModel.name, cache, edit); + + // Step 6.1: Remove the import for the target model since it's now defined in the same file + await this.removeModelImport(document, targetModel.name, edit); + + // Step 7: Add the composition field + await this.addCompositionField(document, sourceModel.name, fieldName, targetModel.name, false, cache, edit); + + // Step 8: Delete the target model file if not referenced elsewhere + if (!isReferencedElsewhere) { + await this.deleteTargetModelFile(targetModel, edit); + } + + // Add necessary imports to the workspace edit + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, new Set(["Model", "BaseModel", "Field", "Composition", "OwnerReference"])); + + // Step 9: Focus on the newly modified field + await this.sourceCodeService.focusOnElement(document, fieldName); + + // Step 10: Show success message + const message = isReferencedElsewhere + ? `Reference converted to composition! The origin + private async addEnumDeletionToWorkspacal ${targetModel.name} model was kept as it's referenced elsewhere.` + : `Reference converted to composition! The original ${targetModel.name} model was deleted and recreated as a component.`; + + vscode.window.showInformationMessage(message); + + // Return the consolidated workspace edit containing all changes + return edit; + } catch (error) { + vscode.window.showErrorMessage(`Failed to change reference to composition: ${error}`); + console.error("Error changing reference to composition:", error); + return edit; // Return the edit even if there was an error + } + } + + /** + * Validates that the specified field is a valid reference field. + */ + private async validateReferenceField( + cache: MetadataCache, + sourceModelName: string, + fieldName: string + ): Promise<{ + sourceModel: DecoratedClass; + document: vscode.TextDocument; + referenceField: PropertyMetadata; + targetModel: DecoratedClass; + }> { + // Get source model + const sourceModel = cache.getModelByName(sourceModelName); + if (!sourceModel) { + throw new Error(`Source model '${sourceModelName}' not found in the project`); + } + + // Get field + const referenceField = sourceModel.properties[fieldName]; + if (!referenceField) { + throw new Error(`Field '${fieldName}' not found in model '${sourceModelName}'`); + } + + // Check if field has @Reference decorator + const hasReferenceDecorator = referenceField.decorators.some(d => d.name === "Reference"); + if (!hasReferenceDecorator) { + throw new Error(`Field '${fieldName}' is not a reference field`); + } + + // Extract target model name from the field type + const targetModelName = referenceField.type; + const targetModel = cache.getModelByName(targetModelName); + if (!targetModel) { + throw new Error(`Target model '${targetModelName}' not found in the project`); + } + + // Open source document + const document = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + if (!document) { + throw new Error(`Could not open document for model '${sourceModelName}'`); + } + + return { sourceModel, document, referenceField, targetModel }; + } + + /** + * Checks if a model is referenced by other fields in other models. + */ + private isModelReferencedElsewhere( + cache: MetadataCache, + targetModelName: string, + excludeSourceModelName: string, + excludeFieldName: string + ): boolean { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + // Skip the source model when checking the specific field + if (model.name === excludeSourceModelName) { + // Check other fields in the same model + for (const [fieldName, field] of Object.entries(model.properties)) { + if (fieldName === excludeFieldName) { + continue; // Skip the field we're converting + } + + if (this.isFieldReferencingModel(field, targetModelName)) { + return true; + } + } + } else { + // Check all fields in other models + for (const field of Object.values(model.properties)) { + if (this.isFieldReferencingModel(field, targetModelName)) { + return true; + } + } + } + } + + return false; + } + + /** + * Checks if a field references a specific model. + */ + private isFieldReferencingModel(field: PropertyMetadata, targetModelName: string): boolean { + // Check if field has relationship decorators and the type matches + const hasRelationshipDecorator = field.decorators.some(d => + d.name === "Reference" || d.name === "Composition" || d.name === "Relationship" + ); + + if (hasRelationshipDecorator && field.type === targetModelName) { + return true; + } + + // Also check for array types like "TargetModel[]" + if (hasRelationshipDecorator && field.type === `${targetModelName}[]`) { + return true; + } + + return false; + } + + /** + * Asks user for confirmation before proceeding with the conversion. + */ + private async confirmConversion(targetModelName: string, isReferencedElsewhere: boolean): Promise { + const message = isReferencedElsewhere + ? `Convert reference to composition? The referenced model '${targetModelName}' is used elsewhere, so it will be kept and a new component model will be created.` + : `Convert reference to composition? The referenced model '${targetModelName}' is not used elsewhere, so it will be deleted and recreated as a component model.`; + + const choice = await vscode.window.showWarningMessage( + message, + { modal: true }, + "Convert", + "Cancel" + ); + + return choice === "Convert"; + } + + /** + * Generates the TypeScript code for the new component model by copying the target model's class body. + */ + private async generateComponentModelCode( + targetModel: DecoratedClass, + sourceModel: DecoratedClass, + cache: MetadataCache + ): Promise { + // Step 1: Get the target model document to extract the class body + const targetDocument = await vscode.workspace.openTextDocument(targetModel.declaration.uri); + + // Step 2: Extract the complete class body from the target model + const classBody = this.sourceCodeService.extractClassBody(targetDocument, targetModel.name); + + // Step 3: Get datasource from source model + const sourceModelDecorator = cache.getModelDecoratorByName("Model", sourceModel); + const dataSource = sourceModelDecorator?.arguments?.[0]?.dataSource; + + // Step 4: Extract any enums from the target model file + const enumDefinitions = this.sourceCodeService.extractEnumDefinitions(targetDocument); + + // Step 5: Check for enum name conflicts and resolve them + const sourceDocument = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + const resolvedEnums = await this.resolveEnumConflicts(enumDefinitions, sourceDocument, classBody, sourceModel.name); + + // Step 6: Add owner field to the class body + const ownerFieldCode = this.generateOwnerFieldCode(sourceModel.name); + const updatedClassBody = resolvedEnums.updatedClassBody + '\n\n' + ownerFieldCode; + + // Step 7: Generate the complete component model content + const docs = cache.getModelDecoratorByName("Model", targetModel)?.arguments?.[0]?.docs; + let componentModelCode = await this.modelService.generateModelFileContent( + targetModel.name, + updatedClassBody, + dataSource, + undefined, + true, // This is a component model (no export keyword) + targetModel.declaration.uri.fsPath, + cache, + docs + ); + + // Step 8: Extract only the component model part (remove imports and add enums) + const componentModelParts = this.extractComponentModelFromFileContent(componentModelCode); + + // Step 9: Add enum definitions if any exist + if (resolvedEnums.enumDefinitions.length > 0) { + const enumsContent = resolvedEnums.enumDefinitions.join('\n\n'); + return `${enumsContent}\n\n${componentModelParts}`; + } + + return componentModelParts; + } + + + /** + * Resolves enum name conflicts between target and source files. + */ + private async resolveEnumConflicts( + enumDefinitions: string[], + sourceDocument: vscode.TextDocument, + classBody: string, + sourceModelName: string + ): Promise<{ enumDefinitions: string[]; updatedClassBody: string }> { + if (enumDefinitions.length === 0) { + return { enumDefinitions: [], updatedClassBody: classBody }; + } + + const sourceContent = sourceDocument.getText(); + const existingEnums = this.extractEnumNames(sourceContent); + const resolvedEnums: string[] = []; + let updatedClassBody = classBody; + + for (const enumDef of enumDefinitions) { + const enumName = this.extractEnumName(enumDef); + + if (enumName && existingEnums.includes(enumName)) { + // Conflict detected, rename the enum + const newEnumName = `${sourceModelName}${enumName}`; + existingEnums.push(newEnumName); // Add to list to avoid future conflicts + + // Update enum definition + const updatedEnumDef = enumDef.replace( + new RegExp(`enum\\s+${enumName}\\b`), + `enum ${newEnumName}` + ); + + // Update class body to use new enum name + updatedClassBody = updatedClassBody.replace( + new RegExp(`\\b${enumName}\\b`, 'g'), + newEnumName + ); + + resolvedEnums.push(updatedEnumDef); + } else { + resolvedEnums.push(enumDef); + if (enumName) { + existingEnums.push(enumName); + } + } + } + + return { enumDefinitions: resolvedEnums, updatedClassBody }; + } + + /** + * Extracts enum names from file content. + */ + private extractEnumNames(content: string): string[] { + const enumRegex = /(?:export\s+)?enum\s+(\w+)/g; + const enumNames: string[] = []; + let match; + + while ((match = enumRegex.exec(content)) !== null) { + enumNames.push(match[1]); + } + + return enumNames; + } + + /** + * Extracts enum name from an enum definition. + */ + private extractEnumName(enumDefinition: string): string | null { + const match = enumDefinition.match(/(?:export\s+)?enum\s+(\w+)/); + return match ? match[1] : null; + } + + /** + * Extracts only the component model part from full file content (removes imports). + */ + private extractComponentModelFromFileContent(fileContent: string): string { + const lines = fileContent.split('\n'); + const result: string[] = []; + let foundModel = false; + + for (const line of lines) { + // Skip import lines + if (line.trim().startsWith('import ')) { + continue; + } + + // Skip empty lines before the model + if (!foundModel && line.trim() === '') { + continue; + } + + // Once we find the model decorator or class, include everything + if (line.trim().startsWith('@Model') || line.includes('class ')) { + foundModel = true; + } + + if (foundModel) { + result.push(line); + } + } + + return result.join('\n'); + } + + /** + * Removes the @Reference and @Field decorators from the field. + */ + private async removeReferenceField( + document: vscode.TextDocument, + field: PropertyMetadata, + cache: MetadataCache, + workspaceEdit: vscode.WorkspaceEdit + ): Promise { + // Find the model name that contains this field + const fileMetadata = cache.getMetadataForFile(document.uri.fsPath); + let modelName = 'Unknown'; + + if (fileMetadata) { + for (const [className, classData] of Object.entries(fileMetadata.classes)) { + // Type assertion since we know the structure from cache + const classInfo = classData as DecoratedClass; + if (classInfo.properties[field.name] === field) { + modelName = className; + break; + } + } + } + + // Use the DeleteFieldTool to programmatically remove the field + await this.deleteFieldTool.deleteFieldProgrammatically( + field, + modelName, + cache, + workspaceEdit + ); + } + + /** + * Adds the component model to the source file. + */ + private async addComponentModel( + document: vscode.TextDocument, + componentModelCode: string, + sourceModelName: string, + cache: MetadataCache, + workspaceEdit: vscode.WorkspaceEdit + ): Promise { + + // Manually implement model insertion to use our workspace edit + const lines = document.getText().split("\n"); + + // Find the position to insert the model (after the source model) + let insertPosition = lines.length; // Default to end of file + if (sourceModelName) { + try { + const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, sourceModelName); + insertPosition = classEndLine + 1; + } catch (error) { + console.warn(`Could not find source model "${sourceModelName}", inserting at end of file`); + } + } + + // Insert the component model with proper spacing + const modelWithSpacing = `\n${componentModelCode}\n`; + workspaceEdit.insert(document.uri, new vscode.Position(insertPosition, 0), modelWithSpacing, {label: 'Add component model', needsConfirmation: true}); + } + + /** + * Adds the composition field to the source model. + */ + private async addCompositionField( + document: vscode.TextDocument, + sourceModelName: string, + fieldName: string, + targetModelName: string, + isArray: boolean, + cache: MetadataCache, + workspaceEdit: vscode.WorkspaceEdit + ): Promise { + // Create field info for the composition field + const fieldType: FieldTypeOption = { + label: "Relationship", + decorator: "Composition", + tsType: isArray ? `${targetModelName}[]` : targetModelName, + description: "Composition relationship", + }; + + const fieldInfo: FieldInfo = { + name: fieldName, + type: fieldType, + required: false, // Compositions are typically optional + additionalConfig: { + relationshipType: "composition", + targetModel: targetModelName, + targetModelPath: document.uri.fsPath, + }, + }; + + // Generate the field code + const fieldCode = this.generateCompositionFieldCode(fieldInfo, targetModelName, isArray); + + // Create the field insertion edits manually and merge into main workspace edit + const lines = document.getText().split("\n"); + const { classStartLine, classEndLine } = this.sourceCodeService.findClassBoundaries(lines, sourceModelName); + + // Apply proper indentation + const indentation = detectIndentation(lines, classStartLine, classEndLine); + const indentedFieldCode = applyIndentation(fieldCode, indentation); + + // Insert the field + workspaceEdit.insert(document.uri, new vscode.Position(classEndLine, 0), `\n${indentedFieldCode}\n`, {label: `Add composition field ${fieldName}`, needsConfirmation: true}); + + // Add necessary imports to the workspace edit + const newImports = new Set(["Composition"]); + //await this.sourceCodeService.ensureSlingrFrameworkImports(document, workspaceEdit, newImports); + } + + /** + * Generates the TypeScript code for the composition field. + */ + private generateCompositionFieldCode(fieldInfo: FieldInfo, targetModelName: string, isArray: boolean): string { + const lines: string[] = []; + + // Add Field decorator + lines.push("@Field({})"); + + // Add Composition decorator + lines.push("@Composition()"); + + // Add property declaration + const typeDeclaration = isArray ? `${targetModelName}[]` : targetModelName; + lines.push(`${fieldInfo.name}!: ${typeDeclaration};`); + + return lines.join("\n"); + } + + + /** + * Removes model import from the document. + */ + private async removeModelImport( + document: vscode.TextDocument, + modelName: string, + workspaceEdit: vscode.WorkspaceEdit + ): Promise { + const content = document.getText(); + const lines = content.split("\n"); + + // Find and remove import lines that contain the model name + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check for import statements that import the specific model + if (line.trim().startsWith('import ') && line.includes(modelName)) { + // Check if this import only imports the target model + const importMatch = line.match(/import\s+{([^}]+)}\s+from/); + if (importMatch) { + const imports = importMatch[1].split(',').map(imp => imp.trim()); + + if (imports.length === 1 && imports[0] === modelName) { + // Remove the entire import line + const range = new vscode.Range( + new vscode.Position(i, 0), + new vscode.Position(i + 1, 0) + ); + workspaceEdit.delete(document.uri, range, {label: `Remove import for ${modelName}`, needsConfirmation: true}); + } else if (imports.includes(modelName)) { + // Remove just the model name from the import + const newImports = imports.filter(imp => imp !== modelName); + const newImportLine = line.replace( + /import\s+{[^}]+}/, + `import { ${newImports.join(', ')} }` + ); + const range = new vscode.Range( + new vscode.Position(i, 0), + new vscode.Position(i, line.length) + ); + workspaceEdit.replace(document.uri, range, newImportLine, {label: `Update import removing ${modelName}`, needsConfirmation: true}); + } + } + } + } + } + + /** + * Generates the TypeScript code for the owner field. + */ + private generateOwnerFieldCode(ownerModelName: string): string { + const lines: string[] = []; + + // Add Field decorator + lines.push("@Field({})"); + + // Add OwnerReference decorator + lines.push("@OwnerReference()"); + + // Add property declaration + lines.push(`owner!: ${ownerModelName};`); + + return lines.join("\n"); + } + + /** + * Deletes the target model file if it's safe to do so. + */ + private async deleteTargetModelFile(targetModel: DecoratedClass, workspaceEdit: vscode.WorkspaceEdit): Promise { + try { + workspaceEdit.deleteFile(targetModel.declaration.uri, { ignoreIfNotExists: true }, {label: `Delete original model file ${targetModel.name}`, needsConfirmation: true}); + console.log(`Scheduled deletion of target model file: ${targetModel.declaration.uri.fsPath}`); + } catch (error) { + console.warn(`Could not schedule deletion of target model file: ${error}`); + // Don't throw error here as the conversion was successful + } + } +} \ No newline at end of file diff --git a/src/commands/fields/extractFieldsController.ts b/src/commands/fields/extractFieldsController.ts new file mode 100644 index 0000000..501423a --- /dev/null +++ b/src/commands/fields/extractFieldsController.ts @@ -0,0 +1,264 @@ +import { WorkspaceEdit } from "vscode"; +import { DecoratedClass, FileMetadata, MetadataCache, PropertyMetadata } from "../../cache/cache"; +import { ChangeObject, IRefactorTool, ManualRefactorContext } from "../../refactor/refactorInterfaces"; +import { TreeViewContext } from "../commandHelpers"; +import { UserInputService } from "../../services/userInputService"; +import { SourceCodeService } from "../../services/sourceCodeService"; +import { isModelFile } from "../../utils/metadata"; +import * as vscode from "vscode"; +import { FIELD_TYPE_OPTIONS } from "../interfaces"; + +export abstract class ExtractFieldsController implements IRefactorTool { + protected userInputService: UserInputService; + protected sourceCodeService: SourceCodeService; + + constructor() { + this.userInputService = new UserInputService(); + this.sourceCodeService = new SourceCodeService(); + } + + // Abstract methods - must be implemented by subclasses + abstract getCommandId(): string; + abstract getTitle(): string; + abstract getHandledChangeTypes(): string[]; + abstract prepareEdit(change: ChangeObject, cache: MetadataCache): Promise; + // Abstract method for manual refactor - each tool implements its own prompting logic + abstract initiateManualRefactor(context: ManualRefactorContext): Promise; + + // Concrete methods - shared logic implemented in base class + + /** + * This tool doesn't detect automatic changes. + */ + analyze( + oldFileMeta?: FileMetadata, + newFileMeta?: FileMetadata, + accumulatedChanges?: ChangeObject[] + ): ChangeObject[] { + return []; + } + + /** + * Determines if this tool can handle a manual refactor trigger. + * Allows extraction when multiple fields are selected in a model file. + */ + async canHandleManualTrigger(context: ManualRefactorContext): Promise { + // Must be in a model file + if (!isModelFile(context.uri)) { + return false; + } + + // Get the source model from context + const sourceModel = this.getSourceModelFromContext(context); + if (!sourceModel) { + return false; + } + + // For manual trigger, we allow it if there are fields in the model + return Object.keys(sourceModel.properties || {}).length > 1; // Need at least 2 fields to extract + } + + /** + * Common field selection and validation logic for manual refactors. + */ + protected async getSelectedFieldsFromContext( + context: ManualRefactorContext, + targetType: string + ): Promise<{ sourceModel: DecoratedClass; selectedFields: PropertyMetadata[] } | undefined> { + const sourceModel = this.getSourceModelFromContext(context); + if (!sourceModel) { + vscode.window.showErrorMessage("Could not find a model in the current context"); + return undefined; + } + + // Get all fields in the model + const allFields = Object.values(sourceModel.properties) as PropertyMetadata[]; + if (allFields.length < 2) { + vscode.window.showErrorMessage(`Model must have at least 2 fields to extract some to ${targetType}`); + return undefined; + } + + let selectedFields: PropertyMetadata[] | undefined; + + const treeViewContext = context.treeViewContext as TreeViewContext | undefined; + + if (treeViewContext?.fieldItems && treeViewContext.fieldItems.length > 0) { + // Tree view context: use the selected field items + selectedFields = treeViewContext.fieldItems.map((fieldItem: any) => { + const fieldItemName = fieldItem.label.toLowerCase(); + const field = allFields.find((prop) => prop.name === fieldItemName); + if (!field) { + throw new Error(`Could not find field '${fieldItem.label}' in model '${context.metadata?.name}'`); + } + return field; + }); + } else { + // Let user select which fields to extract + selectedFields = await this.selectFieldsForExtraction(allFields, targetType); + if (!selectedFields || selectedFields.length === 0) { + return undefined; + } + } + + return { sourceModel, selectedFields }; + } + + /** + * Shows user a quick pick to select which fields to extract. + */ + public async selectFieldsForExtraction(allFields: PropertyMetadata[], type:string): Promise { + const fieldItems = allFields.map((field) => ({ + label: field.name, + description: this.getFieldTypeDescription(field), + field: field, + })); + + const typeUpper = type.charAt(0).toUpperCase() + type.slice(1); + + const selectedItems = await vscode.window.showQuickPick(fieldItems, { + canPickMany: true, + placeHolder: `Select fields to extract to the new ${type} model`, + title: `Extract Fields to ${typeUpper}`, + }); + + return selectedItems?.map((item) => item.field); + } + + /** + * Gets a description of the field type for display in the quick pick. + */ + private getFieldTypeDescription(field: PropertyMetadata): string { + const decoratorName = this.getDecoratorName(field.decorators); + return `@${decoratorName}`; + } + + /** + * Gets the decorator name for a field's type. + */ + private getDecoratorName(decorators: any[]): string { + const typeDecorator = decorators.find((d) => FIELD_TYPE_OPTIONS.some((o) => o.decorator === d.name)); + return typeDecorator ? typeDecorator.name : "Text"; + } + + /** + * Gets the source model from the refactor context, handling different context types. + */ + protected getSourceModelFromContext(context: ManualRefactorContext): DecoratedClass | null { + // Case 1: metadata is already a DecoratedClass (model) + if (context.metadata && "properties" in context.metadata) { + return context.metadata as DecoratedClass; + } + + // Case 2: metadata is a PropertyMetadata (field) - find the containing model + if (context.metadata && "decorators" in context.metadata) { + const fieldMetadata = context.metadata as PropertyMetadata; + return this.findSourceModelForField(context.cache, fieldMetadata); + } + + // Case 3: no specific metadata - try to find model at the range + return this.findModelAtRange(context.cache, context.uri, context.range); + } + + /** + * Finds the model that contains the given field. + */ + private findSourceModelForField(cache: MetadataCache, fieldMetadata: PropertyMetadata): DecoratedClass | null { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + const fieldInModel = Object.values(model.properties).find( + (prop) => + prop.name === fieldMetadata.name && + prop.declaration.uri.fsPath === fieldMetadata.declaration.uri.fsPath && + prop.declaration.range.start.line === fieldMetadata.declaration.range.start.line + ); + + if (fieldInModel) { + return model; + } + } + + return null; + } + + /** + * Finds the model class that contains the given range. + */ + private findModelAtRange(cache: MetadataCache, uri: vscode.Uri, range: vscode.Range): DecoratedClass | null { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + if (model.declaration.uri.fsPath === uri.fsPath && model.declaration.range.contains(range)) { + return model; + } + } + + return null; + } + + /** + * Generates field code directly from PropertyMetadata, preserving all decorator information. + */ + protected generateFieldCodeFromPropertyMetadata(property: PropertyMetadata): string { + const lines: string[] = []; + + // Add all decorators in the same order as the original + for (const decorator of property.decorators) { + if (decorator.name === "Field" || decorator.name === "Relationship") { + // Handle Field and Relationship decorators with their arguments + if (decorator.arguments && decorator.arguments.length > 0) { + lines.push(`@${decorator.name}({`); + const args = decorator.arguments[0]; // Usually the first argument contains the options object + if (typeof args === "object" && args !== null) { + // Format each property of the arguments object + for (const [key, value] of Object.entries(args)) { + if (typeof value === "string") { + lines.push(` ${key}: "${value}",`); + } else if (typeof value === "boolean") { + lines.push(` ${key}: ${value},`); + } else if (typeof value === "number") { + lines.push(` ${key}: ${value},`); + } else { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } + } + } + lines.push("})"); + } else { + lines.push(`@${decorator.name}({})`); + } + } else { + // Handle type decorators (Text, Choice, etc.) with their arguments + if (decorator.arguments && decorator.arguments.length > 0) { + const args = decorator.arguments[0]; + if (typeof args === "object" && args !== null && Object.keys(args).length > 0) { + lines.push(`@${decorator.name}({`); + for (const [key, value] of Object.entries(args)) { + if (typeof value === "string") { + lines.push(` ${key}: "${value}",`); + } else if (typeof value === "boolean") { + lines.push(` ${key}: ${value},`); + } else if (typeof value === "number") { + lines.push(` ${key}: ${value},`); + } else if (Array.isArray(value)) { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } else { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } + } + lines.push("})"); + } else { + lines.push(`@${decorator.name}()`); + } + } else { + lines.push(`@${decorator.name}()`); + } + } + } + + // Add property declaration using the original type + lines.push(`${property.name}!: ${property.type};`); + + return lines.join("\n"); + } +} diff --git a/src/commands/fields/extractFieldsToComposition.ts b/src/commands/fields/extractFieldsToComposition.ts new file mode 100644 index 0000000..8d07074 --- /dev/null +++ b/src/commands/fields/extractFieldsToComposition.ts @@ -0,0 +1,404 @@ +import * as vscode from "vscode"; +import { + ChangeObject, + ManualRefactorContext, + ExtractFieldsToCompositionPayload, +} from "../../refactor/refactorInterfaces"; +import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; +import { AddCompositionTool } from "../models/addComposition"; +import { AddFieldTool } from "./addField"; +import { DeleteFieldTool } from "../../refactor/tools/deleteField"; +import { FieldInfo } from "../interfaces"; +import { detectIndentation, applyIndentation } from "../../utils/detectIndentation"; +import { ExtractFieldsController } from "./extractFieldsController"; +import { ModelService } from "../../services/modelService"; + +/** + * Refactor tool for extracting multiple fields from a model to a new composition model. + * + * This tool allows users to select multiple fields and move them to a new composition + * model, creating a composition relationship between the source and new models. + * It provides preview functionality before applying changes. + */ +export class ExtractFieldsToCompositionTool extends ExtractFieldsController { + private addCompositionTool: AddCompositionTool; + private addFieldTool: AddFieldTool; + private deleteFieldTool: DeleteFieldTool; + private modelService: ModelService; + + constructor() { + super(); + this.addCompositionTool = new AddCompositionTool(); + this.addFieldTool = new AddFieldTool(); + this.deleteFieldTool = new DeleteFieldTool(); + this.modelService = new ModelService(); + } + + /** + * Returns the VS Code command identifier for this refactor tool. + */ + getCommandId(): string { + return "slingr-vscode-extension.extractFieldsToComposition"; + } + + /** + * Returns the human-readable title shown in refactor menus. + */ + getTitle(): string { + return "Extract Fields to Composition"; + } + + /** + * Returns the types of changes this tool handles. + */ + getHandledChangeTypes(): string[] { + return ["EXTRACT_FIELDS_TO_COMPOSITION"]; + } + + /** + * Initiates the manual refactor by prompting user for field selection and composition name. + */ + async initiateManualRefactor( + context: ManualRefactorContext, + ): Promise { + const fieldSelection = await this.getSelectedFieldsFromContext(context, "composition"); + if (!fieldSelection) { + return undefined; + } + + const { sourceModel, selectedFields } = fieldSelection; + + // Get the composition field name + const compositionFieldName = await this.userInputService.showPrompt( + "Enter the name for the new composition field (e.g., 'address', 'contactInfo'):" + ); + if (!compositionFieldName) { + return undefined; + } + + const payload: ExtractFieldsToCompositionPayload = { + sourceModelName: sourceModel.name, + compositionFieldName: compositionFieldName, + fieldsToExtract: selectedFields, + isManual: true, + }; + + return { + type: "EXTRACT_FIELDS_TO_COMPOSITION", + uri: context.uri, + description: `Extract ${selectedFields.length} field(s) to new composition '${compositionFieldName}' in model '${sourceModel.name}'`, + payload, + }; + } + + /** + * Prepares the workspace edit for the refactor operation. + */ + async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { + const payload = change.payload as ExtractFieldsToCompositionPayload; + let edit = new vscode.WorkspaceEdit(); + let innerModelName = ""; + + try { + const sourceModel = cache.getModelByName(payload.sourceModelName); + if (!sourceModel) { + throw new Error(`Could not find source model '${payload.sourceModelName}'`); + } + + const combinedEdit = new vscode.WorkspaceEdit(); + + // Step 1: Create the composition field and new model WITH the extracted fields already included + // Use PropertyMetadata directly to preserve all decorator information + const fieldsToAdd = payload.fieldsToExtract; // These are already PropertyMetadata objects + + // Create the composition with the fields included + const result = await this.createCompositionWithFields( + cache, + payload.sourceModelName, + payload.compositionFieldName, + fieldsToAdd + ); + edit = result.edit; + innerModelName = result.innerModelName; + + + // Step 2: Remove the fields from the source model + for (const field of payload.fieldsToExtract) { + await this.deleteFieldTool.deleteFieldProgrammatically(field, sourceModel.name, cache,edit); + } + + return edit; + } catch (error) { + vscode.window.showErrorMessage(`Failed to prepare extract fields to composition edit: ${error}`); + throw error; + } + } + + /** + * Creates a composition relationship with the extracted fields already included in the inner model. + */ + private async createCompositionWithFields( + cache: MetadataCache, + sourceModelName: string, + compositionFieldName: string, + fieldsToAdd: PropertyMetadata[] + ): Promise<{ edit: vscode.WorkspaceEdit; innerModelName: string }> { + const sourceModel = cache.getModelByName(sourceModelName); + if (!sourceModel) { + throw new Error(`Could not find source model '${sourceModelName}'`); + } + + const document = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + const edit = new vscode.WorkspaceEdit(); + + // Determine inner model name and check if it should be an array + const { innerModelName, isArray } = this.determineInnerModelInfo(compositionFieldName); + + // Step 1: Add the inner model WITH the extracted fields already included + await this.addInnerModelWithFieldsToWorkspace(edit, document, innerModelName, sourceModelName, fieldsToAdd, cache); + + // Step 2: Add the composition field to the outer model + await this.addCompositionFieldToWorkspace( + edit, + document, + sourceModelName, + compositionFieldName, + innerModelName, + isArray, + cache + ); + + return { edit, innerModelName }; + } + + /** + * Extracts the dataSource from a model using the cache. + */ + private extractDataSourceFromModel(model: DecoratedClass, cache: MetadataCache): string | undefined { + const modelDecorator = model.decorators.find((d) => d.name === "Model"); + return modelDecorator?.arguments?.[0]?.dataSource; + } + + /** + * Determines the inner model name and whether the field should be an array. + */ + private determineInnerModelInfo(fieldName: string): { innerModelName: string; isArray: boolean } { + const singularName = this.toSingular(fieldName); + const innerModelName = this.toPascalCase(singularName); + const isArray = fieldName !== singularName; // If we converted from plural to singular, it's an array + + return { innerModelName, isArray }; + } + + /** + * Converts a potentially plural field name to singular using basic rules. + */ + private toSingular(fieldName: string): string { + if (!fieldName) { + return ""; + } + + // Rule 1: Handle "...ies" -> "...y" (e.g., "cities" -> "city") + if (fieldName.toLowerCase().endsWith("ies")) { + return fieldName.slice(0, -3) + "y"; + } + + // Rule 2: Handle "...es" -> "..." (e.g., "boxes" -> "box", "wishes" -> "wish") + if (fieldName.toLowerCase().endsWith("es")) { + const base = fieldName.slice(0, -2); + // Check if the base word ends in s, x, z, ch, sh + if (["s", "x", "z"].some((char) => base.endsWith(char)) || ["ch", "sh"].some((pair) => base.endsWith(pair))) { + return base; + } + } + + // Rule 3: Handle simple "...s" -> "..." (e.g., "cats" -> "cat") + if (fieldName.toLowerCase().endsWith("s") && !fieldName.toLowerCase().endsWith("ss")) { + return fieldName.slice(0, -1); + } + + // If no plural pattern was found, return the original string + return fieldName; + } + + /** + * Converts camelCase to PascalCase. + */ + private toPascalCase(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + /** + * Adds inner model with fields to workspace edit. + */ + private async addInnerModelWithFieldsToWorkspace( + edit: vscode.WorkspaceEdit, + document: vscode.TextDocument, + innerModelName: string, + outerModelName: string, + fieldsToAdd: PropertyMetadata[], + cache: MetadataCache + ): Promise { + // Check if inner model already exists + const existingModel = cache.getModelByName(innerModelName); + if (existingModel) { + throw new Error(`A model named '${innerModelName}' already exists in the project`); + } + + // Get data source from outer model + const outerModelClass = cache.getModelByName(outerModelName); + if (!outerModelClass) { + throw new Error(`Could not find model metadata for '${outerModelName}'`); + } + + const dataSource = this.extractDataSourceFromModel(outerModelClass, cache); + + // Generate class body from fields + const classBodyLines: string[] = []; + for (const property of fieldsToAdd) { + const fieldCode = this.generateFieldCodeFromPropertyMetadata(property); + classBodyLines.push(fieldCode); + classBodyLines.push(""); // Empty line between fields + } + + // Add owner field + const ownerFieldCode = this.generateOwnerFieldCode(outerModelName); + classBodyLines.push(ownerFieldCode); + + const classBody = classBodyLines.join("\n"); + + // Collect required imports from PropertyMetadata decorators + const existingImports = new Set(); + for (const property of fieldsToAdd) { + for (const decorator of property.decorators) { + existingImports.add(decorator.name); + } + } + // Add OwnerReference import for the owner field + existingImports.add("OwnerReference"); + + // Ensure required imports are added to the document + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, existingImports); + + // Generate the model content without imports (since we're adding to existing file) + const modelContent = await this.modelService.generateModelFileContent( + innerModelName, + classBody, + dataSource, + existingImports, + true, // isComponent = true since it's an inner model + document.uri.fsPath, + cache, + undefined, // no docs + false // includeImports = false since we're adding to existing file + ); + + // Find insertion point after the outer model + const lines = document.getText().split("\n"); + let insertionLine = lines.length; // Default to end of file + + try { + const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, outerModelName); + insertionLine = classEndLine + 1; + } catch (error) { + console.warn(`Could not find model ${outerModelName}, inserting at end of file`); + } + + // Add the model content at the correct position with proper spacing + const spacing = insertionLine < lines.length ? "\n\n" : "\n"; + edit.insert(document.uri, new vscode.Position(insertionLine, 0), `\n${modelContent}\n`); + } + + /** + * Adds composition field to the outer model workspace edit. + */ + private async addCompositionFieldToWorkspace( + edit: vscode.WorkspaceEdit, + document: vscode.TextDocument, + outerModelName: string, + fieldName: string, + innerModelName: string, + isArray: boolean, + cache: MetadataCache + ): Promise { + // Check if composition field already exists + const outerModelClass = cache.getModelByName(outerModelName); + if (!outerModelClass) { + throw new Error(`Could not find model metadata for '${outerModelName}'`); + } + + const existingFields = Object.keys(outerModelClass.properties || {}); + if (existingFields.includes(fieldName)) { + throw new Error(`Field '${fieldName}' already exists in model ${outerModelName}`); + } + + // Create field info for the composition field + const fieldType = { + label: "Relationship", + decorator: "Composition", + tsType: isArray ? `${innerModelName}[]` : innerModelName, + description: "Composition relationship", + }; + + const fieldInfo: FieldInfo = { + name: fieldName, + type: fieldType, + required: false, // Compositions are typically optional + }; + + // Generate the field code + const fieldCode = this.generateCompositionFieldCode(fieldInfo, innerModelName, isArray); + + // Find class boundaries and add field + const lines = document.getText().split("\n"); + const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, outerModelName); + const indentation = detectIndentation(lines, 0, lines.length); + const indentedFieldCode = applyIndentation(fieldCode, indentation); + + edit.insert(document.uri, new vscode.Position(classEndLine, 0), `\n${indentedFieldCode}\n`); + } + + /** + * Generates the composition field code. + */ + private generateCompositionFieldCode(fieldInfo: FieldInfo, innerModelName: string, isArray: boolean): string { + const lines: string[] = []; + + // Add Field decorator + if (fieldInfo.required) { + lines.push("@Field({"); + lines.push(" required: true"); + lines.push("})"); + } else { + lines.push("@Field({})"); + } + + // Add Composition decorator + lines.push("@Composition()"); + + // Add property declaration + const typeAnnotation = isArray ? `${innerModelName}[]` : innerModelName; + lines.push(`${fieldInfo.name}!: ${typeAnnotation};`); + + return lines.join("\n"); + } + + /** + * Generates the TypeScript code for the owner field. + */ + private generateOwnerFieldCode(ownerModelName: string): string { + const lines: string[] = []; + + // Add Field decorator + lines.push("@Field({})"); + + // Add OwnerReference decorator + lines.push("@OwnerReference()"); + + // Add property declaration + lines.push(`owner!: ${ownerModelName};`); + + return lines.join("\n"); + } + +} diff --git a/src/commands/fields/extractFieldsToEmbedded.ts b/src/commands/fields/extractFieldsToEmbedded.ts new file mode 100644 index 0000000..e075a27 --- /dev/null +++ b/src/commands/fields/extractFieldsToEmbedded.ts @@ -0,0 +1,304 @@ +// src/commands/fields/extractFieldsToEmbedded.ts +import * as vscode from "vscode"; +import { + ChangeObject, + ManualRefactorContext, + ExtractFieldsToEmbeddedPayload, +} from "../../refactor/refactorInterfaces"; +import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; +import { DeleteFieldTool } from "../../refactor/tools/deleteField"; +import { ExtractFieldsController } from "./extractFieldsController"; +import { ModelService } from "../../services/modelService"; +import * as path from "path"; + +/** + * Refactor tool for extracting multiple fields from a model to a new embedded model. + * + * This tool allows users to select multiple fields and move them to a new embedded + * model in a separate file, creating an embedded relationship between the source and new models. + * The embedded model extends BaseModel and has no dataSource. + */ +export class ExtractFieldsToEmbeddedTool extends ExtractFieldsController { + private deleteFieldTool: DeleteFieldTool; + private modelService: ModelService; + + constructor() { + super(); + this.deleteFieldTool = new DeleteFieldTool(); + this.modelService = new ModelService(); + } + + /** + * Returns the VS Code command identifier for this refactor tool. + */ + getCommandId(): string { + return "slingr-vscode-extension.extractFieldsToEmbedded"; + } + + /** + * Returns the human-readable title shown in refactor menus. + */ + getTitle(): string { + return "Extract Fields to Embedded"; + } + + /** + * Returns the types of changes this tool handles. + */ + getHandledChangeTypes(): string[] { + return ["EXTRACT_FIELDS_TO_EMBEDDED"]; + } + + /** + * Initiates the manual refactor by prompting user for field selection, new model name, and embedded field name. + */ + async initiateManualRefactor(context: ManualRefactorContext): Promise { + const fieldSelection = await this.getSelectedFieldsFromContext(context, "embedded"); + if (!fieldSelection) { + return undefined; + } + + const { sourceModel, selectedFields } = fieldSelection; + + // Get the new model name + const newModelName = await this.userInputService.showPrompt("Enter the name for the new embedded model:"); + if (!newModelName) { + return undefined; + } + + // Get the embedded field name + const embeddedFieldName = await this.userInputService.showPrompt( + "Enter the name for the new embedded field (e.g., 'address', 'profile'):" + ); + if (!embeddedFieldName) { + return undefined; + } + + const sourceDir = path.dirname(sourceModel.declaration.uri.fsPath); + const newModelPath = path.join(sourceDir, `${newModelName}.ts`); + const newModelUri = vscode.Uri.file(newModelPath); + + const payload: ExtractFieldsToEmbeddedPayload = { + sourceModelName: sourceModel.name, + newModelName: newModelName, + embeddedFieldName: embeddedFieldName, + fieldsToExtract: selectedFields, + isManual: true, + urisToCreate: [ + { + uri: newModelUri, + }, + ], + }; + + return { + type: "EXTRACT_FIELDS_TO_EMBEDDED", + uri: context.uri, + description: `Extract ${selectedFields.length} field(s) to new embedded model '${newModelName}' with embedded field '${embeddedFieldName}' in model '${sourceModel.name}'`, + payload, + }; + } + + /** + * Prepares the workspace edit for the refactor operation. + */ + async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { + const payload = change.payload as ExtractFieldsToEmbeddedPayload; + + try { + const sourceModel = cache.getModelByName(payload.sourceModelName); + if (!sourceModel) { + throw new Error(`Could not find source model '${payload.sourceModelName}'`); + } + + const edit = new vscode.WorkspaceEdit(); + + // Get the URI from the payload + const newModelUri = payload.urisToCreate![0].uri; + + // Generate the complete file content using ModelService + const completeFileContent = await this.generateCompleteEmbeddedModelFileWithService( + payload.newModelName, + payload.fieldsToExtract, + newModelUri.fsPath + ); + + const metadata: vscode.WorkspaceEditEntryMetadata = { + label: `Create new embedded model file ${path.basename(newModelUri.fsPath)}`, + description: `Creating new embedded model file for ${payload.newModelName}`, + needsConfirmation: true, + }; + + // Create the file with content + edit.createFile( + newModelUri, + { + overwrite: false, + ignoreIfExists: true, + contents: Buffer.from(completeFileContent, "utf8"), + }, + metadata + ); + + // Step 2: Add the embedded field to the source model + await this.addEmbeddedFieldToSourceModel( + edit, + sourceModel, + payload.embeddedFieldName, + payload.newModelName, + cache + ); + + // Step 3: Remove the fields from the source model + for (const field of payload.fieldsToExtract) { + await this.deleteFieldTool.deleteFieldProgrammatically(field, sourceModel.name, cache, edit); + } + + return edit; + } catch (error) { + vscode.window.showErrorMessage(`Failed to prepare extract fields to embedded edit: ${error}`); + throw error; + } + } + + /** + * Generates the complete file content for the new embedded model using ModelService. + * Embedded models extend BaseModel and have no dataSource. + */ + private async generateCompleteEmbeddedModelFileWithService( + modelName: string, + fieldsToExtract: PropertyMetadata[], + targetFilePath: string + ): Promise { + // Generate class body from fields + const classBodyLines: string[] = []; + for (const field of fieldsToExtract) { + const fieldCode = this.generateFieldCodeFromPropertyMetadata(field); + classBodyLines.push(fieldCode); + classBodyLines.push(""); // Empty line between fields + } + const classBody = classBodyLines.join("\n"); + + // Collect required imports from PropertyMetadata decorators + const existingImports = new Set(); + for (const field of fieldsToExtract) { + for (const decorator of field.decorators) { + existingImports.add(decorator.name); + } + } + + + // Use ModelService to generate the complete file content + // Embedded models have no dataSource, so we pass undefined + return await this.modelService.generateModelFileContent( + modelName, + classBody, + undefined, // no dataSource for embedded models + existingImports, + false, // isComponent = false since it's a separate model file + targetFilePath, + undefined, // no cache needed for embedded models + undefined, // no docs + true, // includeImports = true since this is a new file + false // includeDefaultId = false for embedded models + ); + } + + /** + * Adds an embedded field to the source model. + */ + private async addEmbeddedFieldToSourceModel( + edit: vscode.WorkspaceEdit, + sourceModel: DecoratedClass, + embeddedFieldName: string, + targetModelName: string, + cache: MetadataCache + ): Promise { + // Check if embedded field already exists + const existingFields = Object.keys(sourceModel.properties || {}); + if (existingFields.includes(embeddedFieldName)) { + throw new Error(`Field '${embeddedFieldName}' already exists in model ${sourceModel.name}`); + } + + const document = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + + // Generate the embedded field code + const fieldCode = this.generateEmbeddedFieldCode(embeddedFieldName, targetModelName); + + // Add required imports + const requiredImports = new Set(["Field", "Embedded"]); + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); + + // Add import for the target model + await this.sourceCodeService.addModelImport(document, targetModelName, edit, cache); + + // Find class boundaries and add field + const lines = document.getText().split("\n"); + const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, sourceModel.name); + + const metadata: vscode.WorkspaceEditEntryMetadata = { + label: `Add embedded field ${embeddedFieldName}`, + description: `Adding embedded field ${embeddedFieldName} of type ${targetModelName}`, + needsConfirmation: true, + }; + + edit.insert(sourceModel.declaration.uri, new vscode.Position(classEndLine, 0), `\n${fieldCode}\n`, metadata); + } + + /** + * Public method for programmatic usage of the extract fields to embedded functionality. + */ + public async extractFieldsToEmbedded( + cache: MetadataCache, + sourceModelName: string, + fieldsToExtract: PropertyMetadata[], + newModelName: string, + embeddedFieldName: string + ): Promise { + const sourceModel = cache.getModelByName(sourceModelName); + if (!sourceModel) { + throw new Error(`Could not find source model '${sourceModelName}'`); + } + + const sourceDir = path.dirname(sourceModel.declaration.uri.fsPath); + const newModelPath = path.join(sourceDir, `${newModelName}.ts`); + const newModelUri = vscode.Uri.file(newModelPath); + + const payload: ExtractFieldsToEmbeddedPayload = { + sourceModelName: sourceModelName, + newModelName: newModelName, + embeddedFieldName: embeddedFieldName, + fieldsToExtract: fieldsToExtract, + isManual: false, + urisToCreate: [ + { + uri: newModelUri, + }, + ], + }; + + const changeObject: ChangeObject = { + type: "EXTRACT_FIELDS_TO_EMBEDDED", + uri: sourceModel.declaration.uri, + description: `Extract ${fieldsToExtract.length} field(s) to new embedded model '${newModelName}' with embedded field '${embeddedFieldName}' in model '${sourceModelName}'`, + payload, + }; + + return await this.prepareEdit(changeObject, cache); + } + + /** + * Generates the embedded field code with only @Embedded() decorator. + */ + private generateEmbeddedFieldCode(fieldName: string, targetModelName: string): string { + const lines: string[] = []; + + // Add Embedded decorator (no arguments needed) + lines.push(" @Embedded()"); + + // Add property declaration + lines.push(` ${fieldName}!: ${targetModelName};`); + + return lines.join("\n"); + } +} \ No newline at end of file diff --git a/src/commands/fields/extractFieldsToParent.ts b/src/commands/fields/extractFieldsToParent.ts new file mode 100644 index 0000000..160fec2 --- /dev/null +++ b/src/commands/fields/extractFieldsToParent.ts @@ -0,0 +1,265 @@ +import * as vscode from "vscode"; +import { + ChangeObject, + ManualRefactorContext, + ExtractFieldsToParentPayload, +} from "../../refactor/refactorInterfaces"; +import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; +import { NewModelTool } from "../models/newModel"; +import { AddFieldTool } from "./addField"; +import { DeleteFieldTool } from "../../refactor/tools/deleteField"; +import { ExtractFieldsController } from "./extractFieldsController"; +import { ModelService } from "../../services/modelService"; +import * as path from "path"; + +/** + * Refactor tool for extracting multiple fields from a model to a new abstract parent model. + * + * This tool allows users to select multiple fields and move them to a new abstract parent + * model extending BaseModel. The source model will then extend from this new parent model + * instead of its current parent. It provides preview functionality before applying changes. + */ +export class ExtractFieldsToParentTool extends ExtractFieldsController { + private newModelTool: NewModelTool; + private addFieldTool: AddFieldTool; + private deleteFieldTool: DeleteFieldTool; + private modelService: ModelService; + + constructor() { + super(); + this.newModelTool = new NewModelTool(); + this.addFieldTool = new AddFieldTool(); + this.deleteFieldTool = new DeleteFieldTool(); + this.modelService = new ModelService(); + } + + /** + * Returns the VS Code command identifier for this refactor tool. + */ + getCommandId(): string { + return "slingr-vscode-extension.extractFieldsToParent"; + } + + /** + * Returns the human-readable title shown in refactor menus. + */ + getTitle(): string { + return "Extract Fields to Parent"; + } + + /** + * Returns the types of changes this tool handles. + */ + getHandledChangeTypes(): string[] { + return ["EXTRACT_FIELDS_TO_PARENT"]; + } + + /** + * Initiates the manual refactor by prompting user for field selection and new parent model name. + */ + async initiateManualRefactor(context: ManualRefactorContext): Promise { + const fieldSelection = await this.getSelectedFieldsFromContext(context, "parent"); + if (!fieldSelection) { + return undefined; + } + + const { sourceModel, selectedFields } = fieldSelection; + + // Get the new parent model name + const newParentModelName = await this.userInputService.showPrompt("Enter the name for the new abstract parent model:"); + if (!newParentModelName) { + return undefined; + } + + const sourceDir = path.dirname(sourceModel.declaration.uri.fsPath); + const newParentModelPath = path.join(sourceDir, `${newParentModelName}.ts`); + const newParentModelUri = vscode.Uri.file(newParentModelPath); + + const payload: ExtractFieldsToParentPayload = { + sourceModelName: sourceModel.name, + newParentModelName: newParentModelName, + fieldsToExtract: selectedFields, + isManual: true, + urisToCreate: [ + { + uri: newParentModelUri, + }, + ], + }; + + return { + type: "EXTRACT_FIELDS_TO_PARENT", + uri: context.uri, + description: `Extract ${selectedFields.length} field(s) to new abstract parent model '${newParentModelName}' for model '${sourceModel.name}'`, + payload, + }; + } + + /** + * Prepares the workspace edit for the refactor operation. + */ + async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { + const payload = change.payload as ExtractFieldsToParentPayload; + + try { + const sourceModel = cache.getModelByName(payload.sourceModelName); + if (!sourceModel) { + throw new Error(`Could not find source model '${payload.sourceModelName}'`); + } + + const edit = new vscode.WorkspaceEdit(); + + // Get the URI from the payload + const newParentModelUri = payload.urisToCreate![0].uri; + + // Generate the complete file content for the abstract parent model using ModelService + const completeFileContent = await this.generateCompleteParentModelFileWithService( + payload.newParentModelName, + payload.fieldsToExtract, + newParentModelUri.fsPath + ); + + const metadata: vscode.WorkspaceEditEntryMetadata = { + label: `Create new abstract parent model file ${path.basename(newParentModelUri.fsPath)}`, + description: `Creating new abstract parent model file for ${payload.newParentModelName}`, + needsConfirmation: true, + }; + + // Create the file with content + edit.createFile( + newParentModelUri, + { + overwrite: false, + ignoreIfExists: true, + contents: Buffer.from(completeFileContent, "utf8"), + }, + metadata + ); + + // Step 2: Update the source model to extend from the new parent model + await this.updateSourceModelToExtendParent( + edit, + sourceModel, + payload.newParentModelName, + cache + ); + + // Step 3: Remove the fields from the source model + for (const field of payload.fieldsToExtract) { + await this.deleteFieldTool.deleteFieldProgrammatically(field, sourceModel.name, cache, edit); + } + + return edit; + } catch (error) { + vscode.window.showErrorMessage(`Failed to prepare extract fields to parent edit: ${error}`); + throw error; + } + } + + /** + * Generates the complete file content for the new abstract parent model using ModelService. + */ + private async generateCompleteParentModelFileWithService( + modelName: string, + fieldsToExtract: PropertyMetadata[], + targetFilePath: string + ): Promise { + // Generate class body from fields + const classBodyLines: string[] = []; + for (const field of fieldsToExtract) { + const fieldCode = this.generateFieldCodeFromPropertyMetadata(field); + classBodyLines.push(fieldCode); + classBodyLines.push(""); // Empty line between fields + } + const classBody = classBodyLines.join("\n"); + + // Collect required imports from PropertyMetadata decorators + const existingImports = new Set(); + for (const field of fieldsToExtract) { + for (const decorator of field.decorators) { + existingImports.add(decorator.name); + } + } + + // Use ModelService to generate the complete file content + // Abstract parent models have no dataSource and no default id field + const modelContent = await this.modelService.generateModelFileContent( + modelName, + classBody, + undefined, // no dataSource for abstract parent models + existingImports, + false, // isComponent = false since it's a separate model file + targetFilePath, + undefined, // no cache needed for abstract parent models + undefined, // no docs + true, // includeImports = true since this is a new file + ); + + // Replace the class declaration to make it abstract + return modelContent.replace( + `export class ${modelName} extends BaseModel {`, + `export abstract class ${modelName} extends BaseModel {` + ); + } + + /** + * Updates the source model to extend from the new parent model instead of its current parent. + */ + private async updateSourceModelToExtendParent( + edit: vscode.WorkspaceEdit, + sourceModel: DecoratedClass, + newParentModelName: string, + cache: MetadataCache + ): Promise { + const document = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + + // Add import for the parent model + await this.sourceCodeService.addModelImport(document, newParentModelName, edit, cache); + + // Find the class declaration line and update it to extend from the new parent + const lines = document.getText().split("\n"); + const classLine = this.findClassDeclarationLine(lines, sourceModel.name); + + if (classLine === -1) { + throw new Error(`Could not find class declaration for ${sourceModel.name}`); + } + + const currentLine = lines[classLine]; + const newLine = this.updateClassExtension(currentLine, sourceModel.name, newParentModelName); + + const lineRange = new vscode.Range( + new vscode.Position(classLine, 0), + new vscode.Position(classLine, currentLine.length) + ); + + const metadata: vscode.WorkspaceEditEntryMetadata = { + label: `Update ${sourceModel.name} to extend ${newParentModelName}`, + description: `Changing class inheritance for ${sourceModel.name}`, + needsConfirmation: true, + }; + + edit.replace(sourceModel.declaration.uri, lineRange, newLine, metadata); + } + + /** + * Finds the line number of the class declaration. + */ + private findClassDeclarationLine(lines: string[], className: string): number { + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line.includes(`class ${className}`) && line.includes("extends")) { + return i; + } + } + return -1; + } + + /** + * Updates the class extension to use the new parent model. + */ + private updateClassExtension(currentLine: string, className: string, newParentModelName: string): string { + // Replace the current extends clause with the new parent + const extendsPattern = /extends\s+\w+/; + return currentLine.replace(extendsPattern, `extends ${newParentModelName}`); + } +} \ No newline at end of file diff --git a/src/commands/fields/extractFieldsToReference.ts b/src/commands/fields/extractFieldsToReference.ts new file mode 100644 index 0000000..127946a --- /dev/null +++ b/src/commands/fields/extractFieldsToReference.ts @@ -0,0 +1,326 @@ +import * as vscode from "vscode"; +import { + ChangeObject, + ManualRefactorContext, + ExtractFieldsToReferencePayload, +} from "../../refactor/refactorInterfaces"; +import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; +import { NewModelTool } from "../models/newModel"; +import { AddFieldTool } from "./addField"; +import { DeleteFieldTool } from "../../refactor/tools/deleteField"; +import { ExtractFieldsController } from "./extractFieldsController"; +import { ModelService } from "../../services/modelService"; +import * as path from "path"; + +/** + * Refactor tool for extracting multiple fields from a model to a new reference model. + * + * This tool allows users to select multiple fields and move them to a new reference + * model in a separate file, creating a reference relationship between the source and new models. + * It provides preview functionality before applying changes. + */ +export class ExtractFieldsToReferenceTool extends ExtractFieldsController { + private newModelTool: NewModelTool; + private addFieldTool: AddFieldTool; + private deleteFieldTool: DeleteFieldTool; + private modelService: ModelService; + + constructor() { + super(); + this.newModelTool = new NewModelTool(); + this.addFieldTool = new AddFieldTool(); + this.deleteFieldTool = new DeleteFieldTool(); + this.modelService = new ModelService(); + } + + /** + * Returns the VS Code command identifier for this refactor tool. + */ + getCommandId(): string { + return "slingr-vscode-extension.extractFieldsToReference"; + } + + /** + * Returns the human-readable title shown in refactor menus. + */ + getTitle(): string { + return "Extract Fields to Reference"; + } + + /** + * Returns the types of changes this tool handles. + */ + getHandledChangeTypes(): string[] { + return ["EXTRACT_FIELDS_TO_REFERENCE"]; + } + + /** + * Initiates the manual refactor by prompting user for field selection, new model name, and reference field name. + */ + async initiateManualRefactor(context: ManualRefactorContext): Promise { + const fieldSelection = await this.getSelectedFieldsFromContext(context, "reference"); + if (!fieldSelection) { + return undefined; + } + + const { sourceModel, selectedFields } = fieldSelection; + + // Get the new reference model name + const newModelName = await this.userInputService.showPrompt("Enter the name for the new reference model:"); + if (!newModelName) { + return undefined; + } + + // Get the reference field name + const referenceFieldName = await this.userInputService.showPrompt("Enter the name for the reference field:"); + if (!referenceFieldName) { + return undefined; + } + + const sourceDir = path.dirname(sourceModel.declaration.uri.fsPath); + const newModelPath = path.join(sourceDir, `${newModelName}.ts`); + const newModelUri = vscode.Uri.file(newModelPath); + + const payload: ExtractFieldsToReferencePayload = { + sourceModelName: sourceModel.name, + newModelName: newModelName, + referenceFieldName: referenceFieldName, + fieldsToExtract: selectedFields, + isManual: true, + urisToCreate: [ + { + uri: newModelUri, + }, + ], + }; + + return { + type: "EXTRACT_FIELDS_TO_REFERENCE", + uri: context.uri, + description: `Extract ${selectedFields.length} field(s) to new reference model '${newModelName}' with reference field '${referenceFieldName}' in model '${sourceModel.name}'`, + payload, + }; + } + + /** + * Prepares the workspace edit for the refactor operation. + */ + async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { + const payload = change.payload as ExtractFieldsToReferencePayload; + + try { + const sourceModel = cache.getModelByName(payload.sourceModelName); + if (!sourceModel) { + throw new Error(`Could not find source model '${payload.sourceModelName}'`); + } + + const edit = new vscode.WorkspaceEdit(); + + // Get the URI from the payload + const newModelUri = payload.urisToCreate![0].uri; + + // Generate the complete file content using ModelService + const completeFileContent = await this.generateCompleteReferenceModelFileWithService( + payload.newModelName, + payload.fieldsToExtract, + sourceModel, + cache, + newModelUri.fsPath + ); + + const metadata: vscode.WorkspaceEditEntryMetadata = { + label: `Create new model file ${path.basename(newModelUri.fsPath)}`, + description: `Creating new model file for ${payload.newModelName}`, + needsConfirmation: true, + }; + + // Create the file with content + edit.createFile( + newModelUri, + { + overwrite: false, + ignoreIfExists: true, + contents: Buffer.from(completeFileContent, "utf8"), + }, + metadata + ); + + // Step 2: Add the reference field to the source model + await this.addReferenceFieldToSourceModel( + edit, + sourceModel, + payload.referenceFieldName, + payload.newModelName, + cache + ); + + // Step 3: Remove the fields from the source model + for (const field of payload.fieldsToExtract) { + await this.deleteFieldTool.deleteFieldProgrammatically(field, sourceModel.name, cache, edit); + } + + return edit; + } catch (error) { + vscode.window.showErrorMessage(`Failed to prepare extract fields to reference edit: ${error}`); + throw error; + } + } + + /** + * Helper method to get selected fields from editor selections. + */ + private getSelectedFields(model: DecoratedClass, selections: readonly vscode.Selection[]): PropertyMetadata[] { + const selectedFields: PropertyMetadata[] = []; + for (const selection of selections) { + for (const field of Object.values(model.properties)) { + if (selection.intersection(field.declaration.range)) { + selectedFields.push(field); + } + } + } + return selectedFields; + } + + /** + * Creates a new reference model in a separate file with the extracted fields. + */ + private async createReferenceModelWithFields( + sourceModel: DecoratedClass, + newModelName: string, + fieldsToExtract: PropertyMetadata[], + cache: MetadataCache, + newModelUri: vscode.Uri + ): Promise<{ edit: vscode.WorkspaceEdit; newModelUri: vscode.Uri }> { + // Check if model with this name already exists + const existingModel = cache.getModelByName(newModelName); + if (existingModel) { + throw new Error(`A model named '${newModelName}' already exists in the project`); + } + + const edit = new vscode.WorkspaceEdit(); + + // Generate the complete file content including imports and model using ModelService + const completeFileContent = await this.generateCompleteReferenceModelFileWithService( + newModelName, + fieldsToExtract, + sourceModel, + cache, + newModelUri.fsPath + ); + + edit.createFile(newModelUri, { + overwrite: false, + ignoreIfExists: true, + contents: Buffer.from(completeFileContent, "utf8"), + }); + + return { edit, newModelUri }; + } + + /** + * Generates the complete file content for the new reference model using ModelService. + */ + private async generateCompleteReferenceModelFileWithService( + modelName: string, + fieldsToExtract: PropertyMetadata[], + sourceModel: DecoratedClass, + cache: MetadataCache, + targetFilePath: string + ): Promise { + // Extract data source from source model + const dataSource = this.extractDataSourceFromModel(sourceModel, cache); + + // Generate class body from fields + const classBodyLines: string[] = []; + for (const field of fieldsToExtract) { + const fieldCode = this.generateFieldCodeFromPropertyMetadata(field); + classBodyLines.push(fieldCode); + classBodyLines.push(""); // Empty line between fields + } + const classBody = classBodyLines.join("\n"); + + // Collect required imports from PropertyMetadata decorators + const existingImports = new Set(); + for (const field of fieldsToExtract) { + for (const decorator of field.decorators) { + existingImports.add(decorator.name); + } + } + + // Use ModelService to generate the complete file content + return await this.modelService.generateModelFileContent( + modelName, + classBody, + dataSource, + existingImports, + false, // isComponent = false since it's a separate model file + targetFilePath, + cache, + undefined, // no docs + true // includeImports = true since this is a new file + ); + } + + + + /** + * Extracts the dataSource from a model using the cache. + */ + private extractDataSourceFromModel(model: DecoratedClass, cache: MetadataCache): string | undefined { + const modelDecorator = model.decorators.find((d) => d.name === "Model"); + return modelDecorator?.arguments?.[0]?.dataSource; + } + + /** + * Adds a reference field to the source model. + */ + private async addReferenceFieldToSourceModel( + edit: vscode.WorkspaceEdit, + sourceModel: DecoratedClass, + referenceFieldName: string, + targetModelName: string, + cache: MetadataCache + ): Promise { + // Check if reference field already exists + const existingFields = Object.keys(sourceModel.properties || {}); + if (existingFields.includes(referenceFieldName)) { + throw new Error(`Field '${referenceFieldName}' already exists in model ${sourceModel.name}`); + } + + const document = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + + // Generate the reference field code + const fieldCode = this.generateReferenceFieldCode(referenceFieldName, targetModelName); + + // Add required imports + const requiredImports = new Set(["Field", "Reference"]); + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); + + // Add import for the target model + await this.sourceCodeService.addModelImport(document, targetModelName, edit, cache); + + // Find class boundaries and add field + const lines = document.getText().split("\n"); + const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, sourceModel.name); + + edit.insert(sourceModel.declaration.uri, new vscode.Position(classEndLine, 0), `\n${fieldCode}\n`); + } + + /** + * Generates the reference field code. + */ + private generateReferenceFieldCode(fieldName: string, targetModelName: string): string { + const lines: string[] = []; + + // Add Field decorator + lines.push(" @Field({})"); + + // Add Reference decorator + lines.push(" @Reference()"); + + // Add property declaration + lines.push(` ${fieldName}!: ${targetModelName};`); + + return lines.join("\n"); + } +} diff --git a/src/commands/interfaces.ts b/src/commands/interfaces.ts index 6f4796f..44ed54c 100644 --- a/src/commands/interfaces.ts +++ b/src/commands/interfaces.ts @@ -25,8 +25,8 @@ export interface AIEnhancedTool { processWithAI( userInput: string, targetUri: vscode.Uri, + modelName: string, cache: MetadataCache, - additionalContext?: any ): Promise; } diff --git a/src/commands/models/addComposition.ts b/src/commands/models/addComposition.ts new file mode 100644 index 0000000..85e8512 --- /dev/null +++ b/src/commands/models/addComposition.ts @@ -0,0 +1,496 @@ +import * as vscode from "vscode"; +import { MetadataCache, DecoratedClass } from "../../cache/cache"; +import { AIEnhancedTool, FieldInfo, FieldTypeOption } from "../interfaces"; +import { UserInputService } from "../../services/userInputService"; +import { ProjectAnalysisService } from "../../services/projectAnalysisService"; +import { SourceCodeService } from "../../services/sourceCodeService"; +import { FileSystemService } from "../../services/fileSystemService"; +import { ModelService } from "../../services/modelService"; +import { ExplorerProvider } from "../../explorer/explorerProvider"; +import { detectIndentation, applyIndentation } from "../../utils/detectIndentation"; + +/** + * Tool for adding composition relationships to existing Model classes. + * + */ +export class AddCompositionTool { + private userInputService: UserInputService; + private projectAnalysisService: ProjectAnalysisService; + private sourceCodeService: SourceCodeService; + private fileSystemService: FileSystemService; + private modelService: ModelService; + + constructor() { + this.userInputService = new UserInputService(); + this.projectAnalysisService = new ProjectAnalysisService(); + this.sourceCodeService = new SourceCodeService(); + this.fileSystemService = new FileSystemService(); + this.modelService = new ModelService(); + } + + /** + * Adds a composition relationship to an existing model file. + * + * @param cache - The metadata cache for context about existing models + * @param modelName - The name of the model to which the composition is being added + * @returns Promise that resolves when the composition is added + */ + public async addComposition(cache: MetadataCache, modelName: string): Promise { + try { + // Step 1: Validate target file + const { modelClass, document } = await this.validateAndPrepareTarget(modelName, cache); + + // Step 2: Get field name from user + const fieldName = await this.getCompositionFieldName(modelClass); + if (!fieldName) { + return; // User cancelled + } + + await this.addCompositionProgrammatically(cache, modelName, fieldName); + } catch (error) { + vscode.window.showErrorMessage(`Failed to add composition: ${error}`); + console.error("Error adding composition:", error); + } + } + + /** + * Adds a composition relationship programmatically with a predefined field name. + * This method is used by other tools that need to create compositions without user interaction. + * + * @param cache - The metadata cache for context about existing models + * @param modelName - The name of the model to which the composition is being added + * @param fieldName - The predefined field name for the composition + * @returns Promise that resolves with the created inner model name when the composition is added + */ + public async addCompositionProgrammatically( + cache: MetadataCache, + modelName: string, + fieldName: string + ): Promise { + const edit = new vscode.WorkspaceEdit(); + try { + // Step 1: Validate target file + const { modelClass, document } = await this.validateAndPrepareTarget(modelName, cache); + + // Step 2: Determine inner model name and array status + const { innerModelName, isArray } = this.determineInnerModelInfo(fieldName); + + // Step 3: Check if inner model already exists + await this.validateInnerModelName(cache, innerModelName); + + // Step 4: Create the inner model + await this.createInnerModel(document, innerModelName, modelClass.name, cache); + + // Step 5: Add composition field to outer model + await this.addCompositionField(document, modelClass.name, fieldName, innerModelName, isArray, cache); + + // Add required imports + const requiredImports = new Set(["Model", "Field", "Relationship", "BaseModel", "UUID", "PrimaryKey"]); + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); + + // Apply the edit + await vscode.workspace.applyEdit(edit); + + // Step 6: Focus on the newly created field + await this.sourceCodeService.focusOnElement(document, fieldName); + + // Step 7: Show success message + vscode.window.showInformationMessage( + `Composition relationship created successfully! Added ${innerModelName} model and ${fieldName} field.` + ); + + return innerModelName; + } catch (error) { + console.error("Error adding composition programmatically:", error); + throw error; + } + } + + /** + * Creates a WorkspaceEdit for adding a composition relationship programmatically without applying it. + * This method prepares all the necessary changes (inner model creation and composition field addition) + * and returns them as a WorkspaceEdit that can be applied later or combined with other edits. + * + * @param cache - The metadata cache for context about existing models + * @param modelName - The name of the model to which the composition is being added + * @param fieldName - The predefined field name for the composition + * @returns Promise that resolves to a WorkspaceEdit containing all necessary changes and the inner model name + * @throws Error if validation fails or models already exist + * + */ + public async createAddCompositionWorkspaceEdit( + cache: MetadataCache, + modelName: string, + fieldName: string + ): Promise<{ edit: vscode.WorkspaceEdit; innerModelName: string }> { + // Step 1: Validate target file + const { modelClass, document } = await this.validateAndPrepareTarget(modelName, cache); + + // Step 2: Determine inner model name and array status + const { innerModelName, isArray } = this.determineInnerModelInfo(fieldName); + + // Step 3: Check if inner model already exists + await this.validateInnerModelName(cache, innerModelName); + + // Step 4: Check if composition field already exists + const existingFields = Object.keys(modelClass.properties || {}); + if (existingFields.includes(fieldName)) { + throw new Error(`Field '${fieldName}' already exists in model ${modelClass.name}`); + } + + // Step 5: Create the workspace edit + const edit = new vscode.WorkspaceEdit(); + + // Step 6: Add inner model creation edit + await this.addInnerModelEditToWorkspace(edit, document, innerModelName, modelClass.name, cache); + + // Step 7: Add composition field edit + await this.addCompositionFieldEditToWorkspace( + edit, + document, + modelClass.name, + fieldName, + innerModelName, + isArray, + cache + ); + + return { edit, innerModelName }; + } + + /** + * Adds inner model creation edits to the provided WorkspaceEdit. + */ + private async addInnerModelEditToWorkspace( + edit: vscode.WorkspaceEdit, + document: vscode.TextDocument, + innerModelName: string, + outerModelName: string, + cache: MetadataCache + ): Promise { + // Determine data source from outer model + const outerModelClass = cache.getModelByName(outerModelName); + if (!outerModelClass) { + throw new Error(`Could not find model metadata for '${outerModelName}'`); + } + + const outerModelDecorator = cache.getModelDecoratorByName("Model", outerModelClass); + const dataSource = outerModelDecorator?.arguments?.[0]?.dataSource; + + // Generate the inner model code using ModelService + const innerModelCode = await this.generateInnerModelCodeWithService(innerModelName, dataSource, document.uri.fsPath, outerModelName); + + // Add required imports + const requiredImports = new Set(["Model", "Field", "Relationship", "BaseModel", "OwnerReference"]); + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); + + // Find insertion point after the outer model + const lines = document.getText().split("\n"); + let insertionLine = lines.length; // Default to end of file + + try { + const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, outerModelName); + insertionLine = classEndLine + 1; + } catch (error) { + // If we can't find the specified model, fall back to end of file + console.warn(`Could not find model ${outerModelName}, inserting at end of file`); + } + + // Insert the inner model with appropriate spacing + const spacing = insertionLine < lines.length ? "\n\n" : "\n"; + edit.insert(document.uri, new vscode.Position(insertionLine, 0), `${spacing}${innerModelCode}\n`); + } + + /** + * Adds composition field creation edits to the provided WorkspaceEdit. + */ + private async addCompositionFieldEditToWorkspace( + edit: vscode.WorkspaceEdit, + document: vscode.TextDocument, + outerModelName: string, + fieldName: string, + innerModelName: string, + isArray: boolean, + cache: MetadataCache + ): Promise { + // Create field info for the composition field + const fieldType: FieldTypeOption = { + label: "Relationship", + decorator: "Composition", + tsType: isArray ? `${innerModelName}[]` : innerModelName, + description: "Composition relationship", + }; + + const fieldInfo: FieldInfo = { + name: fieldName, + type: fieldType, + required: false, // Compositions are typically optional + additionalConfig: { + relationshipType: "composition", + targetModel: innerModelName, + targetModelPath: document.uri.fsPath, + }, + }; + + // Generate the field code + const fieldCode = this.generateCompositionFieldCode(fieldInfo, innerModelName, isArray); + + // Add field insertion edits using the source code service approach + const lines = document.getText().split("\n"); + //const requiredImports = new Set(["Field", "Composition", "BaseModel"]); + + // Add imports + //await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); + + // Find class boundaries and add field + const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, outerModelName); + const indentation = detectIndentation(lines, 0, lines.length); + const indentedFieldCode = applyIndentation(fieldCode, indentation); + + edit.insert(document.uri, new vscode.Position(classEndLine, 0), `\n${indentedFieldCode}\n`); + } + + /** + * Validates the target file and prepares it for composition addition. + */ + private async validateAndPrepareTarget( + modelName: string, + cache: MetadataCache + ): Promise<{ modelClass: DecoratedClass; document: vscode.TextDocument }> { + // Get model information from cache + const modelClass = cache.getModelByName(modelName); + if (!modelClass) { + throw new Error(`Model '${modelName}' not found in the project`); + } + + const document = await vscode.workspace.openTextDocument(modelClass.declaration.uri); + if (!document) { + throw new Error(`Could not open document for model '${modelName}'`); + } + + return { modelClass, document }; + } + + /** + * Gets the composition field name from the user. + */ + private async getCompositionFieldName(modelClass: DecoratedClass): Promise { + const fieldName = await vscode.window.showInputBox({ + prompt: "Enter the composition field name (camelCase)", + placeHolder: "e.g., addresses, phoneNumbers, tasks", + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return "Field name is required"; + } + if (!/^[a-z][a-zA-Z0-9]*$/.test(value.trim())) { + return "Field name must be in camelCase (e.g., addresses, phoneNumbers)"; + } + + // Check if field already exists in the model + const existingFields = Object.keys(modelClass.properties || {}); + if (existingFields.includes(value.trim())) { + return `Field '${value.trim()}' already exists in this model`; + } + + return null; + }, + }); + + return fieldName?.trim() || null; + } + + /** + * Determines the inner model name and whether the field should be an array. + */ + private determineInnerModelInfo(fieldName: string): { innerModelName: string; isArray: boolean } { + const singularName = this.toSingular(fieldName); + const innerModelName = this.toPascalCase(singularName); + const isArray = fieldName !== singularName; // If we converted from plural to singular, it's an array + + return { innerModelName, isArray }; + } + + /** + * Converts a potentially plural field name to singular using basic rules. + * @param fieldName The plural string to convert. + * @returns The singular form of the string. + */ + private toSingular(fieldName: string): string { + if (!fieldName) { + return ""; + } + + // Rule 1: Handle "...ies" -> "...y" (e.g., "cities" -> "city") + if (fieldName.toLowerCase().endsWith("ies")) { + return fieldName.slice(0, -3) + "y"; + } + + // Rule 2: Handle "...es" -> "..." (e.g., "boxes" -> "box", "wishes" -> "wish") + // This is more specific than a simple "s", so it should be checked first. + if (fieldName.toLowerCase().endsWith("es")) { + const base = fieldName.slice(0, -2); + // Check if the base word ends in s, x, z, ch, sh + if (["s", "x", "z"].some((char) => base.endsWith(char)) || ["ch", "sh"].some((pair) => base.endsWith(pair))) { + return base; + } + } + + // Rule 3: Handle simple "...s" -> "..." (e.g., "cats" -> "cat") + // Avoids changing words that end in "ss" (e.g., "address") + if (fieldName.toLowerCase().endsWith("s") && !fieldName.toLowerCase().endsWith("ss")) { + return fieldName.slice(0, -1); + } + + // If no plural pattern was found, return the original string + return fieldName; + } + + /** + * Converts camelCase to PascalCase. + */ + private toPascalCase(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + /** + * Validates that the inner model name doesn't already exist. + */ + private async validateInnerModelName(cache: MetadataCache, innerModelName: string): Promise { + const existingModel = cache.getModelByName(innerModelName); + if (existingModel) { + throw new Error(`A model named '${innerModelName}' already exists in the project`); + } + } + + /** + * Creates the inner model in the same file. + */ + private async createInnerModel( + document: vscode.TextDocument, + innerModelName: string, + outerModelName: string, + cache: MetadataCache + ): Promise { + // Determine data source from outer model + const outerModelClass = cache.getModelByName(outerModelName); + if (!outerModelClass) { + throw new Error(`Could not find model metadata for '${outerModelName}'`); + } + + const outerModelDecorator = cache.getModelDecoratorByName("Model", outerModelClass); + const dataSource = outerModelDecorator?.arguments?.[0]?.dataSource; + + // Generate the inner model code using ModelService + const innerModelCode = await this.generateInnerModelCodeWithService(innerModelName, dataSource, document.uri.fsPath, outerModelName); + + // Use the new insertModel method to insert after the outer model + await this.sourceCodeService.insertModel( + document, + innerModelCode, + outerModelName, // Insert after the outer model + new Set(["Model", "Field", "Relationship", "UUID", "OwnerReference"]) // Ensure required decorators are imported + ); + } + + /** + * Generates the TypeScript code for the inner model using ModelService. + */ + private async generateInnerModelCodeWithService( + innerModelName: string, + dataSource: string | undefined, + targetFilePath: string, + outerModelName: string + ): Promise { + // Generate owner field code + const ownerFieldCode = this.generateOwnerFieldCode(outerModelName); + + // Use ModelService to generate the complete model content + // Inner models are components (not exported) and need the default ID field + return await this.modelService.generateModelFileContent( + innerModelName, + ownerFieldCode, // include owner field in the class body + dataSource, + new Set(["OwnerReference"]), // add OwnerReference to imports + true, // isComponent = true since it's an inner model + targetFilePath, + undefined, // no cache needed + undefined, // no docs + false, // includeImports = false since we're adding to existing file + true // includeDefaultId = true for inner models + ); + } + + /** + * Adds the composition field to the outer model. + */ + private async addCompositionField( + document: vscode.TextDocument, + outerModelName: string, + fieldName: string, + innerModelName: string, + isArray: boolean, + cache: MetadataCache + ): Promise { + // Create field info for the composition field + const fieldType: FieldTypeOption = { + label: "Relationship", + decorator: "Composition", + tsType: isArray ? `${innerModelName}[]` : innerModelName, + description: "Composition relationship", + }; + + const fieldInfo: FieldInfo = { + name: fieldName, + type: fieldType, + required: false, // Compositions are typically optional + additionalConfig: { + relationshipType: "composition", + targetModel: innerModelName, + targetModelPath: document.uri.fsPath, + }, + }; + + // Generate the field code + const fieldCode = this.generateCompositionFieldCode(fieldInfo, innerModelName, isArray); + + // Insert the field + await this.sourceCodeService.insertField(document, outerModelName, fieldInfo, fieldCode, cache, false); + } + + /** + * Generates the TypeScript code for the composition field. + */ + private generateCompositionFieldCode(fieldInfo: FieldInfo, innerModelName: string, isArray: boolean): string { + const lines: string[] = []; + + // Add Field decorator + lines.push("@Field({})"); + + // Add Relationship decorator + lines.push("@Composition()"); + + // Add property declaration + const typeDeclaration = isArray ? `${innerModelName}[]` : innerModelName; + lines.push(`${fieldInfo.name}!: ${typeDeclaration};`); + + return lines.join("\n"); + } + + /** + * Generates the TypeScript code for the owner field. + */ + private generateOwnerFieldCode(ownerModelName: string): string { + const lines: string[] = []; + + // Add Field decorator + lines.push("@Field({})"); + + // Add OwnerReference decorator + lines.push("@OwnerReference()"); + + // Add property declaration + lines.push(`owner!: ${ownerModelName};`); + + return lines.join("\n"); + } +} diff --git a/src/commands/models/addReference.ts b/src/commands/models/addReference.ts new file mode 100644 index 0000000..bd68d92 --- /dev/null +++ b/src/commands/models/addReference.ts @@ -0,0 +1,394 @@ +import * as vscode from "vscode"; +import { MetadataCache, DecoratedClass } from "../../cache/cache"; +import { AIEnhancedTool, FieldInfo, FieldTypeOption } from "../interfaces"; +import { UserInputService } from "../../services/userInputService"; +import { ProjectAnalysisService } from "../../services/projectAnalysisService"; +import { SourceCodeService } from "../../services/sourceCodeService"; +import { FileSystemService } from "../../services/fileSystemService"; +import { ModelService } from "../../services/modelService"; +import { ExplorerProvider } from "../../explorer/explorerProvider"; +import * as path from "path"; + +/** + * Tool for adding reference relationships to existing Model classes. + * + * Allows users to create references to either existing models or new models. + * When referencing a new model, it creates the model in a new file with the same datasource. + */ +export class AddReferenceTool { + private userInputService: UserInputService; + private projectAnalysisService: ProjectAnalysisService; + private sourceCodeService: SourceCodeService; + private fileSystemService: FileSystemService; + private modelService: ModelService; + private explorerProvider: ExplorerProvider; + + constructor(explorerProvider: ExplorerProvider) { + this.userInputService = new UserInputService(); + this.projectAnalysisService = new ProjectAnalysisService(); + this.sourceCodeService = new SourceCodeService(); + this.fileSystemService = new FileSystemService(); + this.modelService = new ModelService(); + this.explorerProvider = explorerProvider; + } + + /** + * Adds a reference relationship to an existing model file. + * + * @param cache - The metadata cache for context about existing models + * @param modelName - The name of the model to which the reference is being added + * @returns Promise that resolves when the reference is added + */ + public async addReference(cache: MetadataCache, modelName: string): Promise { + try { + // Step 1: Validate target file + const { modelClass, document } = await this.validateAndPrepareTarget(modelName, cache); + + // Step 2: Get field name from user + const fieldName = await this.getReferenceFieldName(modelClass); + if (!fieldName) { + return; // User cancelled + } + + // Step 3: Ask if reference is to existing or new model + const referenceType = await this.askReferenceType(); + if (!referenceType) { + return; // User cancelled + } + + let targetModelName: string; + let targetModelPath: string; + + if (referenceType === 'existing') { + // Step 4a: Let user pick existing model on same datasource + const selectedModel = await this.selectExistingModel(modelClass, cache, fieldName); + if (!selectedModel) { + return; // User cancelled + } + targetModelName = selectedModel.name; + targetModelPath = selectedModel.declaration.uri.fsPath; + } else { + // Step 4b: Create new model + const newModelInfo = await this.createNewReferencedModel(modelClass, fieldName, cache); + if (!newModelInfo) { + return; // User cancelled or failed + } + targetModelName = newModelInfo.name; + targetModelPath = newModelInfo.path; + } + + // Step 5: Add reference field to source model + await this.addReferenceField(document, modelClass.name, fieldName, targetModelName, targetModelPath, cache); + + // Step 6: Focus on the newly created field + await this.sourceCodeService.focusOnElement(document, fieldName); + + // Step 7: Show success message + vscode.window.showInformationMessage( + `Reference relationship created successfully! Added ${fieldName} field referencing ${targetModelName}.` + ); + } catch (error) { + vscode.window.showErrorMessage(`Failed to add reference: ${error}`); + console.error("Error adding reference:", error); + } + } + + /** + * Validates the target file and prepares it for reference addition. + */ + private async validateAndPrepareTarget( + modelName: string, + cache: MetadataCache + ): Promise<{ modelClass: DecoratedClass; document: vscode.TextDocument }> { + // Get model information from cache + const modelClass = cache.getModelByName(modelName); + if (!modelClass) { + throw new Error(`Model '${modelName}' not found in the project`); + } + + const document = await vscode.workspace.openTextDocument(modelClass.declaration.uri); + if (!document) { + throw new Error(`Could not open document for model '${modelName}'`); + } + + return { modelClass, document }; + } + + /** + * Gets the reference field name from the user. + */ + private async getReferenceFieldName(modelClass: DecoratedClass): Promise { + const fieldName = await vscode.window.showInputBox({ + prompt: "Enter the reference field name (camelCase)", + placeHolder: "e.g., user, category, parentTask", + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return "Field name is required"; + } + if (!/^[a-z][a-zA-Z0-9]*$/.test(value.trim())) { + return "Field name must be in camelCase (e.g., user, category, parentTask)"; + } + + // Check if field already exists in the model + const existingFields = Object.keys(modelClass.properties || {}); + if (existingFields.includes(value.trim())) { + return `Field '${value.trim()}' already exists in this model`; + } + + return null; + }, + }); + + return fieldName?.trim() || null; + } + + /** + * Asks the user whether they want to reference an existing model or create a new one. + */ + private async askReferenceType(): Promise<'existing' | 'new' | null> { + const choice = await vscode.window.showQuickPick( + [ + { + label: "Reference existing model", + description: "Select from existing models in the same datasource", + value: 'existing' + }, + { + label: "Create new model", + description: "Create a new model file and reference it", + value: 'new' + } + ], + { + placeHolder: "Do you want to reference an existing model or create a new one?", + matchOnDescription: true + } + ); + + return choice?.value as 'existing' | 'new' | null; + } + + /** + * Lets the user select an existing model from the same datasource. + */ + private async selectExistingModel( + sourceModel: DecoratedClass, + cache: MetadataCache, + fieldName: string + ): Promise { + // Get all models with the same datasource + const sameDataSourceModels = cache.getModelsByDataSource(sourceModel); + + if (sameDataSourceModels.length === 0) { + vscode.window.showWarningMessage( + `No other models found with the same datasource as ${sourceModel.name}. Consider creating a new model instead.` + ); + return null; + } + + // Create suggestions based on field name + const suggestions = this.generateSuggestions(fieldName, sameDataSourceModels); + + // Create quick pick items + const quickPickItems = sameDataSourceModels.map(model => ({ + label: model.name, + description: this.getModelDescription(model, cache), + detail: suggestions.includes(model.name) ? "⭐ Suggested based on field name" : undefined, + model: model + })).sort((a, b) => { + // Sort suggestions first + const aIsSuggested = suggestions.includes(a.model.name); + const bIsSuggested = suggestions.includes(b.model.name); + + if (aIsSuggested && !bIsSuggested) { + return -1; + } + if (!aIsSuggested && bIsSuggested) { + return 1; + } + + // Then alphabetically + return a.label.localeCompare(b.label); + }); + + const selected = await vscode.window.showQuickPick(quickPickItems, { + placeHolder: `Select the model to reference from field '${fieldName}'`, + matchOnDescription: true, + matchOnDetail: true + }); + + return selected?.model || null; + } + + /** + * Generates model name suggestions based on the field name. + */ + private generateSuggestions(fieldName: string, availableModels: DecoratedClass[]): string[] { + const suggestions: string[] = []; + const fieldLower = fieldName.toLowerCase(); + + // Direct match (e.g., "user" -> "User") + const directMatch = this.toPascalCase(fieldName); + if (availableModels.some(m => m.name === directMatch)) { + suggestions.push(directMatch); + } + + // Partial matches (e.g., "parentTask" -> "Task") + availableModels.forEach(model => { + const modelLower = model.name.toLowerCase(); + if (fieldLower.includes(modelLower) || modelLower.includes(fieldLower)) { + if (!suggestions.includes(model.name)) { + suggestions.push(model.name); + } + } + }); + + return suggestions; + } + + /** + * Gets a description for a model based on its properties or decorators. + */ + private getModelDescription(model: DecoratedClass, cache: MetadataCache): string { + const modelDecorator = cache.getModelDecoratorByName("Model", model); + const dataSource = modelDecorator?.arguments?.[0]?.dataSource || "default"; + const fieldCount = Object.keys(model.properties || {}).length; + + return `${fieldCount} fields • datasource: ${dataSource}`; + } + + /** + * Creates a new model to be referenced. + */ + private async createNewReferencedModel( + sourceModel: DecoratedClass, + fieldName: string, + cache: MetadataCache + ): Promise<{ name: string; path: string } | null> { + // Suggest model name based on field name + const suggestedName = this.toPascalCase(fieldName); + + const modelName = await vscode.window.showInputBox({ + prompt: "Enter the name for the new model", + value: suggestedName, + placeHolder: "e.g., User, Category, Task", + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return "Model name is required"; + } + if (!/^[A-Z][a-zA-Z0-9]*$/.test(value.trim())) { + return "Model name must be in PascalCase (e.g., User, Category, Task)"; + } + + // Check if model already exists + const existingModel = cache.getModelByName(value.trim()); + if (existingModel) { + return `Model '${value.trim()}' already exists`; + } + + return null; + }, + }); + + if (!modelName?.trim()) { + return null; // User cancelled + } + + const finalModelName = modelName.trim(); + + // Get datasource from source model + const sourceModelDecorator = cache.getModelDecoratorByName("Model", sourceModel); + const dataSource = sourceModelDecorator?.arguments?.[0]?.dataSource; + + // Determine target directory (same as source model's directory or data folder) + const sourceModelDir = path.dirname(sourceModel.declaration.uri.fsPath); + const targetDir = sourceModelDir; + + const targetFilePath = path.join(targetDir, `${finalModelName}.ts`); + + // Generate model content + const modelContent = await this.modelService.generateModelFileContent(finalModelName, '', dataSource, undefined, false, targetFilePath, cache, undefined, true, true); + + // Create the file + const fileName = `${finalModelName}.ts`; + const filePath = path.join(targetDir, fileName); + + try { + const targetFileUri = await this.fileSystemService.createFile(finalModelName, filePath, modelContent, false); + + return { + name: finalModelName, + path: targetFileUri.fsPath + }; + } catch (error) { + throw new Error(`Failed to create new model file: ${error}`); + } + } + + + + + + + /** + * Adds the reference field to the source model. + */ + private async addReferenceField( + document: vscode.TextDocument, + sourceModelName: string, + fieldName: string, + targetModelName: string, + targetModelPath: string, + cache: MetadataCache + ): Promise { + // Create field info for the reference field + const fieldType: FieldTypeOption = { + label: "Relationship", + decorator: "Reference", + tsType: targetModelName, + description: "Reference relationship", + }; + + const fieldInfo: FieldInfo = { + name: fieldName, + type: fieldType, + required: false, // References are typically optional + additionalConfig: { + relationshipType: "reference", + targetModel: targetModelName, + targetModelPath: targetModelPath, + }, + }; + + // Generate the field code + const fieldCode = this.generateReferenceFieldCode(fieldInfo, targetModelName); + + // Insert the field + await this.sourceCodeService.insertField(document, sourceModelName, fieldInfo, fieldCode, cache); + } + + /** + * Generates the TypeScript code for the reference field. + */ + private generateReferenceFieldCode(fieldInfo: FieldInfo, targetModelName: string): string { + const lines: string[] = []; + + // Add Field decorator + lines.push("@Field({})"); + + // Add Relationship decorator for reference + lines.push("@Reference()"); + + // Add property declaration + lines.push(`${fieldInfo.name}!: ${targetModelName};`); + + return lines.join("\n"); + } + + /** + * Converts camelCase to PascalCase. + */ + private toPascalCase(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); + } +} diff --git a/src/commands/models/newModel.ts b/src/commands/models/newModel.ts index 483bec7..59afb7b 100644 --- a/src/commands/models/newModel.ts +++ b/src/commands/models/newModel.ts @@ -5,7 +5,9 @@ import { AddFieldTool } from "../fields/addField"; import { MetadataCache } from "../../cache/cache"; import { AIEnhancedTool, FieldInfo, FIELD_TYPE_OPTIONS } from "../interfaces"; import { FileSystemService } from "../../services/fileSystemService"; +import { ModelService } from "../../services/modelService"; import path from "path"; +import { SourceCodeService } from "../../services/sourceCodeService"; /** * Tool for creating new Model classes with the @Model decorator and extending BaseModel. @@ -36,14 +38,17 @@ import path from "path"; */ export class NewModelTool implements AIEnhancedTool { private fileSystemService: FileSystemService; + private sourceCodeService: SourceCodeService; private defineFieldsTool: DefineFieldsTool; private addFieldTool: AddFieldTool; + private addModelService: ModelService; constructor() { this.fileSystemService = new FileSystemService(); + this.sourceCodeService = new SourceCodeService(); this.defineFieldsTool = new DefineFieldsTool(); this.addFieldTool = new AddFieldTool(); - + this.addModelService = new ModelService(); } /** @@ -57,6 +62,7 @@ export class NewModelTool implements AIEnhancedTool { async processWithAI( userInput: string, targetUri: vscode.Uri, + modelName: string, cache: MetadataCache, additionalContext?: any ): Promise { @@ -86,33 +92,38 @@ export class NewModelTool implements AIEnhancedTool { finalTargetUri = this.fileSystemService.resolveTargetUri(targetUri); } try { - // Step 1: Get model name from user - const modelName = await vscode.window.showInputBox({ - prompt: "Enter the name of the new model (PascalCase)", - placeHolder: "e.g., Task, User, Project", - validateInput: (value) => { - if (!value || value.trim().length === 0) { - return "Model name is required"; - } - if (!/^[A-Z][a-zA-Z0-9]*$/.test(value.trim())) { - return "Model name must be in PascalCase (e.g., Task, UserProfile)"; - } - return null; - }, - }); - - if (!modelName) { - return; // User cancelled + // Step 1: Get model name from user (with loop for duplicate checking) + let modelName: string | undefined; + let existingModels: string[] = []; + + // Get existing model names if cache is available + if (cache) { + existingModels = cache.getDataModelClasses().map((m) => m.name); } - // Check if model name already exists in cache - if (cache) { - const existingModels = cache.getDataModelClasses().map((m) => m.name); - if (existingModels.includes(modelName)) { - vscode.window.showErrorMessage(`A model named ${modelName} already exists. Please choose a different name.`); - return; // Stop the process if model already exists + do { + modelName = await vscode.window.showInputBox({ + prompt: "Enter the name of the new model (PascalCase)", + placeHolder: "e.g., Task, User, Project", + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return "Model name is required"; + } + if (!/^[A-Z][a-zA-Z0-9]*$/.test(value.trim())) { + return "Model name must be in PascalCase (e.g., Task, UserProfile)"; + } + // Check if model name already exists + if (cache && existingModels.includes(value.trim())) { + return `A model named ${value.trim()} already exists. Please choose a different name.`; + } + return null; + }, + }); + + if (!modelName) { + return; // User cancelled } - } + } while (cache && existingModels.includes(modelName.trim())); // Step 2: Get optional documentation const docs = await vscode.window.showInputBox({ @@ -136,10 +147,38 @@ export class NewModelTool implements AIEnhancedTool { return; // User pressed Esc } - // Step 4: Determine target directory + // Step 4: Ask user to select a datasource (optional) + let selectedDataSource: string | undefined; + if (cache) { + const dataSources = cache.getDataSources(); + if (dataSources.length > 0) { + const dataSourceOptions = [ + { label: "None (Skip datasource)", description: "Create model without specifying a datasource", dataSourceName: undefined }, + ...dataSources.map(ds => ({ + label: ds.name, + description: `Type: ${ds.type}`, + dataSourceName: ds.name + })) + ]; + + const selectedOption = await vscode.window.showQuickPick(dataSourceOptions, { + placeHolder: "Select datasource or skip" + }); + + // Check if user cancelled + if (selectedOption === undefined) { + return; // User pressed Esc + } + + // Set the selected datasource (undefined if "None" was selected) + selectedDataSource = selectedOption.dataSourceName; + } + } + + // Step 5: Determine target directory let targetDirectory = this.fileSystemService.determineTargetDirectory(finalTargetUri); - // Step 5: Check if file already exists and handle overwrite + // Step 6: Check if file already exists and handle overwrite const filePath = path.join(targetDirectory, `${modelName}.ts`); const fileUri = vscode.Uri.file(filePath); const fileExists = await this.fileSystemService.fileExists(fileUri); @@ -154,16 +193,23 @@ export class NewModelTool implements AIEnhancedTool { } } - // Step 6: Generate model content - const modelContent = this.generateModelContent( + // Step 7: Generate model content and create file using AddModelService + const edit = new vscode.WorkspaceEdit(); + await this.addModelService.addModelToWorkspaceEdit( + edit, + filePath, modelName, - docs?.trim() || null, - fieldsInfo?.trim() || null, - targetDirectory + undefined, + selectedDataSource, // Use the selected datasource + undefined, // No existing imports + false, // Not a component + cache, + docs // Pass the description if provided ); - - // Step 7: Create the file (without handling overwrite since we already did) - const targetFileUri = await this.fileSystemService.createFile(modelName, filePath, modelContent, false); + + // Apply the workspace edit + await vscode.workspace.applyEdit(edit); + const targetFileUri = vscode.Uri.file(filePath); // Step 8: Open the new file const document = await vscode.workspace.openTextDocument(targetFileUri); @@ -202,6 +248,10 @@ export class NewModelTool implements AIEnhancedTool { ? `Model ${modelName} created and fields processed successfully!` : `Model ${modelName} created successfully!`; + if (selectedDataSource) { + successMessage += ` Using datasource: ${selectedDataSource}.`; + } + if (parentModelInfo) { successMessage += ` Composition relationship added to ${parentModelInfo.name}.`; } @@ -213,46 +263,8 @@ export class NewModelTool implements AIEnhancedTool { } } - /** - * Generates the TypeScript content for a new model class. - * - * @param modelName - The name of the model class - * @param docs - Optional documentation string - * @param fieldsInfo - Optional field information (to be processed later by AI) - * @param targetDirectory - The directory where the model file will be created - * @returns The complete TypeScript content for the model file - */ - private generateModelContent( - modelName: string, - docs?: string | null, - fieldsInfo?: string | null, - targetDirectory?: string - ): string { - const lines: string[] = []; - - // Add imports with dynamically calculated relative paths - lines.push(`import { Model, Field } from 'slingr-framework';`); - lines.push("import { BaseModel } from 'slingr-framework';"); - lines.push(""); - - // Add documentation comment if provided - if (docs) { - lines.push("/**"); - lines.push(` * ${docs}`); - lines.push(" */"); - } - - // Add Model decorator - lines.push(`@Model()`); - // Add class declaration - lines.push(`export class ${modelName} extends BaseModel {`); - lines.push("}"); - lines.push(""); - - return lines.join("\n"); - } /** * Detects if the command is being executed from a model context. @@ -363,11 +375,52 @@ export class NewModelTool implements AIEnhancedTool { await this.addFieldTool.addFieldProgrammatically( parentModelUri, fieldInfo, + newModelName, cache, true // silent mode - suppress success/error messages ); } + /** + * Creates a new model file programmatically without user interaction. + * + * @param modelName - The name of the model to create + * @param targetFilePath - The full file path where the model should be created + * @param docs - Optional documentation for the model + * @param dataSource - Optional datasource configuration + * @returns Promise that resolves when the model is created + */ + public async createModelProgrammatically( + modelName: string, + targetFilePath: string, + docs?: string, + dataSource?: string + ): Promise { + try { + // Create workspace edit + const edit = new vscode.WorkspaceEdit(); + + // Use AddModelService to generate the model + await this.addModelService.addModelToWorkspaceEdit( + edit, + targetFilePath, + modelName, + "", // Empty class body for now + dataSource, + undefined, // No existing imports + false, // Not a component + undefined // No cache + ); + + // Apply the workspace edit + await vscode.workspace.applyEdit(edit); + + return vscode.Uri.file(targetFilePath); + } catch (error) { + throw new Error(`Failed to create model programmatically: ${error}`); + } + } + public toCamelCase(str: string): string { return str.charAt(0).toLowerCase() + str.slice(1); } diff --git a/src/explorer/appTreeItem.ts b/src/explorer/appTreeItem.ts index 3ff4034..fed525d 100644 --- a/src/explorer/appTreeItem.ts +++ b/src/explorer/appTreeItem.ts @@ -39,7 +39,18 @@ export class AppTreeItem extends vscode.TreeItem { case "modelFieldsFolder": iconFileName = "folder.svg"; break; - + case "field": + iconFileName = "field.svg"; + break; + case "referenceField": + iconFileName = "field.svg"; // You could create a specific icon for reference fields + break; + case "compositionField": + iconFileName = "model-type.svg"; // You could create a specific icon for composition fields + break; + case "modelActionsFolder": + iconFileName = "action.svg"; + break; case "dataSourcesRoot": iconFileName = "database.svg"; break; diff --git a/src/explorer/explorerProvider.ts b/src/explorer/explorerProvider.ts index 9e5841a..436d492 100644 --- a/src/explorer/explorerProvider.ts +++ b/src/explorer/explorerProvider.ts @@ -5,7 +5,6 @@ import { AppTreeItem } from "./appTreeItem"; import { promises as fsPromises } from "fs"; import * as path from "path"; - // Define custom MIME types for our drag-and-drop operations const FIELD_MIME_TYPE = "application/vnd.slingr-vscode-extension.field"; const MODEL_MIME_TYPE = "application/vnd.slingr-vscode-extension.model"; @@ -79,14 +78,56 @@ export class ExplorerProvider dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken ): void | Thenable { + // Support multi-drag for fields only if (source.length > 1) { - // Multi-drag is not supported for reordering + // Check if all selected items are fields from the same model + const firstItem = source[0]; + if (firstItem.itemType !== "field" && firstItem.itemType !== "referenceField") { + return; // Multi-drag only supported for fields + } + + // Verify all items are fields from the same model + const modelFilePath = firstItem.parent?.metadata?.declaration.uri.fsPath; + const modelClassName = firstItem.parent?.metadata?.name; + + if (!modelFilePath || !modelClassName) { + return; + } + + const allFieldsFromSameModel = source.every(item => + (item.itemType === "field" || item.itemType === "referenceField") && + item.parent?.metadata?.declaration.uri.fsPath === modelFilePath && + item.parent?.metadata?.name === modelClassName + ); + + if (!allFieldsFromSameModel) { + return; // All fields must be from the same model + } + + // Create multi-field drag data + const fieldNames = source + .filter(item => item.metadata && "name" in item.metadata) + .map(item => (item.metadata as any).name); + + if (fieldNames.length > 0) { + dataTransfer.set( + FIELD_MIME_TYPE, + new vscode.DataTransferItem({ + fields: fieldNames, // Multiple fields + modelPath: modelFilePath, + modelClassName: modelClassName, + isMultiField: true + }) + ); + } return; } + + // Single item drag (existing logic) const draggedItem = source[0]; // We can drag fields, models, or folders - if (draggedItem.itemType === "field" && draggedItem.metadata && "name" in draggedItem.metadata) { + if ((draggedItem.itemType === "field" || draggedItem.itemType === "referenceField") && draggedItem.metadata && "name" in draggedItem.metadata) { // The parent of a field item is the 'modelFieldsFolder', which holds the model's metadata const modelFilePath = draggedItem.parent?.metadata?.declaration.uri.fsPath; const modelClassName = draggedItem.parent?.metadata?.name; @@ -100,7 +141,11 @@ export class ExplorerProvider }) ); } - } else if (draggedItem.itemType === "model" && draggedItem.metadata && this.isDecoratedClass(draggedItem.metadata)) { + } else if ( + draggedItem.itemType === "model" && + draggedItem.metadata && + this.isDecoratedClass(draggedItem.metadata) + ) { // Check if this is a composition model (nested model within another model) if (draggedItem.parent && draggedItem.parent.itemType === "model") { // This is a composition model @@ -178,19 +223,19 @@ export class ExplorerProvider const modelTransferItem = dataTransfer.get(MODEL_MIME_TYPE); const folderTransferItem = dataTransfer.get(FOLDER_MIME_TYPE); - if (fieldTransferItem?.value !== '' && fieldTransferItem) { + if (fieldTransferItem?.value !== "" && fieldTransferItem) { await this.handleFieldDrop(target, fieldTransferItem); return; } // Handle model moving to folders - if (modelTransferItem?.value !== '' && modelTransferItem) { + if (modelTransferItem?.value !== "" && modelTransferItem) { await this.handleModelDrop(target, modelTransferItem); return; } // Handle folder moving to other folders - if (folderTransferItem?.value !== '' && folderTransferItem) { + if (folderTransferItem?.value !== "" && folderTransferItem) { await this.handleFolderDrop(target, folderTransferItem); return; } @@ -202,9 +247,15 @@ export class ExplorerProvider private async handleFieldDrop(target: AppTreeItem | undefined, transferItem: vscode.DataTransferItem): Promise { const draggedData = transferItem.value; + // Check if this is a multi-field operation + const isMultiField = draggedData.isMultiField && draggedData.fields; + const fieldNames = isMultiField ? draggedData.fields : [draggedData.field]; + // Check if someone is trying to drop a composition model into a folder or data root if (target && (target.itemType === "folder" || target.itemType === "dataRoot" || target.itemType === "model")) { - vscode.window.showWarningMessage("Composition models cannot be moved to folders or models. They are part of their parent model structure."); + vscode.window.showWarningMessage( + "Composition models cannot be moved to folders or models. They are part of their parent model structure." + ); return; } @@ -212,8 +263,8 @@ export class ExplorerProvider let targetFieldName: string | null = null; let targetModelPath: string | undefined = undefined; - if (target && target.itemType === "field" && target.metadata && "name" in target.metadata) { - // Dropping onto a regular field + if (target && (target.itemType === "field" || target.itemType === "referenceField") && target.metadata && "name" in target.metadata) { + // Dropping onto a regular field or reference field targetFieldName = target.metadata.name; targetModelPath = target.parent?.metadata?.declaration.uri.fsPath; } else if (target && target.itemType === "model" && target.parent && target.parent.itemType === "model") { @@ -241,59 +292,64 @@ export class ExplorerProvider } if (!target || !targetFieldName || !targetModelPath) { - vscode.window.showWarningMessage("A field can only be dropped onto another field or composition model."); + const fieldWord = isMultiField ? "Fields" : "A field"; + const verbWord = isMultiField ? "can" : "can"; + vscode.window.showWarningMessage(`${fieldWord} ${verbWord} only be dropped onto another field or composition model.`); return; } // Validate the drop operation if (draggedData.modelPath !== targetModelPath) { - vscode.window.showWarningMessage("Fields can only be reordered within the same model."); + const fieldWord = isMultiField ? "Fields" : "Fields"; + vscode.window.showWarningMessage(`${fieldWord} can only be reordered within the same model.`); return; } - if (draggedData.field === targetFieldName) { - return; // Dropped on itself + if (fieldNames.includes(targetFieldName)) { + return; // Dropped on one of the dragged fields } - // Perform the reordering - try { - // 1. Get the new text from ts-morph *without saving*. - const newText = await this.reorderFieldsAndGetText( - draggedData.modelPath, - draggedData.modelClassName, - draggedData.field, - targetFieldName - ); - - if (newText === null) { - vscode.window.showErrorMessage("Failed to reorder fields."); - return; - } - - // 2. Apply the changes to the editor and format. - const uri = vscode.Uri.file(draggedData.modelPath); - const document = await vscode.workspace.openTextDocument(uri); - const editor = await vscode.window.showTextDocument(document); + // For multi-field operations, we need to reorder multiple fields + if (isMultiField) { + try { + // Reorder multiple fields + const newText = await this.reorderMultipleFieldsAndGetText( + draggedData.modelPath, + draggedData.modelClassName, + fieldNames, + targetFieldName + ); - // Replace the entire document content with the new text. - await editor.edit((editBuilder) => { - const fullRange = new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)); - editBuilder.replace(fullRange, newText); - }); + if (newText === null) { + vscode.window.showErrorMessage("Failed to reorder fields."); + return; + } - // Execute the format command on the now-dirty file. - await vscode.commands.executeCommand("editor.action.formatDocument"); + await this.applyTextChanges(draggedData.modelPath, newText); + } catch (error: any) { + console.error("Error reordering multiple fields:", error); + vscode.window.showErrorMessage(`An error occurred: ${error.message}`); + } + } else { + // Single field reordering (existing logic) + try { + const newText = await this.reorderFieldsAndGetText( + draggedData.modelPath, + draggedData.modelClassName, + fieldNames[0], + targetFieldName + ); - // 3. Save the document a single time. - await document.save(); + if (newText === null) { + vscode.window.showErrorMessage("Failed to reorder fields."); + return; + } - // 4. Refresh the tree. The cache will update from the single save event. - setTimeout(() => { - this.refresh(); - }, 200); - } catch (error: any) { - console.error("Error reordering fields:", error); - vscode.window.showErrorMessage(`An error occurred: ${error.message}`); + await this.applyTextChanges(draggedData.modelPath, newText); + } catch (error: any) { + console.error("Error reordering fields:", error); + vscode.window.showErrorMessage(`An error occurred: ${error.message}`); + } } } @@ -312,7 +368,7 @@ export class ExplorerProvider return; } - const srcDataPath = path.join(workspaceFolder.uri.fsPath, 'src', 'data'); + const srcDataPath = path.join(workspaceFolder.uri.fsPath, "src", "data"); const targetPath = target.itemType === "dataRoot" ? srcDataPath : path.join(srcDataPath, target.folderPath || ""); try { @@ -344,15 +400,15 @@ export class ExplorerProvider const workspaceEdit = new vscode.WorkspaceEdit(); const sourceUri = vscode.Uri.file(sourcePath); const targetUri = vscode.Uri.file(newPath); - + workspaceEdit.renameFile(sourceUri, targetUri); - + const success = await vscode.workspace.applyEdit(workspaceEdit); - + if (success) { // Force cache refresh after model move to ensure proper file path updates await this.cache.forceRefresh(); - + // Wait a bit longer and then refresh the tree to ensure cache is fully updated setTimeout(() => { this.refresh(); @@ -368,7 +424,10 @@ export class ExplorerProvider } } - private async handleFolderDrop(target: AppTreeItem | undefined, transferItem: vscode.DataTransferItem): Promise { + private async handleFolderDrop( + target: AppTreeItem | undefined, + transferItem: vscode.DataTransferItem + ): Promise { const draggedData = transferItem.value; // Folders can only be dropped into other folders or the data root @@ -382,7 +441,7 @@ export class ExplorerProvider // Normalize paths for cross-platform comparison const normalizedTargetPath = target.folderPath.replace(/[\/\\]/g, path.sep); const normalizedDraggedPath = draggedData.folderPath.replace(/[\/\\]/g, path.sep); - + if (normalizedTargetPath.startsWith(normalizedDraggedPath)) { vscode.window.showWarningMessage("Cannot move a folder into itself or its subfolder."); return; @@ -395,9 +454,10 @@ export class ExplorerProvider return; } - const srcDataPath = path.join(workspaceFolder.uri.fsPath, 'src', 'data'); + const srcDataPath = path.join(workspaceFolder.uri.fsPath, "src", "data"); const sourcePath = path.join(srcDataPath, draggedData.folderPath); - const targetBasePath = target.itemType === "dataRoot" ? srcDataPath : path.join(srcDataPath, target.folderPath || ""); + const targetBasePath = + target.itemType === "dataRoot" ? srcDataPath : path.join(srcDataPath, target.folderPath || ""); const newPath = path.join(targetBasePath, draggedData.folderName); try { @@ -426,15 +486,15 @@ export class ExplorerProvider const workspaceEdit = new vscode.WorkspaceEdit(); const sourceUri = vscode.Uri.file(sourcePath); const targetUri = vscode.Uri.file(newPath); - + workspaceEdit.renameFile(sourceUri, targetUri); - + const success = await vscode.workspace.applyEdit(workspaceEdit); - + if (success) { // Force cache refresh after folder move to ensure proper file path updates await this.cache.forceRefresh(); - + // Wait a bit longer and then refresh the tree to ensure cache is fully updated setTimeout(() => { this.refresh(); @@ -499,6 +559,95 @@ export class ExplorerProvider return sourceFile.getFullText(); } + /** + * Reorders multiple fields in the model class file and returns the updated text. + * @param modelPath The path to the model class file. + * @param modelClassName The name of the model class to modify. + * @param sourceFieldNames Array of field names to move. + * @param targetFieldName The name of the field to move before. + * @returns The updated source code as a string, or null if an error occurs. + */ + private async reorderMultipleFieldsAndGetText( + modelPath: string, + modelClassName: string, + sourceFieldNames: string[], + targetFieldName: string + ): Promise { + const project = new Project(); + const sourceFile = project.addSourceFileAtPath(modelPath); + + // Find the specific class by name to handle multiple classes in the same file + const classDeclaration = sourceFile.getClass(modelClassName); + + if (!classDeclaration) { + console.error(`Class ${modelClassName} not found in ${modelPath}`); + return null; + } + + const targetProperty = classDeclaration.getProperty(targetFieldName); + if (!targetProperty) { + console.error(`Could not find target property ${targetFieldName} in ${classDeclaration.getName()}`); + return null; + } + + // Get all source properties and their structures + const sourceProperties: { property: any; structure: any }[] = []; + for (const fieldName of sourceFieldNames) { + const property = classDeclaration.getProperty(fieldName); + if (!property) { + console.error(`Could not find source property ${fieldName} in ${classDeclaration.getName()}`); + return null; + } + sourceProperties.push({ + property, + structure: property.getStructure() + }); + } + + const targetIndex = targetProperty.getChildIndex(); + + // Remove all source properties (in reverse order to maintain indices) + for (let i = sourceProperties.length - 1; i >= 0; i--) { + sourceProperties[i].property.remove(); + } + + // Insert all properties at the target position (in original order) + for (let i = 0; i < sourceProperties.length; i++) { + classDeclaration.insertProperty(targetIndex + i, sourceProperties[i].structure); + } + + return sourceFile.getFullText(); + } + + /** + * Applies text changes to a file using VS Code's editor API + * @param filePath The path to the file to modify + * @param newText The new text content + */ + private async applyTextChanges(filePath: string, newText: string): Promise { + // 2. Apply the changes to the editor and format. + const uri = vscode.Uri.file(filePath); + const document = await vscode.workspace.openTextDocument(uri); + const editor = await vscode.window.showTextDocument(document); + + // Replace the entire document content with the new text. + await editor.edit((editBuilder) => { + const fullRange = new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)); + editBuilder.replace(fullRange, newText); + }); + + // Execute the format command on the now-dirty file. + await vscode.commands.executeCommand("editor.action.formatDocument"); + + // 3. Save the document a single time. + await document.save(); + + // 4. Refresh the tree. The cache will update from the single save event. + setTimeout(() => { + this.refresh(); + }, 200); + } + /** * Returns the children of the given element in the tree. * If no element is provided, it returns the root items (Model and UI). @@ -525,20 +674,26 @@ export class ExplorerProvider // --- DATA SOURCES ROOT --- if (element.itemType === "dataSourcesRoot") { - const dataSources = this.cache.getDataSources(); - return dataSources.map(ds => { - const item = new AppTreeItem(ds.name, vscode.TreeItemCollapsibleState.None, "dataSource", this.extensionUri, ds); - item.command = { - command: 'slingr-vscode-extension.handleTreeItemClick', - title: 'Handle Click', - arguments: [item] - }; - return item; - }); + const dataSources = this.cache.getDataSources(); + return dataSources.map((ds) => { + const item = new AppTreeItem( + ds.name, + vscode.TreeItemCollapsibleState.None, + "dataSource", + this.extensionUri, + ds as any + ); + item.command = { + command: "slingr-vscode-extension.handleTreeItemClick", + title: "Handle Click", + arguments: [item], + }; + return item; + }); } // --- Children of a specific Model --- - if (element.itemType === "model" && this.isDecoratedClass(element.metadata)) { + if ((element.itemType === "model" || element.itemType === "compositionField") && this.isDecoratedClass(element.metadata)) { const modelClass = element.metadata; const fields = Object.values(element.metadata.properties).filter((prop) => @@ -551,7 +706,8 @@ export class ExplorerProvider (d) => d.name === "Relationship" && d.arguments.some((arg) => arg.type === "Composition" || arg.type === "composition") - ) + ) || + field.decorators.some((d) => d.name === "Composition") ) { const relationshipType = this.extractBaseTypeFromArrayType(field.type); const relatedModel = this.cache.getDataModelClasses().find((model) => model.name === relationshipType); @@ -559,12 +715,12 @@ export class ExplorerProvider const compositionItem = new AppTreeItem( upperFieldName, vscode.TreeItemCollapsibleState.Collapsed, - "model", + "compositionField", this.extensionUri, relatedModel, element ); - + // Set command for click handling (single vs double-click detection) if (relatedModel) { compositionItem.command = { @@ -818,10 +974,21 @@ export class ExplorerProvider private mapPropertyToTreeItem(propData: PropertyMetadata, itemType: string, parent?: AppTreeItem): AppTreeItem { const upperFieldName = propData.name.charAt(0).toUpperCase() + propData.name.slice(1); + // Check if this is a reference field and adjust the itemType accordingly + let actualItemType = itemType; + if (itemType === "field") { + if (propData.decorators.some((d) => d.name === "Reference")) { + actualItemType = "referenceField"; + } + else if (propData.decorators.some((d) => d.name === "Composition")) { + actualItemType = "compositionField"; + } + } + const item = new AppTreeItem( upperFieldName, vscode.TreeItemCollapsibleState.None, - itemType, + actualItemType, this.extensionUri, propData, parent @@ -877,7 +1044,16 @@ export class ExplorerProvider if (hasFieldDecorator) { // Check if this property has a @Relationship decorator with type: "Composition" const relationshipDecorator = property.decorators.find((d) => d.name === "Relationship"); + const compositionDecorator = property.decorators.find((d) => d.name === "Composition"); + + // If there's a @Composition decorator, we can directly consider it + if (compositionDecorator) { + const baseType = this.extractBaseTypeFromArrayType(property.type); + compositionModels.add(baseType); + continue; // No need to check further + } + // If there's a @Relationship decorator, check its arguments if (relationshipDecorator) { // Check if the relationship decorator has type: "Composition" or "composition" const hasCompositionType = relationshipDecorator.arguments.some( diff --git a/src/explorer/explorerRegistration.ts b/src/explorer/explorerRegistration.ts index c8f44ef..ad64e46 100644 --- a/src/explorer/explorerRegistration.ts +++ b/src/explorer/explorerRegistration.ts @@ -15,7 +15,8 @@ export function registerExplorer( const treeView = vscode.window.createTreeView('slingrExplorer', { treeDataProvider: explorerProvider, dragAndDropController: explorerProvider, - showCollapseAll: true + showCollapseAll: true, + canSelectMany: true }); // Double-click tracking variables @@ -53,10 +54,15 @@ export function registerExplorer( // Handle selection changes (for keyboard navigation and other selection events) const selectionDisposable = treeView.onDidChangeSelection(e => { - const selectedItem = e.selection?.[0] as AppTreeItem; - if (selectedItem) { - // Update info panel for keyboard navigation - quickInfoProvider.update(selectedItem.itemType, selectedItem.metadata as any); + const selectedItems = e.selection as AppTreeItem[]; + if (selectedItems && selectedItems.length > 0) { + // For single selection, update info panel + if (selectedItems.length === 1) { + quickInfoProvider.update(selectedItems[0].itemType, selectedItems[0].metadata); + } else { + // For multi-selection, clear the panel or show first item + quickInfoProvider.update('multipleSelection', undefined); + } } }); diff --git a/src/quickInfoPanel/quickInfoProvider.ts b/src/quickInfoPanel/quickInfoProvider.ts index 78a571e..5a21541 100644 --- a/src/quickInfoPanel/quickInfoProvider.ts +++ b/src/quickInfoPanel/quickInfoProvider.ts @@ -166,7 +166,7 @@ export class QuickInfoProvider implements vscode.WebviewViewProvider { const { itemType, name, parentClassName } = data; let foundMetadata: MetadataItem | undefined; - if (itemType === 'field' && parentClassName) { + if ((itemType === 'field' || itemType === 'referenceField') && parentClassName) { const [parentClass] = this.cache.findMetadata( item => 'properties' in item && item.name === parentClassName ) as DecoratedClass[]; diff --git a/src/quickInfoPanel/renderers/rendererRegistry.ts b/src/quickInfoPanel/renderers/rendererRegistry.ts index 44ccf93..97d7b6c 100644 --- a/src/quickInfoPanel/renderers/rendererRegistry.ts +++ b/src/quickInfoPanel/renderers/rendererRegistry.ts @@ -14,5 +14,7 @@ import { DataSourceRenderer } from './dataSourceRenderer'; export const rendererRegistry = new Map([ ['model', new ModelRenderer()], ['field', new FieldRenderer()], + ['referenceField', new FieldRenderer()], + ['compositionField', new FieldRenderer()], ['dataSource', new DataSourceRenderer()], ]); \ No newline at end of file diff --git a/src/refactor/RefactorController.ts b/src/refactor/RefactorController.ts index 9e38f2e..d55e4be 100644 --- a/src/refactor/RefactorController.ts +++ b/src/refactor/RefactorController.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import { ChangeObject, IRefactorTool, ManualRefactorContext, DeleteModelPayload, RenameModelPayload } from "./refactorInterfaces"; +import { ChangeObject, IRefactorTool, ManualRefactorContext, DeleteModelPayload, RenameModelPayload, ExtractFieldsToReferencePayload } from "./refactorInterfaces"; import { findNodeAtPosition } from "../utils/ast"; import { MetadataCache } from "../cache/cache"; import { AppTreeItem } from "../explorer/appTreeItem"; @@ -146,13 +146,6 @@ export class RefactorController { return; } - const hasFileOps = ('urisToDelete' in changeObject.payload && (changeObject.payload as any).urisToDelete?.length > 0) || - ('newUri' in changeObject.payload && !!(changeObject.payload as any).newUri); - - if (workspaceEdit.size === 0 && !hasFileOps) { - vscode.window.showInformationMessage("No changes were needed for this refactoring."); - return; - } await this.presentChangesForApproval(workspaceEdit, changeObject); } } @@ -183,46 +176,16 @@ export class RefactorController { changeObject: ChangeObject, allChanges?: ChangeObject[] ): Promise { - // Create a new workspace edit with confirmation metadata - const confirmedEdit = new vscode.WorkspaceEdit(); - const metadata: vscode.WorkspaceEditEntryMetadata = { - needsConfirmation: true, - label: "Review Refactoring Changes", - }; - - // Copy all text edits with confirmation metadata - for (const [uri, textEdits] of workspaceEdit.entries()) { - for (const edit of textEdits) { - confirmedEdit.replace(uri, edit.range, edit.newText, metadata); - } - } - - // Add file operations from change payloads to the workspace edit - const changesToProcess = allChanges || [changeObject]; - for (const change of changesToProcess) { - if (change.type === 'DELETE_MODEL') { - const deletePayload = change.payload as DeleteModelPayload; - if (Array.isArray(deletePayload.urisToDelete)) { - for (const uri of deletePayload.urisToDelete) { - confirmedEdit.deleteFile(uri, { recursive: true, ignoreIfNotExists: true }); - } - } - } - - if (change.type === 'RENAME_MODEL') { - const renamePayload = change.payload as RenameModelPayload; - if (renamePayload.newUri) { - confirmedEdit.renameFile(change.uri, renamePayload.newUri); - } - } - } - this.isApplyingEdit = true; try { - const success = await vscode.workspace.applyEdit(confirmedEdit); + let success; + const changesToProcess = allChanges || [changeObject]; + + // Apply the workspace edit directly with refactoring flag + // VS Code will automatically show the refactor preview for file operations and text edits + success = await vscode.workspace.applyEdit(workspaceEdit, { isRefactoring: true }); if (success) { await vscode.workspace.saveAll(false); - const changesToProcess = allChanges || [changeObject]; // Check for compilation errors after applying changes const changesWithPrompts = changesToProcess.filter(change => { const tool = this.changeHandlerMap.get(change.type); @@ -323,13 +286,17 @@ export class RefactorController { const mergedEdit = new vscode.WorkspaceEdit(); const modifiedRanges = new Set(); const allUniqueEdits = new Map(); + const fileOperations = new Set(); // Track file operations to avoid duplicates + let editFromTool: vscode.WorkspaceEdit = new vscode.WorkspaceEdit(); for (const change of changes) { const tool = this.changeHandlerMap.get(change.type); if (tool) { try { - const editFromTool = await tool.prepareEdit(change, this.cache); + editFromTool = await tool.prepareEdit(change, this.cache); + + // Handle text edits with deduplication for (const [uri, textEdits] of editFromTool.entries()) { const uriString = uri.toString(); const existingEdits = allUniqueEdits.get(uriString) || []; @@ -341,24 +308,32 @@ export class RefactorController { existingEdits.push(edit); } } - // Note: modifiedRanges was not part of the original payload interface - // change.payload.modifiedRanges = Array.from(modifiedRanges); if (existingEdits.length > 0) { allUniqueEdits.set(uriString, existingEdits); } - } - if ('urisToDelete' in change.payload && Array.isArray((change.payload as any).urisToDelete)) { - for (const uri of (change.payload as any).urisToDelete) { - mergedEdit.deleteFile(uri, { recursive: true, ignoreIfNotExists: true }); + // Handle file creation operations from change payload + if ('urisToCreate' in change.payload && Array.isArray(change.payload.urisToCreate)) { + for (const createInfo of change.payload.urisToCreate) { + const createOpId = `CREATE::${createInfo.uri.toString()}`; + if (!fileOperations.has(createOpId)) { + fileOperations.add(createOpId); + // If content is provided, create file with content, otherwise just create the file + if (createInfo.content !== undefined) { + mergedEdit.createFile(createInfo.uri, { + ignoreIfExists: true, + contents: Buffer.from(createInfo.content, 'utf8') + }); + } else { + mergedEdit.createFile(createInfo.uri, { ignoreIfExists: true }); + } + } } } - if ('newUri' in change.payload && (change.payload as any).newUri) { - mergedEdit.renameFile(change.uri, (change.payload as any).newUri); - } + } catch (error) { vscode.window.showErrorMessage(`Error preparing refactor for '${change.description}': ${error}`); @@ -367,10 +342,12 @@ export class RefactorController { } } + // Apply all unique text edits for (const [uriString, edits] of allUniqueEdits) { mergedEdit.set(vscode.Uri.parse(uriString), edits); } - return mergedEdit; + // Now it's just returning the edit from the tool. + return editFromTool; } /** @@ -443,7 +420,9 @@ export class RefactorController { if (hasRelevantChange) { disposable.dispose(); - if (timeout) clearTimeout(timeout); + if (timeout) { + clearTimeout(timeout); + } this.checkForCompilationErrors(uris).then(hasErrors => { resolve(hasErrors); @@ -462,7 +441,9 @@ export class RefactorController { this.checkForCompilationErrors(uris).then(hasErrors => { if (hasErrors) { disposable.dispose(); - if (timeout) clearTimeout(timeout); + if (timeout) { + clearTimeout(timeout); + } resolve(true); } }); @@ -477,4 +458,4 @@ export class RefactorController { public getTools(): IRefactorTool[] { return this.tools; } -} +} \ No newline at end of file diff --git a/src/refactor/refactorDisposables.ts b/src/refactor/refactorDisposables.ts index afc1f3e..17816ca 100644 --- a/src/refactor/refactorDisposables.ts +++ b/src/refactor/refactorDisposables.ts @@ -10,11 +10,17 @@ import { findNodeAtPosition } from '../utils/ast'; import { cache } from '../extension'; import { AppTreeItem } from '../explorer/appTreeItem'; import { AddDecoratorTool } from './tools/addDecorator'; +import { ChangeReferenceToCompositionRefactorTool } from './tools/changeReferenceToComposition'; +import { ChangeCompositionToReferenceRefactorTool } from './tools/changeCompositionToReference'; +import { ExtractFieldsToCompositionTool } from '../commands/fields/extractFieldsToComposition'; import { isField, isModelFile } from '../utils/metadata'; +import { ExtractFieldsToReferenceTool } from '../commands/fields/extractFieldsToReference'; import { PropertyMetadata } from '../cache/cache'; import { fieldTypeConfig } from '../utils/fieldTypes'; import { RenameDataSourceTool } from './tools/renameDataSource'; import { DeleteDataSourceTool } from './tools/deleteDataSource'; +import { ExtractFieldsToEmbeddedTool } from '../commands/fields/extractFieldsToEmbedded'; +import { ExtractFieldsToParentTool } from '../commands/fields/extractFieldsToParent'; /** * Returns an array of all available refactor tools for the application. @@ -27,6 +33,7 @@ import { DeleteDataSourceTool } from './tools/deleteDataSource'; * - RenameFieldTool: Handles field renaming operations * - DeleteFieldTool: Handles field deletion operations * - ChangeFieldTypeTool: Handles field type modification operations + * - ExtractFieldsToCompositionTool: Handles extracting fields to composition models * - AddDecoratorTool: Handles adding decorators to fields * - RenameDataSourceTool: Handles data source renaming operations * - DeleteDataSourceTool: Handles data source deletion operations @@ -39,8 +46,14 @@ export function getAllRefactorTools(): IRefactorTool[] { new DeleteFieldTool(), new ChangeFieldTypeTool(), new AddDecoratorTool(), + new ChangeReferenceToCompositionRefactorTool(), + new ChangeCompositionToReferenceRefactorTool(), + new ExtractFieldsToCompositionTool(), new RenameDataSourceTool(), new DeleteDataSourceTool(), + new ExtractFieldsToReferenceTool(), + new ExtractFieldsToEmbeddedTool(), + new ExtractFieldsToParentTool() ]; } @@ -59,12 +72,14 @@ export function registerRefactorCommands(controller: RefactorController, context for (const tool of controller.getTools()) { disposables.push( - vscode.commands.registerCommand(tool.getCommandId(), (context?: vscode.Uri | AppTreeItem | ManualRefactorContext, decoratorName?: string) => { - // The command can now be called with more complex arguments from CodeActions + vscode.commands.registerCommand(tool.getCommandId(), (context?: vscode.Uri | AppTreeItem | ManualRefactorContext, secondArg?: any) => { + // The command can now be called with more complex arguments from CodeActions or tree view multi-selection if (context && 'cache' in context && 'uri' in context) { - controller.handleManualRefactorCommand(tool.getCommandId(), context, decoratorName); + // ManualRefactorContext case - secondArg might be decoratorName + controller.handleManualRefactorCommand(tool.getCommandId(), context, secondArg); } else { - controller.handleManualRefactorCommand(tool.getCommandId(), context); + // Tree view context case - secondArg might be the selected items array + controller.handleManualRefactorCommand(tool.getCommandId(), context, secondArg); } }) ); diff --git a/src/refactor/refactorInterfaces.ts b/src/refactor/refactorInterfaces.ts index 1383504..7834c7f 100644 --- a/src/refactor/refactorInterfaces.ts +++ b/src/refactor/refactorInterfaces.ts @@ -1,5 +1,15 @@ import * as vscode from 'vscode'; import { DataSourceMetadata, DecoratedClass, DecoratorMetadata, FileMetadata, MetadataCache, PropertyMetadata } from '../cache/cache'; +import { TreeViewContext } from '../commands/commandHelpers'; +import { ChangeCompositionToReferencePayload } from './tools/changeCompositionToReference'; + +/** + * Information about a file to be created. + */ +export interface FileCreationInfo { + uri: vscode.Uri; + content?: string; // Optional content for the file +} /** * Common properties shared across all refactoring payloads. @@ -39,9 +49,31 @@ export interface RenameFieldPayload extends BasePayload { oldFieldMetadata: PropertyMetadata; } -export interface DeleteFieldPayload extends BasePayload { +export interface CreateModelPayload { + newModelName: string; + newUri: vscode.Uri | undefined; + isManual: boolean; +} + +export interface CreateModelPayload { + newModelName: string; + newUri: vscode.Uri | undefined; + isManual: boolean; +} + +/** + * Represents the payload for a delete field operation. + * This interface encapsulates the necessary information to identify and process + * the deletion of a field from a specific model. + * + * @property `oldFieldMetadata`: The metadata of the field that is being deleted. + * @property `modelName`: The name of the model from which the field will be deleted. + * @property `isManual`: Optional flag to indicate if the deletion was triggered manually by a user. + */ +export interface DeleteFieldPayload { oldFieldMetadata: PropertyMetadata; modelName: string; + isManual: boolean; } export interface ChangeFieldTypePayload extends BasePayload { @@ -51,6 +83,11 @@ export interface ChangeFieldTypePayload extends BasePayload { oldDecorator?: DecoratorMetadata; } +/** + * Payload interface for adding a decorator to a field. + * @property {PropertyMetadata} fieldMetadata - Metadata information about the property/field + * @property {string} decoratorName - The name of the decorator to be added + */ export interface AddDecoratorPayload extends BasePayload { fieldMetadata: PropertyMetadata; decoratorName: string; @@ -62,6 +99,147 @@ export interface RenameDataSourcePayload extends BasePayload { newUri?: vscode.Uri; } +/** + * Payload interface for changing a reference field to a composition field. + * This involves removing the @Reference decorator and adding a @Composition decorator, + * potentially deleting the referenced model if it's not used elsewhere, + * and creating a component model in the same file. + * + * @property {string} sourceModelName - The name of the model containing the reference field + * @property {string} fieldName - The name of the reference field to be changed + * @property {PropertyMetadata} fieldMetadata - Metadata information about the reference field + * @property {boolean} isManual - Whether the change was initiated manually by the user + */ +export interface ChangeReferenceToCompositionPayload { + sourceModelName: string; + fieldName: string; + fieldMetadata: PropertyMetadata; + isManual: boolean; +} + +/** + * Base payload interface for extracting fields from a source model. + * This interface contains common properties for all field extraction operations. + * + * @property {string} sourceModelName - The name of the source model containing the fields to extract + * @property {PropertyMetadata[]} fieldsToExtract - The field metadata for all fields being extracted + * @property {boolean} isManual - Whether the extraction was initiated manually by the user + */ +export interface BaseExtractFieldsPayload { + sourceModelName: string; + fieldsToExtract: PropertyMetadata[]; + isManual: boolean; +} + +/** + * Payload interface for extracting fields to a composition model. + * This involves moving selected fields from a source model to a new composition model + * and creating a composition relationship between them. + * + * @property {string} sourceModelName - The name of the source model containing the fields to extract + * @property {string} compositionFieldName - The name for the new composition field to be added to the source model + * @property {PropertyMetadata[]} fieldsToExtract - The field metadata for all fields being extracted + * @property {boolean} isManual - Whether the extraction was initiated manually by the user + */ +export interface AddDecoratorPayload { + fieldMetadata: PropertyMetadata; + decoratorName: string; + isManual: boolean; +} + +export interface RenameDataSourcePayload extends BasePayload { + oldName: string; + newName: string; + newUri?: vscode.Uri; +} + +/** + * Payload interface for changing a reference field to a composition field. + * This involves removing the @Reference decorator and adding a @Composition decorator, + * potentially deleting the referenced model if it's not used elsewhere, + * and creating a component model in the same file. + * + * @property {string} sourceModelName - The name of the model containing the reference field + * @property {string} fieldName - The name of the reference field to be changed + * @property {PropertyMetadata} fieldMetadata - Metadata information about the reference field + * @property {boolean} isManual - Whether the change was initiated manually by the user + */ +export interface ChangeReferenceToCompositionPayload { + sourceModelName: string; + fieldName: string; + fieldMetadata: PropertyMetadata; + isManual: boolean; +} + +/** + * Base payload interface for extracting fields from a source model. + * This interface contains common properties for all field extraction operations. + * + * @property {string} sourceModelName - The name of the source model containing the fields to extract + * @property {PropertyMetadata[]} fieldsToExtract - The field metadata for all fields being extracted + * @property {boolean} isManual - Whether the extraction was initiated manually by the user + */ +export interface BaseExtractFieldsPayload { + sourceModelName: string; + fieldsToExtract: PropertyMetadata[]; + isManual: boolean; + urisToCreate?: FileCreationInfo[]; +} + +/** + * Payload interface for extracting fields to a composition model. + * This involves moving selected fields from a source model to a new composition model + * and creating a composition relationship between them. + * + * @property {string} sourceModelName - The name of the source model containing the fields to extract + * @property {string} compositionFieldName - The name for the new composition field to be added to the source model + * @property {PropertyMetadata[]} fieldsToExtract - The field metadata for all fields being extracted + * @property {boolean} isManual - Whether the extraction was initiated manually by the user + */ +export interface ExtractFieldsToCompositionPayload extends BaseExtractFieldsPayload { + compositionFieldName: string; +} + + +/** + * Payload interface for extracting fields to a reference model. + * This involves moving selected fields from a source model to a new reference model in a separate file + * and creating a reference relationship between them. + * + * @property {string} sourceModelName - The name of the source model containing the fields to extract + * @property {string} newModelName - The name of the new reference model to be created + * @property {string} referenceFieldName - The name of the new reference field to be created + * @property {fileCreationInfo?: FileCreationInfo} - Optional info for creating the new model file + */ +export interface ExtractFieldsToReferencePayload extends BaseExtractFieldsPayload { + sourceModelName: string; + newModelName: string; + referenceFieldName: string; +} + +/** + * Payload for extracting fields to a new embedded model. + * @property {string} sourceModelName - The name of the source model containing the fields to extract + * @property {string} newModelName - The name of the new embedded model to be created + * @property {string} embeddedFieldName - The name of the new embedded field to be created + * @property {fileCreationInfo?: FileCreationInfo} - Optional info for creating the new model file + */ +export interface ExtractFieldsToEmbeddedPayload extends BaseExtractFieldsPayload { + sourceModelName: string; + newModelName: string; + embeddedFieldName: string; +} + +/** + * Payload for extracting fields to a new parent model. + * @property {string} sourceModelName - The name of the source model containing the fields to extract + * @property {string} newParentModelName - The name of the new abstract parent model to be created + */ +export interface ExtractFieldsToParentPayload extends BaseExtractFieldsPayload { + sourceModelName: string; + newParentModelName: string; +} + export interface DeleteDataSourcePayload extends BasePayload { dataSourceName: string; urisToDelete: vscode.Uri[]; @@ -82,6 +260,13 @@ export type ChangePayloadMap = { 'ADD_DECORATOR': AddDecoratorPayload; 'RENAME_DATA_SOURCE': RenameDataSourcePayload; 'DELETE_DATA_SOURCE': DeleteDataSourcePayload; + 'CHANGE_REFERENCE_TO_COMPOSITION': ChangeReferenceToCompositionPayload; + 'CHANGE_COMPOSITION_TO_REFERENCE': ChangeCompositionToReferencePayload; + 'EXTRACT_FIELDS_TO_COMPOSITION': ExtractFieldsToCompositionPayload; + 'EXTRACT_FIELDS_TO_REFERENCE': ExtractFieldsToReferencePayload; + 'EXTRACT_FIELDS_TO_EMBEDDED': ExtractFieldsToEmbeddedPayload; + 'EXTRACT_FIELDS_TO_PARENT': ExtractFieldsToParentPayload; + // Add more change types and their payloads as needed }; /** @@ -121,6 +306,8 @@ export interface ManualRefactorContext { uri: vscode.Uri; range: vscode.Range; metadata?: DecoratedClass | PropertyMetadata | DataSourceMetadata; + treeViewContext?: TreeViewContext; + } /** diff --git a/src/refactor/tools/addDecorator.ts b/src/refactor/tools/addDecorator.ts index feac7de..fe4dce3 100644 --- a/src/refactor/tools/addDecorator.ts +++ b/src/refactor/tools/addDecorator.ts @@ -121,7 +121,7 @@ export class AddDecoratorTool implements IRefactorTool { const indentation = fieldLine.text.substring(0, fieldLine.firstNonWhitespaceCharacterIndex); const textToInsert = `@${decoratorName}()\n${indentation}`; const insertPosition = new vscode.Position(fieldMetadata.declaration.range.start.line, fieldMetadata.declaration.range.start.character); - workspaceEdit.insert(fieldMetadata.declaration.uri, insertPosition, textToInsert); + workspaceEdit.insert(fieldMetadata.declaration.uri, insertPosition, textToInsert, {label: `Add @${decoratorName} decorator to '${fieldMetadata.name}'`, needsConfirmation: true}); return workspaceEdit; } diff --git a/src/refactor/tools/changeCompositionToReference.ts b/src/refactor/tools/changeCompositionToReference.ts new file mode 100644 index 0000000..f8c3ad7 --- /dev/null +++ b/src/refactor/tools/changeCompositionToReference.ts @@ -0,0 +1,195 @@ +import * as vscode from "vscode"; +import { + IRefactorTool, + ChangeObject, + ManualRefactorContext, +} from "../refactorInterfaces"; +import { MetadataCache, PropertyMetadata, DecoratedClass } from "../../cache/cache"; +import { ChangeCompositionToReferenceTool } from "../../commands/fields/changeCompositionToReference"; +import { ExplorerProvider } from "../../explorer/explorerProvider"; +import { isModelFile } from "../../utils/metadata"; + +/** + * Payload interface for changing a composition field to a reference field. + */ +export interface ChangeCompositionToReferencePayload { + sourceModelName: string; + fieldName: string; + fieldMetadata: PropertyMetadata; + isManual: boolean; +} + +/** + * Refactor tool for converting composition fields to reference fields. + * + * This tool allows users to convert @Composition fields to @Reference fields + * through the VS Code refactor menu. It validates that the field is indeed + * a composition field before allowing the conversion. + */ +export class ChangeCompositionToReferenceRefactorTool implements IRefactorTool { + + /** + * Returns the VS Code command identifier for this refactor tool. + */ + getCommandId(): string { + return "slingr-vscode-extension.changeCompositionToReference"; + } + + /** + * Returns the human-readable title shown in refactor menus. + */ + getTitle(): string { + return "Change Composition to Reference"; + } + + /** + * Returns the types of changes this tool handles. + */ + getHandledChangeTypes(): string[] { + return ["CHANGE_COMPOSITION_TO_REFERENCE"]; + } + + /** + * Determines if this tool can handle a manual refactor trigger. + * Only allows conversion if this is a component model that has a parent with a composition field. + */ + async canHandleManualTrigger(context: ManualRefactorContext): Promise { + // Must be in a model file + if (!isModelFile(context.uri)) { + return false; + } + + // Must have class metadata (since composition fields are shown as models in explorer) + if (!context.metadata || !('name' in context.metadata)) { + return false; + } + + const componentModel = context.metadata as DecoratedClass; + + // Check if this is a component model by looking for a parent model with a composition field pointing to it + const parentFieldInfo = this.findParentCompositionField(context.cache, componentModel); + + return parentFieldInfo !== null; + } + + /** + * This tool doesn't detect automatic changes. + */ + analyze(): ChangeObject[] { + return []; + } + + /** + * Initiates the manual refactor by creating a change object. + */ + async initiateManualRefactor(context: ManualRefactorContext): Promise { + if (!context.metadata || !('decorators' in context.metadata)) { + return undefined; + } + + // When right-clicking on a composition field (shown as a model in explorer), + // context.metadata is the component model, not the field metadata + const componentModel = context.metadata as DecoratedClass; + + // Find the parent model and field that has a composition relationship to this component model + const cache = context.cache; + const parentFieldInfo = this.findParentCompositionField(cache, componentModel); + + if (!parentFieldInfo) { + vscode.window.showErrorMessage("Could not find the parent model with composition field for this component model"); + return undefined; + } + + const payload: ChangeCompositionToReferencePayload = { + sourceModelName: parentFieldInfo.parentModel.name, + fieldName: parentFieldInfo.fieldName, + fieldMetadata: parentFieldInfo.fieldMetadata, + isManual: true, + }; + + return { + type: "CHANGE_COMPOSITION_TO_REFERENCE", + uri: context.uri, + description: `Change composition field '${parentFieldInfo.fieldName}' to reference in model '${parentFieldInfo.parentModel.name}'`, + payload, + }; + } + + /** + * Prepares the workspace edit for the refactor operation. + * This delegates to the actual implementation tool. + */ + async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { + const payload = change.payload as ChangeCompositionToReferencePayload; + + // We don't actually prepare the edit here since the command tool handles everything + // This is more of a trigger for the actual implementation + let workspaceEdit = new vscode.WorkspaceEdit(); + + // Execute the actual command + try { + const tool = new ChangeCompositionToReferenceTool(); + workspaceEdit = await tool.changeCompositionToReference( + cache, + payload.sourceModelName, + payload.fieldName + ); + } catch (error) { + vscode.window.showErrorMessage(`Failed to change composition to reference: ${error}`); + } + + return workspaceEdit; + } + + /** + * Finds the model that contains the given field. + */ + private findSourceModel(cache: MetadataCache, fieldMetadata: PropertyMetadata): DecoratedClass | null { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + const fieldInModel = Object.values(model.properties).find( + prop => prop.name === fieldMetadata.name && + prop.declaration.uri.fsPath === fieldMetadata.declaration.uri.fsPath && + prop.declaration.range.start.line === fieldMetadata.declaration.range.start.line + ); + + if (fieldInModel) { + return model; + } + } + + return null; + } + + /** + * Finds the parent model that has a composition field pointing to the given component model. + */ + private findParentCompositionField(cache: MetadataCache, componentModel: DecoratedClass): { + parentModel: DecoratedClass; + fieldName: string; + fieldMetadata: PropertyMetadata; + } | null { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + for (const [fieldName, fieldMetadata] of Object.entries(model.properties)) { + // Check if this field has a @Composition decorator and points to the component model + const hasCompositionDecorator = fieldMetadata.decorators.some(d => d.name === "Composition"); + if (hasCompositionDecorator) { + // Check if the field type matches the component model name (handle both singular and array types) + const fieldType = fieldMetadata.type.replace('[]', ''); // Remove array suffix if present + if (fieldType === componentModel.name) { + return { + parentModel: model, + fieldName: fieldName, + fieldMetadata: fieldMetadata + }; + } + } + } + } + + return null; + } +} diff --git a/src/refactor/tools/changeFieldType.ts b/src/refactor/tools/changeFieldType.ts index 7c2d6d7..f78e641 100644 --- a/src/refactor/tools/changeFieldType.ts +++ b/src/refactor/tools/changeFieldType.ts @@ -280,18 +280,18 @@ export class ChangeFieldTypeTool implements IRefactorTool { : `@${newType}()`; if (isReplacing) { - workspaceEdit.replace(change.uri, positionToActOn, decoratorString); + workspaceEdit.replace(change.uri, positionToActOn, decoratorString, {label: `Change field '${field.name}' type to '${newType}'`, needsConfirmation: true}); } else { const document = await vscode.workspace.openTextDocument(change.uri); const decoratorLine = document.lineAt(positionToActOn.start.line); const indentation = decoratorLine.text.substring(0, decoratorLine.firstNonWhitespaceCharacterIndex); const textToInsert = `${decoratorString}\n${indentation}`; - workspaceEdit.insert(change.uri, positionToActOn.start, textToInsert); + workspaceEdit.insert(change.uri, positionToActOn.start, textToInsert, {label: `Add @${newType} decorator to '${field.name}'`, needsConfirmation: true} ); } const typeCorrectionEdit = await this.validateAndCorrectType(field, newType, change.uri); if (typeCorrectionEdit) { - workspaceEdit.replace(change.uri, typeCorrectionEdit.range, typeCorrectionEdit.newText); + workspaceEdit.replace(change.uri, typeCorrectionEdit.range, typeCorrectionEdit.newText, {label: `Replace type of '${field.name}' to '${typeCorrectionEdit.newText}'`, needsConfirmation: true}); } } return workspaceEdit; @@ -338,17 +338,17 @@ export class ChangeFieldTypeTool implements IRefactorTool { const enumString = `\n\nexport enum ${enumName} {\n${enumMembers}\n}`; const document = await vscode.workspace.openTextDocument(uri); const endOfFile = document.lineAt(document.lineCount - 1).range.end; - workspaceEdit.insert(uri, endOfFile, enumString); + workspaceEdit.insert(uri, endOfFile, enumString, {label: `Create enum '${enumName}'`, needsConfirmation: true}); // Generate the new @Choice decorator const labels = values.map(v => ` ${v}: "${this.toTitleCase(v)}"`).join(',\n'); const decoratorString = `@Choice<${enumName}>({\n labels: {\n${labels}\n }\n })`; - workspaceEdit.replace(uri, decoratorPosition, decoratorString); + workspaceEdit.replace(uri, decoratorPosition, decoratorString, {label: `Change field '${field.name}' type to 'Choice'`, needsConfirmation: true}); // Create an edit to change the property's type from 'string' to the new enum name const typeCorrectionEdit = await this.validateAndCorrectType(field, enumName, uri, true); if (typeCorrectionEdit) { - workspaceEdit.replace(uri, typeCorrectionEdit.range, typeCorrectionEdit.newText); + workspaceEdit.replace(uri, typeCorrectionEdit.range, typeCorrectionEdit.newText, {label: `Change type of '${field.name}' to '${enumName}'`, needsConfirmation: true}); } } diff --git a/src/refactor/tools/changeReferenceToComposition.ts b/src/refactor/tools/changeReferenceToComposition.ts new file mode 100644 index 0000000..c655730 --- /dev/null +++ b/src/refactor/tools/changeReferenceToComposition.ts @@ -0,0 +1,154 @@ +import * as vscode from "vscode"; +import { IRefactorTool, ChangeObject, ManualRefactorContext } from "../refactorInterfaces"; +import { MetadataCache, PropertyMetadata, DecoratedClass } from "../../cache/cache"; +import { ChangeReferenceToCompositionTool } from "../../commands/fields/changeReferenceToComposition"; +import { ExplorerProvider } from "../../explorer/explorerProvider"; +import { isModelFile } from "../../utils/metadata"; + +/** + * Payload interface for changing a reference field to a composition field. + */ +export interface ChangeReferenceToCompositionPayload { + sourceModelName: string; + fieldName: string; + fieldMetadata: PropertyMetadata; + isManual: boolean; +} + +/** + * Refactor tool for converting reference fields to composition fields. + * + * This tool allows users to convert @Reference fields to @Composition fields + * through the VS Code refactor menu. It validates that the field is indeed + * a reference field before allowing the conversion. + */ +export class ChangeReferenceToCompositionRefactorTool implements IRefactorTool { + /** + * Returns the VS Code command identifier for this refactor tool. + */ + getCommandId(): string { + return "slingr-vscode-extension.changeReferenceToComposition"; + } + + /** + * Returns the human-readable title shown in refactor menus. + */ + getTitle(): string { + return "Change Reference to Composition"; + } + + /** + * Returns the types of changes this tool handles. + */ + getHandledChangeTypes(): string[] { + return ["CHANGE_REFERENCE_TO_COMPOSITION"]; + } + + /** + * Determines if this tool can handle a manual refactor trigger. + * Only allows conversion if the field is a reference field. + */ + async canHandleManualTrigger(context: ManualRefactorContext): Promise { + // Must be in a model file + if (!isModelFile(context.uri)) { + return false; + } + + // Must have field metadata + if (!context.metadata || !("decorators" in context.metadata)) { + return false; + } + + const fieldMetadata = context.metadata as PropertyMetadata; + + // Check if this field has a @Reference decorator + const hasReferenceDecorator = fieldMetadata.decorators.some((d) => d.name === "Reference"); + + return hasReferenceDecorator; + } + + /** + * This tool doesn't detect automatic changes. + */ + analyze(): ChangeObject[] { + return []; + } + + /** + * Initiates the manual refactor by creating a change object. + */ + async initiateManualRefactor(context: ManualRefactorContext): Promise { + if (!context.metadata || !("decorators" in context.metadata)) { + return undefined; + } + + const fieldMetadata = context.metadata as PropertyMetadata; + + // Find the model that contains this field + const cache = context.cache; + const sourceModel = this.findSourceModel(cache, fieldMetadata); + + if (!sourceModel) { + vscode.window.showErrorMessage("Could not find the model containing this field"); + return undefined; + } + + const payload: ChangeReferenceToCompositionPayload = { + sourceModelName: sourceModel.name, + fieldName: fieldMetadata.name, + fieldMetadata: fieldMetadata, + isManual: true, + }; + + return { + type: "CHANGE_REFERENCE_TO_COMPOSITION", + uri: context.uri, + description: `Change reference field '${fieldMetadata.name}' to composition in model '${sourceModel.name}'`, + payload, + }; + } + + /** + * Prepares the workspace edit for the refactor operation. + * This delegates to the actual implementation tool. + */ + async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { + const payload = change.payload as ChangeReferenceToCompositionPayload; + + // We don't actually prepare the edit here since the command tool handles everything + // This is more of a trigger for the actual implementation + let workspaceEdit = new vscode.WorkspaceEdit(); + + // Execute the actual command + try { + const tool = new ChangeReferenceToCompositionTool(); + workspaceEdit = await tool.changeReferenceToComposition(cache, payload.sourceModelName, payload.fieldName); + } catch (error) { + vscode.window.showErrorMessage(`Failed to change reference to composition: ${error}`); + } + + return workspaceEdit; + } + + /** + * Finds the model that contains the given field. + */ + private findSourceModel(cache: MetadataCache, fieldMetadata: PropertyMetadata): DecoratedClass | null { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + const fieldInModel = Object.values(model.properties).find( + (prop) => + prop.name === fieldMetadata.name && + prop.declaration.uri.fsPath === fieldMetadata.declaration.uri.fsPath && + prop.declaration.range.start.line === fieldMetadata.declaration.range.start.line + ); + + if (fieldInModel) { + return model; + } + } + + return null; + } +} diff --git a/src/refactor/tools/deleteDataSource.ts b/src/refactor/tools/deleteDataSource.ts index 2516557..5607b73 100644 --- a/src/refactor/tools/deleteDataSource.ts +++ b/src/refactor/tools/deleteDataSource.ts @@ -79,7 +79,7 @@ export class DeleteDataSourceTool implements IRefactorTool { async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { const workspaceEdit = new vscode.WorkspaceEdit(); - workspaceEdit.deleteFile(change.uri); + workspaceEdit.deleteFile(change.uri, {}, {label: `Delete data source file`, needsConfirmation: true}); return workspaceEdit; } } \ No newline at end of file diff --git a/src/refactor/tools/deleteField.ts b/src/refactor/tools/deleteField.ts index 970ea99..62d011c 100644 --- a/src/refactor/tools/deleteField.ts +++ b/src/refactor/tools/deleteField.ts @@ -209,7 +209,7 @@ export class DeleteFieldTool implements IRefactorTool { * @param cache The metadata cache (not used in this method). * @returns A promise that resolves to a `WorkspaceEdit` with all necessary changes. */ - public async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { + public async prepareEdit(change: ChangeObject, cache: MetadataCache, edit?:vscode.WorkspaceEdit): Promise { // Type guard to ensure we're working with the correct payload type if (change.type !== 'DELETE_FIELD') { throw new Error(`DeleteFieldTool can only handle DELETE_FIELD changes, received: ${change.type}`); @@ -236,7 +236,8 @@ export class DeleteFieldTool implements IRefactorTool { continue; } - workspaceEdit.replace(ref.uri, ref.range, '/* DELETED_FIELD_REFERENCE */'); + workspaceEdit.replace(ref.uri, ref.range, '/* DELETED_FIELD_REFERENCE */', {label: `Reference to deleted field '${field.name}'`, needsConfirmation: true}); + edit?.replace(ref.uri, ref.range, '/* DELETED_FIELD_REFERENCE */',{label: `Reference to deleted field '${field.name}'`, needsConfirmation: true}); } } @@ -254,7 +255,8 @@ export class DeleteFieldTool implements IRefactorTool { const endLine = doc.lineAt(field.declaration.range.end.line); const fullRangeToDelete = new vscode.Range(startPosition, endLine.rangeIncludingLineBreak.end); - workspaceEdit.delete(field.declaration.uri, fullRangeToDelete); + workspaceEdit.delete(field.declaration.uri, fullRangeToDelete, {label: `Delete field '${field.name}'`, needsConfirmation: true} ); + edit?.delete(field.declaration.uri, fullRangeToDelete, {label: `Delete field '${field.name}'`, needsConfirmation: true} ); } return workspaceEdit; @@ -287,6 +289,37 @@ export class DeleteFieldTool implements IRefactorTool { return false; } + /** + * Deletes a field programmatically without user interaction. + * This is useful for automated refactoring operations. + * + * @param fieldMetadata The metadata of the field to delete + * @param modelName The name of the model containing the field + * @param cache The metadata cache + * @returns A promise that resolves to a WorkspaceEdit for the deletion + */ + public async deleteFieldProgrammatically( + fieldMetadata: PropertyMetadata, + modelName: string, + cache: MetadataCache, + edit?: vscode.WorkspaceEdit + ): Promise { + const payload: DeleteFieldPayload = { + oldFieldMetadata: fieldMetadata, + modelName: modelName, + isManual: true // Use manual mode to actually remove the field declaration + }; + + const change: ChangeObject = { + type: 'DELETE_FIELD', + uri: fieldMetadata.declaration.uri, + description: `Delete field '${fieldMetadata.name}' programmatically.`, + payload + }; + + return await this.prepareEdit(change, cache, edit); + } + /** * Executes a prompt to help fix broken field references after a field deletion. * diff --git a/src/refactor/tools/deleteModel.ts b/src/refactor/tools/deleteModel.ts index 4c50554..61d33e1 100644 --- a/src/refactor/tools/deleteModel.ts +++ b/src/refactor/tools/deleteModel.ts @@ -155,7 +155,7 @@ export class DeleteModelTool implements IRefactorTool { vscode.window.showErrorMessage("Could not find a valid model to delete."); return undefined; } - const model = context.metadata as DecoratedClass; + const model: DecoratedClass = context.metadata; // Check if there are multiple models in the same file const fileMeta = context.cache.getMetadataForFile(context.uri.fsPath); @@ -257,13 +257,22 @@ export class DeleteModelTool implements IRefactorTool { const doc = await vscode.workspace.openTextDocument(ref.uri); const line = doc.lineAt(ref.range.start.line); if (!line.isEmptyOrWhitespace) { - workspaceEdit.delete(ref.uri, line.rangeIncludingLineBreak); + workspaceEdit.delete(ref.uri, line.rangeIncludingLineBreak, {label: `Delete reference to deleted model '${deletedModelName}'`, needsConfirmation: true}); } } catch (e) { console.error(`Could not process reference in ${ref.uri.fsPath}:`, e); - workspaceEdit.replace(ref.uri, ref.range, "/* DELETED_REFERENCE */"); + workspaceEdit.replace(ref.uri, ref.range, "/* DELETED_REFERENCE */", {label: `Reference to deleted model '${deletedModelName}'`, needsConfirmation: true} ); } } + + // Add file deletion operations to the workspace edit + for (const uri of urisToDelete) { + workspaceEdit.deleteFile(uri, { + recursive: true, + ignoreIfNotExists: true + }, + {label: `Delete file or directory '${uri.fsPath}'`, needsConfirmation: true} ); + } await this.cleanupRelationshipFields(deletedModelName, workspaceEdit, cache); return workspaceEdit; @@ -349,12 +358,12 @@ export class DeleteModelTool implements IRefactorTool { new vscode.Position(actualEndLine + 1, 0) ); - workspaceEdit.delete(fileUri, rangeToDelete); + workspaceEdit.delete(fileUri, rangeToDelete, {label: `Delete model class '${modelMetadata.name}'`, needsConfirmation: true}); } catch (error) { console.error(`Error deleting model class from file ${fileUri.fsPath}:`, error); // Fallback: just comment out the class declaration - workspaceEdit.replace(fileUri, modelMetadata.declaration.range, `/* DELETED_MODEL: ${modelMetadata.name} */`); + workspaceEdit.replace(fileUri, modelMetadata.declaration.range, `/* DELETED_MODEL: ${modelMetadata.name} */`, {label: `Comment out model class '${modelMetadata.name}'`, needsConfirmation: true} ); } } @@ -434,7 +443,7 @@ export class DeleteModelTool implements IRefactorTool { // Apply all deletions for (const range of rangesToDelete) { - workspaceEdit.delete(field.declaration.uri, range); + workspaceEdit.delete(field.declaration.uri, range, {label: `Delete decorator for field '${field.name}'`, needsConfirmation: true} ); } } catch (e) { @@ -590,7 +599,7 @@ I have deleted the model **\`${modelName}\`**.${decoratorInfo}${referenceInfo}${ Please analyze each broken reference systematically and provide clear, implementable solutions.`; try { - await vscode.commands.executeCommand('workbench.action.chat.open', prompt ); + await vscode.commands.executeCommand('workbench.action.chat.open', prompt); } catch (error) { console.error('Failed to open chat with custom prompt:', error); } diff --git a/src/refactor/tools/renameDataSource.ts b/src/refactor/tools/renameDataSource.ts index b1346ec..361eb84 100644 --- a/src/refactor/tools/renameDataSource.ts +++ b/src/refactor/tools/renameDataSource.ts @@ -119,19 +119,19 @@ export class RenameDataSourceTool implements IRefactorTool { if (ref.uri.fsPath === declarationUri.fsPath && areRangesEqual(ref.range, declarationRange)) { continue; } - workspaceEdit.replace(ref.uri, ref.range, newName); + workspaceEdit.replace(ref.uri, ref.range, newName, {label: `Update reference to data source '${oldName}'`, needsConfirmation: true} ); } // If it's a manual rename, we also need to change the declaration if (isManual) { - workspaceEdit.replace(declarationUri, declarationRange, newName); + workspaceEdit.replace(declarationUri, declarationRange, newName, {label: `Rename data source declaration from '${oldName}' to '${newName}'`, needsConfirmation: true} ); } // Rename the file if its name matches the old data source name const oldFileName = oldUri.path.split('/').pop()?.replace('.ts', ''); if (oldFileName === oldName) { const newUri = vscode.Uri.joinPath(oldUri, '..', `${newName}.ts`); - workspaceEdit.renameFile(oldUri, newUri); + workspaceEdit.renameFile(oldUri, newUri, {}, {label: `Rename data source file from '${oldName}.ts' to '${newName}.ts'`, needsConfirmation: true} ); } return workspaceEdit; diff --git a/src/refactor/tools/renameField.ts b/src/refactor/tools/renameField.ts index eb0fb45..5c44b8d 100644 --- a/src/refactor/tools/renameField.ts +++ b/src/refactor/tools/renameField.ts @@ -225,11 +225,11 @@ export class RenameFieldTool implements IRefactorTool { if (ref.uri.fsPath === declarationUri.fsPath && areRangesEqual(ref.range, declarationRange)) { continue; } - workspaceEdit.replace(ref.uri, ref.range, newName); + workspaceEdit.replace(ref.uri, ref.range, newName, {label: `Update reference to field '${oldFieldMetadata.name}'`, needsConfirmation: true} ); } if (change.payload.isManual) { - workspaceEdit.replace(declarationUri, declarationRange, newName); + workspaceEdit.replace(declarationUri, declarationRange, newName, {label: `Rename field declaration from '${oldFieldMetadata.name}' to '${newName}'`, needsConfirmation: true} ); } return workspaceEdit; diff --git a/src/refactor/tools/renameModel.ts b/src/refactor/tools/renameModel.ts index a076dc6..747f43a 100644 --- a/src/refactor/tools/renameModel.ts +++ b/src/refactor/tools/renameModel.ts @@ -186,11 +186,16 @@ export class RenameModelTool implements IRefactorTool { if (ref.uri.fsPath === declarationUri.fsPath && areRangesEqual(ref.range, declarationRange)) { continue; } - workspaceEdit.replace(ref.uri, ref.range, newName); + workspaceEdit.replace(ref.uri, ref.range, newName, {label: `Update reference to model '${oldModelMetadata.name}'`, needsConfirmation: true} ); } if (change.payload.isManual) { - workspaceEdit.replace(declarationUri, declarationRange, newName); + workspaceEdit.replace(declarationUri, declarationRange, newName, {label: `Rename model declaration from '${oldModelMetadata.name}' to '${newName}'`, needsConfirmation: true} ); + } + + // Add file rename operation if a new URI is specified + if (payload.newUri) { + workspaceEdit.renameFile(change.uri, payload.newUri, {}, {label: `Rename model file to match new class name`, needsConfirmation: true} ); } return workspaceEdit; diff --git a/src/services/fileSystemService.ts b/src/services/fileSystemService.ts index 4b4ed4d..cfdad46 100644 --- a/src/services/fileSystemService.ts +++ b/src/services/fileSystemService.ts @@ -458,4 +458,114 @@ export class FileSystemService { } return true; } + + /** + * Removes import statements for a specific model from a document. + * This is useful when a model is moved from an external file to the same file, + * making the import unnecessary. + * + * @param document - The document to remove imports from + * @param modelName - The name of the model to remove imports for + * @returns Promise that resolves when the import is removed + */ + public async removeModelImport(document: vscode.TextDocument, modelName: string): Promise { + const edit = new vscode.WorkspaceEdit(); + const content = document.getText(); + const lines = content.split("\n"); + + // Find and remove import lines that contain the model name + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check for import statements that import the specific model + if (this.isImportLineForModel(line, modelName)) { + // Check if this is a single import or multiple imports + if (this.isSingleModelImport(line, modelName)) { + // Remove the entire import line + const lineRange = new vscode.Range(i, 0, i + 1, 0); + edit.delete(document.uri, lineRange); + } else { + // Remove only the specific model from a multi-import line + const updatedLine = this.removeModelFromImportLine(line, modelName); + if (updatedLine !== line) { + const lineRange = new vscode.Range(i, 0, i, line.length); + edit.replace(document.uri, lineRange, updatedLine); + } + } + } + } + + if (edit.size > 0) { + await vscode.workspace.applyEdit(edit); + } + } + + /** + * Checks if a line is an import statement for a specific model. + */ + private isImportLineForModel(line: string, modelName: string): boolean { + // Must be an import line + if (!line.trim().startsWith('import')) { + return false; + } + + // Skip slingr-framework imports + if (line.includes('slingr-framework')) { + return false; + } + + // Check if the model name appears in the import + const importRegex = /import\s+\{([^}]+)\}\s+from/; + const match = line.match(importRegex); + + if (match) { + const importedItems = match[1].split(',').map(item => item.trim()); + return importedItems.includes(modelName); + } + + // Also check for default imports + const defaultImportRegex = new RegExp(`import\\s+${modelName}\\s+from`); + return defaultImportRegex.test(line); + } + + /** + * Checks if the import line only imports a single model. + */ + private isSingleModelImport(line: string, modelName: string): boolean { + const importRegex = /import\s+\{([^}]+)\}\s+from/; + const match = line.match(importRegex); + + if (match) { + const importedItems = match[1].split(',').map(item => item.trim()).filter(item => item.length > 0); + return importedItems.length === 1 && importedItems[0] === modelName; + } + + // For default imports, it's always a single import + const defaultImportRegex = new RegExp(`import\\s+${modelName}\\s+from`); + return defaultImportRegex.test(line); + } + + /** + * Removes a specific model from a multi-import line. + */ + private removeModelFromImportLine(line: string, modelName: string): string { + const importRegex = /import\s+\{([^}]+)\}\s+from(.+)/; + const match = line.match(importRegex); + + if (match) { + const importedItems = match[1] + .split(',') + .map(item => item.trim()) + .filter(item => item.length > 0 && item !== modelName); + + if (importedItems.length > 0) { + return `import { ${importedItems.join(', ')} } from${match[2]}`; + } else { + // If no items left, return empty string to indicate line should be removed + return ''; + } + } + + return line; + } } diff --git a/src/services/modelService.ts b/src/services/modelService.ts new file mode 100644 index 0000000..b5654b5 --- /dev/null +++ b/src/services/modelService.ts @@ -0,0 +1,200 @@ +import * as vscode from "vscode"; +import { MetadataCache } from "../cache/cache"; +import { SourceCodeService } from "./sourceCodeService"; + +export class ModelService { + private sourceCodeService: SourceCodeService; + constructor() { + this.sourceCodeService = new SourceCodeService(); + } + + /** + * Adds a complete model file to the workspace edit. + * + * @param edit - The workspace edit to add the changes to + * @param targetFilePath - The path where the model file will be created + * @param modelName - The name of the new model class + * @param classBody - The complete class body content + * @param dataSource - Optional datasource for the model + * @param existingImports - Set of imports that should be included + * @param isComponent - Whether this is a component model (affects export and class declaration) + * @param cache - Optional metadata cache to lookup datasource information + * @param docs - Optional documentation string for the model + * @param includeImports - Whether to include import statements (default: true) + * @param includeDefaultId - Whether to include the default id field (default: true) + */ + public async addModelToWorkspaceEdit( + edit: vscode.WorkspaceEdit, + targetFilePath: string, + modelName: string, + classBody?: string, + dataSource?: string, + existingImports?: Set, + isComponent: boolean = false, + cache?: MetadataCache, + docs?: string, + includeImports: boolean = true, + includeDefaultId: boolean = true + ): Promise { + const lines: string[] = []; + + if (includeImports) { + // Determine required imports + const imports = new Set(["BaseModel", "Model", "Field"]); + + // Add UUID import only if we're including the default id field + if (includeDefaultId) { + imports.add("UUID"); + } + + // Add existing imports if provided + if (existingImports) { + existingImports.forEach((imp) => imports.add(imp)); + } + + if (classBody) { + // Analyze the class body to determine additional needed imports + const bodyImports = this.sourceCodeService.extractImportsFromClassBody(classBody); + bodyImports.forEach((imp) => imports.add(imp)); + } + + // Add import statement + const sortedImports = Array.from(imports).sort(); + lines.push(`import { ${sortedImports.join(", ")} } from "slingr-framework";`); + + // Add datasource import if applicable + if (dataSource) { + // Use the new findDataSourcePath function for accurate import resolution + try { + const dataSourceImport = await this.sourceCodeService.findDataSourcePath(dataSource, targetFilePath, cache); + if (dataSourceImport) { + lines.push(dataSourceImport); + } + } catch (error) { + console.warn("Could not resolve datasource import, using fallback:", error); + // Fallback to generic import + const cleanDataSource = dataSource.replace(/['"]/g, ""); + lines.push(`import { ${cleanDataSource} } from '../dataSources/${cleanDataSource}';`); + } + } + lines.push(""); + } + + // Add model decorator + if (dataSource) { + lines.push(`@Model({`); + if (docs) { + lines.push(`\tdataSource: ${dataSource},`); + lines.push(`\tdocs: "${docs}"`); + } else { + lines.push(`\tdataSource: ${dataSource}`); + } + lines.push(`})`); + } else if (docs) { + lines.push(`@Model({`); + lines.push(`\tdocs: "${docs}"`); + lines.push(`})`); + } + else { + lines.push(`@Model()`); + } + + // Add class declaration (export only if not a component model) + const exportKeyword = isComponent ? "" : "export "; + lines.push(`${exportKeyword}class ${modelName} extends BaseModel {`); + + // Add default id field if requested + if (includeDefaultId) { + lines.push(``); + lines.push(`\t@Field({`); + lines.push(`\t\tprimaryKey: true,`); + lines.push(`\t})`); + lines.push(`\t@UUID({`); + lines.push(`\t\tgenerated: true,`); + lines.push(`\t})`); + lines.push(`\tid!: string`); + } + + // Add class body (if not empty) + if (classBody && classBody.trim()) { + lines.push(""); + // Split class body into lines and indent each line + const classBodyLines = classBody.split('\n'); + for (const line of classBodyLines) { + if (line.trim()) { + lines.push(`\t${line}`); + } else { + lines.push(''); // Keep empty lines as empty + } + } + lines.push(""); + } + + lines.push(`}`); + + // Create the file content and add it to the workspace edit + const fileContent = lines.join("\n"); + const uri = vscode.Uri.file(targetFilePath); + edit.createFile(uri, { ignoreIfExists: false }); + edit.insert(uri, new vscode.Position(0, 0), fileContent); + } + + /** + * Generates model file content as a string (for backward compatibility or other use cases). + * + * @param modelName - The name of the new model class + * @param classBody - The complete class body content + * @param dataSource - Optional datasource for the model + * @param existingImports - Set of imports that should be included + * @param isComponent - Whether this is a component model (affects export and class declaration) + * @param targetFilePath - Optional path where the model file will be created (for accurate relative import calculation) + * @param cache - Optional metadata cache to lookup datasource information + * @param docs - Optional documentation string for the model + * @param includeImports - Whether to include import statements (default: true) + * @param includeDefaultId - Whether to include the default id field (default: true) + * @returns The complete model file content + */ + public async generateModelFileContent( + modelName: string, + classBody: string, + dataSource?: string, + existingImports?: Set, + isComponent: boolean = false, + targetFilePath?: string, + cache?: MetadataCache, + docs?: string, + includeImports: boolean = true, + includeDefaultId: boolean = true + ): Promise { + // Create a temporary workspace edit to generate the content + const tempEdit = new vscode.WorkspaceEdit(); + const tempFilePath = targetFilePath || ""; + + await this.addModelToWorkspaceEdit( + tempEdit, + tempFilePath, + modelName, + classBody, + dataSource, + existingImports, + isComponent, + cache, + docs, + includeImports, + includeDefaultId + ); + + // Extract the content from the workspace edit + const uri = vscode.Uri.file(tempFilePath); + const edits = tempEdit.get(uri); + + // Find the insert edit and return its content + for (const edit of edits) { + if (edit instanceof vscode.TextEdit && edit.range.isEmpty) { + return edit.newText; + } + } + + return ""; + } +} diff --git a/src/services/projectAnalysisService.ts b/src/services/projectAnalysisService.ts index ccbdbed..f5e3a4d 100644 --- a/src/services/projectAnalysisService.ts +++ b/src/services/projectAnalysisService.ts @@ -6,14 +6,13 @@ import { PropertyMetadata } from "../cache/cache"; import { fieldTypeConfig } from "../utils/fieldTypes"; export class ProjectAnalysisService { - private fileSystemService: FileSystemService; constructor() { this.fileSystemService = new FileSystemService(); } - public async findModelClass( + public async selectModelClass( document: vscode.TextDocument, cache: MetadataCache ): Promise { @@ -31,7 +30,10 @@ export class ProjectAnalysisService { } if (modelClasses.length > 1) { - const selected = await vscode.window.showQuickPick(modelClasses.map((c) => c.name)); + const selected = await vscode.window.showQuickPick( + modelClasses.map((c) => c.name), + { placeHolder: "Select a model class from this file" } + ); return modelClasses.find((c) => c.name === selected); } return undefined; diff --git a/src/services/sourceCodeService.ts b/src/services/sourceCodeService.ts index e03439e..ac30af8 100644 --- a/src/services/sourceCodeService.ts +++ b/src/services/sourceCodeService.ts @@ -1,12 +1,13 @@ import * as vscode from "vscode"; import * as path from "path"; -import { MetadataCache } from "../cache/cache"; +import { DecoratedClass, MetadataCache } from "../cache/cache"; import { FieldInfo } from "../commands/interfaces"; import { detectIndentation, applyIndentation } from "../utils/detectIndentation"; import { FileSystemService } from "./fileSystemService"; import { ProjectAnalysisService } from "./projectAnalysisService"; export class SourceCodeService { + private fileSystemService: FileSystemService; private projectAnalysisService: ProjectAnalysisService; constructor() { @@ -19,14 +20,16 @@ export class SourceCodeService { modelClassName: string, fieldInfo: FieldInfo, fieldCode: string, - cache?: MetadataCache + cache?: MetadataCache, + importModel: boolean = true ): Promise { const edit = new vscode.WorkspaceEdit(); const lines = document.getText().split("\n"); + const newImports = new Set(["Field", fieldInfo.type.decorator]); - await this.ensureSlingrFrameworkImports(document, edit, new Set(["Field", fieldInfo.type.decorator])); + await this.ensureSlingrFrameworkImports(document, edit, newImports); - if (fieldInfo.type.decorator === "Relationship" && fieldInfo.additionalConfig?.targetModel) { + if (importModel && fieldInfo.additionalConfig) { await this.addModelImport(document, fieldInfo.additionalConfig.targetModel, edit, cache); } @@ -55,8 +58,12 @@ export class SourceCodeService { inClass = true; } if (inClass) { - if (line.includes("{")) braceCount++; - if (line.includes("}")) braceCount--; + if (line.includes("{")) { + braceCount++; + } + if (line.includes("}")) { + braceCount--; + } if (braceCount === 0 && classStartLine !== -1) { classEndLine = i; break; @@ -112,6 +119,7 @@ export class SourceCodeService { } } + /** * Adds an import for a target model type. */ @@ -146,27 +154,75 @@ export class SourceCodeService { // Determine the import path let importPath = `./${targetModel}`; - if (cache) { - // Find the file path for the target model - const targetModelFilePath = this.findModelFilePath(cache, targetModel); - - if (targetModelFilePath) { - // Calculate relative path from current file to target model file - const currentFilePath = document.uri.fsPath; - const relativePath = path.relative(path.dirname(currentFilePath), targetModelFilePath); - importPath = relativePath.replace(/\.ts$/, "").replace(/\\/g, "/"); - if (!importPath.startsWith(".")) { - importPath = "./" + importPath; - } - } - } - // Create the import statement const importStatement = `import { ${targetModel} } from '${importPath}';`; edit.insert(document.uri, new vscode.Position(insertLine, 0), importStatement + "\n"); } + /** + * Adds a datasource import to a document. + * + * @param document - The document to add the import to + * @param dataSourceName - The name of the datasource to import + * @param edit - The workspace edit to add changes to + * @param cache - Optional metadata cache to lookup datasource information + * @returns Promise + */ + public async addDataSourceImport( + document: vscode.TextDocument, + dataSourceName: string, + edit: vscode.WorkspaceEdit, + cache?: MetadataCache + ): Promise { + const content = document.getText(); + const lines = content.split("\n"); + + // Clean up the datasource name (remove quotes if it's a string literal) + const cleanDataSourceName = dataSourceName.replace(/['"]/g, ""); + + // Check if the datasource is already imported + const existingImport = lines.find( + (line) => line.includes("import") && line.includes(cleanDataSourceName) && !line.includes("slingr-framework") + ); + + if (existingImport) { + return; // Already imported + } + + // Find the best place to insert the import (after slingr-framework imports, before model imports) + let insertLine = 0; + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes("import") && lines[i].includes("slingr-framework")) { + insertLine = i + 1; + // Look for empty line after slingr-framework import + if (i + 1 < lines.length && lines[i + 1].trim() === "") { + insertLine = i + 1; + break; + } + } else if (lines[i].startsWith("import ") && !lines[i].includes("slingr-framework")) { + // Found other imports, insert before them + break; + } else if (lines[i].includes("@Model") || lines[i].includes("export class")) { + // Found the start of the model definition + break; + } + } + + // Get the datasource import using our findDataSourcePath method + try { + const dataSourceImport = await this.findDataSourcePath(cleanDataSourceName, document.uri.fsPath, cache); + if (dataSourceImport) { + edit.insert(document.uri, new vscode.Position(insertLine, 0), dataSourceImport + "\n"); + } + } catch (error) { + console.warn("Could not resolve datasource import, using fallback:", error); + // Fallback to generic import + const importStatement = `import { ${cleanDataSourceName} } from '../dataSources/${cleanDataSourceName}';`; + edit.insert(document.uri, new vscode.Position(insertLine, 0), importStatement + "\n"); + } + } + /** * Updates import statements in a file to reflect a folder rename. * @@ -258,6 +314,52 @@ export class SourceCodeService { return newRelativePath.replace(/\\/g, "/"); // Normalize path separators for imports } + /** + * Inserts a new model class into a document at the appropriate location. + * + * @param document - The document to insert the model into + * @param modelCode - The complete model code to insert + * @param afterModelName - Optional name of existing model to insert after (defaults to end of file) + * @param requiredImports - Set of imports to ensure are present + */ + public async insertModel( + document: vscode.TextDocument, + modelCode: string, + afterModelName?: string, + requiredImports?: Set + ): Promise { + const edit = new vscode.WorkspaceEdit(); + const lines = document.getText().split("\n"); + + // Ensure required imports are present + if (requiredImports && requiredImports.size > 0) { + await this.ensureSlingrFrameworkImports(document, edit, requiredImports); + } + + // Determine insertion point + let insertionLine = lines.length; // Default to end of file + + if (afterModelName) { + try { + const { classEndLine } = this.findClassBoundaries(lines, afterModelName); + insertionLine = classEndLine + 1; + } catch (error) { + // If we can't find the specified model, fall back to end of file + console.warn(`Could not find model ${afterModelName}, inserting at end of file`); + } + } + + // Detect indentation from the file + //const indentation = detectIndentation(lines, 0, lines.length); + //const indentedModelCode = applyIndentation(modelCode, indentation); + + // Insert the model with appropriate spacing + const spacing = insertionLine < lines.length ? "\n\n" : "\n"; + edit.insert(document.uri, new vscode.Position(insertionLine, 0), `${spacing}${modelCode}\n`); + + await vscode.workspace.applyEdit(edit); + } + /** * Finds the file path for a given model name in the cache. */ @@ -277,4 +379,641 @@ export class SourceCodeService { return undefined; } + + /** + * Finds the datasource path by name in the workspace. + * + * @param dataSourceName - The name of the datasource to find + * @param fromFilePath - The file path from which to calculate relative import path + * @param cache - Optional metadata cache to lookup datasource information + * @returns The import statement for the datasource or null if not found + */ + public async findDataSourcePath(dataSourceName: string, fromFilePath: string, cache?: MetadataCache): Promise { + try { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + return null; + } + + // Clean up the datasource name (remove quotes if it's a string literal) + const cleanDataSourceName = dataSourceName.replace(/['"]/g, ""); + + // First, try to find the datasource in the cache + if (cache) { + const dataSources = cache.getDataSources(); + const targetDataSource = dataSources.find(ds => ds.name === cleanDataSourceName); + + if (targetDataSource) { + // Use the actual file path from the cache + const dataSourceFilePath = targetDataSource.declaration.uri.fsPath; + const fromFileDir = path.dirname(fromFilePath); + const relativePath = path.relative(fromFileDir, dataSourceFilePath); + + // Remove file extension for import + const importPath = relativePath.replace(/\.(ts|js)$/, "").replace(/\\/g, "/"); + + // Ensure the path starts with './' if it's a relative path + const finalImportPath = importPath.startsWith(".") ? importPath : "./" + importPath; + + return `import { ${cleanDataSourceName} } from '${finalImportPath}';`; + } + } + + // Fallback: Look for datasource file in src/dataSources directory + const dataSourcesDir = path.join(workspaceFolder.uri.fsPath, "src", "dataSources"); + + // Try common file extensions for datasource files + const possibleExtensions = [".ts", ".js"]; + + for (const ext of possibleExtensions) { + const dataSourceFile = path.join(dataSourcesDir, cleanDataSourceName + ext); + + try { + // Check if the file exists + await vscode.workspace.fs.stat(vscode.Uri.file(dataSourceFile)); + + // Calculate relative path from the target file to the datasource + const fromFileDir = path.dirname(fromFilePath); + const relativePath = path.relative(fromFileDir, dataSourceFile); + + // Remove file extension for import + const importPath = relativePath.replace(/\.(ts|js)$/, "").replace(/\\/g, "/"); + + // Ensure the path starts with './' if it's a relative path + const finalImportPath = importPath.startsWith(".") ? importPath : "./" + importPath; + + return `import { ${cleanDataSourceName} } from '${finalImportPath}';`; + } catch (error) { + // File doesn't exist, continue to next extension + continue; + } + } + + // If no file found, create a generic import based on standard structure + const fromFileDir = path.dirname(fromFilePath); + const relativePath = path.relative(fromFileDir, dataSourcesDir); + const importPath = relativePath.replace(/\\/g, "/"); + const finalImportPath = importPath.startsWith(".") ? importPath : "./" + importPath; + + return `import { ${cleanDataSourceName} } from '${finalImportPath}/${cleanDataSourceName}';`; + } catch (error) { + console.warn("Could not find datasource path:", error); + return null; + } + } + + /** + * Gets the actual file path where a datasource is defined using the cache. + * This is useful when you need to know the physical location of a datasource. + * + * @param dataSourceName - The name of the datasource to find + * @param cache - The metadata cache to lookup datasource information + * @returns The file path where the datasource is defined, or null if not found + */ + public getDataSourceFilePath(dataSourceName: string, cache: MetadataCache): string | null { + try { + const cleanDataSourceName = dataSourceName.replace(/['"]/g, ""); + const dataSources = cache.getDataSources(); + const targetDataSource = dataSources.find(ds => ds.name === cleanDataSourceName); + + return targetDataSource ? targetDataSource.declaration.uri.fsPath : null; + } catch (error) { + console.warn("Could not find datasource in cache:", error); + return null; + } + } + + /** + * Extracts the datasource import from the source model file. + */ + public async extractDataSourceImport(sourceModel: DecoratedClass, importName: string, cache?: MetadataCache): Promise { + try { + // First, try to use the cache to find the datasource and generate the import + if (cache) { + const dataSourceImport = await this.findDataSourcePath(importName, sourceModel.declaration.uri.fsPath, cache); + if (dataSourceImport) { + return dataSourceImport; + } + } + + // Fallback: Read the source model file to extract datasource imports + const document = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + const content = document.getText(); + const lines = content.split("\n"); + + // Clean up the importName (remove quotes if it's a string literal) + const cleanImportName = importName.replace(/['"]/g, ""); + + // Look for import lines that might contain the datasource + for (const line of lines) { + if ( + line.includes("import") && + (line.includes(cleanImportName) || + line.includes(`'${cleanImportName}'`) || + line.includes(`"${cleanImportName}"`)) + ) { + return line; + } + } + + // Look for import lines from dataSources directory + for (const line of lines) { + if (line.includes("import") && line.includes("dataSources")) { + // Check if this import contains our datasource + if (line.includes(cleanImportName)) { + return line; + } + } + } + + // If no specific import found, create a generic datasource import + // Calculate relative path to dataSources directory + const sourceModelDir = path.dirname(sourceModel.declaration.uri.fsPath); + const workspaceRoot = vscode.workspace.getWorkspaceFolder(sourceModel.declaration.uri)?.uri.fsPath; + + if (workspaceRoot) { + const relativePath = path.relative(sourceModelDir, path.join(workspaceRoot, "src", "dataSources")); + const importPath = relativePath.replace(/\\/g, "/"); + return `import { ${cleanImportName} } from '${ + importPath.startsWith(".") ? importPath : "./" + importPath + }/${cleanImportName}';`; + } + + // Fallback + return `import { ${cleanImportName} } from '../dataSources/${cleanImportName}';`; + } catch (error) { + console.warn("Could not extract datasource import:", error); + return null; + } + } + + /** + * Extracts the complete class body (everything between the class braces) from a model. + * + * @param document - The document containing the model + * @param className - The name of the class to extract from + * @returns The class body content including proper indentation + */ + public extractClassBody(document: vscode.TextDocument, className: string): string { + const lines = document.getText().split("\n"); + const { classStartLine, classEndLine } = this.findClassBoundaries(lines, className); + + // Find the opening brace of the class + let openBraceIndex = -1; + for (let i = classStartLine; i <= classEndLine; i++) { + if (lines[i].includes("{")) { + openBraceIndex = i; + break; + } + } + + if (openBraceIndex === -1) { + throw new Error(`Could not find opening brace for class ${className}`); + } + + // Extract content between the braces (excluding the braces themselves) + const classBodyLines = lines.slice(openBraceIndex + 1, classEndLine); + + // Remove any empty lines at the end + while (classBodyLines.length > 0 && classBodyLines[classBodyLines.length - 1].trim() === "") { + classBodyLines.pop(); + } + + return classBodyLines.join("\n"); + } + + + + /** + * Analyzes class body content to determine which imports are needed. + * + * @param classBody - The class body content to analyze + * @returns Set of import names that should be included + */ + public extractImportsFromClassBody(classBody: string): Set { + const imports = new Set(); + + // Look for decorator patterns + const decoratorPatterns = [ + /@Text\b/g, + /@LongText\b/g, + /@Email\b/g, + /@Html\b/g, + /@Integer\b/g, + /@Money\b/g, + /@Number\b/g, + /@Boolean\b/g, + /@Date\b/g, + /@DateRange\b/g, + /@Choice\b/g, + /@Reference\b/g, + /@Composition\b/g, + /@Relationship\b/g, + ]; + + const decoratorNames = [ + "Text", + "LongText", + "Email", + "Html", + "Integer", + "Money", + "Number", + "Boolean", + "Date", + "DateRange", + "Choice", + "Reference", + "Composition", + "Relationship", + ]; + + decoratorPatterns.forEach((pattern, index) => { + if (pattern.test(classBody)) { + imports.add(decoratorNames[index]); + } + }); + + // Always include Field if there are any field declarations + if (classBody.includes("!:") || classBody.includes(":")) { + imports.add("Field"); + } + + return imports; + } + + /** + * Extracts all model imports from a document (excluding slingr-framework imports). + * + * @param document - The document to extract imports from + * @returns Array of import statements for other models + */ + public extractModelImports(document: vscode.TextDocument): string[] { + const content = document.getText(); + const lines = content.split("\n"); + const modelImports: string[] = []; + + for (const line of lines) { + // Look for import statements that are not from slingr-framework + if ( + line.includes("import") && + line.includes("from") && + !line.includes("slingr-framework") && + !line.includes("vscode") && + !line.includes("path") && + line.trim().startsWith("import") + ) { + modelImports.push(line); + } + } + + return modelImports; + } + + /** + * Focuses on an element in a document navigating to it and highlighting it. + * This method can find and focus on various types of elements including: + * - Class properties (fields with !: or :) + * - Method names + * - Class names + * - Variable declarations + */ + public async focusOnElement(document: vscode.TextDocument, elementName: string): Promise { + try { + // Ensure the document is visible and active + const editor = await vscode.window.showTextDocument(document, { preview: false }); + + // Find the line containing the element + const content = document.getText(); + const lines = content.split("\n"); + + let elementLine = -1; + let elementIndex = -1; + + // Look for different patterns in order of specificity + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Pattern 1: Property declarations (fieldName!: Type or fieldName: Type) + if (line.includes(`${elementName}!:`) || line.includes(`${elementName}:`)) { + elementLine = i; + elementIndex = line.indexOf(elementName); + break; + } + + // Pattern 2: Method declarations (methodName() or methodName( + if ( + line.includes(`${elementName}(`) && + (line.includes("function") || line.includes("){") || line.includes(") {")) + ) { + elementLine = i; + elementIndex = line.indexOf(elementName); + break; + } + + // Pattern 3: Class declarations (class ClassName) + if (line.includes(`class ${elementName}`)) { + elementLine = i; + elementIndex = line.indexOf(elementName); + break; + } + + // Pattern 4: Variable declarations (const elementName, let elementName, var elementName) + if ( + (line.includes(`const ${elementName}`) || + line.includes(`let ${elementName}`) || + line.includes(`var ${elementName}`)) && + (line.includes("=") || line.includes(";")) + ) { + elementLine = i; + elementIndex = line.indexOf(elementName); + break; + } + + // Pattern 5: General word boundary match (as fallback) + const wordBoundaryRegex = new RegExp(`\\b${elementName}\\b`); + if (wordBoundaryRegex.test(line)) { + const match = line.match(wordBoundaryRegex); + if (match && match.index !== undefined) { + elementLine = i; + elementIndex = match.index; + break; + } + } + } + + if (elementLine !== -1 && elementIndex !== -1) { + // Position the cursor at the element name + const startPosition = new vscode.Position(elementLine, elementIndex); + const endPosition = new vscode.Position(elementLine, elementIndex + elementName.length); + + // Set selection to highlight the element name + editor.selection = new vscode.Selection(startPosition, endPosition); + + // Reveal the line in the center of the editor + editor.revealRange(new vscode.Range(startPosition, endPosition), vscode.TextEditorRevealType.InCenter); + } + } catch (error) { + console.warn("Could not focus on element:", error); + // Fallback: just make sure the document is visible + await vscode.window.showTextDocument(document, { preview: false }); + } + } + + /** + * Deletes a specific model class from a file that contains multiple models. + * + * @param fileUri - The URI of the file containing the model + * @param modelMetadata - The metadata of the model to delete + * @param workspaceEdit - The workspace edit to add the deletion to + */ + public async deleteModelClassFromFile( + fileUri: vscode.Uri, + modelMetadata: any, + workspaceEdit: vscode.WorkspaceEdit + ): Promise { + try { + const document = await vscode.workspace.openTextDocument(fileUri); + const text = document.getText(); + const lines = text.split("\n"); + + // Find the class declaration range + const classDeclaration = modelMetadata.declaration; + const startLine = classDeclaration.range.start.line; + const endLine = classDeclaration.range.end.line; + + // Find the @Model decorator using cache information + let actualStartLine = startLine; + + // Check if the model has decorators in the cache + if (modelMetadata.decorators && modelMetadata.decorators.length > 0) { + // Find the @Model decorator specifically + const modelDecorator = modelMetadata.decorators.find((d: any) => d.name === "Model"); + if (modelDecorator && modelDecorator.position) { + // Use the decorator's range from cache for precise deletion + actualStartLine = Math.min(actualStartLine, modelDecorator.position.start.line); + } + } + + // Also look backwards to find any other decorators and comments that belong to this class + for (let i = actualStartLine - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (line === "" || line.startsWith("//") || line.startsWith("/*") || line.endsWith("*/")) { + // Empty lines, single-line comments, or comment blocks - continue looking + actualStartLine = i; + } else if (line.startsWith("@")) { + // Decorator - include it + actualStartLine = i; + } else { + // Found non-empty, non-comment, non-decorator line - stop here + break; + } + } + + // Look forward to find the complete class body (including closing brace) + let actualEndLine = endLine; + let braceCount = 0; + let foundOpenBrace = false; + + for (let i = startLine; i < lines.length; i++) { + const line = lines[i]; + + for (const char of line) { + if (char === "{") { + braceCount++; + foundOpenBrace = true; + } else if (char === "}") { + braceCount--; + if (foundOpenBrace && braceCount === 0) { + actualEndLine = i; + break; + } + } + } + + if (foundOpenBrace && braceCount === 0) { + break; + } + } + + // Include any trailing empty lines that belong to this class + while (actualEndLine + 1 < lines.length && lines[actualEndLine + 1].trim() === "") { + actualEndLine++; + } + + // Create the range to delete (include the newline of the last line) + const rangeToDelete = new vscode.Range( + new vscode.Position(actualStartLine, 0), + new vscode.Position(actualEndLine + 1, 0) + ); + + workspaceEdit.delete(fileUri, rangeToDelete); + } catch (error) { + console.error(`Error deleting model class from file ${fileUri.fsPath}:`, error); + // Fallback: just comment out the class declaration + workspaceEdit.replace(fileUri, modelMetadata.declaration.range, `/* DELETED_MODEL: ${modelMetadata.name} */`); + } + } + + /** + * Extracts enums that are related to a model's Choice fields. + * This analyzes the model's properties and identifies any enums + * that are referenced in @Choice decorators. + * + * @param sourceDocument - The document containing the model + * @param componentModel - The model metadata to analyze + * @param classBody - The class body content (optional optimization) + * @returns Array of enum definition strings + */ + public async extractRelatedEnums( + sourceDocument: vscode.TextDocument, + componentModel: any, + classBody?: string + ): Promise { + const relatedEnums: string[] = []; + const sourceContent = sourceDocument.getText(); + + // Find all Choice fields in the component model + const choiceFields = Object.values(componentModel.properties || {}).filter((property: any) => + property.decorators?.some((decorator: any) => decorator.name === "Choice") + ); + + if (choiceFields.length === 0) { + return relatedEnums; + } + + // For each Choice field, try to find referenced enums + for (const field of choiceFields) { + const choiceDecorator = (field as any).decorators?.find((d: any) => d.name === "Choice"); + if (choiceDecorator) { + // Look for enum references in the property type declaration + const enumNames = this.extractEnumNamesFromChoiceProperty(field); + + for (const enumName of enumNames) { + // Find the enum definition in the source file + const enumDefinition = this.extractEnumDefinition(sourceContent, enumName); + if (enumDefinition && !relatedEnums.includes(enumDefinition)) { + relatedEnums.push(enumDefinition); + } + } + } + } + + return relatedEnums; + } + + /** + * Extracts enum names from a Choice field's property type. + * For Choice fields, the enum is specified in the property type declaration, not the decorator. + * Example: @Choice() status: TaskStatus = TaskStatus.Active; + */ + private extractEnumNamesFromChoiceProperty(property: any): string[] { + const enumNames: string[] = []; + + // The enum name is in the property's type field + if (property.type && typeof property.type === "string") { + // Remove array brackets if present (e.g., "TaskStatus[]" -> "TaskStatus") + const cleanType = property.type.replace(/\[\]$/, ""); + + // Check if this looks like an enum (starts with uppercase, follows enum naming conventions) + // Also exclude common TypeScript types that aren't enums + const isCommonType = ["string", "number", "boolean", "Date", "any", "object", "void"].includes(cleanType); + const enumMatch = cleanType.match(/^[A-Z][a-zA-Z0-9_]*$/); + + if (enumMatch && !isCommonType) { + enumNames.push(cleanType); + console.log(`Found potential enum "${cleanType}" in Choice field "${property.name}"`); + } + } + + return enumNames; + } + + /** + * Extracts the complete enum definition from source content. + */ + private extractEnumDefinition(sourceContent: string, enumName: string): string | null { + // Create regex to match enum definition including export keyword + const enumRegex = new RegExp(`(export\\s+)?enum\\s+${enumName}\\s*\\{[^}]*\\}`, "gs"); + + const match = enumRegex.exec(sourceContent); + if (match) { + return match[0]; + } + + return null; + } + + /** + * Adds enum definitions to the model file content. + */ + public addEnumsToFileContent(modelFileContent: string, enums: string[]): string { + if (enums.length === 0) { + return modelFileContent; + } + + const lines = modelFileContent.split("\n"); + + // Find the position to insert enums (after imports, before the model class) + let insertPosition = 0; + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith("import ")) { + insertPosition = i + 1; + } else if (lines[i].trim() === "" && insertPosition > 0) { + // Found empty line after imports + insertPosition = i; + break; + } else if (lines[i].includes("@Model") || lines[i].includes("class ")) { + // Found the start of the model definition + break; + } + } + + // Insert enums with proper spacing + const enumContent = enums.join("\n\n") + "\n\n"; + lines.splice(insertPosition, 0, enumContent); + + return lines.join("\n"); + } + + /** + * Extracts enum definitions from a document. + */ + public extractEnumDefinitions(document: vscode.TextDocument): string[] { + const content = document.getText(); + const lines = content.split("\n"); + const enumDefinitions: string[] = []; + + let currentEnum: string[] = []; + let inEnum = false; + let braceCount = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check if we're starting an enum + if (line.trim().startsWith("export enum ") || line.trim().startsWith("enum ")) { + inEnum = true; + braceCount = 0; + } + + if (inEnum) { + currentEnum.push(line); + + // Count braces + const openBraces = (line.match(/{/g) || []).length; + const closeBraces = (line.match(/}/g) || []).length; + braceCount += openBraces - closeBraces; + + // If we've closed all braces, we're done with this enum + if (braceCount === 0 && line.includes("}")) { + inEnum = false; + enumDefinitions.push(currentEnum.join("\n")); + currentEnum = []; + } + } + } + + return enumDefinitions; + } } diff --git a/src/test/addComposition.test.ts b/src/test/addComposition.test.ts new file mode 100644 index 0000000..101890f --- /dev/null +++ b/src/test/addComposition.test.ts @@ -0,0 +1,428 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { AddCompositionTool } from '../commands/models/addComposition'; +import { MetadataCache } from '../cache/cache'; + +// Only run tests if we're in a test environment (Mocha globals are available) +if (typeof suite !== 'undefined') { + suite('AddComposition Tool Tests', () => { + let testWorkspaceDir: string; + let testModelFile: string; + let mockCache: MetadataCache; + let addCompositionTool: AddCompositionTool; + + setup(async () => { + // Create a temporary workspace directory for testing + testWorkspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-addcomposition-test-')); + const testDataDir = path.join(testWorkspaceDir, 'src', 'data'); + + // Create the src/data directory structure + fs.mkdirSync(testDataDir, { recursive: true }); + + // Create a sample model file for testing + testModelFile = path.join(testDataDir, 'testModel.ts'); + const modelContent = `import { BaseModel, Field, Text, Model } from 'slingr-framework'; + +@Model() +export class TestModel extends BaseModel { + @Field() + @Text() + existingField!: string; +} +`; + fs.writeFileSync(testModelFile, modelContent); + + // Create mock cache + mockCache = { + getMetadataForFile: (filePath: string) => { + if (filePath === testModelFile) { + return { + classes: { + TestModel: { + name: 'TestModel', + decorators: [{ name: 'Model', arguments: [] }], + properties: { + existingField: { + name: 'existingField', + decorators: [ + { name: 'Field', arguments: [] }, + { name: 'Text', arguments: [] } + ], + type: 'string' + } + }, + methods: {}, + extends: 'BaseModel' + } + } + }; + } + return { classes: {} }; + }, + getDataModelClasses: () => [ + { + name: 'TestModel', + decorators: [{ name: 'Model', arguments: [] }], + properties: { + existingField: { + name: 'existingField', + decorators: [ + { name: 'Field', arguments: [] }, + { name: 'Text', arguments: [] } + ], + type: 'string' + } + }, + methods: {}, + extends: 'BaseModel', + filePath: testModelFile + } + ], + getModelPath: () => testDataDir, + isLoaded: () => true, + refresh: () => Promise.resolve(), + watchFile: () => {}, + unwatchFile: () => {}, + getModelByName: (name: string) => { + if (name === 'TestModel') { + return { + name: 'TestModel', + decorators: [{ name: 'Model', arguments: [] }], + properties: { + existingField: { + name: 'existingField', + decorators: [ + { name: 'Field', arguments: [] }, + { name: 'Text', arguments: [] } + ], + type: 'string' + } + }, + declaration: { uri: vscode.Uri.file(testModelFile) } + }; + } + return null; + }, + getModelDecoratorByName: (decoratorName: string, modelClass: any) => { + if (decoratorName === 'Model' && modelClass?.name === 'TestModel') { + return { + name: 'Model', + arguments: [] + }; + } + return null; + } + } as unknown as MetadataCache; + + const testUri = vscode.Uri.file(testModelFile); + + addCompositionTool = new AddCompositionTool(); + }); + + teardown(() => { + // Clean up temporary files + if (fs.existsSync(testWorkspaceDir)) { + fs.rmSync(testWorkspaceDir, { recursive: true }); + } + }); + + test('should convert plural field names to singular model names', () => { + const tool = new AddCompositionTool(); + + // Access the private method via type assertion for testing + const determineInfo = (tool as any).determineInnerModelInfo.bind(tool); + + // Test plural to singular conversion + assert.deepStrictEqual(determineInfo('addresses'), { + innerModelName: 'Address', + isArray: true + }); + + assert.deepStrictEqual(determineInfo('phoneNumbers'), { + innerModelName: 'PhoneNumber', + isArray: true + }); + + assert.deepStrictEqual(determineInfo('categories'), { + innerModelName: 'Category', + isArray: true + }); + + assert.deepStrictEqual(determineInfo('boxes'), { + innerModelName: 'Box', + isArray: true + }); + + // Test singular field names (should not be array) + assert.deepStrictEqual(determineInfo('profile'), { + innerModelName: 'Profile', + isArray: false + }); + }); + + test('should correctly convert camelCase to PascalCase', () => { + const tool = new AddCompositionTool(); + + // Access the private method via type assertion for testing + const toPascalCase = (tool as any).toPascalCase.bind(tool); + + assert.strictEqual(toPascalCase('address'), 'Address'); + assert.strictEqual(toPascalCase('phoneNumber'), 'PhoneNumber'); + assert.strictEqual(toPascalCase('userProfile'), 'UserProfile'); + }); + + test('should correctly convert plural to singular', () => { + const tool = new AddCompositionTool(); + + // Access the private method via type assertion for testing + const toSingular = (tool as any).toSingular.bind(tool); + + // Test various pluralization patterns + assert.strictEqual(toSingular('addresses'), 'address'); + assert.strictEqual(toSingular('boxes'), 'box'); + assert.strictEqual(toSingular('categories'), 'category'); + assert.strictEqual(toSingular('phoneNumbers'), 'phoneNumber'); + assert.strictEqual(toSingular('companies'), 'company'); + + // Test singular words (should remain unchanged) + assert.strictEqual(toSingular('profile'), 'profile'); + assert.strictEqual(toSingular('user'), 'user'); + }); + + test('should generate correct inner model code', () => { + const tool = new AddCompositionTool(); + + // Access the private method via type assertion for testing + const generateInnerModelCode = (tool as any).generateInnerModelCode.bind(tool); + + const result = generateInnerModelCode('Address', 'User'); + + const expected = `@Model() +export class Address { + + @Field() + @Relationship({ type: 'reference' }) + parent!: User; + +}`; + + assert.strictEqual(result, expected); + }); + + test('should generate correct composition field code for array', () => { + const tool = new AddCompositionTool(); + + // Access the private method via type assertion for testing + const generateCompositionFieldCode = (tool as any).generateCompositionFieldCode.bind(tool); + + const fieldInfo = { + name: 'addresses', + type: { + label: 'Relationship', + decorator: 'Relationship', + tsType: 'Address[]', + description: 'Composition relationship' + }, + required: false, + additionalConfig: { + relationshipType: 'composition', + targetModel: 'Address' + } + }; + + const result = generateCompositionFieldCode(fieldInfo, 'Address', true); + + const expected = `@Field({}) +@Relationship({ + type: 'composition' +}) +addresses!: Address[];`; + + assert.strictEqual(result, expected); + }); + + test('should generate correct composition field code for single object', () => { + const tool = new AddCompositionTool(); + + // Access the private method via type assertion for testing + const generateCompositionFieldCode = (tool as any).generateCompositionFieldCode.bind(tool); + + const fieldInfo = { + name: 'profile', + type: { + label: 'Relationship', + decorator: 'Relationship', + tsType: 'Profile', + description: 'Composition relationship' + }, + required: false, + additionalConfig: { + relationshipType: 'composition', + targetModel: 'Profile' + } + }; + + const result = generateCompositionFieldCode(fieldInfo, 'Profile', false); + + const expected = `@Field({}) +@Relationship({ + type: 'composition' +}) +profile!: Profile;`; + + assert.strictEqual(result, expected); + }); + + test('should create WorkspaceEdit for composition addition without applying', async () => { + const fieldName = 'addresses'; + + const { edit, innerModelName } = await addCompositionTool.createAddCompositionWorkspaceEdit( + mockCache, + 'TestModel', + fieldName + ); + + // Verify the edit contains the expected changes + assert.ok(edit, 'WorkspaceEdit should be created'); + assert.strictEqual(innerModelName, 'Address', 'Inner model name should be Address'); + + const testUri = vscode.Uri.file(testModelFile); + const fileEdits = edit.get(testUri); + assert.ok(fileEdits && fileEdits.length > 0, 'WorkspaceEdit should contain file edits'); + + // Apply edits manually to verify content + const originalContent = fs.readFileSync(testModelFile, 'utf8'); + let modifiedContent = originalContent; + + const sortedEdits = fileEdits.sort((a, b) => a.range.start.line - b.range.start.line || a.range.start.character - b.range.start.character); + for (let i = sortedEdits.length - 1; i >= 0; i--) { + const edit = sortedEdits[i]; + const lines = modifiedContent.split('\n'); + + if (edit.range.isEmpty) { + lines.splice(edit.range.start.line, 0, edit.newText); + } else { + lines.splice(edit.range.start.line, edit.range.end.line - edit.range.start.line + 1, edit.newText); + } + + modifiedContent = lines.join('\n'); + } + + // Verify the composition field was added + assert.ok(modifiedContent.includes('addresses'), 'Composition field name should be in the modified content'); + assert.ok(modifiedContent.includes('@Field({})'), 'Field decorator should be present'); + assert.ok(modifiedContent.includes('@Composition()'), 'Composition decorator should be present'); + assert.ok(modifiedContent.includes('addresses!: Address[]'), 'Field declaration should be present'); + + // Verify the inner model was created + assert.ok(modifiedContent.includes('class Address extends BaseModel'), 'Inner model should be created'); + assert.ok(modifiedContent.includes('@Model()'), 'Model decorator should be present on inner model'); + + // Verify original file is unchanged (since we didn't apply the edit) + const currentContent = fs.readFileSync(testModelFile, 'utf8'); + assert.strictEqual(currentContent, originalContent, 'Original file should remain unchanged'); + }); + + test('should create WorkspaceEdit for singular composition field', async () => { + const fieldName = 'profile'; // Singular field name + + const { edit, innerModelName } = await addCompositionTool.createAddCompositionWorkspaceEdit( + mockCache, + 'TestModel', + fieldName + ); + + assert.ok(edit, 'WorkspaceEdit should be created'); + assert.strictEqual(innerModelName, 'Profile', 'Inner model name should be Profile'); + + const testUri = vscode.Uri.file(testModelFile); + const fileEdits = edit.get(testUri); + assert.ok(fileEdits && fileEdits.length > 0, 'WorkspaceEdit should contain file edits'); + + // Apply edits manually to verify content + const originalContent = fs.readFileSync(testModelFile, 'utf8'); + let modifiedContent = originalContent; + + const sortedEdits = fileEdits.sort((a, b) => a.range.start.line - b.range.start.line || a.range.start.character - b.range.start.character); + for (let i = sortedEdits.length - 1; i >= 0; i--) { + const edit = sortedEdits[i]; + const lines = modifiedContent.split('\n'); + + if (edit.range.isEmpty) { + lines.splice(edit.range.start.line, 0, edit.newText); + } else { + lines.splice(edit.range.start.line, edit.range.end.line - edit.range.start.line + 1, edit.newText); + } + + modifiedContent = lines.join('\n'); + } + + // Verify the composition field was added (should be singular, not array) + assert.ok(modifiedContent.includes('profile!: Profile;'), 'Singular field declaration should be present'); + assert.ok(!modifiedContent.includes('profile!: Profile[]'), 'Should not be array for singular field'); + + // Verify the inner model was created + assert.ok(modifiedContent.includes('class Profile extends BaseModel'), 'Inner model should be created'); + }); + + test('should throw error when composition field already exists', async () => { + const fieldName = 'existingField'; // This field already exists in the test model + + try { + await addCompositionTool.createAddCompositionWorkspaceEdit( + mockCache, + 'TestModel', + fieldName + ); + assert.fail('Should have thrown an error for existing field'); + } catch (error) { + assert.ok(error instanceof Error, 'Should throw an Error'); + assert.ok(error.message.includes('already exists'), 'Error message should mention field already exists'); + } + }); + + test('should throw error when inner model already exists', async () => { + // First add a mock model with the name that would be generated + const originalGetModelByName = mockCache.getModelByName; + mockCache.getModelByName = (name: string) => { + if (name === 'TestModel') { + return originalGetModelByName('TestModel'); + } + if (name === 'Address') { + return { + name: 'Address', + decorators: [{ + name: 'Model', + arguments: [], + position: new vscode.Range(0, 0, 0, 0) + }], + properties: {}, + methods: {}, + references: [], + declaration: { uri: vscode.Uri.file(testModelFile), range: new vscode.Range(0, 0, 0, 0) }, + isDataModel: true + } as any; // Use type assertion to avoid complex mock setup + } + return null; + }; + + try { + await addCompositionTool.createAddCompositionWorkspaceEdit( + mockCache, + 'TestModel', + 'addresses' // This would generate 'Address' model which we mocked as existing + ); + assert.fail('Should have thrown an error for existing inner model'); + } catch (error) { + assert.ok(error instanceof Error, 'Should throw an Error'); + assert.ok(error.message.includes('already exists'), 'Error message should mention model already exists'); + } finally { + // Restore original method + mockCache.getModelByName = originalGetModelByName; + } + }); + }); +} diff --git a/src/test/addField.test.ts b/src/test/addField.test.ts index 65c3c6d..478331b 100644 --- a/src/test/addField.test.ts +++ b/src/test/addField.test.ts @@ -14,6 +14,7 @@ if (typeof suite !== 'undefined') { let testModelFile: string; let mockCache: MetadataCache; let addFieldTool: AddFieldTool; + const modelName = 'TestModel'; setup(async () => { // Create a temporary workspace directory for testing @@ -68,7 +69,16 @@ export class TestModel extends BaseModel { { name: 'TestModel', decorators: [{ name: 'Model', arguments: [] }], - properties: {}, + properties: { + existingField: { + name: 'existingField', + type: 'string', + decorators: [ + { name: 'Field', arguments: [] }, + { name: 'Text', arguments: [] } + ] + } + }, declaration: { uri: vscode.Uri.file(testModelFile) } }, { @@ -77,7 +87,35 @@ export class TestModel extends BaseModel { properties: {}, declaration: { uri: vscode.Uri.file(path.join(testDataDir, 'relatedModel.ts')) } } - ] + ], + getModelByName: (name: string) => { + if (name === 'TestModel') { + return { + name: 'TestModel', + decorators: [{ name: 'Model', arguments: [] }], + properties: { + existingField: { + name: 'existingField', + type: 'string', + decorators: [ + { name: 'Field', arguments: [] }, + { name: 'Text', arguments: [] } + ] + } + }, + declaration: { uri: vscode.Uri.file(testModelFile) } + }; + } + if (name === 'RelatedModel') { + return { + name: 'RelatedModel', + decorators: [{ name: 'Model', arguments: [] }], + properties: {}, + declaration: { uri: vscode.Uri.file(path.join(testDataDir, 'relatedModel.ts')) } + }; + } + return null; + } } as any; addFieldTool = new AddFieldTool(); @@ -163,7 +201,7 @@ export class TestModel extends BaseModel { // Test field creation const modelUri = vscode.Uri.file(testModelFile); - await addFieldTool.addField(modelUri, mockCache); + await addFieldTool.addField(modelUri,modelName, mockCache); // Verify the mock functions were called assert.ok(inputCallCount >= 1, 'Input box should be called for field name'); @@ -229,7 +267,7 @@ export class TestModel extends BaseModel { }; const modelUri = vscode.Uri.file(testModelFile); - await addFieldTool.addField(modelUri, mockCache); + await addFieldTool.addField(modelUri,modelName, mockCache); // Verify relationship-specific interactions const relationshipTypeCall = quickPickCalls.find(call => @@ -297,7 +335,7 @@ export class TestModel extends BaseModel { }; const modelUri = vscode.Uri.file(testModelFile); - await addFieldTool.addField(modelUri, mockCache); + await addFieldTool.addField(modelUri,modelName, mockCache); // Verify enum values were requested const enumValuesCall = inputBoxCalls.find(call => @@ -364,7 +402,7 @@ export class TestModel extends BaseModel { (addFieldTool as any).defineFieldsTool = mockDefineFieldsTool; const modelUri = vscode.Uri.file(testModelFile); - await addFieldTool.addField(modelUri, mockCache); + await addFieldTool.addField(modelUri,modelName, mockCache); // Verify AI prompt was created correctly assert.ok(capturedPrompt, 'AI enhancement prompt should be created'); @@ -393,7 +431,7 @@ export class TestModel extends BaseModel { const invalidUri = vscode.Uri.file(path.join(testWorkspaceDir, 'invalid.txt')); fs.writeFileSync(invalidUri.fsPath, 'not a typescript file'); - await addFieldTool.addField(invalidUri, mockCache); + await addFieldTool.addField(invalidUri,modelName, mockCache); assert.ok(errorMessages.length > 0, 'Should show error message for invalid file'); assert.ok(errorMessages.some(msg => msg.includes('Failed to add field')), 'Should show appropriate error message'); @@ -428,7 +466,7 @@ export class TestModel extends BaseModel { // Should handle cancellation gracefully without throwing errors await assert.doesNotReject(async () => { - await addFieldTool.addField(modelUri, mockCache); + await addFieldTool.addField(modelUri,modelName, mockCache); }, 'Should handle user cancellation gracefully'); } finally { @@ -440,5 +478,121 @@ export class TestModel extends BaseModel { vscode.window.showQuickPick = originalShowQuickPick; } }); + + test('should create WorkspaceEdit for Text field addition without applying', async () => { + const fieldInfo = { + name: 'newTextField', + type: FIELD_TYPE_OPTIONS.find(t => t.decorator === 'Text')!, + required: true + }; + + const targetUri = vscode.Uri.file(testModelFile); + const edit = await addFieldTool.createAddFieldWorkspaceEdit(targetUri, fieldInfo, modelName, mockCache); + + // Verify the edit contains the expected changes + assert.ok(edit, 'WorkspaceEdit should be created'); + + const fileEdits = edit.get(targetUri); + assert.ok(fileEdits && fileEdits.length > 0, 'WorkspaceEdit should contain file edits'); + + // Convert edits to string to verify content + const originalContent = fs.readFileSync(testModelFile, 'utf8'); + let modifiedContent = originalContent; + + // Apply edits manually to verify content + const sortedEdits = fileEdits.sort((a, b) => a.range.start.line - b.range.start.line || a.range.start.character - b.range.start.character); + for (let i = sortedEdits.length - 1; i >= 0; i--) { + const edit = sortedEdits[i]; + const lines = modifiedContent.split('\n'); + + if (edit.range.isEmpty) { + // Insert operation + lines.splice(edit.range.start.line, 0, edit.newText); + } else { + // Replace operation + lines.splice(edit.range.start.line, edit.range.end.line - edit.range.start.line + 1, edit.newText); + } + + modifiedContent = lines.join('\n'); + } + + // Verify the field was added + assert.ok(modifiedContent.includes('newTextField'), 'Field name should be in the modified content'); + assert.ok(modifiedContent.includes('@Field({'), 'Field decorator should be present'); + assert.ok(modifiedContent.includes('required: true'), 'Required property should be set'); + assert.ok(modifiedContent.includes('@Text()'), 'Text decorator should be present'); + + // Verify original file is unchanged (since we didn't apply the edit) + const currentContent = fs.readFileSync(testModelFile, 'utf8'); + assert.strictEqual(currentContent, originalContent, 'Original file should remain unchanged'); + }); + + test('should create WorkspaceEdit for Choice field with enum', async () => { + const fieldInfo = { + name: 'statusField', + type: FIELD_TYPE_OPTIONS.find(t => t.decorator === 'Choice')!, + required: false + }; + + const enumValues = ['Active', 'Inactive', 'Pending']; + const targetUri = vscode.Uri.file(testModelFile); + const edit = await addFieldTool.createAddFieldWorkspaceEdit(targetUri, fieldInfo, modelName, mockCache, enumValues); + + // Verify the edit contains the expected changes + assert.ok(edit, 'WorkspaceEdit should be created'); + + const fileEdits = edit.get(targetUri); + assert.ok(fileEdits && fileEdits.length > 0, 'WorkspaceEdit should contain file edits'); + + // Apply edits manually to verify content + const originalContent = fs.readFileSync(testModelFile, 'utf8'); + let modifiedContent = originalContent; + + const sortedEdits = fileEdits.sort((a, b) => a.range.start.line - b.range.start.line || a.range.start.character - b.range.start.character); + for (let i = sortedEdits.length - 1; i >= 0; i--) { + const edit = sortedEdits[i]; + const lines = modifiedContent.split('\n'); + + if (edit.range.isEmpty) { + lines.splice(edit.range.start.line, 0, edit.newText); + } else { + lines.splice(edit.range.start.line, edit.range.end.line - edit.range.start.line + 1, edit.newText); + } + + modifiedContent = lines.join('\n'); + } + + // Verify the field was added + assert.ok(modifiedContent.includes('statusField'), 'Field name should be in the modified content'); + assert.ok(modifiedContent.includes('@Choice()'), 'Choice decorator should be present'); + + // Verify enum was created - statusField should generate StatusField enum name + assert.ok(modifiedContent.includes('export enum StatusField {'), 'Enum should be created'); + assert.ok(modifiedContent.includes("Active = 'active'"), 'Enum values should be present'); + assert.ok(modifiedContent.includes("Inactive = 'inactive'"), 'Enum values should be present'); + assert.ok(modifiedContent.includes("Pending = 'pending'"), 'Enum values should be present'); + + // Verify original file is unchanged + const currentContent = fs.readFileSync(testModelFile, 'utf8'); + assert.strictEqual(currentContent, originalContent, 'Original file should remain unchanged'); + }); + + test('should throw error when field already exists', async () => { + const fieldInfo = { + name: 'existingField', // This field already exists in the test model + type: FIELD_TYPE_OPTIONS.find(t => t.decorator === 'Text')!, + required: false + }; + + const targetUri = vscode.Uri.file(testModelFile); + + try { + await addFieldTool.createAddFieldWorkspaceEdit(targetUri, fieldInfo, modelName, mockCache); + assert.fail('Should have thrown an error for existing field'); + } catch (error) { + assert.ok(error instanceof Error, 'Should throw an Error'); + assert.ok(error.message.includes('already exists'), 'Error message should mention field already exists'); + } + }); }); } diff --git a/src/test/addReference.test.ts b/src/test/addReference.test.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts deleted file mode 100644 index c712201..0000000 --- a/src/test/extension.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as assert from 'assert'; - -// You can import and use all API from the 'vscode' module -// as well as import your extension to test it -import * as vscode from 'vscode'; -// import * as myExtension from '../../extension'; - -// Only run tests if we're in a test environment (Mocha globals are available) -if (typeof suite !== 'undefined') { - suite('Extension Test Suite', () => { - vscode.window.showInformationMessage('Start all tests.'); - - test('Sample test', () => { - assert.strictEqual(-1, [1, 2, 3].indexOf(5)); - assert.strictEqual(-1, [1, 2, 3].indexOf(0)); - }); - }); -} diff --git a/src/test/newModel.test.ts b/src/test/newModel.test.ts index c25bdde..c2699ee 100644 --- a/src/test/newModel.test.ts +++ b/src/test/newModel.test.ts @@ -481,28 +481,6 @@ export class ParentModel extends BaseModel { } }); - test('Should test AI enhancement functionality', async () => { - const targetUri = vscode.Uri.file(testDataDir); - const userInput = "Create a User model with name, email, and authentication fields"; - - // Mock the createNewModel method to track if it was called - let createNewModelCalled = false; - const originalCreateNewModel = newModelTool.createNewModel; - newModelTool.createNewModel = async (uri: any, cache?: any) => { - createNewModelCalled = true; - return Promise.resolve(); - }; - - try { - await newModelTool.processWithAI(userInput, targetUri, mockCache); - - assert.ok(createNewModelCalled, 'createNewModel should be called by processWithAI'); - - } finally { - newModelTool.createNewModel = originalCreateNewModel; - } - }); - test('Should generate correct model content', () => { // Test the private generateModelContent method by creating a new instance // and calling createNewModel with mocked inputs diff --git a/src/test/refactor/changeCompositionToReference.test.ts b/src/test/refactor/changeCompositionToReference.test.ts new file mode 100644 index 0000000..e4d3513 --- /dev/null +++ b/src/test/refactor/changeCompositionToReference.test.ts @@ -0,0 +1,280 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { ChangeCompositionToReferenceTool } from '../../commands/fields/changeCompositionToReference'; +import { ChangeCompositionToReferenceRefactorTool } from '../../refactor/tools/changeCompositionToReference'; +import { PropertyMetadata, DecoratorMetadata } from '../../cache/cache'; + +// Only run tests if we're in a test environment (Mocha globals are available) +if (typeof suite !== 'undefined') { + suite('ChangeCompositionToReference Tool Tests', () => { + let changeCompositionToReferenceTool: ChangeCompositionToReferenceTool; + let mockExplorerProvider: any; + + setup(() => { + mockExplorerProvider = { + refresh: () => {} + }; + changeCompositionToReferenceTool = new ChangeCompositionToReferenceTool(); + }); + + const createMockPropertyMetadata = (name: string, type: string, decoratorNames: string[]): PropertyMetadata => { + const decorators: DecoratorMetadata[] = decoratorNames.map(decoratorName => ({ + name: decoratorName, + arguments: [], + position: new vscode.Range(10, 0, 10, 10) + })); + + return { + name, + type, + decorators, + references: [], + declaration: { + uri: vscode.Uri.file('/src/data/model.ts'), // Use correct model file path + range: new vscode.Range(10, 0, 10, 10) + } + }; + }; + + suite('Tool Instantiation', () => { + test('should create ChangeCompositionToReferenceTool instance successfully', () => { + assert.ok(changeCompositionToReferenceTool); + assert.ok(changeCompositionToReferenceTool instanceof ChangeCompositionToReferenceTool); + }); + + test('should create ChangeCompositionToReferenceRefactorTool instance successfully', () => { + const refactorTool = new ChangeCompositionToReferenceRefactorTool(); + assert.ok(refactorTool); + assert.equal(refactorTool.getCommandId(), 'slingr-vscode-extension.changeCompositionToReference'); + assert.equal(refactorTool.getTitle(), 'Change Composition to Reference'); + assert.deepStrictEqual(refactorTool.getHandledChangeTypes(), ['CHANGE_COMPOSITION_TO_REFERENCE']); + }); + }); + + suite('Refactor Tool Capability Check', () => { + test('should correctly identify component models for manual trigger', async () => { + const refactorTool = new ChangeCompositionToReferenceRefactorTool(); + + // Mock cache with parent model that has composition field pointing to component model + const mockCache = { + getDataModelClasses: () => [ + { + name: 'ParentModel', + properties: { + 'componentField': { + name: 'componentField', + type: 'ComponentModel', + decorators: [ + { name: 'Field', arguments: [], position: new vscode.Range(5, 0, 5, 10) }, + { name: 'Composition', arguments: [], position: new vscode.Range(6, 0, 6, 15) } + ], + references: [], + declaration: { + uri: vscode.Uri.file('/src/data/parent.ts'), + range: new vscode.Range(7, 0, 7, 20) + } + } + } + } + ] + }; + + // Mock context with component model metadata (since composition fields show as models in explorer) + const mockContext = { + uri: vscode.Uri.file('/src/data/parent.ts'), + range: new vscode.Range(10, 0, 10, 10), + cache: mockCache as any, + metadata: { + name: 'ComponentModel', + decorators: [ + { name: 'Model', arguments: [], position: new vscode.Range(1, 0, 1, 7) } + ], + properties: {}, + methods: {}, + references: [], + declaration: { + uri: vscode.Uri.file('/src/data/parent.ts'), + range: new vscode.Range(10, 0, 15, 1) + }, + isDataModel: true + } + }; + + const canHandle = await refactorTool.canHandleManualTrigger(mockContext); + assert.strictEqual(canHandle, true); + }); + + test('should reject models that are not component models', async () => { + const refactorTool = new ChangeCompositionToReferenceRefactorTool(); + + // Mock cache with no composition relationships + const mockCache = { + getDataModelClasses: () => [ + { + name: 'IndependentModel', + properties: { + 'normalField': { + name: 'normalField', + type: 'string', + decorators: [ + { name: 'Field', arguments: [], position: new vscode.Range(5, 0, 5, 10) }, + { name: 'Text', arguments: [], position: new vscode.Range(6, 0, 6, 15) } + ], + references: [], + declaration: { + uri: vscode.Uri.file('/src/data/independent.ts'), + range: new vscode.Range(7, 0, 7, 20) + } + } + } + } + ] + }; + + // Mock context with independent model metadata + const mockContext = { + uri: vscode.Uri.file('/src/data/independent.ts'), + range: new vscode.Range(10, 0, 10, 10), + cache: mockCache as any, + metadata: { + name: 'IndependentModel', + decorators: [ + { name: 'Model', arguments: [], position: new vscode.Range(1, 0, 1, 7) } + ], + properties: {}, + methods: {}, + references: [], + declaration: { + uri: vscode.Uri.file('/src/data/independent.ts'), + range: new vscode.Range(10, 0, 15, 1) + }, + isDataModel: true + } + }; + + const canHandle = await refactorTool.canHandleManualTrigger(mockContext); + assert.strictEqual(canHandle, false); + }); + + test('should reject fields without decorators for manual trigger', async () => { + const refactorTool = new ChangeCompositionToReferenceRefactorTool(); + + // Create a proper mock cache with the required methods + const mockCache = { + getDataModelClasses: () => [], + getMetadataByPositionAndType: () => null + }; + + const mockContext = { + uri: vscode.Uri.file('/src/data/model.ts'), // Use correct model file path + range: new vscode.Range(10, 0, 10, 10), + cache: mockCache as any, + metadata: createMockPropertyMetadata('testField', 'string', []) + }; + + const canHandle = await refactorTool.canHandleManualTrigger(mockContext); + assert.strictEqual(canHandle, false); + }); + }); + + suite('Tool Configuration', () => { + test('should have correct command registration details', () => { + const refactorTool = new ChangeCompositionToReferenceRefactorTool(); + + // Verify command ID matches expected pattern + assert.strictEqual(refactorTool.getCommandId(), 'slingr-vscode-extension.changeCompositionToReference'); + + // Verify title is descriptive + assert.strictEqual(refactorTool.getTitle(), 'Change Composition to Reference'); + + // Verify it handles the correct change type + const handledTypes = refactorTool.getHandledChangeTypes(); + assert.strictEqual(handledTypes.length, 1); + assert.strictEqual(handledTypes[0], 'CHANGE_COMPOSITION_TO_REFERENCE'); + }); + + test('should return empty array for automatic analysis', () => { + const refactorTool = new ChangeCompositionToReferenceRefactorTool(); + const changes = refactorTool.analyze(); + assert.strictEqual(Array.isArray(changes), true); + assert.strictEqual(changes.length, 0); + }); + }); + + suite('Tool Integration Tests', () => { + test('should properly convert PropertyMetadata to FieldInfo', () => { + // Create a mock composition field metadata + const mockProperty = createMockPropertyMetadata('address', 'Address', ['Composition', 'Field']); + + // Test the convertPropertyToFieldInfo method would work + // Note: This is an indirect test since the method is private + // We test the type mapping logic that would be used + + const expectedFieldTypes = ['Text', 'LongText', 'Email', 'Html', 'Integer', 'Money', 'Date', 'DateRange', 'Boolean', 'Choice', 'Relationship']; + assert.ok(expectedFieldTypes.length > 0); + + // Test that a Composition decorator would be recognized + const hasCompositionDecorator = mockProperty.decorators.some(d => d.name === 'Composition'); + assert.strictEqual(hasCompositionDecorator, true); + }); + + test('should have valid field type options available', () => { + // Import the FIELD_TYPE_OPTIONS to test they're available + const { FIELD_TYPE_OPTIONS } = require('../../commands/interfaces'); + + assert.ok(FIELD_TYPE_OPTIONS); + assert.ok(Array.isArray(FIELD_TYPE_OPTIONS)); + assert.ok(FIELD_TYPE_OPTIONS.length > 0); + + // Check that required field types exist + const textOption = FIELD_TYPE_OPTIONS.find((opt: any) => opt.decorator === 'Text'); + assert.ok(textOption); + assert.strictEqual(textOption.tsType, 'string'); + }); + }); + + suite('Error Handling', () => { + test('should handle invalid metadata gracefully', async () => { + const refactorTool = new ChangeCompositionToReferenceRefactorTool(); + + // Create a proper mock cache with the required methods + const mockCache = { + getDataModelClasses: () => [], + getMetadataByPositionAndType: () => null + }; + + // Mock context with invalid metadata + const mockContext = { + uri: vscode.Uri.file('/src/data/model.ts'), // Use correct model file path + range: new vscode.Range(10, 0, 10, 10), + cache: mockCache as any, + metadata: undefined + }; + + const canHandle = await refactorTool.canHandleManualTrigger(mockContext); + assert.strictEqual(canHandle, false); + }); + + test('should handle non-model files gracefully', async () => { + const refactorTool = new ChangeCompositionToReferenceRefactorTool(); + + // Create a proper mock cache with the required methods + const mockCache = { + getDataModelClasses: () => [], + getMetadataByPositionAndType: () => null + }; + + // Mock context with non-model file + const mockContext = { + uri: vscode.Uri.file('/test/script.js'), // Not a TypeScript model file + range: new vscode.Range(10, 0, 10, 10), + cache: mockCache as any, + metadata: createMockPropertyMetadata('testField', 'TestModel', ['Composition']) + }; + + const canHandle = await refactorTool.canHandleManualTrigger(mockContext); + assert.strictEqual(canHandle, false); + }); + }); + }); +} diff --git a/src/test/refactor/deleteModel.test.ts b/src/test/refactor/deleteModel.test.ts index d05c2a7..62b46f8 100644 --- a/src/test/refactor/deleteModel.test.ts +++ b/src/test/refactor/deleteModel.test.ts @@ -582,7 +582,7 @@ if (typeof suite !== 'undefined') { const mockFileContent = `import { Model, Field } from '@slingr/platform'; @Model() -export class User extends PersistentModel { +export class User extends BaseModel { @Field() name: string; @@ -591,7 +591,7 @@ export class User extends PersistentModel { } @Model() -export class Order extends PersistentModel { +export class Order extends BaseModel { @Field() orderNumber: string; }`;