Menus Built in Code, Checked Against Your Docs
A menu declared in a DFM is easy for a tool to see. A menu built with TMenuItem.Create, a helper routine and a const array is not — and it is exactly the kind that drifts away from its documentation. drag-lint now reads those menus statically, prints the tree, and compares it with the menu paths your docs promise.
menu-tree and menu-drift are merged to the drag-lint main branch and ship in the next release. They are not in the engine deployed today. Every output below comes from a build of the merged code, run on a scratch copy of a small fixture project from the repository.
drag-lint grew out of the code base of Micronite, our quality and production-control software for manufacturers, but the first menu we pointed this feature at was closer to home: drag-lint’s own RAD Studio plugin.
A menu that no DFM describes
The drag-lint IDE plugin builds its whole drag-lint menu at run time. There is no TMainMenu in a form to read. The code looks like this (trimmed from the test fixture that mirrors the plugin’s shape):
Root:= TMenuItem.Create(nil);
Root.Caption:= 'Fixture';
if MainMenu <> nil then MainMenu.Items.Add(Root)
else AddActionMenu('ToolsMenu', nil, Root, True, True);
AddItem(Root, 'Open...', DoOpen); { helper: caption + handler }
AddSep(Root); { helper: separator }
AddItem(Root, Format('Find %s...', ['Text']), DoFind);
AddItem(Root, CAP_EXPORT, DoExport); { caption from a const }
var SubTools: TMenuItem:= TMenuItem.Create(Root);
SubTools.Caption:= 'Tools && Options';
Root.Add(SubTools);
AddHeader(SubTools, 'Formatting');
AddItem(SubTools, 'Format Unit', DoFormat);
Captions come from literals, Format, a const; items are created by helper routines that take the parent menu item; submenus are made inline. No caption appears next to the parent it belongs to.
Worse, some captions are not in the source as text next to a call at all. In the fixture’s Catalog submenu the items are produced by a loop over a const array of records, and the group headers by a function that is a case over an enum:
CATALOG: array[...] of TCatalogEntry = (
(Id: 'basic' ; Caption: 'Basic Report...' ; Kind: ckBasic ),
(Id: 'advanced'; Caption: 'Advanced Report...'; Kind: ckAdvanced),
(Id: 'summary' ; Caption: 'Summary Report' ; Kind: ckBasic ));
...
ckBasic: Result:= 'Basic Group';
else
Result:= 'Advanced Group';
A tool that scans the DFMs sees nothing here. A tool that searches for Caption := or for the helper’s name finds a flat list of captions, with no idea which submenu each one lives under — so a caption that moved to another parent looks unchanged, and a renamed helper silently empties the scan. We had exactly that scraper, and it is why this feature exists.
Printing the tree the code builds
menu-tree works from the project’s drag-lint index and a parse of only the routines that build menus. Nothing runs and no IDE is needed.
drag-lint menu-tree --project MenuTreeFixture.dproj --db fx.sqlite --format text
[root] Fixture (MT.Builder.pas:72, certain)
[item] Direct Item -> MT.Builder.DoDirect (MT.Builder.pas:101, certain)
[item] Open... -> MT.Builder.DoOpen (MT.Builder.pas:77, certain)
[separator] (MT.Builder.pas:78, certain)
[item] Find %s... -> MT.Builder.DoFind (MT.Builder.pas:79, certain)
[item] Export Everything -> MT.Builder.DoExport (MT.Builder.pas:80, certain)
[submenu] Tools & Options (MT.Builder.pas:82, certain)
[header] Formatting (MT.Builder.pas:85, certain)
[item] Format Unit -> MT.Builder.DoFormat (MT.Builder.pas:86, certain)
[submenu] Catalog (MT.Builder.pas:88, certain)
[header] Basic Group (MT.Catalog.pas:51, derived)
[header] Advanced Group (MT.Catalog.pas:51, derived)
[item] Basic Report... -> MT.Builder.DoCatalog (MT.Catalog.pas:53, derived)
[item] Advanced Report... -> MT.Builder.DoCatalog (MT.Catalog.pas:53, derived)
[item] Summary Report -> MT.Builder.DoCatalog (MT.Catalog.pas:53, derived)
[separator] (MT.Builder.pas:107, certain)
[item] About -> MT.Builder.DoAbout (MT.Builder.pas:108, certain)
...
[external-root] mnuTools (MT.Dfm.pas:47, certain)
[item] Code Added Two -> MT.Dfm.TFixtureForm.DoTwo (MT.Dfm.pas:48, certain)
[item] Code Added One -> MT.Dfm.TFixtureForm.DoOne (MT.Dfm.pas:44, certain)
NOTE dynamic caption at MT.Dynamic.pas:52: Dyn.Caption:= SomeVar
NOTE unparented item at MT.Dynamic.pas:56: 'Orphan Item' is created but never added to a menu
Real output, trimmed (the fixture’s second root, Fixture Dynamic, is cut where the ... is). Each node carries its kind, its handler, where it was made and how sure the reader is.
Notice what came out. Direct Item was added with Insert(0, ...), and its handler is the real routine even though it is reached through a wrapper object. The Catalog items and headers came out of the const array and the case function, marked derived. mnuTools is a menu that lives in a DFM; the fixture adds two items to it in code, and they are listed under an external root of that name instead of being guessed into a path.
How it recognises a helper
It does not know the names AddItem, AddSep or AddHeader. A routine counts as a helper because of what its body does: it creates a menu item and adds it under a menu-item parameter. When a call passes a node or an anonymous method, the callee’s body is walked with its parameters bound to the arguments. Renaming AddItem to PutItem in a copy of the fixture leaves the tree unchanged — a test the repository carries.
| Caption written as | Result | Confidence |
|---|---|---|
Literal, 'a' + 'b', Format('...', ...), a const identifier |
The text (a Format keeps its pattern) |
certain |
An element of a const array of records, a case-of-literals function |
One node per value | derived |
| Anything else (a variable, a call result) | One dynamic node and a NOTE |
unknown |
A caption the reader cannot evaluate is never dropped. It appears as a dynamic node, and as a NOTE line, so the output says what it could not see.
Other formats and exit codes
--format paths prints one leaf per line as path, handler, file:line and confidence, separated by tabs, convenient for diffing or grepping in a build step. --format json writes schema menu-tree/1. --root <QName> walks one builder routine only. Exit 0 means it ran; 2 means a usage or setup problem; 3 means the index is stale (a file it must read changed after it was indexed) and it refuses, printing the reindex command, rather than answering from old code.
Fixture > Direct Item MT.Builder.DoDirect certain
Fixture > Open... MT.Builder.DoOpen certain
Fixture > Find %s... MT.Builder.DoFind certain
Fixture > Tools & Options > Format Unit MT.Builder.DoFormat certain
Fixture > Catalog > Basic Report... MT.Builder.DoCatalog derived
First leaves of --format paths, with the tab-separated columns re-aligned and the file:line column left out.
Comparing the code with the docs
Documentation of a menu usually has the form of a path: drag-lint > Reports > Circular unit dependencies.... drag-lint’s feature registry records such paths as ide-menu surfaces, one per feature, and generates the help pages from them. menu-drift builds the live tree as menu-tree does and compares it with those registered paths, by full path, not by leaf caption:
drag-lint menu-drift --project MenuTreeFixture.dproj --db fx.sqlite --registry registry
| Class | Meaning | Printed with |
|---|---|---|
| MISSING | The docs name a path the code does not build, and the caption is nowhere else. | Docs ahead of code
|
| MOVED | The caption exists, under a different parent. | Right item, wrong path
|
| UNREGISTERED | The code builds an item that no documented surface covers. | Code ahead of docs A skeleton surface line to paste into a registry entry. |
| NOTE | A dynamic caption, an unparented item, or an exemption that is no longer needed. | Information Never fails the check by itself. |
With a registry that matches the fixture, the check is quiet except for the notes, and exits 0:
NOTE dynamic caption at MT.Dynamic.pas:52: Dyn.Caption:= SomeVar
NOTE unparented item at MT.Dynamic.pas:56: 'Orphan Item' is created but never added to a menu
menu-drift: missing=0 unregistered=0 moved=0 notes=2
To see the classes we changed a scratch copy of the registry: deleted the entry for Export Everything, renamed the documented Open... to Open File... and Find %s... to Search..., and moved Format Unit out of its Tools & Options submenu. The result, exit code 1:
MISSING Fixture > Search... (entry find)
MISSING Fixture > Open File... (entry open)
MOVED Fixture > Format Unit (entry format-unit) now at Fixture > Tools & Options > Format Unit
UNREGISTERED Fixture > Open... -> MT.Builder.DoOpen (MT.Builder.pas:77)
skeleton: { "type": "ide-menu", "path": "Fixture > Open..." }
UNREGISTERED Fixture > Find %s... -> MT.Builder.DoFind (MT.Builder.pas:79)
skeleton: { "type": "ide-menu", "path": "Fixture > Find %s..." }
UNREGISTERED Fixture > Export Everything -> MT.Builder.DoExport (MT.Builder.pas:80)
skeleton: { "type": "ide-menu", "path": "Fixture > Export Everything" }
...
menu-drift: missing=2 unregistered=3 moved=1 notes=2
Real output, the two NOTE lines elided. A rename shows up as a MISSING plus an UNREGISTERED pair; the renamed? hint appears only when the old and new captions share 12 leading characters, which these short captions do not.
What it deliberately ignores
Separators, headers, submenus and external roots are never UNREGISTERED. A documented path that names a live submenu is not MISSING. A documented path under a parent that holds a dynamic item is never MISSING, because the dynamic item may be it; the note says so. Order inside a submenu is not compared. Captions are compared in a normalised form (&& becomes &, a trailing ... and surrounding punctuation are dropped, case is folded), the same keys the feature registry uses. The summary line is menu-drift: missing=N unregistered=N moved=N notes=N; exit 0 means no drift, 1 drift, 2 usage or setup, 3 a stale index.
Two jobs for the same pair of verbs
Keeping docs and help in sync with the real menus
Run on the drag-lint plugin itself, the check began with 5 missing and 26 unregistered menu paths. We fixed the registry entries until it reported none, and the check now runs in our test battery on the plugin index, passing only on exit 0. A new menu item that nobody documented, or a caption someone renamed, now fails our test battery instead of waiting for a user to notice.
Auditing an application’s code-built menus
Point menu-tree at any indexed Delphi project to get a list of every item its code puts on a menu, with the routine that handles the click and the line that created it. Items added to a DFM-declared menu appear under an external root named after the menu component, so you can see what code adds to a menu that the form designer shows as nearly empty. Run menu-tree --format paths before and after a refactor and diff the two files to see whether the menu changed.
The registry half of menu-drift is drag-lint’s own feature registry: a folder of entries\*.json, each with ide-menu surfaces, and an exemptions.json. For another project you supply a folder in that shape; the tool does not read arbitrary documentation.
Honest edges
- Static means static. A caption that depends on a run-time value shows as a
dynamicnode and a note, never as a guess. - Only the menu-item shapes it knows. DevExpress
TdxBar*items, shortcuts and menu order are not compared. Context menus, About-form buttons and tool windows are out of scope in this version. - External roots are not compared. Items added to a DFM menu or to a menu looked up from the IDE are listed by
menu-treebut ignored bymenu-drift. - Computed on demand. There is no stored menu table in the index yet, so each run re-reads the routines that build menus, and the index must be current (exit 3 otherwise).
- Ships in the next release. Until then the verbs exist only in a build of the main branch.