diff --git a/README.md b/README.md index 54e25b3..c6c7830 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ This extension contributes the following settings: "phpNamespaceRefactor.additionalExtensions": [ "php" ], + "phpNamespaceRefactor.rename": true } ``` @@ -73,6 +74,14 @@ This extension contributes the following settings: - Default: "php". +**phpNamespaceRefactor.rename** + +- Can be triggered by pressing F2 or the preferred rename shortcut. +- The feature can be enabled or disabled in the settings. + +- Default: true. + + ## Release notes See [./CHANGELOG.md](./CHANGELOG.md) diff --git a/package.json b/package.json index 3898f8b..c8e8ca8 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,20 @@ ], "main": "./dist/extension.js", "contributes": { + "commands": [ + { + "command": "phpNamespaceRefactor.rename", + "title": "Rename Namespace", + "category": "PHP Namespace Refactor" + } + ], + "keybindings": [ + { + "command": "phpNamespaceRefactor.rename", + "key": "f2", + "when": "editorTextFocus && resourceExtname == .php" + } + ], "configuration": { "languages": [ { @@ -84,6 +98,11 @@ "php" ], "description": "List of additional file extensions to consider for namespace refactoring." + }, + "phpNamespaceRefactor.rename": { + "type": "boolean", + "default": true, + "description": "Enable or disable the Rename Namespace or Class command." } } } diff --git a/src/app/events/FileRenameHandler.ts b/src/app/commands/FileRenameHandler.ts similarity index 56% rename from src/app/events/FileRenameHandler.ts rename to src/app/commands/FileRenameHandler.ts index 317b6c1..df309e6 100644 --- a/src/app/events/FileRenameHandler.ts +++ b/src/app/commands/FileRenameHandler.ts @@ -1,13 +1,24 @@ +import { FileRenameEvent, Uri, workspace, WorkspaceEdit } from 'vscode'; import { inject, injectable } from 'tsyringe'; -import { FileRenameEvent } from 'vscode'; import { FileRenameFeature } from '@app/features/FileRenameFeature'; +interface Props { + oldUri: Uri + newUri: Uri +} + @injectable() export class FileRenameHandler { constructor( @inject(FileRenameFeature) private fileRenameFeature: FileRenameFeature, ) {} + public static create({ oldUri, newUri }: Props): void { + const edit = new WorkspaceEdit(); + edit.renameFile(oldUri, newUri); + workspace.applyEdit(edit); + } + public async handle(event: FileRenameEvent) { await this.fileRenameFeature.execute(event.files); } diff --git a/src/app/commands/RenameHandler.ts b/src/app/commands/RenameHandler.ts new file mode 100644 index 0000000..4486c68 --- /dev/null +++ b/src/app/commands/RenameHandler.ts @@ -0,0 +1,31 @@ +import { inject, injectable } from 'tsyringe'; +import { PHP_EXTENSION } from '@infra/utils/constants'; +import { RenameFeature } from '@app/features/RenameFeature'; +import { TextEditor } from 'vscode'; + +interface Props { + activeEditor?: TextEditor; +} + +@injectable() +export class RenameHandler { + constructor( + @inject(RenameFeature) private readonly renameFeature: RenameFeature + ) {} + + public async handle({ activeEditor }: Props) { + if (!activeEditor) { + return; + } + + const document = activeEditor.document; + if (!document.fileName.endsWith(PHP_EXTENSION)) { + return; + } + + await this.renameFeature.execute({ + document, + position: activeEditor.selection.active, + }); + } +} diff --git a/src/app/features/RenameFeature.ts b/src/app/features/RenameFeature.ts new file mode 100644 index 0000000..43a8911 --- /dev/null +++ b/src/app/features/RenameFeature.ts @@ -0,0 +1,40 @@ +import { inject, injectable } from 'tsyringe'; +import { Position, TextDocument, Uri, window } from 'vscode'; +import { ExtractNameFromCursor } from '@app/services/rename/ExctratNameFromCursor'; +import { FileRenameResolver } from '@app/services/rename/FileRenameResolver'; +import { RenameValidator } from '@app/services/rename/RenameValidator'; + +interface Props { + document: TextDocument + position: Position +} + +@injectable() +export class RenameFeature { + constructor( + @inject(ExtractNameFromCursor) private extractNameFromCursor: ExtractNameFromCursor, + @inject(RenameValidator) private renameValidator: RenameValidator, + @inject(FileRenameResolver) private fileRenameResolver: FileRenameResolver, + ) { + } + + public async execute({ document, position }: Props): Promise { + const value = await this.extractNameFromCursor.execute({ document, position }); + if (!value) { + return; + } + + const newName = await window.showInputBox({ + value, + title: '', + prompt: '', + validateInput: (value: string) => this.renameValidator.validate(value) + }); + + if (!newName || newName.trim() === '') { + return; + } + + this.fileRenameResolver.execute({ document, position, newName }); + } +} diff --git a/src/app/services/rename/ExctratNameFromCursor.ts b/src/app/services/rename/ExctratNameFromCursor.ts new file mode 100644 index 0000000..7bcd588 --- /dev/null +++ b/src/app/services/rename/ExctratNameFromCursor.ts @@ -0,0 +1,35 @@ +import { Position, TextDocument } from 'vscode'; +import { injectable } from 'tsyringe'; +import { PHP_CLASS_DECLARATION_REGEX } from '@app/services/update/ClassNameUpdater'; + +interface Props { + document: TextDocument + position: Position +} + +const NAMESPACE_REGEX = /^\s*namespace\s+([\w\\]+);/; + +@injectable() +export class ExtractNameFromCursor { + public async execute({ document, position }: Props): Promise { + const text = document.getText(); + const lines = text.split('\n'); + + const currentLine = lines[position.line] ?? null; + if (null === currentLine) { + return null; + } + + const namespaceMatch = currentLine.match(NAMESPACE_REGEX); + if (namespaceMatch) { + return namespaceMatch[1] ?? null; + } + + const classMatch = currentLine.match(PHP_CLASS_DECLARATION_REGEX); + if (classMatch) { + return classMatch[1] ?? null; + } + + return null; + } +} diff --git a/src/app/services/rename/FileRenameResolver.ts b/src/app/services/rename/FileRenameResolver.ts new file mode 100644 index 0000000..c9363f0 --- /dev/null +++ b/src/app/services/rename/FileRenameResolver.ts @@ -0,0 +1,54 @@ +import { inject, injectable } from 'tsyringe'; +import { Position, TextDocument, Uri, window } from 'vscode'; +import { FileRenameHandler } from '@app/commands/FileRenameHandler'; +import { RenameTypeDetector } from './RenameTypeDetector'; +import { WorkspacePathResolver } from '@domain/workspace/WorkspacePathResolver'; + +interface Props { + document: TextDocument + position: Position + newName: string +} + +@injectable() +export class FileRenameResolver { + constructor( + @inject(WorkspacePathResolver) private workspacePathResolver: WorkspacePathResolver, + @inject(RenameTypeDetector) private renameTypeDetector: RenameTypeDetector, + ) { + } + + public async execute({ document, position, newName }: Props): Promise { + if (!newName.length) { + throw new Error('New name cannot be empty'); + } + + const type = this.renameTypeDetector.execute({ document, position }); + + const currentUri = document.uri; + const oldPath = currentUri.fsPath; + const fileName = this.workspacePathResolver.extractClassNameFromPath(oldPath); + + if (type === 'namespace') { + try { + const newDirectoryPath = await this.workspacePathResolver.getDirectoryFromNamespace(newName); + + FileRenameHandler.create({ + oldUri: currentUri, + newUri: Uri.file(`${newDirectoryPath}/${fileName}.php`), + }); + return; + } catch (error) { + window.showErrorMessage(`Error renaming namespace: ${error}`); + } + } + + const directory = oldPath.substring(0, oldPath.lastIndexOf('/')); + const extension = oldPath.substring(oldPath.lastIndexOf('.')); + + FileRenameHandler.create({ + oldUri: currentUri, + newUri: Uri.file(`${directory}/${fileName}${extension}`), + }); + } +} diff --git a/src/app/services/rename/RenameTypeDetector.ts b/src/app/services/rename/RenameTypeDetector.ts new file mode 100644 index 0000000..e19a21e --- /dev/null +++ b/src/app/services/rename/RenameTypeDetector.ts @@ -0,0 +1,28 @@ +import { Position, TextDocument } from 'vscode'; +import { injectable } from 'tsyringe'; + +interface Props { + document: TextDocument + position: Position +} + +type TypeRename = 'namespace' | 'class'; + +@injectable() +export class RenameTypeDetector { + public execute({ document, position }: Props): TypeRename { + const text = document.getText(); + const lines = text.split('\n'); + const currentLine = lines[position.line] ?? ''; + + if (currentLine.match(/^\s*namespace\s+/)) { + return 'namespace'; + } + + if (currentLine.match(/^\s*(?:abstract\s+)?(?:final\s+)?(?:class|interface|trait)\s+/)) { + return 'class'; + } + + throw new Error('Type rename not identified'); + } +} diff --git a/src/app/services/rename/RenameValidator.ts b/src/app/services/rename/RenameValidator.ts new file mode 100644 index 0000000..385434b --- /dev/null +++ b/src/app/services/rename/RenameValidator.ts @@ -0,0 +1,34 @@ +import { injectable } from 'tsyringe'; + +const NAMESPACE_REGEX = /^[A-Za-z_][A-Za-z0-9_]*(\\[A-Za-z_][A-Za-z0-9_]*)*$/; + +@injectable() +export class RenameValidator { + public validate(value: string): string | null { + if (!value || value.trim() === '') { + return 'Name cannot be empty'; + } + + if (value.startsWith('\\')) { + return 'Cannot start with \\'; + } + + if (value.endsWith('\\')) { + return 'Cannot end with \\'; + } + + if (value.includes('\\\\')) { + return 'Cannot contain double \\\\'; + } + + if (!/^[A-Za-z]/.test(value)) { + return 'Must start with a letter'; + } + + if (!NAMESPACE_REGEX.test(value)) { + return 'Invalid format. Use: Foo\\Bar'; + } + + return null; + } +} diff --git a/src/app/services/update/ClassNameUpdater.ts b/src/app/services/update/ClassNameUpdater.ts index 6ca9b2c..650fab9 100644 --- a/src/app/services/update/ClassNameUpdater.ts +++ b/src/app/services/update/ClassNameUpdater.ts @@ -3,7 +3,7 @@ import { Range, Uri, workspace, WorkspaceEdit } from "vscode"; import { TextDocumentOpener } from "../TextDocumentOpener"; import { WorkspacePathResolver } from '@domain/workspace/WorkspacePathResolver'; -const CLASS_REGEX = /^\s*(?:abstract\s+)?(?:final\s+)?(?:class|interface|trait)\s+(\w+)/m; +export const PHP_CLASS_DECLARATION_REGEX = /^\s*(?:abstract\s+)?(?:final\s+)?(?:class|interface|trait)\s+(\w+)/m; interface Props { newUri: Uri, @@ -19,7 +19,7 @@ export class ClassNameUpdater { public async execute({ newUri }: Props): Promise { const { document, text } = await this.textDocumentOpener.execute({ uri: newUri }); - const match = CLASS_REGEX.exec(text); + const match = PHP_CLASS_DECLARATION_REGEX.exec(text); if (!match) { return; } diff --git a/src/domain/workspace/ConfigurationLocator.ts b/src/domain/workspace/ConfigurationLocator.ts index 0ac011c..9e706e2 100644 --- a/src/domain/workspace/ConfigurationLocator.ts +++ b/src/domain/workspace/ConfigurationLocator.ts @@ -1,11 +1,14 @@ import { workspace, WorkspaceConfiguration } from 'vscode'; import { injectable } from "tsyringe"; +export const Config = 'phpNamespaceRefactor'; + export const ConfigKeys = { AUTO_IMPORT_NAMESPACE: 'autoImportNamespace', REMOVE_UNUSED_IMPORTS: 'removeUnusedImports', IGNORED_DIRECTORIES: 'ignoredDirectories', ADDITIONAL_EXTENSIONS: 'additionalExtensions', + RENAME: 'rename', } as const; export type Props = { @@ -15,11 +18,11 @@ export type Props = { @injectable() export class ConfigurationLocator { - private config: WorkspaceConfiguration; + private config: WorkspaceConfiguration; - constructor() { - this.config = workspace.getConfiguration('phpNamespaceRefactor'); - } + constructor() { + this.config = workspace.getConfiguration(Config); + } public get({ key, defaultValue }: Props): T { return this.config.get(key, defaultValue as T); diff --git a/src/domain/workspace/WorkspacePathResolver.ts b/src/domain/workspace/WorkspacePathResolver.ts index 2f2f014..9e09712 100644 --- a/src/domain/workspace/WorkspacePathResolver.ts +++ b/src/domain/workspace/WorkspacePathResolver.ts @@ -1,14 +1,21 @@ +import { BACKSLASH_RE, TRAILING_BACKSLASHES_RE } from '@infra/autoload/AutoloadPathResolver'; import { basename, dirname } from 'path'; -import { injectable } from "tsyringe"; -import { workspace } from 'vscode'; +import { inject, injectable } from "tsyringe"; +import { ComposerAutoloadManager } from '@infra/autoload/ComposerAutoloadManager'; +import { WORKSPACE_ROOT_PATH } from '@infra/utils/constants'; type AbsolutePath = string | null | undefined @injectable() export class WorkspacePathResolver { + constructor( + @inject(ComposerAutoloadManager) private composerAutoloadManager: ComposerAutoloadManager, + ) { + } + public removeWorkspaceRoot(filePath: AbsolutePath) { return filePath - ?.replace(this.getRootPath(), '') + ?.replace(WORKSPACE_ROOT_PATH, '') .replace(/^\/|\\/g, '') || ''; } @@ -20,9 +27,37 @@ export class WorkspacePathResolver { return basename(filePath || '', '.php') || ''; } - public getRootPath() { - return workspace.workspaceFolders - ? workspace.workspaceFolders[0].uri.fsPath - : ''; + public async getDirectoryFromNamespace(namespace: string): Promise { + const { autoload, autoloadDev } = await this.composerAutoloadManager.execute(); + + const workspaceRoot = WORKSPACE_ROOT_PATH; + + for (const currentAutoload of [autoload, autoloadDev]) { + if (!currentAutoload || Object.keys(currentAutoload).length === 0) { + continue; + } + + const sortedPrefixes = Object.keys(currentAutoload).sort((a, b) => b.length - a.length); + + for (const prefix of sortedPrefixes) { + const cleanPrefix = prefix.replace(TRAILING_BACKSLASHES_RE, ''); + if (!namespace.startsWith(cleanPrefix)) { + continue; + } + + const relativePart = namespace.substring(cleanPrefix.length); + const relativePath = relativePart.replace(/^\\+/, '').replace(BACKSLASH_RE, '/'); + + const baseDirectory = currentAutoload[prefix].replace(/\/$/, ''); + + const fullPath = relativePath + ? `${workspaceRoot}/${baseDirectory}/${relativePath}` + : `${workspaceRoot}/${baseDirectory}`; + + return fullPath; + } + } + + throw new Error(`No autoload mapping found for namespace: ${namespace}`); } } diff --git a/src/extension.ts b/src/extension.ts index 88858d3..20e329c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,20 +1,31 @@ import "reflect-metadata"; import * as fs from 'fs'; -import { FileRenameEvent, workspace } from 'vscode'; -import { COMPOSER_FILE } from '@infra/utils/constants'; +import { commands, FileRenameEvent, window, workspace } from 'vscode'; +import { COMPOSER_FILE, WORKSPACE_ROOT_PATH } from '@infra/utils/constants'; +import { Config, ConfigKeys } from '@domain/workspace/ConfigurationLocator'; import { container } from "tsyringe"; -import { FileRenameHandler } from '@app/events/FileRenameHandler'; -import { WorkspacePathResolver } from './domain/workspace/WorkspacePathResolver'; +import { FeatureFlagManager } from '@domain/workspace/FeatureFlagManager'; +import { FileRenameHandler } from '@app/commands/FileRenameHandler'; +import { RenameHandler } from '@app/commands/RenameHandler'; export function activate() { - const workspacePathResolver = container.resolve(WorkspacePathResolver); - const files = fs.readdirSync(workspacePathResolver.getRootPath()); + const files = fs.readdirSync(WORKSPACE_ROOT_PATH); if (!files.includes(COMPOSER_FILE)) { return; } - const handler = container.resolve(FileRenameHandler); + const fileRenameHandler = container.resolve(FileRenameHandler); workspace.onDidRenameFiles(async (event: FileRenameEvent) => { - await handler.handle(event); + await fileRenameHandler.handle(event); }); + + const configuration = container.resolve(FeatureFlagManager); + if (configuration.isActive({ key: ConfigKeys.RENAME })) { + commands.registerCommand(Config + '.' + ConfigKeys.RENAME, async () => { + const renameHandler = container.resolve(RenameHandler); + renameHandler.handle({ + activeEditor: window.activeTextEditor, + }); + }); + } } diff --git a/src/infra/autoload/AutoloadPathResolver.ts b/src/infra/autoload/AutoloadPathResolver.ts index fb4d060..979e10f 100644 --- a/src/infra/autoload/AutoloadPathResolver.ts +++ b/src/infra/autoload/AutoloadPathResolver.ts @@ -9,17 +9,20 @@ interface Props { workspaceRoot: string, } +export const BACKSLASH_RE = /\\/g; +export const TRAILING_BACKSLASHES_RE = /\\+$/; + @injectable() export class AutoloadPathResolver { public async execute({ autoload, workspaceRoot }: Props) { for (const prefix in autoload) { - const src = autoload[prefix].replace(/\\/g, '/'); + const src = autoload[prefix].replace(BACKSLASH_RE, '/'); if (!workspaceRoot.startsWith(src)) { continue; } - const prefixBase = prefix.split('\\":').at(0)?.replace(/\\+$/, '') || ''; + const prefixBase = prefix.split('\\":').at(0)?.replace(TRAILING_BACKSLASHES_RE, '') || ''; const srcReplace = src.endsWith('/') ? prefixBase + '\\' : prefixBase; diff --git a/src/infra/autoload/ComposerAutoloadManager.ts b/src/infra/autoload/ComposerAutoloadManager.ts index 791f9f7..8f7c1d5 100644 --- a/src/infra/autoload/ComposerAutoloadManager.ts +++ b/src/infra/autoload/ComposerAutoloadManager.ts @@ -1,7 +1,6 @@ -import { inject, injectable } from "tsyringe"; -import { COMPOSER_FILE } from '@infra/utils/constants'; +import { COMPOSER_FILE, WORKSPACE_ROOT_PATH } from '@infra/utils/constants'; import { promises as fs } from 'fs'; -import { WorkspacePathResolver } from '@domain/workspace/WorkspacePathResolver'; +import { injectable } from "tsyringe"; interface ComposerAutoload { autoload: Record; @@ -19,13 +18,8 @@ let cacheModifiedTime: number | null = null; @injectable() export class ComposerAutoloadManager { - constructor( - @inject(WorkspacePathResolver) private workspacePathResolver: WorkspacePathResolver, - ) {} - public async execute() { - const workspaceRoot = this.workspacePathResolver.getRootPath(); - + const workspaceRoot = WORKSPACE_ROOT_PATH; if (!workspaceRoot) { return DEFAULT; } diff --git a/src/infra/utils/constants.ts b/src/infra/utils/constants.ts index b6b0f20..cbe8cf4 100644 --- a/src/infra/utils/constants.ts +++ b/src/infra/utils/constants.ts @@ -1,3 +1,9 @@ +import { workspace } from 'vscode'; + export const COMPOSER_FILE = 'composer.json'; export const PHP_EXTENSION = '.php'; + +export const WORKSPACE_ROOT_PATH = workspace.workspaceFolders + ? workspace.workspaceFolders[0].uri.fsPath + : '';