Untangling Circular Uses in Delphi
drag-lint finds circular uses groups, shows them in the RAD Studio IDE, and can cut one edge for you behind a compile gate that rolls back on failure. This article walks through what you click and what you see first, then what the engine does underneath — and, just as important, what it refuses to do.
We build drag-lint alongside our main product, Micronite — quality and production-control software for manufacturers — and the cycle fixer was tried on copies of that product’s own client project as well as on the small demos below.
A cycle that compiles is still a cycle
Delphi only forbids a mutual interface-section cycle: if two units use each other in their interface sections, the compiler stops with F2047 Circular unit reference. A cycle that is closed by one implementation uses compiles fine — and quietly keeps the units welded together. Our running example is two units from the drag-lint repository, docs\examples\circular-uses-demo.
unit Customers;
interface
uses
Orders; { interface uses: half of the cycle }
type
TCustomer = class
private
FOrders: TArray<TOrder>; { forces the uses }
...
unit Orders;
interface
type
TOrder = class ... end; { holds its customer as a TObject }
implementation
uses
System.SysUtils,
Customers; { implementation uses: closes the cycle }
Customers needs TOrder in its interface. TOrder.Describe reads the customer’s name, so Orders reaches back into Customers from its implementation. Legal, but a change to Orders now recompiles Customers and vice versa.
Where to click to see the cycles
Cycle detection reads unit-to-unit uses edges from the drag-lint index, not from the files on disk, so the project must be indexed first. Then, in RAD Studio:
drag-lint > Uses & Dependencies > Circular Uses Report (cycles + fix plan)...
The menu item runs cycles --plan --edges --causes against the active project’s index and writes the result as a text report. Two other entry points show the same cycles from a different angle:
drag-lint > Reports > Circular unit dependencies...draws a chart of each group with every measuredusesedge inside it and how heavy each edge is.- The drag-lint Structure window’s Diagnostics tree carries a
circular-useswarning on every unit that is part of a cycle. The rule is on by default.
What the report says
For the demo, the edge listing and the cause listing are, verbatim:
1 circular unit group(s) found:
[2 units] customers <-> orders (has interface coupling -- widest recompile blast radius)
customers uses orders [interface <-- move-to-implementation candidate]
orders uses customers [implementation]
1 circular unit group(s) found:
[2 units] customers <-> orders (has interface coupling -- widest recompile blast radius)
* customers's INTERFACE needs orders via:
line 13: TOrder [class] -> move/extract this
The first block is cycles --edges, the second cycles --causes. A group with an interface edge is flagged as the one with the widest recompile blast radius; an implementation-only group is marked “legal, lower impact”.
The same cycle also shows up per file as a lint finding, which carries its own fix command:
Orders.pas:25:1 [warning] circular-uses: Circular unit dependency among 2 units:
Customers -> Orders -- it already routes through an implementation-section uses,
so it compiles; this is a coupling smell, not a build error. Extract the shared
code into a new unit (moving another use to the implementation section will NOT
break this cycle)
fix: drag-lint cycles --fix --project <X.dproj> --unit orders
Asking drag-lint to untangle it
There are three places to start the fix. All three run the same engine verb, cycles --fix, with the active project, its index and the IDE’s active platform. The IDE gives each such job a 120 minute limit, longer than the engine’s own limits, so the engine always times out and rolls back before the IDE would ever kill it.
| Where | What you click | What it does |
|---|---|---|
| The drag-lint menu | drag-lint > Uses & Dependencies > Fix Circular Uses Mechanically (dry run)... |
Dry run only The engine picks the group. There is no apply from a menu click. All files are saved first, because the engine plans and compiles from disk. |
| The Uses & Deps tab | View > Tool Windows > drag-lint, tab Uses & Deps, section This project -- Circular uses (cycles --fix) |
Dry run, then Apply One row per cycle group, with Dry run and Apply buttons. Apply asks first (Yes / No, default No). |
| The Structure window | Diagnostics tree, right-click a circular-uses finding: Fix this cycle (dry run)... or Apply cycle fix... |
Dry run, confirm, apply On a cycle finding these two items replace the generic Fix it. Apply cycle fix... runs the dry run, asks you, and only then runs the apply. |
Step 1: the dry run
A dry run plans the group, checks the plan against the files on disk, stages the result in a shadow tree and prints a verdict. No source file of your project is written. It does build your project once (the baseline build, see below), and that build writes what any build writes: DCUs and the exe.
The verdict is one of two things. For the demo pair it is a refusal, and the refusal says why:
Cycle 1: customers <-> orders
- base-class: TOrder Orders.pas:6 -- refused: base-class -- `Customers.pas:13`:
`TOrder` is used here as something other than a parameter type (a field,
variable, cast or type argument)
Verdict: refused
compile: baseline 2.2 s
Real dry run on the demo pair, exit code 1. Customers holds a field of TOrder, and TOrder’s own method bodies use TCustomer, so neither can simply move. drag-lint does not guess; the group stays yours, and --plan still prints the playbook and the standard remedy.
For a group the engine can cut, the verdict is mechanical. This is the three-unit group from the repository’s larger circular-demo project:
Fix it (mechanical, compile-gated):
cuts : demoblueprint -> demoblueprint.viewmodel (routine-call inversion) (chosen)
requires: the project builds with 0 errors before the fix (the dry run checks this)
Verdict: mechanical (dry run; add --apply to write)
Shadow tree: ...\shadow\
compile: baseline 1.6 s
Exit code 0. Above this block the dry run prints the whole plan: why the group cycles, which edge it cuts, every edit as current text and new text.
Step 2: what you accept or refuse
The apply confirmation is built from the dry run’s result. It names the units of the cycle, the recipe and the edge it cuts, how many files will be edited, the names of any new units (or “none”), the baseline build status and time, and what happens if the build fails. The default button is No; answering No leaves the files untouched and the note reads “cancelled -- nothing written”.
Two further things can stop an apply before it starts. The IDE refuses to apply while any file the plan edits has unsaved changes (the apply rewrites those files on disk and reloads the buffers, which would drop your edits). And a refused verdict has no Apply at all: the fix is simply not offered.
What you accept with Yes is bounded. Every file the fix touches is backed up as <file>.BCK<n> with a recovery.txt; the project is built in place; and if that build fails, every file is put back. You can also refuse by choosing a different group: one run fixes one circular group, and the text output ends with one line naming each group it did not plan.
Step 3: the apply, on a copy
We ran the apply on a scratch copy of circular-demo:
Verdict: mechanical
Applied: 3 file(s) written, 0 new; compile ok
backup: ...\DemoBlueprint.pas -> ...\DemoBlueprint.pas.BCK1
backup: ...\DemoBlueprint.ViewModel.pas -> ...\DemoBlueprint.ViewModel.pas.BCK1
backup: ...\DemoBlueprint.Interfaces.pas -> ...\DemoBlueprint.Interfaces.pas.BCK1
Re-index: done
Verified: the re-indexed `cycles` output matches the prediction
compile: baseline 1.4 s, after 1.5 s
Exit code 0. The three-unit group is gone from the re-indexed cycles output; the other two groups of that project were left alone, as the plan predicted.
Which cycle, and which edge
Finding the cycles
The index records every unit’s uses entries, tagged interface or implementation. A “cycle” in the report is a strongly connected component of that graph, not a ring: two loops that share a unit form one group, and the report draws the edges that exist rather than inventing a traversal order. Each group is classified by its edges:
- Interface-coupled: at least one unit uses another in its interface. Widest recompile blast radius, so it comes first.
- Implementation-only: legal, lower impact, optional to fix.
Two parts
The fix is split to match. Part A removes the interface coupling: it turns an interface-coupled group into a legal implementation-only one. Part B then cuts one edge of what remains. --part a or --part b asks for one; --part all applies A, re-indexes and re-plans, then applies B, in one run (it needs --apply).
Choosing the edge
For Part B the engine evaluates every edge of the group and weighs the recipes in a fixed order: move globals, then routine-call inversion, then member hand-over. Within one recipe the edge whose surface has the fewest members wins, then the one with the fewest references. One edge is cut per run, and every other edge keeps its reason for not being cut. On the three-unit group the plan prints exactly that:
- demoblueprint -> demoblueprint.viewmodel: recipe: routine-call inversion -- cut by this plan
- democontrolplan -> demoblueprint: not mechanical: TDemoBlueprint (class): its method
bodies use DemoBlueprintViewModelCreate, which live in units of this cycle --
they would have to move with it
- demoblueprint.viewmodel -> democontrolplan: not mechanical: TDemoControlPlan (class):
its method bodies use TDemoBlueprint, which live in units of this cycle --
they would have to move with it
What the engine actually edits
Part A: move declarations into a leaf
Enums, records, constants and variables that tie the cycle together move, unchanged, into a new leaf unit named <Declaring>.Untangle (or an existing unit that is still a leaf). Each consuming unit then gets a uses decision: keep the old partner, move it from the interface uses to the implementation uses when only the implementation still needs it, or remove it when nothing else from it is used. A class whose method bodies use the cycle is not moved. Where every use of it is a const or value parameter that only reads members, a base class carrying just those members is extracted instead; anywhere else the plan refuses with base-class, as the demo pair did above.
Part B, recipe R1: routine-call inversion
When an edge is exactly one routine call and every type in the routine’s signature is declared outside the cycle, the consumer calls it through a procedural variable declared in a leaf. The declaring unit assigns the variable in its initialization section. This is the real before and after for DemoBlueprint:
{ DemoBlueprint.pas -- before }
implementation
uses
DemoBlueprint.ViewModel; // <-- the edge to cut: used for ONE routine call
...
FViewModel := DemoBlueprintViewModelCreate(FFolderId);
{ DemoBlueprint.pas -- after }
implementation
// <-- the edge to cut: used for ONE routine call
...
FViewModel := DemoBlueprintViewModelCreatePtr(FFolderId);
{ DemoBlueprint.Interfaces.pas -- the leaf gains }
TDemoBlueprintViewModelCreatePtr = function(const pFolderId: Integer): IDemoBlueprintViewModel;
var
DemoBlueprintViewModelCreatePtr: TDemoBlueprintViewModelCreatePtr;
{ DemoBlueprint.ViewModel.pas -- the declaring unit gains }
initialization
DemoBlueprintViewModelCreatePtr := DemoBlueprintViewModelCreate;
The signature is copied verbatim, every const and parameter name included. A comment that sat on the removed uses line stays behind as a lone comment.
Part B, recipe R2: member hand-over
When an edge is only is / as checks against one class plus reads of its members, the class implements an I<Class>Surface interface declared in its leaf, and the consumer goes through Supports / as on the interface.
Shadow tree, compile gate, atomic apply
Is the project clean to begin with?
Before anything is staged, the baseline build runs on your untouched project (msbuild /p:Config=Debug, for the platform you name; the IDE passes its active one). If it does not build, the verdict is dirty-baseline, nothing is written, and the fix is not offered. The Release configuration is never built.
Can the plan be applied to the files exactly as they are?
The plan is checked against the files on disk and staged in a shadow tree (C:\TEMP\draglint_cyclefix_<pid>\ by default, deleted at exit unless a message of the run cites a file in it; --shadow-out <dir> keeps one). A file that changed after it was indexed gives stale-file; a line that is not what the planner saw gives anchor-mismatch.
Does the result compile?
On --apply the backups are made, the edits are written and the project is built in place. One bounded repair is allowed on a compile error (R4: a cast through the plan’s ancestor, a missing leaf unit in uses, a re-emitted pointer type); anything else, or a second failure, rolls every file back.
Did it do what it said it would?
The project is re-indexed (unless --no-reindex) and cycles is run again. Its output must equal the plan’s prediction, or the result is reported as written but not verified. An apply that skips the compile is refused outright (--apply --no-compile is exit 2).
The write is all-or-nothing. A write or build that fails part-way ends with every file restored byte for byte, or, if the restore itself fails, the failure named. One caveat is stated in the engine’s own docs: a rollback restores your sources, but not the DCUs the failed build wrote, so rebuild the project after a rollback before you trust its output.
What it will not do, and why
The engine never guesses. Every refusal prints as Verdict: refused with one line per reason, and the kinds fall into a few families:
| Family | Examples | Why it stops |
|---|---|---|
| The run and the gate | dirty-baseline, compile-error, build-timeout, no-compiler, stale-file, anchor-mismatch, outside-roots, non-ascii-text |
Unsafe to write The project does not build, the files are not what was indexed, or the plan would edit a file outside the project’s own roots, or insert a non-ASCII character into an ANSI file. |
| The plan | base-class, blocked-symbol, directive, unresolved-edge, edge-still-used, manual-edit |
A human decision A class whose bodies use the cycle would have to move, a compiler directive sits inside an edited region, the index could not resolve an edge, or the consumer would still use the old unit for another name. |
| R1, routine-call inversion | overloaded-routine, signature-in-cycle, init-order, duplicate-identifier, unit-leaves-program |
Order and linking matter For example |
| R2, member hand-over | member-write, construction, class-method, with-member, ref-counted, exception-semantics |
The interface is not enough Writes, construction, class methods and reference-counting semantics cannot be expressed through a read-only interface surface. |
A refused group is yours. --plan still prints the playbook and the standard remedy for it, and the repository ships a script that applies a playbook’s edits literally if you decide to follow one by hand. Moving a class whose method bodies use the cycle, and any cut a refusal names, stay human decisions.
Honest edges
- It builds your project. A dry run costs one full build; an apply costs up to three (baseline, after, one repair). On a 625-file Micronite client project that meant a dry run of about two minutes.
--no-compileskips the build in a dry run only. - One group per run. With no
--cycleor--unit, the first mechanical group is planned and the rest are named, not touched. - Part A does not always remove the cycle. It makes an interface-coupled group legal. On the demo pair, moving a uses entry to the implementation section would not break the cycle, as the lint message itself says; extracting the shared code does.
- Some shapes have no recipe (inheriting classes, unresolved
set-type edges, class-reference arguments). They are printed with their reason and the standard remedy, and the engine says it cannot help.