Skip to content

About

This expert for the Delphi IDE brings back some refactoring based on the DelphiLSP

Resources

Stars

0 stars

Watchers

0 watching

Forks

 
 

Repository files navigation

Delphi Refactoring Light

Version 1.23.8 — the same number the IDE shows in the About box, on the splash screen and in the first row of the plugin's status window, so you can tell at a glance whether your installed build is the current one.

A design-time package for Delphi 13 that connects to the built-in Delphi Language Server (DelphiLSP.exe) to provide a broad set of refactoring and code-analysis features directly in the editor:

Shortcut Feature
Ctrl+Alt+Shift+R Rename — rename an identifier project-wide, semantically (including interface implementations)
Ctrl+Alt+Shift+U Find References — list every occurrence of an identifier
Ctrl+Alt+Shift+F Find Unit References (project-wide) — list every file in the project that imports the current unit, with each identifier of that unit it actually uses
Ctrl+Alt+Shift+I Find Implementations — list all class implementations of an interface/virtual method
Ctrl+Alt+Shift+G Find original symbol — jump to the declaration via the plugin's own LSP session, as a reliable alternative to Ctrl+Click (which is flaky in Delphi 13.1). Based on PR #9 by Dumach; rebind to plain Ctrl+G in Tools → Options → Refactoring Light if you prefer the PR's original chord (the default avoids shadowing the IDE's own Ctrl+G)
Ctrl+Alt+Shift+Space Code Completion — suggestions via DelphiLSP
Ctrl+Alt+Shift+M Extract Method — move the selected block into a new method
Ctrl+Alt+Shift+V / T Extract variable — turn the selected expression into an inline var declared right before its statement, and Wrap in try..finally (T) with the cleanup inferred from the preceding statement (.Free, .EndUpdate, .Leave, ...)
Ctrl+Alt+Shift+P Convert properties — switch the selected properties between direct field access and getter / setter methods, in both directions
(menu only) Expand include files — write the content of {$I} files into their units (current unit, selected units, a directory or the whole project) so the code can be debugged where it runs
Ctrl+Alt+Shift+S Change signature — add, remove, reorder or rename parameters, change their type, modifier or default value; every header of the method's family (interfaces, implementing classes, overrides) and every call site verified via DelphiLSP change with it
Ctrl+Alt+Shift+D Safe delete — delete a method, routine, field, property, variable or constant only after proving that nothing uses it (every occurrence checked via DelphiLSP, form files, interface implementations)
Ctrl+Alt+Shift+A Align method signature — compare a method's class/interface declaration with its implementation and highlight mismatches
Ctrl+Alt+Shift+W Remove with — rewrite a with statement as inline-vars + qualified accesses. Menu entry and shortcut open the same small dialog to pick the scope (at cursor / current unit / selected units / project-wide); the last choice is preselected
Ctrl+Shift+M Move identifier to other unit — move a type / class / routine / const / var to another existing unit and update consumer uses clauses
(menu only) Extract / extend interface — pick members of the class under the cursor and either extract them into a new interface (in its own Interfaces.<Name>.pas unit) or add them to an existing interface; the class is rewritten to implement the interface and missing accessors are synthesised
(menu only) Add IInterface support to class — turn a non-TInterfacedObject class (any TObject / TPersistent / TComponent descendant) into a refcount-managed class that frees itself when the last IInterface reference is dropped
(menu only) Semantic replace — apply project-wide find / replace rules loaded from a per-project JSON file; identifier-bounded matching, comment- and string-aware, optional local-var hoisting when a rule fires multiple times in the same routine, automatic uses-clause augmentation
(menu only) Find unit for identifier — type an identifier, see which unit(s) declare it (from a background-maintained index over the whole search path) and add the chosen unit to the current file's uses clause
(automatic + menu) Live quick fixes (lightbulb) — a VS-style lightbulb appears at the caret whenever the line has an applicable fix: add the missing uses entry (E2003 / H2443), "did you mean" typo correction against the identifier index (E2003), fix or remove a broken uses entry (F2613), align an implementation header with its declaration (E2037). Also reachable on demand via Add unit for identifier at cursor / Resolve missing units... (batch dialog), and by double-clicking an error in the IDE's Structure view
(menu only) Uses cleanup (current unit) — flags uses entries that are unused (remove) or only used from the implementation section (move down — helps against circular interface references). Conservative index-based analysis: ambiguous identifiers count as usage, qualified unit-name access counts as usage, units without indexed source are never touched, a clause with {$IFDEF}s is judged per entry when the plugin's LSP session knows which regions the compiler currently skips (an entry on a skipped line is reported, never offered — in the other configuration it is compiled and this analysis has no evidence about it), and without that information such a clause stays unanalysed as before, units whose only contribution is an initialization/finalization section are kept (side effects), and units the form designer writes itself are kept — for a form unit the loaded designer is asked which units it would re-add (the unit of every component class and its ancestors, plus everything the registered selection editors request through ISelectionEditor.RequiresUnits — which is how a TDBGrid pulls in the data unit and how DevExpress pulls in a dozen cx units), and the row names the component responsible (cxGrid1: TcxGrid); in a form unit whose form is not loaded, textually unused entries are marked unverified and never pre-ticked, because outside the IDE nothing re-adds them and a wrong removal can break the build or the form streaming; an optional keep list (options page, masks such as dxSkin*, plus an Always keep button in the dialog) covers what no designer can report; unused entries come pre-ticked, Select all / Select none buttons cover the actionable rows, and rows the analysis cannot act on cannot be ticked at all (note: initialization side effects, class helpers and operator visibility are invisible to any textual analysis — review before applying)
(menu only) Project checks — three compiler-uncatchable checks: DFM event-handler signatures (with auto-fix incl. handler generation), interface GUID duplicates, and circular unit references

All actions are reachable from the IDE's top-level Refactor menu and from the editor's right-click menu. In the editor menu the plugin registers its entries through the official INTAEditorLocalMenu API (Delphi 12+), inserted right after the IDE's own Refactoring category, so the IDE keeps building its menu exactly as it always does. On older IDEs a fallback takes over the IDE's own Refactoring entry instead: the plugin takes over the IDE's own Refactoring entry in both places (same position, same localized caption, the IDE's own entries hidden) and fills it with its own, context-sensitively enabled entries. If the editor popup has no Refactoring entry to take over, a Refactoring Light submenu is appended at the end instead.

All dialogs follow the active IDE theme (dark mode included), every result table sorts by clicking a column header (numeric columns sort numerically, click again to reverse), and the DFM event-handler check additionally has a live filter box over its findings.

Unlike purely text-based tools, this package uses the actual LSP requests that DelphiLSP advertises in its initialize response: textDocument/definition, textDocument/declaration, textDocument/implementation, textDocument/documentSymbol, textDocument/hover, textDocument/completion, and publishDiagnostics push notifications (used to detect inactive {$IFDEF} regions — diagnostic code H2655/H2656 with tag Unnecessary — and undeclared identifiers — code E2003, feeding the auto-import quick fix). DelphiLSP does not implement textDocument/rename, textDocument/references, textDocument/foldingRange, textDocument/selectionRange or textDocument/documentHighlight — for rename and find-references the package therefore runs a project-wide text search and verifies every candidate semantically via textDocument/definition. Identifiers that happen to share a name but belong to different symbols are cleanly distinguished.

The package starts its own DelphiLSP session in single-process mode. An earlier version used serverType: controller (the same mode the IDE itself uses) to maximise the diagnostic coverage, but extensive testing showed that DelphiLSP's controller-mode sub-agents (Agent0/Agent1) need parent-process / COM-bridge context only BDS.exe can provide — spawned by anything else they crash silently and every subsequent textDocument/hover returns Internal server error. Single-process mode resolves hover reliably; the trade-off is that inactive-region diagnostics now depend on whatever DelphiLSP volunteers without the controller's returnDccFlags/returnHoverModel hints. By default the LSP is pre-warmed automatically when a project opens, so the first refactoring action does not have to pay the cold-start cost; this can be turned off in Tools → Options → Refactoring Light. Every refactoring dialog shows the current LSP warm-up status in its title bar.

In the last three weeks I tested with big projects and used it myself in real life. A few fixes were neccessary, but now it should be a good help though it might not work in all cases. Changes to files that are open in the IDE go through the editor, so they can be undone there (Ctrl+Z). Files that are NOT open in an editor are written directly to disk and are not on the IDE's undo stack - for a project-wide rename keep the "Create backup" option on (the previous state of every affected file is copied to %LOCALAPPDATA%\DelphiRefactoringLight\backup<timestamp>\ with its full path mirrored) or use version control.

If you encounter any problems, please let me now, so I can fix it.

Features in Detail

Rename (Ctrl+Alt+Shift+R)

  • Reads the editor position and picks up the identifier under the cursor.
  • Runs a text search across all project files, then verifies each candidate semantically via textDocument/definition.
  • Extends the candidate set via TImplementationFinder with class method implementations — so renaming an interface method also renames the implementations in every class that implements that interface.
  • Shows a preview dialog with a list view: file, line, kind (Interface declaration, Class declaration, Implementation, Call, ...), original line, and preview line. A second tab holds the full diagnostic log.
  • Name conflict check: when the new name already exists — in code of the files the rename touches, as a member of the owner class, or declared in a unit the declaring file uses — the status line says so and the details tab lists the places. Such a rename can compile and still change what a name refers to; it is reported, not refused.
  • Optional backup (on by default): before anything is written, every affected file (the editor buffer for open files, unsaved changes included) is copied to %LOCALAPPDATA%\DelphiRefactoringLight\backup\<timestamp>\, full path mirrored; the result message names the folder.
  • Applies the changes byte-precisely via IOTAEditWriter and reloads modified modules in the IDE.
  • Refuses at once when the identifier is a reserved word or its declaration lies in the RAD Studio installation (RTL/VCL) — such names occur thousands of times, and verifying each one would keep the IDE busy for minutes.
  • Include files ({$I file} / {$INCLUDE file}): the scans of rename, find references, find unit references and safe delete also cover the files the project's units include. DelphiLSP cannot analyse an include file on its own, so a position inside one is verified through the including unit: that unit is sent to DelphiLSP with the include expanded in place (exactly what the compiler sees), and afterwards its original text is sent again. An occurrence DelphiLSP still cannot resolve is listed as UNVERIFIED and not renamed (safe delete counts it as a use). Starting a refactoring inside an include file is not reliably supported: the same file can be included in different places with a different context.

Find References (Ctrl+Alt+Shift+U)

  • Reads the identifier under the cursor.

  • Tries textDocument/references on the LSP server first.

  • If that returns nothing (or the server does not support it), falls back to the same strategy as Rename: project-wide text search plus per-candidate verification via textDocument/definition.

  • Shows the results in a dialog (file, line, column, kind, line preview, note). The Kind column says how the symbol is used at each place: Declaration, Implementation, Call, Inherited call, Write, Read, Method reference, Address (@), Property accessor, Type use or Uses clause. The symbol's own kind (procedure, function, data, type) decides the ambiguous shapes: X := Foo; is a call of a function, a method reference of a procedure and a read of a variable. The MCP tool find_references returns the same value as kind.

  • An occurrence DelphiLSP gives no answer for is not simply dropped. It is first resolved from the sources: the qualifier before it (lMyClassA.Init) is looked up as a variable, parameter or field, and the member is searched in its declared type and that type's ancestors. Three outcomes:

    • it is a member of another type — the occurrence is not a reference and disappears (it used to be an unverified row you had to judge yourself);
    • it is our member — the row says verified via TMyClassA, and Rename renames it (this is what keeps an inactive {$IFDEF} branch consistent);
    • the type declares the member several times (overloads) — then the argument count decides: a call with one argument can only reach a declaration that takes one. When several take that many, the argument types are compared as far as the text shows them — a typecast (Init(TMyListA(AListe), ASpur)), a literal or a variable declared in the same file; a descendant counts for its ancestor's parameter. Only a real tie stays UNVERIFIED, and Rename leaves it alone.
  • An unqualified call inside another method of the same type (Init(...) rather than Something.Init(...)) is resolved against that type, the way the compiler does — unless a local variable of that name shadows the member.

    Records and old-style objects count as types here, not just classes and interfaces, and a qualifier declared in another unit is resolved through the identifier index.

    The same step also makes the scan faster, which is the only way to: DelphiLSP answers one request at a time (a second one while the first is open comes back as Request removed), so a verification costs one round trip per occurrence. Everything the sources can decide saves one. Measured on this plugin's own sources, Find References for IRenameHost.SetStatus: 170 occurrences, 89 of them settled without asking DelphiLSP, same 35 references as before. The status line and the MCP result say how many requests were saved.

    This is also the answer to a Delphi 13.1 bug (RSS-5463): when a class declares a method as a private / public overload pair, DelphiLSP answers nothing at all for it from another unit — no definition, no completion. Find original symbol uses the same resolution and jumps to the declaration anyway.

  • Interfaces: for a class method that implements an interface method, the declaration in the interface counts as a use — also when the interface is never called — and calls through the interface are found ("declared in interface IFoo", "call via interface IFoo"). Interface inheritance is followed (IFoo = interface(IBase)). For an interface method it works the other way round: the implementing classes' methods and the calls on them ("implemented by TFoo", "call via class TFoo").

  • Closing the window stops the search. The scan runs on the IDE's main thread, so it polls after every file and every verified occurrence: once the window is gone (or the IDE is shutting down) it ends at the next step instead of keeping the IDE busy. The same applies to Find Implementations and Find Unit References.

  • The rows follow your edits. A result window is a worklist: you fix the first occurrence, delete a line, and every line number below it would be wrong. Each row remembers the text of the line it was found on, so when you come back to the window the lines are looked up again (editor buffer first, so unsaved edits count) and brought up to date; the status line says how many moved. A row whose line was itself rewritten cannot be located any more — it is marked with ? and keeps its old position rather than pointing you somewhere unrelated. This applies to every window that uses this dialog: Find References, Find Implementations, Find original symbol and the "After the move/edit" list of Edit methods.

  • Double-click or Enter jumps to the location.

Find Unit References (Ctrl+Alt+Shift+F)

  • Works on the active unit — no editor cursor needed.
  • Step 1 (textual): scans every project source file's uses clauses (interface + implementation) for the current unit's name. Files with a uses entry but no actual symbol usage get a single "dead reference" row.
  • Step 2 (LSP): queries textDocument/documentSymbol for the unit, builds the set of exported identifier names, then scans every using-file for matching tokens (comment- and string-aware).
  • Step 3 (LSP verify): for every candidate it asks textDocument/definition and only keeps hits that resolve back into the original unit. Common names like Create / Free that happen to live in another unit get filtered out.
  • Result list: one row per occurrence with file, line, column, identifier and line preview; dead-reference rows show "<unit> is listed in the uses clause but no symbols of it are used here".
  • Double-click / Enter jumps to the location.

Find Implementations (Ctrl+Alt+Shift+I)

  • Place the cursor on an interface method declaration or on a call of an interface/virtual method.
  • Uses LSP textDocument/definition to reach the actual method declaration (so calls like aa.Bar inside TXyz.Test resolve correctly to ITest.Bar rather than to the containing class).
  • Determines the containing type at the declaration (e.g. ITest).
  • Scans all project files for class method implementation lines (procedure TClass.Method, function TClass.Method, etc.) and keeps only those classes that actually implement the containing type, either directly or via inheritance.
  • Results are shown in the same dialog as Find References (title "Implementations: <name>").
  • Double-click or Enter jumps to the implementation.
  • The same TImplementationFinder class is also used internally by the Rename feature.

Code Completion (Ctrl+Alt+Shift+Space)

  • Queries textDocument/completion at the cursor position.
  • Displays suggestions in an owner-drawn popup (sort, filter, detail tooltip).
  • Inserts the chosen entry into the editor cleanly (via IOTAEditWriter, byte-precise, no Auto-Indent interference).

Align Method Signature (Ctrl+Alt+Shift+A)

  • Place the cursor on a method name (in the class/interface declaration or on the implementation).
  • Queries textDocument/documentSymbol for the current file and walks the tree to collect every match: methods inside class types, methods inside interface types, and stand-alone implementations.
  • Resolves the counterpart in another unit via textDocument/definition and queries that file as well.
  • The actual signature text is read directly from the saved source file, not from the LSP's name field — DelphiLSP serves that field from a symbol cache that lags behind editor edits, so reading the file gives the truth on disk.
  • Each entry is normalized (whitespace and case folded, leading TClass. qualifier stripped) and compared against the majority signature.
  • The interactive dialog lists every entry with role (Class decl., Interface decl., Implementation), container, file, line, match flag and the full signature. Rows that differ from the majority are tinted red.
  • Double-click to jump to a location (dialog stays open), Enter to jump and close.
  • Align rewrites the selected row to the majority signature: a declaration gets the reference signature (directives such as virtual; override; stay), an implementation goes through the E2037 fixer, which keeps its parameter names because the body uses them. An implementation is aligned only after the class declaration of its unit matches. Not saved; Ctrl+Z undoes it.

Remove with (Ctrl+Alt+Shift+W)

  • Remove with... in the menu and the shortcut both open a dialog with four scopes (the choice of the last run is preselected, a double-click or Enter confirms):
    • At cursor only — just the with-statement that encloses the caret. Preselected the first time, since it's the fastest single-edit case. If no with encloses the caret, falls back to the current unit.
    • In current unit — the active editor file.
    • In selected units... — opens a multi-select list of all project source files; scan only the chosen ones.
    • In whole project... — project-wide scan. On very large code bases (10k+ files) this can take many minutes; that's why it is never the default choice.
  • Saves any unsaved editor buffers, starts / reuses DelphiLSP and ensures the chosen file set is indexed. Per-file: triggers RefreshDocument (which sends didOpen plus a didChange v2 with the full content, mirroring what the IDE itself sends), actively requests textDocument/documentSymbol to force analysis, then waits up to 90 s (30 s per file in project-wide scope) for any inactive-region diagnostics. The dialog title bar reflects the active scope and the current LSP warm-up status.
  • Scans every *.pas / *.dpr / *.dpk in the chosen scope for with ... do statements:
    • begin..end bodies — full block.
    • Single statement bodies — one expression terminated by ;.
    • Compound bodies — try..end / case..end / asm..end. The block is kept verbatim and var-decls are emitted right above it (no redundant begin..end wrapping). Body indentation is left-shifted by the difference between the original opener column and the new with-keyword column.
  • For each occurrence, hoists the with-target(s) into Pascal inline variables at the with-statement location (Delphi 10.3+ syntax) and rewrites every body identifier with the appropriate qualifying prefix:
    • Side-effect-free single identifiers (FParser, Self.FFoo, p^, Self.FNode^) keep the prefix directly — no temp variable.
    • Multi-segment dotted paths and any expression with parens / brackets / as-cast are forced into a temp var so each side-effect runs exactly once.
    • Temp-var naming uses the LSP-resolved type when known (e.g. Form.GetBitmap() → LBitmap, TRegIniFile.Create(...) → LRegIniFile); leading F (field convention) and T (type convention) are stripped. A textual fallback ("walk back to the last identifier in the expression") covers cases where LSP cannot help. Final-fallback name is LWithN (1-based). Names never start with _ — the Delphi style guide prefers leading capital letters.
    • Cross-target collisions and collisions with body-identifiers append the target index to disambiguate (LAdd / LAdd2).
  • Target-type resolution chain — on top of the basic textDocument/definition query, the engine walks several Pascal-specific shapes:
    1. Pointer / simple aliases (tLiFo = _L_List;, _L_List = ^_L_List_Node;) are hopped through (max 4 hops) with a text-fallback for the DelphiLSP self-reference quirk.
    2. Constructor targets (with TFoo.Create(...) do) are recognised by parsing constructor TFoo.Create(...) lines — the class qualifier is the implicit return type.
    3. as-cast targets (with Comp as TWinControl do) follow LSP to the class declaration directly.
    4. Inheritance walk — up to 6 ancestors via class(Parent) so inherited members match (e.g. RowCount on a local TStringGrid descendant resolves against Vcl.Grids.TStringGrid).
    5. Parent-unit hints — when LSP self-refs on a class(UnitX.TBar) parent, the unit qualifier is stored as a fallback hint: any body-ref whose LSP result lands in a file with that name segment matches.
    6. Pre-compiled types (e.g. TDockableForm from DockForm.dcu in DesignIde.bpl, no .pas shipped) — resolved partially via the constructor / decl-line heuristic, temp var is still emitted; body refs the engine cannot prove stay unqualified for manual review.
  • Body identifiers are mapped to the right target via four strategies, in order:
    1. Direct member parsing of each target's class/record body (rightmost-target-wins, matching Pascal with semantics).
    2. textDocument/definition as fallback for inherited members (in the direct class range or any ancestor's range).
    3. DeclFile soft match for cases where the type's full source is unavailable but the decl file is known.
    4. ParentUnitHints (basename of the body-ref's LSP result vs the parent's unit qualifier).
  • Inactive {$IFDEF}-region detection: when DelphiLSP pushes publishDiagnostics with code H2655/H2656 and tag Unnecessary for a file, the wizard skips any with-statement inside such a region with status "inactive $IFDEF region — skipped". If DelphiLSP delivers no diagnostics for a file at all (which can happen in single-process mode for files outside the active build configuration, or when the server has not finished analysing), all occurrences in that file are skipped with status "LSP no diagnostics — skipped (dead-code unknown)" — unless the file contains no conditional directive at all (no {$IF..}/{$ELSE..}, no {$I} include): then no line of it can be inactive and the question does not arise. The wizard deliberately does not fall back to a text-based {$IFDEF} scanner — Pascal's $IF defined(...), $IFOPT, nested $IFs and project-specific defines make a reliable scanner impractical, and silently rewriting code that might be dead would be worse than the honest skip.
  • Review dialog with two tabs:
    • Diff — before / after side by side, per occurrence.
    • Debug — per-target type info (resolved type file, class line range, parsed direct members, chosen inline-var name, qualify-prefix, ancestor list, parent hints, resolve note) and per-body-identifier resolution (LSP result, match source, applied prefix). Useful for verifying the rewrite is sound before applying.
  • Apply selected, Apply all or Close. Applied edits go through IOTAEditWriter so they are individually undoable in the IDE.
  • v1 limitations: nested with-statements inside the body are flagged as multi-target / manual review.

Extract variable / Wrap in try..finally (Ctrl+Alt+Shift+V / Ctrl+Alt+Shift+T)

  • Extract variable: select an expression on one line; the plugin proposes a name (Foo.Bar.Count → LCount), declares var LCount := Foo.Bar.Count; right before the statement that contains it and replaces the selection. Deliberately never at the routine's begin, and refused where even the statement start would change the meaning: after a short-circuit and/or (if Assigned(X) and (X.Foo > 0) would dereference nil), in a loop condition, in a branch or loop body on the same line, as the only statement of a then/else/do branch, on a case branch, inside a with, or when the name is already used in the routine. Uses Delphi's inline variables (10.3+).
  • Wrap in try..finally: select complete statements; they move into a try block, and the finally part is inferred from the statement right before them — X := TFoo.Create → X.Free, X.BeginUpdate → X.EndUpdate, X.Enter/Acquire/Lock → Leave/Release/Unlock, TMonitor.Enter(X) → TMonitor.Exit(X), otherwise a TODO comment. Only wrapper lines are added, so a wrong guess is a compile error, never silent damage. Selections with an unbalanced begin/try/case..end are refused.
  • Both write through the editor (undoable) and are not saved.

Change signature (Ctrl+Alt+Shift+S)

Put the caret on a method or routine - its declaration, implementation or any call - and choose Change signature.... The dialog shows the parameters in a grid: edit modifier, name, type and default value, Add a parameter (with the value existing calls should pass, unless it has a default), Remove one, or move them up / down. The preview below updates as you type and lists every edit with the resulting line.

  • The whole family changes together: declaration and implementation; for a class method the interface methods it implements and the other classes implementing them; for an interface method every implementing class; for a virtual / override method the whole override chain (a descendant that hides the method without override is left alone and named).
  • Calls are found like in Safe delete: every whole-word occurrence is asked where it leads, only those DelphiLSP attributes to the family are changed. Arguments are reordered, values for new parameters inserted, and a default value a call relied on is written out when the parameter moves or its default changes. A call without parentheses (Obj.Foo;) gets them when it needs arguments now. inherited Foo(...) inside an override passes the new parameters on by name. An occurrence DelphiLSP cannot resolve (inactive {$IFDEF} branch) is listed as NOT VERIFIED and left for you.
  • Renamed parameters are followed into the method bodies (not after a ., not in comments or strings); a parameter that has a different name in another implementing class keeps it there.
  • Refused, with the place: overloaded and message methods, event handlers bound in a form file, a method used as a method reference (OnClick := Foo, @Foo) or as a property accessor when the change is more than a rename, a removed parameter still used in a body, a new name that already means something in a body, a parameter list that contains a comment or directive, headers whose parameter counts already differ, declarations in the RTL / VCL. The result type is not changed.
  • Apply re-checks that none of the touched files changed since the analysis, then writes all of them (open units in the editor, undoable, not saved; closed units on disk). The MCP tool change_signature does the same for Claude Code: without params it reports parameters, family and calls, with params the plan, apply=true writes it.

Edit methods

Put the caret in a class - on a member, inside one of its method bodies, anywhere in its declaration - and choose Edit methods.... The dialog lists every member of that class with a checkbox, and what you tick is what the two tabs act on - select several methods before opening it (their declarations or their bodies, either works) and exactly those arrive ticked; with nothing selected the member at the caret is ticked. Apply first asks DelphiLSP about the members you edited — on the state that is still intact, because afterwards those call sites are the ones that no longer compile — then writes, and then a non-modal window lists the verified occurrences at their current lines: double-click to walk through them and adjust what the edit deliberately did not touch. This is the deliberate counterpart to a full "move method" refactoring: the plugin does the mechanical half and names everything it does not do, so the division of labour is visible instead of implied.

  • Target tab: pick the class the members move into - in this unit or, by choosing the unit first (combo or Browse...), in another one - and the section (private / protected / public / published) the declaration lands in. The declaration leaves the old class, the body travels along and its implementation header is requalified to the new owner. A move inside one unit is one rewrite of one file.
  • The uses clauses follow: if the source still calls what moved, it gains the target unit in its implementation uses; if a moved declaration names a type of the old unit, the target needs the source in its interface uses - and because exactly that direction can close a unit cycle, it is reported in the box below rather than risked silently.
  • Signature tab: the parameter grid edits the first ticked member - modifier, name, type, default value, Add / Remove / up / down - and is written to the declaration and its implementation header, never to one of the two. The modifier checkboxes below are tri-state and apply to every ticked member at once: grey = leave alone, ticked = add (virtual, override, overload, inline, static, reintroduce), unticked = remove.
  • What it refuses, with the reason in the member's own row: an overload, a virtual / dynamic / override / abstract / message member, a published one, anything whose declaration or body cannot be delimited - the same rules Safe delete uses, so there is no second set of them. Moving a member into its own class is refused too.
  • What it tells you instead of doing it: the fields and methods of the old class that the moved body still uses (they stay behind), a member the form designer binds as an event handler (the .dfm would lose its handler), and - for a signature change - that the call sites are not rewritten. Every occurrence of the ticked members is listed below with its kind (call, method reference, write, read, ...); double-click goes there. Those are text matches, not DelphiLSP-verified, and Apply does not touch them. For a parameter list whose calls must change with it, use Change signature (Ctrl+Alt+Shift+S), which verifies and rewrites every call.
  • Apply re-reads both units, refuses when the source changed since the analysis, re-plans on the fresh text and writes the target first - so a failed write can never leave the member nowhere. Edits go through the editor (undoable) and are not saved. The MCP tool edit_method does the same for Claude Code: without members / target_class it reports the class, its members with their movability and the occurrences; with them the plan and the edits, apply=true writes it.

Safe delete (Ctrl+Alt+Shift+D)

Put the caret on a symbol - its declaration or any use - and choose Safe delete.... The plugin deletes the declaration (and the implementation of a method or routine) only after proving that nothing uses it:

  • Every whole-word occurrence in the project scope (comments and strings excluded) is asked where it leads. Only an occurrence DelphiLSP attributes to a different symbol is harmless. One that leads to the declaration is a use, and one DelphiLSP cannot resolve counts as a use, too — that is what a call in an inactive {$IFDEF} branch looks like, and deleting the declaration would break the other configuration.
  • Form files (.dfm / .fmx) bind event handlers and components by name, so any mention there blocks.
  • Refused outright: overloaded methods (another overload could silently take over the calls), virtual / dynamic / abstract / override / message methods (reachable through dispatch), published members (streaming, RTTI), methods implementing an interface method, multi-line data declarations and types with a body.
  • Supported: methods of classes, records and interfaces, free and nested routines, fields, properties, unit-level and local variables and constants (one name out of A, B, C: Integer is removed on its own), single-line types. A /// doc comment directly above goes with it, and so does a var / const keyword that would be left without declarations.
  • The dialog lists what will be deleted and every occurrence with its verdict; Delete is enabled only when the check passed. The edit goes through the editor (undoable) and is not saved. Code outside the project scope (other projects using the unit) is not seen — the dialog says so.

Convert properties (Ctrl+Alt+Shift+P)

Select the property declarations (or put the caret on one) and choose Convert properties (field / getter, setter).... The dialog lists every selected property with what will happen or why not:

  • Field access → getter / setter (getter, setter or both): property Name: string read FName write FName; becomes read GetName write SetName; function GetName: string; / procedure SetName(const Value: string); go into the private section (a new one is created before the first visibility keyword, so no other member changes its visibility), the implementations (Result := FName; / FName := Value;) after the class's last method.
  • Getter / setter → field access: only for TRIVIAL accessors (Result := FName;, Exit(FName);, FName := Value;), which are then removed. Kept, with the reason shown, when the accessor is used anywhere else (for a non-private one: in any file of the project), or is virtual / override / overload / message.
  • Not supported (and said so): array and indexed properties, class properties, multi-line declarations, an accessor name that already exists.
  • The change goes through the editor (undoable) and is not saved; a round trip there and back gives the original text byte for byte.

Expand include files (menu only)

Expand include files replaces every {$I file} / {$INCLUDE file} by the file's content, framed by marker comments that keep the directive:

// >>> include begin: {$I Foo.inc}
...content of Foo.inc...
// <<< include end: Foo.inc

Nested includes are expanded too, a trailing // comment in an include cannot swallow the code after the directive, and the unit's line break style is kept. The menu entry Expand include files... asks where: current unit, selected units, a directory (recursive) or the whole project. Open files are changed in the editor buffer (undoable, not saved), all others on disk — go back with your version control system.

Extract Method (Ctrl+Alt+Shift+M)

  • Validates the selection with a Pascal tokenizer (paren balance, if/then/else, repeat/until, try/except/finally, no selection crossing method boundaries, ...).
  • For every identifier in the selection, asks LSP textDocument/hover for the symbol kind (local / parameter / field / global) and textDocument/definition for the declaration site.
  • Determines:
    • Parameters of the new method (locals from the surrounding method that are read inside the block).
    • Local variables of the new method (declarations from the block itself).
    • Return value if a single trailing identifier is written-then-read.
    • The enclosing function's Result when the block assigns it: the new routine becomes a function of that type and the call reads Result := NewMethod(...). Result is not a variable, so it is never moved into the new routine as a local - that compiled and silently threw the value away. A block that reads Result, or assigns it only inside an if / loop / case, is refused with the reason: the value the function already has would be lost. (In a procedure a local named Result is ordinary and nothing changes.)
  • Parameters are prefixed with A in the new method signature and body (Delphi convention, e.g. ACertFilename instead of CertFilename); the call site keeps the original variable names.
  • Generates the new method (class-qualified if applicable), replaces the selected block with the call, and removes the now-unused var entries in the original method.
  • Refreshes the form and class structure in the IDE (Module.Refresh(False), FormEditor.MarkModified).

Move identifier to other unit (Ctrl+Shift+M)

  • Place the cursor on a top-level symbol to move: type / class / interface / record, routine (function/procedure), constant, variable, or resource string. For classes, the move includes the class declaration and every method-implementation block in the same unit (matched by TClass.Method qualifier; overloads with the same name move together).
  • A modal dialog lists the project's other .pas files; pick the target unit.
  • Move to new unit... (separate menu entry) creates the target instead: it asks for the unit name (created next to the current unit, a full path is accepted; the default for TCustomerList is CustomerList), writes an empty unit (UTF-8 with BOM, CRLF), adds it to the project and runs the same move. When only the moved implementation needs identifiers that stay in the source unit, the source goes into the new unit's implementation uses; when the rest of the source unit still uses the moved symbol, the source gets the new unit (in its implementation uses when only its implementation needs it). When the declaration itself needs them (a field of a type that stays behind, say), the move is refused before anything is created — the new unit would have to use the source in its interface while the source uses the new unit, a circular unit reference. MCP tool: move_to_new_unit.
  • The engine performs the move:
    1. Locate the declaration range (interface section, including any preceding type / var / const keyword as appropriate) and the impl block range(s).
    2. Collect required uses: for each identifier referenced inside the moved range it asks textDocument/definition and records the declaring file's unit name. Identifiers that resolve to System (built-ins like Length, IntToStr, ...) are filtered out. Identifiers whose declaration falls inside the moved range itself (local vars, parameters, the symbol's own type members) are also filtered.
    3. Insert the declaration into the target's interface section (just before implementation) with one blank line of padding on each side; insert the impl block(s) just before the final end.. A section header (type / var / const / resourcestring) is prepended automatically if needed.
    4. Add required uses to the target: interface-side uses for identifiers referenced from the moved declaration (visible-surface types), implementation-side uses for identifiers referenced only from the impl body. Avoids double-imports.
    5. Remove the declaration and impl block(s) from the source. The cleanup pass strips orphan { TClassName } class-marker comments above moved method blocks, drops now-empty section headers (type/var/const/resourcestring with no body below them), and collapses runs of blank lines.
    6. Update consumer uses: every other project file that already referenced the moved symbol via the old unit name gets the target unit added to its interface uses. If the source unit is no longer referenced from that consumer at all, it is removed.
  • Source-unit uses are intentionally left intact (over-imports are harmless; the engine does not attempt to prove unused-uses safety here).
  • Edits are applied via IOTAEditWriter and individually undoable.
  • v1 limitations:
    • No new-unit creation.
    • Cross-unit qualified references (SourceUnit.X) in consumers are not rewritten — only the uses clause is adjusted.
    • The cleanup is heuristic; review the diff before committing.

Extract / extend interface (menu only)

Two related actions, both reached through the editor's Refactoring Light → Extract / extend interface submenu (no keyboard shortcut by design — this is rarely a hot path and the wizard wants room to think). Both work on the class whose declaration the cursor is in (or just below).

  • Extract new interface from class...

    • Parses the class declaration into a structured member list: fields, properties, methods, with their visibility (strict private / private / protected / public / published) and modifier (class procedure ...).
    • Opens a non-modal dialog with the members listed group-by-visibility (the visibility line itself is clickable and toggles the whole section), plus preset buttons (All public+Pub., Properties, Fields, ...). Preset buttons are additive — clicking Properties after Fields extends the selection, it does not replace it; a second click on the same preset clears that group. Every change live-updates the interface preview on the right.
    • The default target unit is Interfaces.<Bare>.pas next to the source unit (e.g. TForm11 → Interfaces.Form11.pas); the GUID is auto-generated and re-rollable in the dialog.
    • On OK:
      1. Writes a new unit with the interface declaration and a uses clause restricted to exactly what the interface needs. Type identifiers referenced in the interface (TButton, TListView, ...) are resolved via LSP textDocument/definition; the resulting unit names land in the new unit's uses. If LSP cannot resolve every type (e.g. when the source unit itself has compile errors), the new unit's uses is topped off with the source unit's own interface-uses so the result always compiles. A diagnostic dialog after the run reports <resolved>/<attempted> so the user knows what LSP delivered.
      2. Registers the new unit with the active project (IOTAProject.AddFile) and opens it in the editor.
      3. Rewrites the class in the source unit: adds the new interface to the ancestor list, synthesises private function GetX: T; / procedure SetX(const AValue: T); accessors (grouped under the existing private section — no duplicate sections) for every field and every property without explicit read / write methods, and appends matching function TClass.GetX: T; begin Result := X; end; implementations before the unit's end.. The class-side does not gain a parallel property declaration because a property of the same name as the field would clash with the existing field.
      4. Adds the new unit to the source unit's uses clause (Delphi convention: at the end).
    • Source-unit edits of files open in the IDE go through IOTAEditWriter (via TEditorHelper.ReplaceFileContent) so they are individually undoable there; files not open in an editor are written directly to disk (not undoable).
  • Add to existing interface...

    • Scans the project for IXxx = interface declarations and offers them in a dropdown.
    • The member checklist is the same as for Extract new, with one addition: members whose would-emit names are already declared in the selected target interface (Button1, GetButton1, SetButton1, ...) are auto-checked, displayed with an [in interface] prefix, and disabled so they cannot be re-added. Preset buttons skip them.
    • On OK the same class-side rewrite happens, but instead of writing a new unit, the new declarations are spliced into the existing interface just before its end;. Type-resolution adds any missing units to the existing interface unit's own uses clause (via LSP, with the same source-uses safety net as Extract new).
  • Known limitations of both flows:

    • Generic classes (TFoo<T>) are parsed but the generic parameter is dropped from the suggested interface name; rename in the dialog if needed.
    • Indexed / default array properties are emitted as their source signature; no special interface-syntax handling.
    • Class-body {$IFDEF} branches are not tracked — members from both branches end up in the list, even when only one is active.
    • Overloaded methods are emitted individually (no dedup).
    • Default member visibility for VCL classes ($M+, the common case) is assumed to be published. Pure TObject descendants would actually default to public; the dialog shows a default visibility header in that case so the difference is visible.

Add IInterface support to class (menu only)

Reachable via Refactoring Light → Extract / extend interface → Add IInterface support to class…. Turns a class that does not descend from TInterfacedObject (any TObject / TPersistent / TComponent descendant) into a refcount-managed class. After the rewrite the instance can be held purely as an IInterface and frees itself when the last interface reference is released — no .Free call needed.

No dialog, no choice: the cursor must be inside a class, the wizard does the rest.

  • Detects whether the class already declares IInterface / IUnknown in its ancestor list and bails out with a message if so.
  • Picks a code-emission mode from the base class:
    • TObject-like (TObject, TPersistent, or unknown base — e.g. for a class declared as just TFoo = class): fresh QueryInterface / _AddRef / _Release declarations (no override).
    • TComponent-style (anything else, e.g. TComponent, TForm, TFrame): the same three methods are declared with override, since TComponent declares them as virtual stdcall.
  • Splices the additions into the existing last private section (or opens a fresh one if the class has none), and appends the implementations just before the unit's end. with one blank line of padding on each side.
  • Generated declarations:
    • FRefCount: Integer; (private field)
    • function QueryInterface(const IID: TGUID; out Obj): HResult; stdcall; (with override for TComponent-style)
    • function _AddRef: Integer; stdcall; (with override for TComponent-style)
    • function _Release: Integer; stdcall; (with override for TComponent-style)
    • procedure AfterConstruction; override;
    • class function NewInstance: TObject; override;
  • Generated implementations mirror System.TInterfacedObject:
    • NewInstance sets FRefCount := 1 after inherited NewInstance (so an interface cast during the constructor cannot drop the count to 0 and trigger a premature Destroy).
    • AfterConstruction decrements FRefCount back to 0.
    • _AddRef / _Release use AtomicIncrement / AtomicDecrement; _Release calls Destroy when the count hits 0.
    • QueryInterface forwards to GetInterface, returning S_OK or E_NOINTERFACE.
  • Existing NewInstance / AfterConstruction are detected and merged. The wizard scans the class body, omits the declaration when the user has it already, and splices the required statement (<ClassName>(Result).FRefCount := 1; or AtomicDecrement(FRefCount);) into the existing routine body just before its closing end;. The Pascal-aware walker tracks nested begin / try / case / record blocks so the insertion lands at the right depth. The success dialog reports which statements were spliced; if a body cannot be parsed (very rare), a warning lists the statement to add by hand.

Usage after the rewrite (no .Free call):

var
  I: IInterface;
begin
  I := TMyClass.Create;          // NewInstance: FRefCount := 1, AfterConstruction: 0, cast: 1
  // ... use I ...
end;                             // I drops scope -> _Release -> FRefCount = 0 -> Destroy
  • Limitations:
    • The class loses its .Free-managed lifecycle. Pure TObject-style construction with AOwner (TComponent.Create(AOwner)) becomes unsound — the owner would Notification(opRemove).Free the instance while interface references are still held.
    • When the class additionally implements a derived interface like ITest = interface(IInterface), the compiler treats each interface as its own VMT slot and the IInterface methods generated here only fill the standalone IInterface slot. Adding ITest will require either a method-resolution clause or a separate manual implementation — this wizard does not handle that case.

Semantic replace (menu only)

Project-wide find / replace driven by a per-project JSON rules file. Replaces dotted identifier expressions (typically the migration shape Manager.Config.WriteConfig → TAppCentral.Get<IConfig>.WriteConfig), keeps each file's uses clause in sync with what the rewrites need, and optionally hoists a local variable when the same rule fires multiple times in the same routine.

Every match is verified with DelphiLSP (the declaration of the LAST identifier of find), so a local variable or another class that happens to spell the same path is never rewritten:

  • With the rule's optional declaredIn ("Vcl.Forms" or "Forms" — Declared in unit in the rule editor) the symbol must be declared in that unit.
  • Without it, all matches of a rule must lead to the same declaration: the most frequent one is the symbol, the others are listed as SKIPPED: another symbol of that name.
  • A match DelphiLSP gives no answer for (typically an inactive {$IFDEF} branch) is listed as NOT VERIFIED and replaced only when the preview's checkbox Also replace the N occurrence(s) DelphiLSP could not verify is ticked (off by default).
  • The preview shows the verdict per match; without a DelphiLSP session it says so and falls back to the old text-only behaviour. MCP tool: semantic_replace (report, apply, include_unverified).

Reached via Refactoring Light → Semantic replace..., which opens a dialog with the same kind of scope choice as Remove with:

  • In current unit — act only on the editor's current file.
  • In selected units… — a checklist of every project *.pas with a live filter and Select all / Clear shortcuts.
  • In whole project… — every project source file.
  • Edit rules… — opens the rule editor (read-write the JSON file).

Rules file

Rules live at <project_root>/semantic-replace.json. The wizard creates a starter file with one fully-commented example the first time it is needed. Schema:

{
  "rules": [
    {
      "find":    "Manager.Config.WriteConfig",
      "replace": "TAppCentral.Get<IConfig>.WriteConfig",
      "uses":    ["AppCentral.Core", "AppCentral.Config"],
      "localVar": {
        "name":    "LConfig",
        "type":    "IConfig",
        "value":   "TAppCentral.Get<IConfig>",
        "replace": "LConfig.WriteConfig"
      }
    }
  ]
}
  • find — literal text to match. Whole-identifier matching: FooManager.Config.WriteConfig will NOT trigger a rule whose find is Manager.Config.WriteConfig.

  • replace — what to substitute when no local-var hoisting is in play.

  • uses — unit names to add to the interface-section uses of every file that ends up with at least one edit. Existing entries are deduped case-insensitively.

  • localVar — optional. When all four sub-fields are set AND the rule fires twice or more inside the same routine body, the wizard

    1. inserts var <name>: <type> := <value>; right after the routine's begin (Delphi 10.3+ inline var, indented to match the body),
    2. uses localVar.replace instead of replace for every occurrence inside that routine.

    Routines where the rule fires only once are not hoisted; they get the plain replace.

The rule editor dialog provides Add / Edit / Delete buttons plus a single-rule edit dialog with separate fields for each property; on OK the rules are written back to the JSON file using TJSONObject.Format(2) so they stay human-readable.

How matching works

The engine runs a single-pass comment / string-aware state machine over the source text. Matches inside // line comments, { brace comments }, (* paren-star comments *) and 'string literals' are skipped. Each match is bounded to whole identifiers on both sides — the previous and next character must NOT be a letter, digit or underscore.

Method bodies are detected by a separate pass that recognises procedure / function / constructor / destructor headers (with or without a leading class), finds the matching begin, and tracks begin / try / case / record nesting to find the body's terminating end;. Each match is attributed to the body that contains it, which drives the local-var hoisting decision.

Preview dialog

Before any file is written the wizard shows a preview dialog containing a per-file summary and a per-match before / after diff:

=== Unit1.pas ===
    C:\Users\Foo\Projects\App\Unit1.pas

    uses += AppCentral.Core, AppCentral.Config

    L42:
      - Manager.Config.WriteConfig('key1', 'value1');
      + LConfig.WriteConfig('key1', 'value1');

    L43:
      - Manager.Config.WriteConfig('key2', 'value2');
      + LConfig.WriteConfig('key2', 'value2');

    -- 1 local var(s) will be hoisted right after BEGIN.

=== Unit2.pas ===
    ...

Click Apply to commit, Cancel to discard.

Applying

For every file with at least one match:

  1. The new content is produced by the engine in memory (single pass, all edits sorted by offset and applied in one go).
  2. The file's interface-section uses clause is augmented with the deduped union of uses entries from every rule that fired. The wizard preserves the existing layout: single-line uses stays single-line, one-unit-per-line uses gets an extra line with matching indent.
  3. The new content is pushed back through TEditorHelper.ReplaceFileContent (i.e. IOTAEditWriter), so the change is undoable from the IDE and shows up instantly without a manual reload.

A confirmation message reports how many files were touched and how many occurrences were replaced.

Limitations

  • Rule matching is purely textual — the engine does not verify via LSP that Manager.Config.WriteConfig really refers to the symbol you think it does. Two unrelated types that happen to share the same dotted name will both get rewritten. Constrain find enough that the prefix is project-unique.
  • Local-var hoisting is per-routine; the same expression appearing in two different routines is hoisted twice (once per routine), not factored out further.
  • The hoisted statement uses Delphi 10.3+ inline-var syntax (var name: type := value; inside the body). On older compilers it would need to be a classical var-block above begin; the wizard does not currently emit that form.
  • No diff syntax highlighting in the preview.

Find unit for identifier (menu only)

Type an identifier and find out which unit(s) declare it, then add that unit to the current file's uses clause — the classic "Find Unit" / "Add Unit" workflow, backed by a plugin-maintained identifier index rather than the LSP (DelphiLSP has no cross-search-path symbol query).

  • Background identifier index (TUnitIndex): a single worker thread parses the interface section of every reachable .pas into an identifier -> unit(s) map. Two separate scopes, each with its own on-disk cache under %APPDATA%\DelphiRefactoringLight\unitindex\<hash>.idx:
    • global — the IDE's Library / Browsing paths (RTL / VCL / third-party). Keyed by the global-dir set, so every project that sees the same library paths shares one cached index. It barely changes, so after the first pass it is only re-scanned every ~10 minutes. Starts building at plugin load — no project needed.
    • project — the project's own sources plus the .dproj DCC_UnitSearchPath. Keyed by the .dproj path, re-scanned every 30 s since it changes while you edit. Added when a project opens.
  • Parsing is incremental (path + mtime + size), so only changed files are re-read. The published snapshot is an immutable, reference-counted view: the search runs on a background thread and scans it lock-free, so typing in the filter never blocks the UI or the worker.
  • The dialog pre-fills with the identifier under the cursor, filters live as you type (substring, capped at 200 rows — keep typing to narrow), and offers Add to interface uses, Add to implementation uses and Go to (open the declaring unit). The uses insertion creates a clause if the section has none and is comment-aware.
  • Limitation: DCU-only units (no .pas on the search path) are not indexed. The parser is heuristic (class / record bodies are skipped via end-counting); occasional false positives just add an extra candidate unit.

Live quick fixes (lightbulb)

Quick fixes driven by real compiler diagnostics instead of guessing. Each diagnostic is language-independently keyed on its code; affected identifiers are taken from the source position (never from the localized message text). Four providers:

  • E2003 Undeclared identifier — if the background unit index knows the identifier, the declaring unit is offered for the uses clause. If it is unknown everywhere, a fuzzy scan over the index (edit distance ≤ 1–2, incl. transpositions) offers "did you mean" renames (TStirngList → TStringList); applying one replaces the token and adds the declaring unit to uses when it is not yet reachable.
  • F2613 Unit not found — the broken uses entry gets fuzzy name corrections (Windwos → Winapi.Windows, matched against full and last-segment unit names) plus a Remove from uses action.
  • E2037 Declaration differs — rewrites the implementation header to match the declaration: parameter modifiers, types and the return type come from the declaration, the implementation's parameter names are kept (the same merge the DFM auto-fix uses). Multi-line headers are collapsed; ambiguous overloads are refused rather than guessed.
  • H2443 Inline function not expanded — the unit named in the hint is offered for uses.
  • H2164 Variable declared but never used — removes the variable from its declaration: dropped from a comma list (A, Unused, B: Integer; → A, B: Integer;), a lone declaration loses its line, and a var block left empty loses the var keyword line too. Initialized inline vars (var X := Compute;) are deliberately refused — the initializer may carry a side effect.
  • E2029/E2066 Missing ';' — inserts the semicolon at the end of the statement it terminates (same line before the unexpected token, else the previous non-blank line). Only offered when the compiler's expected token is ';'.
  • W1036 Variable might not have been initialized — inserts X := Default(<Type>); right after the enclosing routine's begin (type taken from the routine's var block; exotic type shapes are refused).
  • H2077 Value assigned never used — removes the dead assignment, but only when the whole statement sits on one line and the right-hand side cannot carry a side effect (no calls, no indexing).
  • W1010 Method hides virtual method — appends reintroduce; as the first directive after the declaration.
  • E2291 Missing implementation of interface method — copies the method's declaration from the interface (the same unit, or the unit the identifier index knows for it; the calling convention is kept) into the class's public section (created when missing) and generates the empty implementation. With several missing methods an additional Implement all N missing interface methods fix does them in one go. Overloaded interface methods are refused (the diagnostic does not say which overload is missing). Class Completion (Ctrl+Shift+C) does not do this.
  • E2065 Unsatisfied forward declaration — two shapes: a declared-but-unimplemented method or routine gets an empty implementation generated before the unit's final end. (class-qualified automatically, parameter list and return type taken from the declaration; ambiguous overloads and external declarations are refused; a constructor or destructor body chains to the ancestor: inherited; for override and destructors, inherited Create; for a TObject descendant, a TODO comment where the right ancestor constructor cannot be known) — and a forward class declaration (TNode = class;) that never received its full declaration gets a minimal TNode = class(TObject) &hellip; end; stub inserted after the forward line.

The DFM event-handler check also gained generation: missing handler rows are now auto-fixable too — the expected parameter list is resolved at check time (component source first, the built-in table with synthesized parameter names as fallback), and the fix inserts the declaration into the form class plus an empty implementation body.

Hint-based fixes (H2443, H2164) never appear in the Structure view, so they ride on the plugin's own LSP session — and to make them show up in normal IDE use, every real compile re-arms one LSP analysis of the active buffer (IOTAIDENotifier50.AfterCompile, code-insight compiles excluded): compile, and the hint fixes appear. (The Messages window itself has no read API — IOTAMessageServices can only add messages — so the diagnostics are re-derived rather than scraped.)

UI:

  • Permanent editor markers: every location with an applicable fix gets a dotted orange underline in the code editor (the affected token, or a short mark at the code edge for line-level fixes like E2037) — so fixes are discoverable even where the IDE itself shows nothing (hints!). Drawn through the official code-editor paint API (INTACodeEditorServices / TNTACodeEditorNotifier, ToolsAPI.Editor), strictly inside the IDE's paint cycle; repaints are triggered from the poll tick when the fix set changes. IDE only — the standalone's plain TMemo has no such hook.
  • Live lightbulb: whenever the caret sits on a line with at least one applicable fix, a small non-focus-stealing hint appears just below the caret (💡 Add unit for "TStringList" / 💡 2 quick fixes). Clicking it opens the quick-fix popup anchored at the caret: one row per action (candidate units expand to one row each), target-section toggle for uses additions, Apply button; double-click applies immediately, Escape / clicking elsewhere cancels. The hint is bound to the error line — it hides when the caret moves elsewhere, the line scrolls out of view, or the buffer changes.
  • Two complementary diagnostics sources (merged):
    • Structure view (IDE): the IDE's own Error Insight, tapped via the official Structure-view API (IOTAStructureView + IOTAStructureNotifier) — fires live on every re-evaluation, so error-based fixes appear in step with the IDE's red squiggle, without needing a saved project. Carries errors only.
    • Own LSP session (both hosts): a low-frequency poller pushes the (unsaved) buffer to the plugin's own DelphiLSP session once per idle buffer state (~1.5 s debounce) and collects the full diagnostics — the only source of hint fixes (H2443/H2164). Its result is the superset and replaces the structure result for the same buffer state (never the other way around), so hint fixes survive later Structure-view refreshes, reappear a few seconds after applying another fix, and show up right after opening a project — no compile needed. It never cold-starts the LSP by itself (it waits for the prewarmer), and a real compile still triggers an immediate refresh.
  • Several fixes at once: Show all quick fixes... lists every fix of the unit with its kind; tick fixes (or select one and press Tick all of this kind, e.g. every Remove unused variable) and Apply ticked. The batch runs bottom-up with the uses-clause fixes last; before each fix its line is located again (an earlier fix may have inserted or removed lines), and a fix whose line cannot be found is skipped and reported, never applied somewhere else. Add unit fixes with several candidate units are skipped (they need your choice). MCP tool: apply_quick_fixes (kind or fix_ids).
  • Structure-view double-click: double-clicking an error entry in the Structure pane's Errors node performs the IDE's normal jump-to-error and then opens the quick-fix popup right at the error position (a no-op when there is no fix for that line).
  • On-demand entries (editor context menu / Refactor menu / standalone menu):
    • Add unit for identifier at cursor — resolves the identifier under the caret (single candidate: applied immediately; several: chooser popup). The Refactor-menu entry is context-sensitive: it shows "(N found)" when the live check has fresh results and is disabled when the check is certain there is nothing to fix.
    • Resolve missing units... — batch dialog listing every unresolved identifier in the file with its suggested unit and target section; check the ones to add.
  • Section-aware uses editing: the target section defaults to interface when the identifier is used above implementation, else implementation. If the unit is already listed under implementation but is now needed at interface level, it is moved (removed from the implementation clause, added to the interface clause — listing it in both would not compile); the reverse direction is a no-op since interface-level uses cover the implementation too. Clause rewriting handles single-line and one-unit-per-line layouts and preserves comments; clauses containing {$IFDEF}s are never rewritten (the action is refused rather than risking a mangled clause).
  • Candidate lists are deduplicated by unit name (third-party libraries often ship the same source in several directories); a project-scope hit wins over a library hit.
  • Limitation: shares the index limitations of Find unit for identifier (DCU-only units are unknown). The live check only reports identifiers with at least one known unit — everything else stays a plain compiler error.

Project checks (menu only)

Three checks for problems the compiler does not catch, reachable via Refactoring Light → Project checks. Each opens a results dialog; double-click / Go to jumps to the offending location.

  • DFM event handlers — scans every form's .dfm for OnXxx = Handler references and flags two failure modes the compiler ignores (the .dfm binds handlers by name):
    • Missing handler — the referenced method does not exist → the form crashes with "Method not found" on load.
    • Signature mismatch — the handler's parameter list deviates from the event type → stack corruption when the event fires. The expected signature is resolved from the component's actual source on the search / browsing path (version-correct, no hard-coded tables), and compiler directives inside parameter lists (var NodeHeight: {$if CompilerVersion >= 36.0}TDimension{$else}Integer{$ifend}) are resolved to their active branch before comparing, so IFDEF'd signatures are not falsely flagged. // comments inside multi-line signatures are stripped as well.
    • The Details column bold-highlights the differing parameter types; interchangeable integer aliases (Integer / LongInt / ...) can optionally be ignored. An "Auto-fix" column marks rows the plugin can correct itself: checking them and pressing Fix checked signatures rewrites both the class declaration and the implementation header (anchored by name, not line number, so fixing several handlers in one file stays correct), keeps the handler's own parameter names (only types / modifiers are corrected), and adds any newly-required unit to the uses clause — the declaring unit of each parameter type is resolved via LSP GotoDefinition and cached across the batch. Files are written straight to disk when not open in the IDE (so a large batch does not flood the editor with tabs); an optional Apply via IDE (open form + save) mode re-streams the DFM instead.
  • Interface GUIDs — lists every interface / dispinterface and its GUID; duplicates are shown in red on top. A GUID shared by a matching interface and dispinterface pair is not counted as a duplicate.
  • Circular unit references — a Tarjan strongly-connected-components analysis over the project's uses graph. Tabs for the cycle groups, the shortest / longest cycle lengths (how many units a cycle spans), the individual edge levers (ranked by how many units would leave the cyclic set if that one uses entry were removed), the complete list of simple cycles, and the hotspots (which unit appears in the most cycles, and via which uses entry). Double-click jumps to the exact uses entry.

Standalone executable

The same wizards also ship as a self-contained Windows .exe that runs without the Delphi IDE. The standalone hosts the wizards behind an IEditorHelper abstraction: the standalone host manages the project file, the file tree, and an embedded TMemo, then exposes them to the wizards exactly the way the IDE plugin does. Useful for bulk refactorings, scripted runs, or working on a machine where the IDE itself is locked. See Standalone/README.md for build instructions, the parity matrix (which wizards work, which need TSynEdit first), and architecture notes.

Requirements

  • Delphi 13 (BDS 37.0) — tested with the bundled DelphiLSP.exe. The LSP executable path is read from the registry (HKCU\Software\Embarcadero\BDS\<version>\RootDir) with a fallback via IOTAServices.GetRootDirectory, so the package works with any standard RAD Studio installation path.
  • One *.delphilsp.json per project next to the .dpr/.dpk (see DelphiRefactoringLight.delphilsp.json for an example). The package hands this file to the LSP via workspace/didChangeConfiguration.

Installation

Recommended: via the Delphi Install Helper (DIH)

install.cmd

The script:

  1. builds the helper tool delinst.exe from the dih\ sub-folder,
  2. reads DelphiRefactoringLight.xml,
  3. builds Packages\DelphiRefactoringLight.dproj for both IDEs (Win32 and Win64, Release), and
  4. registers each BPL in the matching registry branch.

32-bit and 64-bit IDE

RAD Studio 12+ ships two IDEs - bin\bds.exe (32-bit) and bin64\bds.exe (64-bit) - and they do not share their package registration. The installer therefore handles both in one run:

IDE Platform BPL Registry branch
bin\bds.exe Win32 $(BDSCOMMONDIR)\Bpl\DelphiRefactoringLight370.bpl ...\37.0\Known Packages
bin64\bds.exe Win64 $(BDSCOMMONDIR)\Bpl\Win64\DelphiRefactoringLight370.bpl ...\37.0\Known Packages x64

A design-time package built for Win32 cannot be loaded by the 64-bit IDE and vice versa, so registering one BPL in both branches does not work - each IDE needs its own build.

The 370 suffix comes from {$LIBSUFFIX AUTO} in the .dpk (the product version without the dot). Removing that directive makes the build produce DelphiRefactoringLight.bpl while the registration still points at the suffixed name - the package then has to be installed by hand.

The Known Packages mechanism is the standard registration path for BPL-based wizards in Delphi: the IDE loads the package, calls its Register procedure, which Registers the main wizard via RegisterPackageWizard and the key bindings via IOTAKeyboardServices.

An IDE restart is required afterwards so the package is loaded cleanly. The package itself shows a restart hint dialog after installation.

Further scripts:

  • rebuild.cmd — build only (no IDE registration).
  • uninstall.cmd — remove the package from the IDE registry.

MCP bridge for Claude Code

install.cmd also builds RefactoringLightMcp.exe (copied to %LOCALAPPDATA%\DelphiRefactoringLight\mcp\) and prints the line that registers it with Claude Code. It connects Claude Code to every running IDE with the plugin, so rename, find references, quick fixes and the rest work on the IDE's live buffers.

Every refactoring of this plugin is reachable through the bridge: rename, change signature, safe delete, convert properties, semantic replace, move to a new or an existing unit, extract method, extract variable, extract interface, add IInterface support, wrap in try..finally, remove with, uses cleanup, expand includes, add / remove unit, align method signature, the DFM event-handler fix and the quick fixes — plus the read-only checks and searches (find references, implementations, unit references, original symbol, unit for an identifier, uses paths and cycles, interface GUIDs, debug consistency, blame). What runs in the IDE and what an agent asks for is the same code; only the dialog is missing.

Nothing is written without being shown first. Every tool that changes code takes apply (default false): the answer then lists the changes as file, line, before, after and, where a single buffer revision can be pinned, hands out a token. The tools that re-run their whole analysis inside the apply call (extract method, extract interface, add IInterface, align signature, DFM events) need none — there is no window between preview and apply for a buffer to change in. Passing that token with apply: true applies the change — and refuses when one of the files changed in the meantime, so an agent can never apply a change it was never shown. get_quick_fixes follows the same idea: each fix carries the diagnostic behind it (code, message, position), the affected line verbatim and, where the fix is a text edit, the very changes the apply would make.

What it costs a session: Claude Code keeps the tool schemas out of the context until one is needed (tool search), so what is always loaded is the server's short instructions and the tool names. While no IDE runs, the bridge offers only its own two tools (ide_instances, select_ide); the others appear by themselves as soon as an IDE with the plugin starts, in a running session too.

Where to register it:

  • --scope user (the printed default): available in every Claude Code session on the machine. Convenient if you mostly work on Delphi code.
  • --scope local: only in the folder where you run the command, and not written to any file in the repository. Use this if you also work on other code and want nothing Delphi-related in those sessions. Run it once per Delphi project folder.
  • --scope project is not recommended: it writes the exe's path, which differs per machine, into a .mcp.json that is meant to be committed.

The server name is your choice (claude mcp add <name> ...); the bridge works under any name. It becomes the prefix of every tool name, so a shorter one saves a few tokens - but permission rules you wrote for the old name (mcp__delphi-refactoring-light__*) then need updating.

Alternative: manual install inside the IDE

  1. Open Packages\DelphiRefactoringLight.dproj in RAD Studio.
  2. Platform: Win32 for the 32-bit IDE, Win64 for the 64-bit IDE (bin64\bds.exe); configuration: Release.
  3. Right-click the package in the Project Manager → Install.
  4. Restart the IDE.

Note: The manual path also registers under Known Packages, but with whichever output directory the IDE used (usually .\Output\...). If you later switch to install.cmd, a second entry with a different path appears — the package is then loaded twice (visible as duplicate keyboard shortcut conflicts and access violations). Remove the stale entry manually:

reg delete "HKCU\Software\Embarcadero\BDS\37.0\Known Packages" /v "<old path>\DelphiRefactoringLight.bpl" /f

Project Layout

DelphiRefactoringLight/
|
|-- Source/                                  # All Pascal units
|   |-- Expert.Registration.pas              # Register, wizard init
|   |-- Expert.RenameWizard.pas              # IOTAMenuWizard for Rename
|   |-- Expert.RenameDialog.pas              # Preview dialog with list view + details
|   |-- Expert.CompletionWizard.pas          # Completion trigger
|   |-- Expert.CompletionPopup.pas           # Owner-drawn popup
|   |-- Expert.ExtractMethod.pas             # Extract-method logic
|   |-- Expert.ExtractMethodDialog.pas       # Progress / preview dialog
|   |-- Expert.PascalScanner.pas             # The shared Pascal lexer + identifier / comment / string helpers
|   |-- Expert.IncludeExpansion.pas          # {$I} expansion with a position map; DelphiLSP inside include files
|   |-- Expert.IncludeExpander.pas           # "Expand include files" (marked, for debugging) + MCP tool
|   |-- Expert.InterfaceLinks.pas            # Interface <-> class links of a method (find references, safe delete)
|   |-- Expert.PropertyConvert.pas           # Property converter: the planner (pure)
|   |-- Expert.PropertyConvertWizard.pas     # Property converter: dialog + MCP tool
|   |-- Expert.SelectionValidator.pas        # Extract Method: selection validation
|   |-- Expert.FindReferencesWizard.pas      # Find-references logic
|   |-- Expert.FindReferencesDialog.pas      # Results dialog with list view
|   |-- Expert.ImplementationFinder.pas      # Shared impl finder (rename + find-impl)
|   |-- Expert.FindImplementationsWizard.pas # Find-implementations wizard
|   |-- Expert.SafeDeletePlan.pas            # Safe delete: what would be removed, vetoes (pure)
|   |-- Expert.SafeDelete.pas                # Safe delete: usage check via DelphiLSP, dialog, MCP tool
|   |-- Expert.SignatureEdit.pas             # Change signature: parameter / argument lists, call classification, the plan (pure)
|   |-- Expert.ChangeSignature.pas           # Change signature: family, verified calls, dialog, MCP tool
|   |-- Expert.MethodEdit.pas                # Edit methods: members, move plan, modifier / signature edits (pure)
|   |-- Expert.MethodEditWizard.pas          # Edit methods: analysis, occurrences, dialog, MCP tool
|   |-- Expert.SignatureCheck.pas            # Signature collection / normalization
|   |-- Expert.SignatureCheckDialog.pas      # Align-signature dialog
|   |-- Expert.SignatureCheckWizard.pas      # Align-signature wizard
|   |-- Expert.WithScanner.pas               # Tokenizer for `with` occurrences
|   |-- Expert.WithRewriter.pas              # Per-occurrence inline-var + prefix rewrite
|   |-- Expert.WithRefactorDialog.pas        # Review dialog (Diff / Debug tabs)
|   |-- Expert.WithRefactorWizard.pas        # Project-wide remove-with orchestrator
|   |-- Expert.UnitReferencesWizard.pas      # Find-unit-references wizard
|   |-- Expert.UnitReferencesDialog.pas      # Find-unit-references dialog
|   |-- Expert.UnitRenameWatcher.pas         # Catches IDE unit-renames -> rename wizard
|   |-- Expert.MoveToUnit.pas                # Move-identifier engine
|   |-- Expert.MoveToUnitDialog.pas          # Target-unit picker dialog
|   |-- Expert.MoveToUnitWizard.pas          # Move-identifier wizard
|   |-- Expert.ExtractInterface.pas          # Extract-interface engine: class parser, interface emitter, clash detection
|   |-- Expert.ExtractInterfaceDialog.pas    # Member checklist + live preview dialog (extract / extend)
|   |-- Expert.ExtractInterfaceWizard.pas    # Workflow: LSP type resolution, file emit, class rewrite
|   |-- Expert.SemanticReplace.pas           # Semantic-replace engine: rule loader, scanner, applier
|   |-- Expert.SemanticReplaceDialogs.pas    # Rule editor, unit picker, preview dialogs
|   |-- Expert.SemanticReplaceWizard.pas     # Workflow: scope picking, dry-run + preview, push edits
|   |-- Expert.DfmEventCheck.pas             # DFM event-handler check engine + signature auto-fix
|   |-- Expert.DfmEventCheckDialog.pas       # DFM event-handler results dialog (bold diff, auto-fix)
|   |-- Expert.InterfaceGuidCheck.pas        # Interface/GUID collection + duplicate detection
|   |-- Expert.InterfaceGuidDialog.pas       # Interface GUID results dialog (live refresh)
|   |-- Expert.UsesGraph.pas                 # Uses-graph + Tarjan SCC / cycle / lever / hotspot analysis
|   |-- Expert.CircularRefsDialog.pas        # Circular-reference results dialog (tabs)
|   |-- Expert.UnitIndex.pas                 # Background identifier index (global + project scopes)
|   |-- Expert.UsesEditor.pas                # Section-aware "add/move unit in uses" (interface/implementation)
|   |-- Expert.FindUnitDialog.pas            # Find-unit-for-identifier dialog (threaded search)
|   |-- Expert.AutoImport.pas                # Auto-import engine + live lightbulb + chooser/batch UI
|   |-- Expert.StructureErrors.pas           # IDE-only: taps Error Insight via the Structure-view API
|   |-- Expert.LspPrewarmer.pas              # IOTAIDENotifier: pre-warms LSP + unit index on project open
|   |-- Expert.PluginSettings.pas            # Plugin settings (registry-backed)
|   |-- Expert.OptionsFrame.pas / .dfm       # Tools > Options > Refactoring Light page
|   |-- Expert.OptionsPage.pas               # ToolsAPI options-page registration
|   |-- Expert.ContextMenu.pas               # "Refactoring Light" submenu in the editor popup + IDE Refactor menu
|   |-- Expert.Shortcuts.pas                 # User-configurable shortcut storage
|   |-- Expert.KeyBinding.pas                # Shortcut registration
|   |-- Expert.EditorHelperIntf.pas          # IEditorHelper interface (host abstraction; no ToolsAPI deps)
|   |-- Expert.EditorHelper.pas              # ToolsAPI-backed IEditorHelper impl + backwards-compat facade
|   |-- Expert.LspManager.pas                # LSP client singleton + project indexer
|   |-- Expert.IdeCodeInsight.pas            # IDE-internal code insight wrapper
|   |-- Expert.RestartHint.pas               # PID-based restart hint
|   |-- Lsp.Client.pas                       # LSP client (asynchronous)
|   |-- Lsp.JsonRpc.pas                      # JSON-RPC 2.0 transport
|   |-- Lsp.Protocol.pas                     # LSP data types
|   |-- Lsp.Uri.pas                          # URI <-> path
|   |-- Rename.WorkspaceEdit.pas             # WorkspaceEdit apply helper
|   `-- Delphi.FileEncoding.pas              # BOM detection
|
|-- Packages/                                # Delphi project files
|   |-- DelphiRefactoringLight.dpk           # Package source
|   |-- DelphiRefactoringLight.dproj         # Project file
|   `-- DelphiRefactoringLight.res           # Resources
|
|-- Standalone/                              # Standalone Windows .exe host
|   |-- RefactoringLightStandalone.dpr       # Application entry point
|   |-- RefactoringLightStandalone.dproj     # msbuild project (defines STANDALONE_BUILD)
|   |-- Standalone.MainForm.pas / .dfm       # Main form: file tree + TMemo + menu
|   |-- Standalone.EditorHelper.pas          # IEditorHelper impl backed by .dproj XML + in-memory buffers
|   |-- build.bat                            # msbuild driver (Debug | Release)
|   `-- README.md                            # Build, parity matrix, architecture
|
|-- dih/                                     # Delphi Install Helper tool
|   |-- delinst.dpr / .dproj / .res
|   |-- DIH.*.pas                            # Engine, builder, registry, ...
|   |-- builddih.cmd                         # builds delinst.exe
|   `-- closedialog.ps1                      # helper script for bds.exe
|
|-- DelphiRefactoringLight.xml               # DIH configuration
|-- install.cmd                              # Install via DIH
|-- uninstall.cmd                            # Uninstall via DIH
`-- rebuild.cmd                              # Build only

Architecture Notes

  • Singleton LSP client (TLspManager): one DelphiLSP.exe instance shared across all requests. LSP executable path is resolved from the registry via IOTAServices.GetBaseRegistryKey, with a fallback to GetRootDirectory and a last-resort hard-coded path.
  • Single-process LSP mode (not serverType: controller): the package sends minimal initializationOptions and a full set of client capabilities (hover, definition, documentSymbol, publishDiagnostics with tagSupport, ...). An earlier release tried to mirror the IDE's serverType: controller + agentCount: 2 setup — this is what RAD Studio itself uses to get inactive-region diagnostics — but DelphiLSP's Agent0/Agent1 sub-processes terminate within seconds when spawned outside BDS.exe (verified by replaying the IDE's exact byte sequence into a fresh DelphiLsp_real.exe: every textDocument/hover came back as -32800 "Request removed" or -32603 "Internal server error"). Single-process mode trades reduced diagnostic coverage for reliable hover/definition, which most refactorings depend on.
  • Sequential requests: DelphiLSP does not tolerate parallel requests — the client uses BatchSize = 1.
  • didChange after didOpen: RefreshDocument follows didOpen with a didChange notification (version 2, full document text). The IDE proxy log shows DelphiLSP only starts diagnostic analysis after the first didChange; without it, documentSymbol and hover time out on cold files.
  • Project-open LSP pre-warmer (TLspPrewarmer): an IOTAIDENotifier listens for ofnFileOpened of .dproj/.dpr files and kicks off a background thread (priority THREAD_PRIORITY_BELOW_NORMAL) that calls EnsureProjectIndexed. By the time the user triggers their first refactoring, DelphiLSP usually has the project fully analysed. Opt out via Tools → Options → Refactoring Light → "Pre-warm DelphiLSP ...".
  • Per-file diagnostic wait: refactoring wizards that depend on diagnostics (currently the Remove with wizard, for inactive-region detection) explicitly block until publishDiagnostics for the file in question has arrived (up to 30 s timeout per file). Idempotent: files that already have diagnostics return instantly. When no diagnostics arrive within the window, affected occurrences are reported as "LSP no diagnostics — skipped (dead-code unknown)" rather than rewritten on guesswork.
  • Inactive {$IFDEF}-region tracking: TLspClient parses every publishDiagnostics notification, filters for source = "DelphiLSP" + tag = Unnecessary (or code = H2655/H2656), and stores the resulting line-range table per file. IsLineInactive(file, line) is a direct lookup. HasReceivedDiagnostics(file) tells callers whether a False from IsLineInactive means "verified active" or "no data".
  • LSP-driven type-to-unit resolution (Extract Interface): for each type identifier referenced by the chosen interface members, the engine collects the line of the member that introduced it, then runs textDocument/definition on that exact column (a localised search inside [M.LineStart, M.LineStart+3] avoids the off-by-line bugs that a whole-file scan would hit on mixed line endings or duplicated identifiers like TForm inside TForm11). The resulting target-file path is mapped back to a unit name. Partial-failure mode: when LSP cannot resolve every type (often because the source unit itself has compile errors), the result is unioned with the source unit's own interface-uses so the new / extended interface unit still compiles. A diagnostic dialog after the run reports <resolved>/<attempted> and the LSP error if any.
  • Single-pass comment- and string-aware scanner (Semantic Replace): one state machine walks the source text from start to end with five states (code / line-comment / brace-comment / paren-star-comment / string), so matches inside strings and comments are skipped without a separate tokenisation pass. The same routine emits matches for ALL rules at once (whole-identifier-bounded), which keeps the per-file cost linear in the file size regardless of rule count. A second comment-aware pass discovers routine bodies (procedure / function / constructor / destructor plus matching end; with proper nesting), so the local-var hoisting decision can be made per-routine.
  • One shared Pascal lexer (Expert.PascalScanner): token kinds with line/column, comments and {$...} directives on request, Delphi 12 multi-line strings (''' ... ''') as one token, char literals (#13, #$0D), numbers with $/%/_/exponent (and 1..5 as a range, not a float), &-escaped identifiers and Unicode identifiers. The same unit supplies IsIdentStart/IsIdentChar/IsIdentifier, StripLineComment (string-aware: a 'http://...' literal stays intact) and MaskCommentsAndStrings, which blanks comments, directives and strings while keeping every position. Before, about twenty private copies of these helpers gave different answers (suggestion from issue #11).
  • IOTAEditWriter instead of InsertText: byte-precise edits without IDE auto-indent interference.
  • Module.Refresh(False) instead of True: reloads the form module without discarding in-editor changes.
  • PID-based restart hint: a marker file in %TEMP% stores the process ID; if it matches the current IDE, the package was re-installed during the running session and a restart hint is shown.
  • Background identifier index (TUnitIndex, for Find unit for identifier): a single low-priority worker thread parses the interface sections of the whole search path into an identifier -> unit(s) map, split into a project-independent global scope (IDE library / browsing paths, shared across projects, rarely re-scanned) and a project scope (project sources + DCC_UnitSearchPath, re-scanned every 30 s). Both are cached to disk (path + mtime + size, incremental). The main thread only gathers the source roots via ToolsAPI; the worker does the file I/O and publishes an immutable, reference-counted snapshot whose reference is swapped under a lock, so lookups scan lock-free on any thread. The dialog's substring search therefore runs on a background thread and never blocks the UI.
  • IDE main-menu integration (TContextMenuInstaller): the same "Refactoring Light" action tree that hangs in the editor popup is also injected directly into the IDE's top-level Refactor menu (mnuRefactoring). The menu is located language-independently (component name, then a normalized-caption match against the shipped IDE translations, then an empty-placeholder heuristic). Because that menu is driven by an action (actnMMRefactoring) that the IDE disables when there is no refactoring context, the plugin hooks the action's OnUpdate to keep it openable and to enable/disable each of its own entries by context (no project → all disabled; project but no active editor → only the project-wide entries). The IDE's own (contextually disabled) Refactor entries are hidden while the plugin is loaded and restored on uninstall. The editor popup's own Refactoring entry is taken over the same way: on every popup round the entry is re-located (name / normalized-caption match), hidden, and the plugin's tree is inserted at its position under its localized caption; the takeover is undone before the IDE's own OnPopup bookkeeping runs, so the IDE always sees its menu unmodified. Diagnostics: the first popup (and a failed match) dumps the popup structure to %TEMP%\RefactoringLight-editorpopup.log.
  • Error-Insight tap via the Structure view (Expert.StructureErrors, IDE only): there is no public ToolsAPI for reading Error Insight diagnostics directly, but the Structure pane mirrors them — and the pane IS scriptable: BorlandIDEServices implements IOTAStructureView (see StructureViewAPI.pas), and an IOTAStructureNotifier receives StructureChanged on every re-evaluation. Error entries are parsed language-independently (the E2003 code prefix and the trailing (line:col) are the same in every IDE language; the identifier itself is read from the buffer at that position, never from the localized message). This makes the IDE's own analysis the primary diagnostics source for the auto-import lightbulb — zero extra analysis cost, works in unsaved projects.
  • Notifier threading rule (hard-won): the Structure notifier is dispatched from CheckSynchronize during the IDE's LSP refresh — and TThread.Queue/ForceQueue procs run in CheckSynchronize too. Touching windows there (show/hide/SetWindowPos/opening popups) triggers a synchronous activation cascade into TEditWindow.ActivateModule → TParseThread.CancelAndLock, which can deadlock the IDE against its parser thread. The plugin therefore updates only STATE in notifier/queue callbacks; every hint/popup window operation runs from plain WM_TIMER ticks.
  • Live lightbulb mechanics (TAutoImportLive): a 400 ms UI timer reads the active buffer via two cheap, side-effect-free helpers (GetActiveFileName from TopBuffer, GetCaretLineCol from TopView.CursorPos — deliberately NOT GetCurrentContext, which moves the edit position and would collapse the user's selection when called from a timer). The hint window is WS_EX_NOACTIVATE (clicking it never steals the editor focus) and is shown/hidden via raw ShowWindow calls — symmetrically, since a VCL Hide would be a no-op for a window VCL never marked visible. In LSP-fallback mode the poller sends the (unsaved) buffer content on the main thread and lets a worker thread wait for the diagnostics push; the worker never touches ToolsAPI.

Tests

Tests\ holds a DUnitX project (DelphiRefactoringLightTests.dproj) for the IDE-free layer: scanners, parsers, the uses editor, the quick-fix providers, file encoding and URI conversion. Neither the IDE nor DelphiLSP is needed. Run Tests\run-tests.cmd (Win32) or Tests\run-tests.cmd Win64; the exit code is 0 when every test passes.

Line endings and encoding: every Delphi source is UTF-8 with BOM and uses CRLF. .gitattributes pins CRLF on checkout for sources, projects, scripts and documents (text form files only when they are not binary; .res & co are binary), independent of your core.autocrlf. The test TRepoHygieneTests fails when a source without BOM or with LF line endings appears. LF-only sources are not cosmetic: the debugger can put breakpoints on the wrong lines.

Contributors

  • Ian Branch — the code audit in issue #10 (together with Claude Code), which led to a round of robustness and safety fixes (file encoding, editor write paths, LSP session lifetime, quick-fix guards, with rewriting), the DUnitX test fixtures in Tests\, and the ideas in issue #11.
  • Dumach — Find original symbol (PR #9).
  • kalwados — Delphi 12.x compatibility (PR #12).
  • The testers in the Delphi-PRAXiS thread, whose reports shaped many of the fixes.

AI Disclosure

This project was developed with the assistance of Claude (Anthropic). The architecture, design decisions, requirements, and quality assurance were provided by the human author. The AI assisted with code generation and documentation.

License

This project is licensed under the Mozilla Public License 2.0.

About

This expert for the Delphi IDE brings back some refactoring based on the DelphiLSP

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages