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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Ideal for projects using PSR-4, making it easy to reorganize directories without

- Additional Extensions: Specify the file extensions to consider during the namespace refactoring process.

- Rename Properties (off by default): When a class is renamed, also rename its class-typed constructor properties (promoted or not, readonly or not) and their `$this->x` usages to match the new class name.

## Requirements

- PHP 7.4+
Expand All @@ -47,7 +49,8 @@ This extension contributes the following settings:
"php"
],
"phpNamespaceRefactor.rename": true,
"phpNamespaceRefactor.editFilesInBackground": true
"phpNamespaceRefactor.editFilesInBackground": true,
"phpNamespaceRefactor.renameProperties": false
}
```

Expand Down Expand Up @@ -91,6 +94,23 @@ This extension contributes the following settings:

- Default: true.

**phpNamespaceRefactor.renameProperties**

- When a class is renamed, also renames its class-typed constructor properties (promoted or not, readonly or not) and every `$this->x` usage to match the new class name — e.g. `private Test $teste` becomes `private NewTest $newTest` when `Teste` is renamed to `Novo`.
- If more than one property shares the same type in a constructor, the file is skipped rather than guessing which one to rename.
- Accepts either a boolean or an object:
```json
"phpNamespaceRefactor.renameProperties": true
```
```json
"phpNamespaceRefactor.renameProperties": {
"renameMismatchedNames": true
}
```
Setting an object automatically enables the feature. `renameMismatchedNames` (default `false`) additionally renames properties whose current name doesn't already match the class name on purpose (e.g. `private Test $service` becomes `private NewTest $newTest`); without it, only properties already named after the old class are renamed.

- Default: false.

## Documentation

For architecture, internals, and troubleshooting notes, see [./docs/](./docs/README.md).
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Inside `app/`:

- `commands/` — entry points triggered by VS Code commands or events (`RenameHandler`, `FileRenameHandler`)
- `features/` — orchestrates the flow of a single user interaction (`RenameFeature`)
- `operations/` — runs a full refactor operation (`ClassRenameOperation`, `NamespaceRenameOperation`, `FileMoveOperation`)
- `operations/` — runs a full refactor operation (`ClassRenameOperation`, `NamespaceRenameOperation`, `FileMoveOperation`, `PropertyRenameOperation`)
- `services/` — reusable steps used by the operations (`NamespaceBatchUpdater`, `MissingClassImporter`, `DirectoryMovedFilesResolver`, `remove/ImportRemover`, `update/*`)
- `subscribers/` — react to workspace events to keep the namespace index up to date (`FileCreatedSubscriber`, `FileDeletedSubscriber`, `FileSavedSubscriber`)

Expand Down
34 changes: 31 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,17 @@ All keys are centralized in `ConfigKeys` (`src/domain/workspace/ConfigurationLoc
| `phpNamespaceRefactor.additionalExtensions` | `ADDITIONAL_EXTENSIONS` | `string[]` | `["php"]` | `FileExtensionResolver` and `WorkspaceIndex` |
| `phpNamespaceRefactor.rename` | `RENAME` | `boolean` | `true` | The `phpNamespaceRefactor.rename` command (`extension.ts`) and the F2 keybinding's `when` clause (`package.json`) |
| `phpNamespaceRefactor.editFilesInBackground` | `EDIT_FILES_IN_BACKGROUND` | `boolean` | `true` | `FileEditApplier` |
| `phpNamespaceRefactor.renameProperties` | `RENAME_PROPERTIES` | `boolean \| { renameMismatchedNames?: boolean }` | `false` | `PropertyRenameSettingsResolver`, consumed by `FileMoveOperation`/`PropertyRenameOperation` |

## How configuration is read

Two classes access `workspace.getConfiguration('phpNamespaceRefactor')`, each with a distinct purpose:
Three classes access `workspace.getConfiguration('phpNamespaceRefactor')`, each with a distinct purpose:

- **`ConfigurationLocator`** (`src/domain/workspace/ConfigurationLocator.ts`) — generic read, used for settings of any type (`ignoredDirectories`, `additionalExtensions`)
- **`FeatureFlagManager`** (`src/domain/workspace/FeatureFlagManager.ts`) — specialized `boolean` read, with `defaultValue = true`. Used for every on/off flag (`autoImportNamespace`, `removeUnusedImports`, `rename`, `editFilesInBackground`)
- **`FeatureFlagManager`** (`src/domain/workspace/FeatureFlagManager.ts`) — specialized `boolean` read, with `defaultValue = true`. Used for every plain on/off flag (`autoImportNamespace`, `removeUnusedImports`, `rename`, `editFilesInBackground`)
- **`PropertyRenameSettingsResolver`** (`src/domain/property/PropertyRenameSettingsResolver.ts`) — the one setting whose raw value isn't a plain boolean; see [`phpNamespaceRefactor.renameProperties`](#phpnamespacerefactorrenameproperties) below

Neither class caches the `WorkspaceConfiguration` — each instance reads `workspace.getConfiguration()` in its constructor, and since both are `@injectable()` (not singleton), a fresh read happens on every `container.resolve()`. This means a change to the user's configuration is picked up on the next operation, with no need to reload the window.
None of the three caches the `WorkspaceConfiguration` — `ConfigurationLocator`/`FeatureFlagManager` read `workspace.getConfiguration()` in their constructor, and `PropertyRenameSettingsResolver` reads through a fresh `ConfigurationLocator` on every `resolve()` call. All three are `@injectable()` (not singleton), so a fresh read happens on every `container.resolve()`. This means a change to the user's configuration is picked up on the next operation, with no need to reload the window.

## `phpNamespaceRefactor.editFilesInBackground`

Expand All @@ -38,3 +40,29 @@ Filters files by simple substring match against `fsPath` (not a glob) — see `W
## `phpNamespaceRefactor.additionalExtensions`

Normalized by `normalizeExtensions()` (`src/infra/utils/extensions.ts`): strips leading dots and whitespace, lowercases, and always guarantees `php` is included even if not listed. `FileExtensionResolver.match()` sorts extensions from longest to shortest before comparing, so a compound extension (e.g. `class.php`) is never shadowed by the plain `php` entry.

## `phpNamespaceRefactor.renameProperties`

Master switch for `PropertyRenameOperation` — off by default, unlike the extension's other flags, because it renames identifiers (constructor parameters, promoted/non-promoted properties, `$this->x` usages), not just type hints. Two conditions gate it: the class itself must have actually been renamed (`oldClassName !== newClassName` — a plain directory move is a no-op), and only files `NamespaceBatchUpdater`/`MultiFileReferenceUpdater` already determined were affected by that specific rename are ever touched, never an independent workspace-wide scan — so an unrelated class in another namespace that happens to share a short name is never at risk.

For each candidate file, `ClassTypedPropertyLocator` looks for the single constructor property typed as the renamed class (promoted or not, readonly or not); if more than one property shares that type, the file is skipped entirely rather than guessing which one to rename — see [file-move.md](./operations/file-move.md#3-property-rename-propertyrenameoperation-optional).

### Accepted values

This one setting doubles as its own sub-option, via `PropertyRenameSettingsResolver`:

```jsonc
"phpNamespaceRefactor.renameProperties": false // disabled (default)
"phpNamespaceRefactor.renameProperties": true // enabled, mismatched names left alone
"phpNamespaceRefactor.renameProperties": {} // enabled, mismatched names left alone
"phpNamespaceRefactor.renameProperties": { "renameMismatchedNames": true } // enabled, mismatched names renamed too
```

`renameMismatchedNames` controls what happens when a property's current name doesn't already follow the class-name convention:

- unset/`false` (default) — a property is only renamed if its name already matches the *old* class name (e.g. `$teste` for `Teste`); a property named something else on purpose (e.g. `$service`) is left untouched
- `true` — mismatched names are renamed too, to match the *new* class name (e.g. `$service` → `$novo`)

Any object value implies the feature is enabled — `false` is the only way to turn it off; there's no `{ "enabled": false }` form.

**Why one polymorphic setting instead of two plain booleans:** VS Code's settings schema doesn't allow a key to be both a leaf value and the parent of another key. An earlier version declared `phpNamespaceRefactor.renameProperties` (boolean) alongside `phpNamespaceRefactor.renameProperties.renameMismatchedNames` (boolean) as two separate keys — VS Code detected the conflict, logged `Ignoring phpNamespaceRefactor.renameProperties.renameMismatchedNames as phpNamespaceRefactor.renameProperties is false` in the console, and silently resolved **both** settings to `false` regardless of what the user configured. Collapsing them into a single `boolean | object` setting sidesteps the conflict entirely, since there's only ever one registered key.
1 change: 1 addition & 0 deletions docs/operations/class-rename.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ It then delegates the rename to `FileRenameHandler.create()`, which triggers VS
- Update the `namespace` declaration in the file
- Update the class name inside the file (via `ClassNameUpdater`)
- Update every `use` statement referencing the class throughout the project
- Optionally, rename class-typed constructor properties (and their `$this->x` usages) in the affected files to match the new class name — only when `renameProperties` is enabled, see `PropertyRenameOperation` in [file-move.md](./file-move.md#3-property-rename-propertyrenameoperation-optional)

## Difference from a direct Explorer rename

Expand Down
31 changes: 24 additions & 7 deletions docs/operations/file-move.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ onDidRenameFiles (VS Code event)
→ DirectoryMovedFilesResolver.execute() 1. expands directory moves into per-file moves
→ for each .php file:
→ NamespaceBatchUpdater.execute() 2. updates namespace/class + references
→ MissingClassImporter.execute() 3. (optional) auto-imports classes from the old directory
→ ImportRemover.execute() 4. (optional, checked internally) removes stale imports
→ PropertyRenameOperation.execute() 3. (optional) renames class-typed properties to match the new class name
→ MissingClassImporter.execute() 4. (optional) auto-imports classes from the old directory
→ ImportRemover.execute() 5. (optional, checked internally) removes stale imports
```

### Serialized queue
Expand Down Expand Up @@ -46,6 +47,8 @@ For each moved file, `src/app/services/NamespaceBatchUpdater.ts`:
5. If the namespace declaration wasn't found/changed (step 4 returns `false`), the operation stops here — there's nothing to propagate for a file with no `namespace` declaration
6. Otherwise, calls `MultiFileReferenceUpdater` to propagate the change across the rest of the project

`NamespaceBatchUpdater.execute()` returns the list of files `MultiFileReferenceUpdater` determined were affected (empty when it never ran, e.g. no `namespace` declaration to key off). `FileMoveOperation` forwards that same list into step 3 below, so property renaming only ever touches files this specific rename actually reached — never an independent, broader scan.

#### `MultiFileReferenceUpdater` — how affected files are found

Combines two sources, without relying on either alone:
Expand All @@ -62,13 +65,24 @@ For each affected file, it replaces via regex:

Every edit across every affected file is accumulated into a **single `WorkspaceEdit`** before being applied (`FileEditApplier.apply`), so the whole refactor shows up as one undo-stack entry in VS Code instead of one edit per file — see [issue #72](https://github.com/rejmann/php-namespace-refactor/issues/72).

At the end, `MultiFileReferenceUpdater` always calls `ImportRemover.execute({ uri: newUri })` for the moved file itself (in addition to the call `FileMoveOperation` already makes in step 4 below — both are no-ops if the flag is disabled or there's nothing to remove).
At the end, `MultiFileReferenceUpdater` always calls `ImportRemover.execute({ uri: newUri })` for the moved file itself (in addition to the call `FileMoveOperation` already makes in step 5 below — both are no-ops if the flag is disabled or there's nothing to remove).

### 3. Property rename (`PropertyRenameOperation`, optional)

Only runs if `renameProperties` resolves to enabled (off by default — see [configuration.md](../configuration.md#phpnamespacerefactorrenameproperties)). `PropertyRenameSettingsResolver` reads the raw setting (`boolean | { renameMismatchedNames?: boolean }`) once in `FileMoveOperation` and turns it into `{ enabled, renameMismatchedNames }`; only `enabled` gates whether this step runs at all. Also only acts when the class itself was actually renamed (`oldClassName !== newClassName`); a plain move to a different directory with the same class name is a no-op.

1. Derives the expected old/new property names from the class names (`PropertyNameResolver`, e.g. `Teste` → `teste`)
2. Builds the candidate file list from the `affectedFiles` `FileMoveOperation` received back from `NamespaceBatchUpdater` (step 2's output), plus the moved file itself — deliberately **not** an independent workspace-wide scan, so a differently-namespaced class that happens to share a short name is never touched
3. For each candidate file whose text contains the new class name, `ClassTypedPropertyLocator` looks for the constructor property typed as that class — promoted or not, readonly or not, confirming a non-promoted parameter by checking for a `$this->x = $x;` assignment in the constructor body (see `ConstructorSpanFinder` for how the constructor's parameter list and body are located via brace/paren matching). The property's own declaration line doesn't need a type hint to be found this way — a legacy `private $x;` typed only via a `@var ClassName` docblock is still matched and renamed, via `PropertyDeclarationPattern`, once the constructor param already confirmed what class it holds
4. If more than one property shares that type in the same file, it's ambiguous — the file is skipped entirely rather than guessing
5. If exactly one property is found: it's renamed when its current name already matches the *old* class-name convention, or — only when `renameMismatchedNames` also resolved to `true` — regardless of what it was named before
6. Renaming rewrites the constructor parameter/property declaration and every `$this->x` usage in that file, accumulated into a single `WorkspaceEdit` (its own undo stop, separate from `MultiFileReferenceUpdater`'s)

### 3. Auto-import of classes from the source directory (`MissingClassImporter`, optional)
### 4. Auto-import of classes from the source directory (`MissingClassImporter`, optional)

Only runs if the `autoImportNamespace` flag is enabled. Lists the `.php` files still left in the source directory, checks which classes from those files are used in the moved file's text but not imported, and inserts the corresponding `use` statements.

### 4. Removing stale imports (`ImportRemover`)
### 5. Removing stale imports (`ImportRemover`)

Unlike the other flags, the `removeUnusedImports` check happens **inside** `ImportRemover` itself (not in `FileMoveOperation`) — so it's always called, but returns immediately if the flag is disabled.

Expand All @@ -78,8 +92,10 @@ When enabled: it collects the class names declared in the other files of the mov

| Flag | Behavior |
|---|---|
| `autoImportNamespace` | Enables step 3 (auto-import of classes from the old directory) |
| `removeUnusedImports` | Enables the import removal in step 4 (checked inside `ImportRemover`) |
| `renameProperties` (`true`/`{}`) | Enables step 3 (renaming class-typed constructor properties to match the new class name) |
| `renameProperties: { renameMismatchedNames: true }` | Extends step 3 to also rename properties whose name doesn't already match the class-name convention |
| `autoImportNamespace` | Enables step 4 (auto-import of classes from the old directory) |
| `removeUnusedImports` | Enables the import removal in step 5 (checked inside `ImportRemover`) |
| `editFilesInBackground` | Doesn't change what's edited, only whether touched files open a tab in the editor or are saved silently — see [configuration.md](../configuration.md) |

## Error handling
Expand All @@ -90,6 +106,7 @@ Each file in the batch is processed inside its own `try/catch` in `FileMoveOpera

- `DirectoryMovedFilesResolver` — expands directory moves into per-file moves
- `NamespaceBatchUpdater` — orchestrates the namespace, class name, and reference update
- `PropertyRenameOperation` — renames class-typed constructor properties (and their `$this->x` usages) to match a renamed class
- `MissingClassImporter` — detects and injects missing imports
- `ImportRemover` — removes unused imports
- `FeatureFlagManager` — checks which features are enabled in the settings
13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,19 @@
"type": "boolean",
"default": true,
"description": "Apply refactor edits to files without opening them, keeping only files that were already open in the editor. Disable to have every edited file opened in the editor as before."
},
"phpNamespaceRefactor.renameProperties": {
"type": ["boolean", "object"],
"default": false,
"properties": {
"renameMismatchedNames": {
"type": "boolean",
"default": false,
"description": "Also rename properties whose current name doesn't match the class name (e.g. $service for a Teste type)."
}
},
"additionalProperties": false,
"description": "Rename constructor-typed properties (promoted or not, readonly or not) - and their $this->x usages - to match the class name when renaming a class. Set to true/false to toggle, or to an object like { \"renameMismatchedNames\": true } to also rename properties whose current name doesn't match the class."
}
}
}
Expand Down
16 changes: 15 additions & 1 deletion src/app/operations/FileMoveOperation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { DirectoryMovedFilesResolver } from '@app/services/DirectoryMovedFilesRe
import { MissingClassImporter } from '@app/services/MissingClassImporter';
import { NamespaceBatchUpdater } from '@app/services/NamespaceBatchUpdater';
import { ImportRemover } from '@app/services/remove/ImportRemover';
import { PropertyRenameSettingsResolver } from '@domain/property/PropertyRenameSettingsResolver';
import { ConfigKeys } from '@domain/workspace/ConfigurationLocator';
import { FeatureFlagManager } from '@domain/workspace/FeatureFlagManager';
import { inject, injectable } from 'tsyringe';

import type { FileMove } from './FileMove';
import { PropertyRenameOperation } from './PropertyRenameOperation';

@injectable()
export class FileMoveOperation {
Expand All @@ -16,6 +18,8 @@ export class FileMoveOperation {
@inject(MissingClassImporter) private missingClassImporter: MissingClassImporter,
@inject(ImportRemover) private importRemover: ImportRemover,
@inject(FeatureFlagManager) private featureFlagManager: FeatureFlagManager,
@inject(PropertyRenameOperation) private propertyRenameOperation: PropertyRenameOperation,
@inject(PropertyRenameSettingsResolver) private propertyRenameSettingsResolver: PropertyRenameSettingsResolver,
) {}

public async execute(files: ReadonlyArray<FileMove>): Promise<void> {
Expand All @@ -27,7 +31,17 @@ export class FileMoveOperation {
}

try {
await this.namespaceBatchUpdater.execute({ newUri, oldUri });
const affectedFiles = await this.namespaceBatchUpdater.execute({ newUri, oldUri });

const propertyRenameSettings = this.propertyRenameSettingsResolver.resolve();
if (propertyRenameSettings.enabled) {
await this.propertyRenameOperation.execute({
oldUri,
newUri,
affectedFiles,
renameMismatchedNames: propertyRenameSettings.renameMismatchedNames,
});
}

if (this.featureFlagManager.isActive({ key: ConfigKeys.AUTO_IMPORT_NAMESPACE })) {
await this.missingClassImporter.execute({ oldUri, newUri });
Expand Down
Loading
Loading