◆ Verified refactoring for Harbour

Far beyond search-and-replace.

A refactoring tool that reads your Harbour code the way the compiler does — so it changes exactly what you mean, never guesses, and proves every edit before it keeps it. Rename, extract a function, reorder parameters — verified against the compiler, or not done at all.

A command-line tool and a VSCode extension
0 bytes We extended the Harbour compiler so it hands over the facts — and it changes nothing in your build: with the switches off, byte-identical output. The core proposal ↗

Pull a block out into its own function. You point at the lines — 13 to 17 — and nothing else. The tool works out the arguments, the return value, and which local should travel with it.

before — app.prg
PROCEDURE Main()
 
   ? Fatura( { 10, 20, 30 }, 10 )
 
   RETURN
 
FUNCTION Fatura( aItens, nDesconto )
 
   LOCAL nTotal
   LOCAL nItem
   LOCAL cLinha
 
   nTotal := 0
   FOR EACH nItem IN aItens
      nTotal += nItem
   NEXT
   nTotal -= nTotal * nDesconto / 100
 
   cLinha := "Total: " + hb_ntos( nTotal )
 
   RETURN cLinha
after — app.prg
PROCEDURE Main()
 
   ? Fatura( { 10, 20, 30 }, 10 )
 
   RETURN
 
FUNCTION Fatura( aItens, nDesconto )
 
   LOCAL nTotal
   LOCAL cLinha
 
   nTotal := CalculaTotal( aItens, nDesconto )
 
   cLinha := "Total: " + hb_ntos( nTotal )
 
   RETURN cLinha
 
STATIC FUNCTION CalculaTotal( aItens, nDesconto )
 
   LOCAL nTotal
 
   LOCAL nItem
 
   nTotal := 0
   FOR EACH nItem IN aItens
      nTotal += nItem
   NEXT
   nTotal -= nTotal * nDesconto / 100
 
   RETURN nTotal
$ hbrefactor extract-function app.hbp app.prg 13-17 CalculaTotal
extract-function: lines 13-17 of FATURA -> CalculaTotal( aItens, nDesconto ) returning nTotal
  LOCAL nItem (line 10) is used only in the selection - moves to CalculaTotal
verified: symbols preserved (+CalculaTotal); run your test suite to confirm behaviour

Nobody told it that aItens and nDesconto are the arguments, that nTotal is what comes back, or that nItem is used nowhere else and should move in. It read that off the data flow the compiler already computed — then checked the symbol table before keeping a single line.

TIER 1

Start here

This whole section assumes you have never used a refactoring tool — only Find & Replace, maybe a regex. No jargon. If you came up on Clipper, you're exactly who this is for.

The problem

Have you ever done this?

You needed to rename a variable — say nTotal — so you opened Find & Replace and hit Replace All. It worked… mostly.

It also changed the word inside a string your user sees on screen. And inside a comment. And it renamed a different nTotal in another function that had nothing to do with yours. And it missed the calls in the other file, because the code around them looked a little different. You found out three weeks later — when the client called.

It's not your fault. Find & Replace works on text. It has no idea what a variable is, what a function is, or what a comment is. Every run of characters that matches, it changes — the right ones and the wrong ones, blindly. Regular expressions? Smarter matching, but still text. Still blind to what your code means.

The idea

So what is refactoring?

Refactoring means changing the shape of your code without changing what it does. Rename a variable, pull a block of lines into its own function, reorder a function's parameters — before and after, the program behaves exactly the same. It just reads better, or is ready for the next change.

A refactoring tool does those changes for you. But the key word is understands: hbrefactor doesn't see text — it sees variables, functions, calls, and scopes, the same things the Harbour compiler sees when it builds your program.

So when you say "rename this Total," it knows which one you mean:

Total the variable → renamed
"Total: 42" a string → left alone
// fix Total a comment → left alone
Total() a function → left alone

Same six letters, four different meanings. A text tool can't tell them apart. hbrefactor can — because it asks the compiler.

Trust

"How do I know it didn't break anything?"

Fair question — especially if you've been burned before. This is the part that sets hbrefactor apart from every "trust me" tool: it doesn't trust its own edit.

After it changes your source, it recompiles your project and compares the result against the original program. If a single thing is different — if the program the machine would run is not identical — it throws the edit away, puts your files back exactly as they were, and tells you why.

The Fact Rule

And when it can't be sure about something, it doesn't guess. It says "I don't know" out loud, instead of quietly doing the wrong thing. The whole tool is built on one promise: when we can't prove it, we won't claim it.

Your language, untouched

It's still 100% Harbour — we don't change the syntax

A worry worth naming out loud: does this turn my code into some weird dialect?

No. hbrefactor never invents new syntax and never adds a language of its own. Your .prg stays plain Harbour — the same code the normal Harbour compiler builds, the same code any Harbour programmer can read. It reshapes your code; it never replaces your language. Even when it adds type information (next section), it uses words Harbour has understood forever — you can keep writing Harbour exactly as you always have.

…and it doesn't change your build either. Not one byte.

For the tool to work on facts, Harbour's compiler had to start handing them over — so we extended the compiler. Which raises the fair question: what did you do to my compiler?

Nothing, unless you ask. The new information is emitted only behind a switch. With that switch off — the state every existing project is in — the patched compiler produces the same bytes as the one you use today. Measured, not asserted — and the measurement is a script you can run: every .prg the two compilers both accept is built to pcode by each and compared byte for byte, with zero divergences.

That work lives on a branch of Harbour itself, and it has its own page — written for the Harbour maintainers: the case, the honest shape of the diff, the two bugs it fixes in stock Harbour, and what we still don't have good answers for. If you maintain Harbour, that page is for you.

Extending the compiler without breaking anything →

Spotlight · annotate

Materialize the facts your code already implies

Most Harbour code never states what things are. You know oMenu is a UWMenu. The compiler doesn't — so hbrefactor honestly labels calls on it possible. It will not guess.

But the type isn't really unknown — it's implied: a class's constructor returns that class. annotate makes that implied fact explicit, using words Harbour has understood forever:

Before
LOCAL oMenu := UWMenu():New()
oMenu:AddItem( "Sair" )
// the fact is implied, but nobody wrote it down
After · annotate --apply
LOCAL oMenu AS CLASS UWMENU := UWMenu():New()
oMenu:AddItem( "Sair" )
// now the compiler carries it — and can check it

Why write it into your code at all? Because a fact the tool worked out is not a fact the compiler carries. Written down, it becomes checkable: usages and rename go from possible to confirmed; with -kt it's enforced at runtime; the next programmer can read it; and it costs zero pcode — proven byte-for-byte, your program is identical.

It only writes what a declaration can back. A type it could merely guess goes in the report, never into your source — an earlier version inferred types silently, and that was removed on purpose: a guess wearing a fact's clothes doesn't ship.

On a real project (Harbour's own hbhttpd, 14 classes) it wrote 31 declarations + 7 annotations, all verified, in about 3 seconds — and any failed check rolls everything back, byte-for-byte.

TIER 2

Going deeper

Comfortable so far? Here's what's really going on under the friendly surface — still no PhD required, still for the working programmer.

What it does

Every refactoring, before and after

Below is each thing the tool can do, shown as the code you start with, the one command you run, and the code you end up with. None of it is written by hand. Every block on this page is produced by running the tool for real: the "before" has to compile clean, the command has to run, and the "after" has to compile clean — or the build fails and the page does not ship.

Reorder a function's parameters

Change the signature once; every call site follows — including the ones you would have missed.

before — app.prg
PROCEDURE Main()
 
   Registra( "erro", "disco cheio" )
   Registra( "aviso", "memoria baixa" )
 
   RETURN
 
FUNCTION Registra( cNivel, cTexto )
 
   RETURN cNivel + ": " + cTexto
after — app.prg
PROCEDURE Main()
 
   Registra( "disco cheio", "erro" )
   Registra( "memoria baixa", "aviso" )
 
   RETURN
 
FUNCTION Registra( cTexto, cNivel )
 
   RETURN cNivel + ": " + cTexto
$ hbrefactor reorder-params app.hbp Registra cTexto,cNivel
reorder-params: Registra( CNIVEL, CTEXTO ) -> ( CTEXTO, CNIVEL )
  app.prg:3
  app.prg:3
  app.prg:4
  app.prg:4
  app.prg:8
  app.prg:8
verified: 6 site(s) reordered; symbols intact; run your test suite to confirm behaviour

Rename a variable

The word appears in a string, in a comment, and again as a different variable in another function. Only your variable moves.

before — app.prg
PROCEDURE Main()
 
   LOCAL nTotal
 
   nTotal := Soma( 2, 3 )
 
   // nTotal fecha o caixa do dia
   ? "nTotal = ", nTotal
 
   RETURN
 
FUNCTION Soma( nA, nB )
 
   LOCAL nTotal                 // outra funcao, outro nTotal
 
   nTotal := nA + nB
 
   RETURN nTotal
after — app.prg
PROCEDURE Main()
 
   LOCAL nTotalGeral
 
   nTotalGeral := Soma( 2, 3 )
 
   // nTotal fecha o caixa do dia
   ? "nTotal = ", nTotalGeral
 
   RETURN
 
FUNCTION Soma( nA, nB )
 
   LOCAL nTotal                 // outra funcao, outro nTotal
 
   nTotal := nA + nB
 
   RETURN nTotal
$ hbrefactor rename app.hbp app.prg:3:10 nTotalGeral
rename-local: nTotal -> nTotalGeral in MAIN (app.prg)
  app.prg:3:10
  app.prg:5:4
  app.prg:8:19
verified: all 1 module(s) byte-identical (-gh -l)

Rename a method

The declaration, the definition, and every message sent to it — plus the function name Harbour generates behind your back, which the tool predicts and then verifies against the symbol table.

before — app.prg
#include "hbclass.ch"
 
PROCEDURE Main()
 
   LOCAL oPag := Pagamento():New()
   LOCAL oReg := Registro():New()
 
   oPag:Enviar()
   oReg:Gravar()
 
   RETURN
 
CREATE CLASS Pagamento
   METHOD New() CONSTRUCTOR
   METHOD Enviar()
ENDCLASS
 
METHOD New() CLASS Pagamento
   RETURN Self
 
METHOD Enviar() CLASS Pagamento
   RETURN "pago"
 
CREATE CLASS Registro
   METHOD New() CONSTRUCTOR
   METHOD Gravar()
ENDCLASS
 
METHOD New() CLASS Registro
   RETURN Self
 
METHOD Gravar() CLASS Registro
   RETURN "gravado"
after — app.prg
#include "hbclass.ch"
 
PROCEDURE Main()
 
   LOCAL oPag := Pagamento():New()
   LOCAL oReg := Registro():New()
 
   oPag:Transmitir()
   oReg:Gravar()
 
   RETURN
 
CREATE CLASS Pagamento
   METHOD New() CONSTRUCTOR
   METHOD Transmitir()
ENDCLASS
 
METHOD New() CLASS Pagamento
   RETURN Self
 
METHOD Transmitir() CLASS Pagamento
   RETURN "pago"
 
CREATE CLASS Registro
   METHOD New() CONSTRUCTOR
   METHOD Gravar()
ENDCLASS
 
METHOD New() CLASS Registro
   RETURN Self
 
METHOD Gravar() CLASS Registro
   RETURN "gravado"
$ hbrefactor rename app.hbp app.prg:21:8 Transmitir
rename-method: PAGAMENTO:Enviar -> Transmitir
  app.prg:15:11
  app.prg:21:8
  app.prg:8:9
  predicted: PAGAMENTO_ENVIAR -> PAGAMENTO_TRANSMITIR
  predicted string: "Enviar" -> "Transmitir" (app.prg)
verified: 3 edit(s); message and generated function renamed, other modules byte-identical

Inline a variable

The opposite move: fold a local back into the places that read it, and drop the declaration.

before — app.prg
PROCEDURE Main()
 
   ? Frete( 250 )
 
   RETURN
 
FUNCTION Frete( nPeso )
 
   LOCAL nTaxa := 1.75
 
   IF nPeso > 100
      RETURN nPeso * nTaxa * 2
   ENDIF
 
   RETURN nPeso * nTaxa
after — app.prg
PROCEDURE Main()
 
   ? Frete( 250 )
 
   RETURN
 
FUNCTION Frete( nPeso )
 
   IF nPeso > 100
      RETURN nPeso * 1.75 * 2
   ENDIF
 
   RETURN nPeso * 1.75
$ hbrefactor inline-local app.hbp app.prg Frete nTaxa
inline-local: nTaxa := 1.75 in FRETE (app.prg)
  app.prg:12:22
  app.prg:15:19
  declaration on line 9 removed
verified: 2 use(s) replaced; symbols intact; run your test suite to confirm behaviour

Rename a word of your own DSL

This is the one no other refactoring tool does. REGISTRAR is not Harbour — it is a command you invented with #xcommand. The tool renames the rule in the header and every place you used it, then proves the preprocessed output is unchanged.

before — app.prg
#include "app.ch"
 
PROCEDURE Main()
 
   REGISTRAR Caldeira AS Temperatura
   REGISTRAR Bomba    AS Pressao
 
   RETURN
 
FUNCTION AddSensor( cNome, cTipo )
 
   RETURN cNome + "/" + cTipo
after — app.prg
#include "app.ch"
 
PROCEDURE Main()
 
   MONITORAR Caldeira AS Temperatura
   MONITORAR Bomba    AS Pressao
 
   RETURN
 
FUNCTION AddSensor( cNome, cTipo )
 
   RETURN cNome + "/" + cTipo
$ hbrefactor rename app.hbp app.prg:5:4 MONITORAR
rename-dsl: REGISTRAR -> MONITORAR
  app.prg:5:4
  app.prg:6:4
  app.ch:1:11
verified: 2 application site(s) + 1 directive occurrence(s); .ppo and .hrb byte-identical

Write down the types your code already proves

The compiler knows oConta is a Conta — it just never wrote it down. annotate materializes that fact, and verifies the program is byte-identical afterwards. Now every message sent to oConta can be resolved with certainty instead of a guess.

before — app.prg
#include "hbclass.ch"
 
CREATE CLASS Conta
   METHOD New() CONSTRUCTOR
   METHOD Saldo()
ENDCLASS
 
METHOD New() CLASS Conta
   RETURN Self
 
METHOD Saldo() CLASS Conta
   RETURN 100
 
PROCEDURE Main()
 
   LOCAL oConta := Conta():New()
 
   ? oConta:Saldo()
 
   RETURN
after — app.prg
#include "hbclass.ch"
 
CREATE CLASS Conta
   METHOD New() CONSTRUCTOR
   METHOD Saldo()
ENDCLASS
 
METHOD New() CLASS Conta
   RETURN Self
 
METHOD Saldo() CLASS Conta
   RETURN 100
 
PROCEDURE Main()
 
   LOCAL oConta AS CLASS CONTA := Conta():New()
 
   ? oConta:Saldo()
 
   RETURN
$ hbrefactor annotate app.hbp --apply
annotate --apply: 0 declaration(s) + 1 AS CLASS annotation(s)
verified: .hrb byte-identical without -kt; compiles clean under -w3 -es2; runs under -kt (checks pass)

Find every use of a method — and only that method

Two classes, both with a method called Enviar. Ask who uses Pagamento:Enviar and the look-alike is not merely flagged — it is excluded, with the proof named.

the code — app.prg
#include "hbclass.ch"
 
PROCEDURE Main()
 
   LOCAL oPag := Pagamento():New()
   LOCAL oReg := Registro():New()
 
   oPag:Enviar()
   oReg:Enviar()
 
   RETURN
 
CREATE CLASS Pagamento
   METHOD New() CONSTRUCTOR
   METHOD Enviar()
ENDCLASS
 
METHOD New() CLASS Pagamento
   RETURN Self
 
METHOD Enviar() CLASS Pagamento
   RETURN "pago"
 
CREATE CLASS Registro
   METHOD New() CONSTRUCTOR
   METHOD Enviar()
ENDCLASS
 
METHOD New() CLASS Registro
   RETURN Self
 
METHOD Enviar() CLASS Registro
   RETURN "gravado"
$ hbrefactor usages app.hbp Pagamento:Enviar
app.prg:8: confirmed send (receiver class PAGAMENTO via declared types) in MAIN  | oPag:Enviar()
app.prg:9: excluded send within the declared class graph (dispatches to REGISTRO:ENVIAR) in MAIN  | oReg:Enviar()
app.prg:21: method definition Enviar (class Pagamento)  | METHOD Enviar() CLASS Pagamento
app.prg:32: excluded method definition (implements REGISTRO:ENVIAR)  | METHOD Enviar() CLASS Registro
app.prg:15: method declaration (class PAGAMENTO)  | METHOD Enviar()
app.prg:26: excluded method declaration (declares REGISTRO:ENVIAR)  | METHOD Enviar()
6 result(s) for 'Pagamento:Enviar'

This command only reads. It never edits — and the suite fails the build if a single byte of the source moves.

And then it renames one of them

Two classes, both with a method called Enviar. Same project, same run: the query above already knows which send reaches which class — so the rename acts on that, and the send it proved goes elsewhere is left exactly where it was.

before — app.prg
#include "hbclass.ch"
 
PROCEDURE Main()
 
   LOCAL oPag := Pagamento():New()
   LOCAL oReg := Registro():New()
 
   oPag:Enviar()
   oReg:Enviar()
 
   RETURN
 
CREATE CLASS Pagamento
   METHOD New() CONSTRUCTOR
   METHOD Enviar()
ENDCLASS
 
METHOD New() CLASS Pagamento
   RETURN Self
 
METHOD Enviar() CLASS Pagamento
   RETURN "pago"
 
CREATE CLASS Registro
   METHOD New() CONSTRUCTOR
   METHOD Enviar()
ENDCLASS
 
METHOD New() CLASS Registro
   RETURN Self
 
METHOD Enviar() CLASS Registro
   RETURN "gravado"
after — app.prg
#include "hbclass.ch"
 
PROCEDURE Main()
 
   LOCAL oPag := Pagamento():New()
   LOCAL oReg := Registro():New()
 
   oPag:Transmitir()
   oReg:Enviar()
 
   RETURN
 
CREATE CLASS Pagamento
   METHOD New() CONSTRUCTOR
   METHOD Transmitir()
ENDCLASS
 
METHOD New() CLASS Pagamento
   RETURN Self
 
METHOD Transmitir() CLASS Pagamento
   RETURN "pago"
 
CREATE CLASS Registro
   METHOD New() CONSTRUCTOR
   METHOD Enviar()
ENDCLASS
 
METHOD New() CLASS Registro
   RETURN Self
 
METHOD Enviar() CLASS Registro
   RETURN "gravado"
$ hbrefactor rename app.hbp app.prg:21:8 Transmitir
rename-method: PAGAMENTO:Enviar -> Transmitir
  app.prg:15:11
  app.prg:21:8
  app.prg:8:9
  predicted: PAGAMENTO_ENVIAR -> PAGAMENTO_TRANSMITIR
  predicted string: "Enviar" -> "Transmitir" (app.prg)
verified: 3 edit(s); message and generated function renamed for PAGAMENTO; 1 send proven to dispatch elsewhere left untouched

And knowing when to stop — when the receiver is not a fact

The same two classes, with one thing removed: nothing declares what Pagamento() hands back. Now no fact says which class a send reaches, and two classes answering one name IS ambiguity. So it refuses — naming the other class, and the send it could not place.

the code — unchanged — app.prg
#include "hbclass.ch"
 
PROCEDURE Main()
 
   LOCAL oPag := Pagamento():New()
   LOCAL oReg := Registro():New()
 
   oPag:Enviar()
   oReg:Enviar()
 
   RETURN
 
CREATE CLASS Pagamento
   METHOD Enviar()
ENDCLASS
 
METHOD Enviar() CLASS Pagamento
   RETURN "pago"
 
CREATE CLASS Registro
   METHOD Enviar()
ENDCLASS
 
METHOD Enviar() CLASS Registro
   RETURN "gravado"
$ hbrefactor rename app.hbp app.prg:17:8 Transmitir
hbrefactor: 'Enviar' is also a member of: REGISTRO (app.prg), and the send at app.prg:8 does not resolve to a receiver (possible send (dynamic dispatch, receiver unknown)) - the rename is ambiguous; refusing

It refused — and the source is byte-for-byte unchanged. This suite fails the build if a refusal ever edits a single character.

Reaching further

It reaches inside your INLINE methods

Harbour classes lean on INLINE — one-line methods, OPERATOR overloads, ACCESS/ASSIGN:

METHOD Total INLINE ::Soma( 0 ):nCents

That ::Soma() is a real call to Soma on the same object — a reference you'd want "find all references" to catch. But the receiver of an INLINE body is generated by the directive: it has no token in your source to anchor on, so the tool used to shrug it off as possible (receiver unknown). Now the compiler records that receiver's class as a fact, and the call comes back confirmed:

confirmed send (receiver declared AS CLASS MOEDA, codeblock)  | METHOD Total INLINE ::Soma( 0 ):nCents

And it costs your program nothing: that fact is never turned into a runtime check — not even under -kt — because an INLINE's receiver is always its own class anyway. So the label stays confirmed, never guaranteed, and a -kt build gains zero extra instructions (proven byte-for-byte). As always, it isn't an hbclass trick — a DSL of your own that generates a typed-receiver block gets exactly the same, with the tool's source never naming your directive.

The same now holds for delegated properties — the Class(y) shorthand where one VAR forwards to another field or object:

VAR nEcho AS Numeric IS nRaw          // reading nEcho reads nRaw
VAR nVia  AS Numeric IS nCount TO oPart

Each of these generates a hidden read and write block on the same source line, and "find all references" for nRaw (or oPart) now returns confirmed inside both — where it used to shrug possible. The compiler attaches each block's own parameters to that block, so the tool types the receiver by the exact block it sits in, never confusing the two.

Data, not just behavior

A class's data, not just its methods

A class is data plus behavior — and renaming the data used to be the gap: the tool would stop and say "that's a VAR/DATA, not a method". Now it does it. Point at the member and rename: the declaration, every read, every write, and the class's own internal registration of the member all move together.

before — app.prg
#include "hbclass.ch"
 
PROCEDURE Main()
 
   LOCAL oConta := Conta():New()
 
   oConta:nLimite := 500
 
   ? oConta:nLimite
 
   RETURN
 
CREATE CLASS Conta
   VAR nSaldo  INIT 0
   VAR nLimite INIT 0
   METHOD New() CONSTRUCTOR
   METHOD Sobra()
ENDCLASS
 
METHOD New() CLASS Conta
   RETURN Self
 
METHOD Sobra() CLASS Conta
   ::nLimite := ::nLimite - ::nSaldo
   RETURN ::nLimite
after — app.prg
#include "hbclass.ch"
 
PROCEDURE Main()
 
   LOCAL oConta := Conta():New()
 
   oConta:nTeto := 500
 
   ? oConta:nTeto
 
   RETURN
 
CREATE CLASS Conta
   VAR nSaldo  INIT 0
   VAR nTeto INIT 0
   METHOD New() CONSTRUCTOR
   METHOD Sobra()
ENDCLASS
 
METHOD New() CLASS Conta
   RETURN Self
 
METHOD Sobra() CLASS Conta
   ::nTeto := ::nTeto - ::nSaldo
   RETURN ::nTeto
$ hbrefactor rename app.hbp app.prg:15:8 nTeto
rename-data: CONTA:nLimite -> nTeto
  app.prg:15:8
  app.prg:7:11
  app.prg:9:13
  app.prg:24:6
  app.prg:24:19
  app.prg:25:13
  predicted: _NLIMITE -> _NTETO
  predicted string: "nLimite" -> "nTeto" (app.prg)
verified: 6 edit(s); DATA member getter+setter renamed, registration re-derived, other modules byte-identical

Reading a member and writing to it are two different messages as far as the compiler is concerned (nLimite and _nLimite) — the tool renames both, and proves it against the symbol table before keeping a single edit.

And the guard. If two classes in your project happen to have a member of the same name, what decides is whether anything says which class each :nSaldo reaches. Here nothing does — the receiver is not a fact — so the rename is genuinely ambiguous, and it refuses naming both the other class and the send it could not place:

the code — unchanged — app.prg
#include "hbclass.ch"
 
PROCEDURE Main()
 
   LOCAL oConta := Conta():New()
 
   oConta:nSaldo := 100
 
   ? oConta:nSaldo
 
   RETURN
 
CREATE CLASS Conta
   VAR nSaldo INIT 0
ENDCLASS
 
CREATE CLASS Poupanca
   VAR nSaldo INIT 0
ENDCLASS
$ hbrefactor rename app.hbp app.prg:14:8 nDisponivel
hbrefactor: 'nSaldo' is also a member of: POUPANCA (app.prg), and the send at app.prg:7 does not resolve to a receiver (possible send (dynamic dispatch, receiver unknown)) - the rename is ambiguous; refusing

It refused — and the source is byte-for-byte unchanged. This suite fails the build if a refusal ever edits a single character.

That's the same rule that has always governed homonymous methods — data members simply inherit it.

Owned by fact

Same name, different owner — it only edits yours

Real code reuses names. Say your DSL labels something Vendas — and a function named Vendas() already exists, unrelated. Rename the label: both DSL sites move, and the real function does not.

before — app.prg
#include "app.ch"
 
PROCEDURE Main()
 
   ROTULO Vendas   // a label your DSL turns into a string
   ? Vendas()      // the real function - same spelling, a different owner
 
   RETURN
 
GERA Vendas        // the word becomes part of a generated function name
 
FUNCTION Vendas()
 
   RETURN 42
 
FUNCTION RegRotulo( cNome )
 
   RETURN cNome
after — app.prg
#include "app.ch"
 
PROCEDURE Main()
 
   ROTULO Receita   // a label your DSL turns into a string
   ? Vendas()      // the real function - same spelling, a different owner
 
   RETURN
 
GERA Receita        // the word becomes part of a generated function name
 
FUNCTION Vendas()
 
   RETURN 42
 
FUNCTION RegRotulo( cNome )
 
   RETURN cNome
$ hbrefactor rename app.hbp app.prg:5:11 Receita
rename-pp-marker: Vendas -> Receita
  app.prg:5:11
  app.prg:10:6
  predicted: GERA_VENDAS -> GERA_RECEITA
  predicted string: "Vendas" -> "Receita" (app.prg)
verified: 2 edit(s); derived artifacts renamed as predicted

Look at predicted: GERA_VENDAS → GERA_RECEITA. The DSL generates a function whose name is built from the word — so the tool works out what the compiler will produce, renames it, and then checks the symbol table to confirm the prediction came true. Meanwhile ? Vendas() and FUNCTION Vendas() — the same six letters, a different owner — are left exactly as they were. An occurrence is edited only when a compiler fact ties it to the thing you pointed at; a matching spelling is never enough.

It goes one level deeper. A directive can define another directive — that's how Harbour's own class system works inside, and your DSLs can do it too. Renaming the word — at either position — edits both lines together and predicts the derived string:

before — app.prg
#include "app.ch"
 
PROCEDURE Main()
 
   DEFREGRA Ponto   // creates, at preprocess time, the rule `USA Ponto`
   USA Ponto        // uses the rule that was just created
 
   RETURN
 
FUNCTION Marca( cNome )
 
   RETURN cNome
after — app.prg
#include "app.ch"
 
PROCEDURE Main()
 
   DEFREGRA Marco   // creates, at preprocess time, the rule `USA Ponto`
   USA Marco        // uses the rule that was just created
 
   RETURN
 
FUNCTION Marca( cNome )
 
   RETURN cNome
$ hbrefactor rename app.hbp app.prg:5:13 Marco
rename-pp-marker: Ponto -> Marco
  app.prg:5:13
  app.prg:6:8
  predicted string: "Ponto" -> "Marco" (app.prg)
verified: 2 edit(s); derived artifacts renamed as predicted

Behind both: the compiler now records the genealogy of preprocessor rules — when a rule is created by another rule's expansion, the dump says which application created it — and the tool only edits an occurrence whose owner is proven. Honest limit: if one module mixes the two roles so tightly that even the symbol table can't separate them, the tool still refuses and rolls back rather than guess.

Going deeper

A directive can be switched off — and the rename follows it there

A #define does not have to live to the end of the file. You can turn it off — and that is ordinary hygiene, but also a trap for every refactoring tool, because renaming the rule is only half the job. Rename the directive and forget the #xuncommand, and the switch-off is left pointing at a rule that no longer exists. It then turns off nothing, and the rule leaks past the line where you ended it — silently changing the meaning of code the rename never touched. Nothing catches it: if no site after the switch-off uses the word, the compiled output is byte-identical either way, so a "verified" check happily passes.

hbrefactor follows the directive to where it dies — across the file boundary, from the header where it was born to the source where it ends:

before — app.ch
// the rule is born here
#xcommand LACRA <x> => Selar( <x>, 1 )
after — app.ch
// the rule is born here
#xcommand SELA <x> => Selar( <x>, 1 )
before — app.prg
#include "app.ch"
 
PROCEDURE Main()
 
   LACRA 1
 
   RETURN
 
// from here on the directive is switched OFF
#xuncommand LACRA <x> => Selar( <x>, 1 )
 
FUNCTION Selar( nVal, nSelo )
 
   RETURN nVal + nSelo
after — app.prg
#include "app.ch"
 
PROCEDURE Main()
 
   SELA 1
 
   RETURN
 
// from here on the directive is switched OFF
#xuncommand SELA <x> => Selar( <x>, 1 )
 
FUNCTION Selar( nVal, nSelo )
 
   RETURN nVal + nSelo
$ hbrefactor rename app.hbp app.prg:5:4 SELA
rename-dsl: LACRA -> SELA
  app.prg:5:4
  app.prg:10:13
  app.ch:2:11
verified: 1 application site(s) + 2 directive occurrence(s); .ppo and .hrb byte-identical

Three sites, two files: the call, the #xcommand that creates the rule, and the #xuncommand that retires it. The rule still ends exactly where you said it ends.

And it reads that lifetime in the other direction too. Once a rule is switched off, its word is an ordinary word again — so a name that word had made un-renameable becomes free. Renaming a local to TRAVA, below the line where TRAVA was switched off, used to be refused forever; now it simply works, because nothing there captures it. The honest limit: it only frees the name when the switch-off comes before any line of code in that module — a rule switched off on line 100 still captures on line 50, and the tool won't pretend to know where your name will land.

This works because the compiler now says that a directive was removed, and which one it removed. Before, the preprocessor performed the removal and reported nothing; the tool could only have gone hunting for the text #xuncommand in your source — blind to the abbreviated spellings, to the several forms the removal takes, and to a directive arriving from an included header. The fact was the whole problem.

That freed name is available to a command too — and there the lifetime hides a trap. A #uncommand removes by pattern, not by head. Switching a word off does not simply free it: if the rule you now rename to that word has the same shape as the one you switched off, that same #uncommand removes the rule you just renamed, and your call sites quietly start expanding through the other rule. It compiles clean. Nothing warns you.

The tool doesn't reason about this — it asks the preprocessor, before touching a byte. Different shapes, so the switch-off can't bite, and the rename goes through. Same shape, and it refuses, naming the directive that would do the damage:

the code — unchanged — app.ch
// both rules have the SAME shape <x>, and VERIFICA is switched off below
#xcommand VERIFICA <x> => Checa( <x>, 2 )
#xcommand CONFERE  <x> => Checa( <x>, 1 )
the code — unchanged — app.prg
#include "app.ch"
 
// VERIFICA is switched off here, before a single line of code in this module
#xuncommand VERIFICA <x> => Checa( <x>, 2 )
 
PROCEDURE Main()
 
   CONFERE 1
 
   RETURN
 
FUNCTION Checa( nVal, nModo )
 
   RETURN nVal + nModo
$ hbrefactor rename app.hbp app.ch:3:11 VERIFICA
hbrefactor: 'VERIFICA' would be turned off by the #xuncommand VERIFICA at app.prg:4 - after the rename that directive removes the renamed rule (it matches by pattern), and the sites would expand through #xcommand VERIFICA (app.ch:2)

It refused — and the source is byte-for-byte unchanged. This suite fails the build if a refusal ever edits a single character.

And the mirror image: a #uncommand whose pattern matches no rule removes nothing. Harbour accepts it in silence — you read the file, see the switch-off, and believe the command is off while it is still live. usages names it:

the code — app.prg
#include "app.ch"
 
// This removal switches off NOTHING: the shape <x>, <y> was never registered.
// Harbour accepts it in silence - the directive sits there, dead, looking alive.
#xuncommand CONFERE <x>, <y> => Checa( <x>, <y> )
 
PROCEDURE Main()
 
   CONFERE 1
 
   RETURN
 
FUNCTION Checa( nVal, nModo )
 
   RETURN nVal + nModo
$ hbrefactor usages app.hbp CONFERE
app.ch:1: directive (#xcommand CONFERE, 1 marker(s))  | #xcommand CONFERE <x> => Checa( <x>, 1 )
app.prg:5: directive (#xuncommand CONFERE, 2 marker(s)) - ORPHAN: removes no rule (dead directive)  | #xuncommand CONFERE <x>, <y> => Checa( <x>, <y> )
app.prg:9:4: application (#xcommand CONFERE, app.ch:1)  | CONFERE 1
app.ch:1:11: in rule match (#xcommand CONFERE)  | #xcommand CONFERE <x> => Checa( <x>, 1 )
app.prg:5:13: in rule match (#xuncommand CONFERE)  | #xuncommand CONFERE <x>, <y> => Checa( <x>, <y> )
5 result(s) for 'CONFERE'

This command only reads. It never edits — and the suite fails the build if a single byte of the source moves.

A report, never an edit. The tool won't rewrite a directive whose intent it can't verify: it can prove the switch-off is dead, but not what you meant it to say.

Going deeper

"Would this new name collide?" — it asks the preprocessor instead of guessing

A #command matches its keywords abbreviated. #command ROTULA is also matched by ROTU, ROTUL, ROTULA — from four letters on. So renaming one directive can quietly hijack another: give a rule a new head that happens to share a four-letter prefix with a rule that already exists, and the preprocessor starts routing your calls to the wrong one.

Here two rules live side by side — #command ROTULA and #command PAUTAR. Rename PAUTAR to ROTULAGEM, which looks safe (no rule is called that). The tool refuses, names the rule you would have collided with, and shows you the exact spelling that would become ambiguous:

the code — unchanged — app.prg
#include "app.ch"
 
PROCEDURE Main()
 
   PAUTAR 1
 
   RETURN
 
FUNCTION Registra( nVal, nTipo )
 
   RETURN nVal + nTipo
$ hbrefactor rename app.hbp app.prg:5:4 ROTULAGEM
hbrefactor: 'ROTULAGEM' collides by abbreviation with rule #command ROTULA (app.ch:3) - after the rename, writing 'ROTU' would match BOTH rules

It refused — and the source is byte-for-byte unchanged. This suite fails the build if a refusal ever edits a single character.

What matters is who decided that. hbrefactor does not reimplement Harbour's matching rule — it starts a live preprocessor, feeds it the name you want, and watches whether the rule fires. The authority on "does this collide" is the same preprocessor that will compile your code tomorrow; when Harbour's rule changes, the answer changes with it, and nothing here has to be kept in sync. The difference is not academic: the earlier version did reimplement the rule — and it refused renames that were perfectly safe, because a hand-written copy of a compiler's logic is always a worse copy.

Going deeper

What it sees but must not touch

"Never edits what it cannot prove" protects you from a wrong edit. It says nothing about the other failure — silence: a rename that is perfectly correct and never mentions the place in your program where that name lives on, unverifiable, still meaning something.

A string that macro-expands at run time is one such place. "modelo &cLayout do relatorio" is not text — Harbour expands &cLayout to the value of that memvar while your program runs. So renaming the memvar changes what the string does, and the string still cannot be edited, because nothing can prove what your program will build out of it. The rename is refused, as before — but now the refusal shows its work, for every such string in the project, matched on the name (note that &cLayoutBase, a different memvar, is left out):

the code — unchanged — app.prg
MEMVAR cLayout
 
PROCEDURE Main()
 
   PRIVATE cLayout := "compacto"
 
   Imprime( "modelo &cLayout do relatorio" )
   Imprime( "arquivo &cLayoutBase nao e' o mesmo nome" )
 
   RETURN
 
STATIC PROCEDURE Imprime( cTexto )
 
   ? cTexto
 
   RETURN
$ hbrefactor rename app.hbp app.prg:5:12 cModelo
warning: app.prg:7: string contains '&cLayout' - macro-expanded at RUN TIME to the value of the memvar it names, so its behaviour follows this rename; the string is data and will NOT be edited
warning: MAIN (app.prg) uses macro '&'
hbrefactor: scope with holes - code outside the static graph may see 'cLayout'; refusing

It refused — and the source is byte-for-byte unchanged. This suite fails the build if a refusal ever edits a single character.

Code whose meaning depends on where it sits is the other. __LINE__ has no value written anywhere — it is the position, so extracting a function moves it:

before — app.prg
PROCEDURE Main()
 
   LOCAL nTotal := 0
   LOCAL i
 
   FOR i := 1 TO 3
      nTotal += i * 10
   NEXT
 
   ? "apurado na linha " + Str( __LINE__ )
   ? "total: " + Str( nTotal )
   ? "modulo " + __FILE__
 
   RETURN
after — app.prg
PROCEDURE Main()
 
   LOCAL nTotal := 0
 
   nTotal := Soma( nTotal )
 
   ? "apurado na linha " + Str( __LINE__ )
   ? "total: " + Str( nTotal )
   ? "modulo " + __FILE__
 
   RETURN
 
STATIC FUNCTION Soma( nTotal )
 
   LOCAL i
 
   FOR i := 1 TO 3
      nTotal += i * 10
   NEXT
 
   RETURN nTotal
$ hbrefactor extract-function app.hbp app.prg 6-8 Soma
warning: this module expands __LINE__ at 1 site(s) whose lines shift with this edit (line 10) - the expanded values follow the new position; the new values are correct (the code really moves), review only if they were meant to be stable
extract-function: lines 6-8 of MAIN -> Soma( nTotal ) returning nTotal
  LOCAL i (line 4) is used only in the selection - moves to Soma
verified: symbols preserved (+Soma); run your test suite to confirm behaviour

That is a warning, not a refusal: the edit goes through, because nothing is wrong — after the move the new number is the right one. It is precise about what actually shifts, too. A site above the edit is not named, and neither is __FILE__, which moves down the file exactly the same way but whose value does not follow the line. A rename says nothing at all: it moves no lines.

And the discipline that makes the other two worth reading — knowing when to say nothing:

the code — app.prg
PROCEDURE Main()
 
   LOCAL cSaldo := "1.234,00"
 
   ? "cSaldo"
 
   TEXT
cSaldo apurado no periodo
   ENDTEXT
 
   ? cSaldo
 
   RETURN
$ hbrefactor usages app.hbp --at app.prg:3:10
app.prg:3:10: cSaldo - identifier
query: cSaldo
app.prg:3: declaration (local) in MAIN  | LOCAL cSaldo := "1.234,00"
app.prg:3: write (local) in MAIN  | LOCAL cSaldo := "1.234,00"
app.prg:11: read (local) in MAIN  | ? cSaldo
3 result(s) for 'cSaldo'

This command only reads. It never edits — and the suite fails the build if a single byte of the source moves.

Line 5 of that file reads ? "cSaldo" — a string whose contents happen to equal the name — and it is not in the list. Nothing about it is a reference: cSaldo is a local, and a local cannot be reached by name while your program runs. Comparing a string's text against the name you asked about, to decide what that string is, is the one thing this tool must never do.

Where the line actually sits

Harbour lets a program build code while it runs and use it in the next instant. No refactoring tool controls that, and this one does not pretend to. It splits cleanly instead: it refuses where it cannot see — a macro whose target is only known at run time is a hole in the reachable scope, and the command stops rather than promise a completeness it cannot have — and it reports where it can see, as with the &cLayout string above, which the compiler itself resolves.

A call that reaches a name is one of those holes. Harbour's own runtime has functions that take a name and go find the symbol — __mvGet and __mvPut for variables, Do for functions, Type and hb_macroBlock, which compile source while your program runs and can therefore reach anything. The compiler states which of its functions do this, so the refusal names and locates the call: SHOW (app.prg:13) calls __MVGET, which resolves a memvar name at run time. It does not depend on how the name was written — __mvGet( "xC" + "fg" ) has no literal xCfg in it and refuses just the same, because what is read is the call, never the text of the string.

And where it neither refuses nor can prove, it does not stop you. Renaming a function does not stop for a macro — macro evaluation is run time, and no refactoring tool controls it. You get what was proved: the sites edited and the comparison behind them. Where a name is built at run time is a property of your project, not of one rename, and it is the audit that answers that — a command you run when you want to know.

No output of this tool should be read as “your dynamic code is covered.” A & still builds code out of anything at all, and a function of your own written in C is outside what any compilation can see. The promise is narrower and firmer: verified means verified against what compilation can see — and where it cannot see, you get a refusal or a stated reach, never a guess.

The differentiator

The Fact Rule & the certainty ladder

hbrefactor deals in facts, not guesses. Every claim it makes about your code comes from the official Harbour toolchain — the compiler, the project builder (hbmk2), the virtual machine — or it's honestly reported as unknown. Heuristics are not the product.

That honesty is visible in how it labels every method call it finds:

possible Dynamic dispatch — no fact decides. The honest "I don't know."
possible send (receiver unknown)
confirmed The receiver's class is known from declared types.
confirmed send (receiver declared AS CLASS PAYMENT)
excluded Provably not the queried class — the dispatch resolves elsewhere in the declared class graph.
excluded send ... (dispatches to LOGGER:SEND)
guaranteed The strongest — the compiler itself marked this site as checked under -kt. The tool reads the mark; it doesn't deduce coverage.

It never labels a call more certain than the facts allow: a receiver with no declared type stays possible, and an exclusion requires the whole inheritance chain to be declared inside your project — a parent from outside, or a class built at runtime, keeps the honest possible.

Why it holds

Why "no new syntax" actually holds

The type information annotate writes — DECLARE, _HB_MEMBER, AS CLASS — isn't new. These have been in the language forever. They cost zero pcode: not one extra instruction in your compiled program. And they feed the exact channel that tools already read.

So annotate doesn't extend the language — it fills in blanks the language always had room for. When a needed fact genuinely doesn't exist, hbrefactor extends the core (the compiler emits a new fact) — never the syntax you write. That's the founding principle in one line.

Opt-in · -kt

Fail-fast: declared types, actually enforced

Want your declared types enforced? Compile with -kt. Now every AS CLASS becomes a runtime invariant, checked by name on the live object. Assign the wrong thing and your program stops exactly there — naming the variable, what it expected, and what it got — instead of blowing up three screens later with a cryptic "method does not exist."

LOCAL oConta AS CLASS Conta := Conta():New()
LOCAL bPaga  := {|| oConta := PegaDeAlgumLugar() }  // this lie used to pass silently
Eval( bPaga )
─ now aborts right here ─
  Error BASE/3012  expected S:CONTA, got C: MAIN:OCONTA

It even reaches code blocks — the callbacks and filters where Harbour really lives — and on the way there, a 20-year-old compiler crash had to be fixed so it could. A subclass passes (is-a); a class built at runtime passes by name — the check works on the live object, where static analysis can't reach. -kt is opt-in, per project.

The toolbox

What it does

Every classic refactoring, re-seated on the compiler's facts — each one verified: recompile, compare, roll back.

rename

Locals, parameters, statics, memvars, functions, methods, a class's data member (VAR/DATA) — even a preprocessor directive word or match-marker. It renames the symbol, never the text.

usages

Every reference to a symbol, scope-aware, with honest certainty labels; homonyms of other classes excluded by fact. Peek them right inside VSCode.

extract-function

Pull a range of lines into a new function — or a new METHOD when you're inside one; the locals it needs move with it.

inline-local

Fold a variable back into its uses — purity judged by the compiler's own tree, not a guess.

reorder-params

Change a function's or method's parameter order and fix every call site to match, behavior-preserving.

call-graph

See who calls what — across modules and out to external functions; method sends shown as dynamic edges, honestly distinct from static calls.

find-dynamic-calls

Audit the project file by file: which modules can be refactored on proof, and which reach names that only exist while the program runs. Run it when you want to organize, the way you run a formatter.

annotate

Materialize implied types (DECLARE / AS CLASS), verified, with automatic rollback.

exec-registry

Snapshot classes that only exist at runtime, by running just the registration code in a sandbox.

And the trait that makes it different in kind: all of this works on any construct a preprocessor directive creates — Harbour's own class system is just one case. Write your own DSL (#xcommand sugar of your invention) and rename, usages and reorder work on it with zero configuration; the test suite proves it on invented DSLs the tool's source never mentions by name.

Where you use it

From the terminal, or inside your editor

Everything lives in the command-line tool — fully usable on its own. Nothing is hidden behind an IDE.

hbrefactor rename       hbhttpd.hbp core.prg:941:17 HttpErrorHandler
hbrefactor usages       hbhttpd.hbp UHttpd:LogError
hbrefactor call-graph   hbhttpd.hbp UErrorHandler
hbrefactor annotate     hbhttpd.hbp --apply

A project is anything hbmk2 accepts: a .hbp, a .hbc, or just a list of .prg files — including container / multi-target .hbp (-hbcontainer, sub-projects, -target=): sources of all targets count.

The VSCode extension is a thin layer on top: it finds the symbol under your cursor and calls the same CLI — and it finds the right .hbp for you by asking hbmk2 which project actually compiles your file (nearest first when it must ask; never guessed by proximity).

> hbrefactor
  • hbrefactor: Usages (all references)⇧⌥F12
  • hbrefactor: Rename Symbol under cursorF2
  • hbrefactor: Extract selection to new function
  • hbrefactor: Annotate apply (verified)
  • hbrefactor: Call graph
TIER 3

Under the hood

Want the machinery? This is where the tool touches the compiler itself. Optional reading — the sections above are enough to use it.

Mechanism

How the facts are produced

hbrefactor never re-implements Harbour's grammar. Re-parsing the language in a tool is a degraded copy that drifts from the real thing. Instead it asks the official tools and reads the answer:

The core work

What we changed in Harbour's core

For those facts to exist, the compiler had to emit them. That work lives on a branch of Harbour itself — feature/compiler-ast-dump — and grew as a ladder of dump schemas, one fact at a time:

SchemaThe fact it added
ast-1the -x switch: full token stream + functions dumped as .ast.json
ast-2preprocessor rules and each application (the DSL channel)
ast-3derivation trail on synthesized tokens (who created what)
ast-4the language's declared-type channel
ast-5the rule inside (match/result markers)
ast-6which push carries a RETURN value
ast-7-kt: declared types enforced as runtime invariants
ast-8the compiler's own mark of what -kt actually checked (no deduced coverage)
ast-9exact written position of every declared name (the editor's anchor)
ast-10declared class parentage (_HB_SUPER) — who inherits from whom, as fact
ast-11each codeblock carries its own parameters — types a send's receiver by the exact block (delegated getters/setters on one line stop colliding)
ast-12a directive marker is stamped generates when the name you wrote gets pasted/stringified into an artifact — telling "rename a name that becomes code" apart from "rename a symbol that just flows into a command"
ast-13rule genealogy: a rule created by another rule's expansion records which application created it — and derivation now survives token clones, so a generated rule's artifacts stay traceable through its uses
ast-14a marker a rule matches but never uses is no longer indistinguishable from a word of the rule itself — the preprocessor stopped throwing the binding away
ast-15which word of the rule a token matched. #command accepts its keywords abbreviated (from 4 letters), so GRAV may be the rule's own keyword or GRAVAR cut short — only the preprocessor knows, and now it says so
ast-16a directive has a lifetime: #undef / #xuncommand switch it off partway through a file. The preprocessor did that and told nobody — so renaming the directive left the switch-off behind, pointing at a name that no longer existed, and the directive silently leaked past the point where you turned it off. Also: the dump used to describe a #ycommand as if its keywords could be abbreviated, which they cannot

Along the way: a 20-year-old segfault fixed (annotating a code-block parameter with AS CLASS used to crash the compiler — stock Harbour still does), and a false W0019 "duplicate declaration" warning silenced when a re-declaration merely completes a missing type — it had blocked 18 methods in hbhttpd.

This is the founding principle in action: when a fact doesn't exist, extend the core so it does — never build a guess inside the tool.

Every one of those rungs is gated

Fifteen schema versions, and none of them costs an existing project anything: with the switches off, the compiled output is byte-identical to stock Harbour. That guarantee is not a nice-to-have — it is the precondition for any of this ever reaching upstream Harbour.

The full case for the maintainers — the consolidated argument, the honest shape of the diff, the two bugs it fixes in stock Harbour, and the open questions we can't answer yet — lives on the branch's own page.

The core proposal, in full →

Honest status

Where it stands pre-1.0

hbrefactor is an active, living experiment — CLI 0.5.0, VSCode extension 0.13.0 (they version independently). The behavior contract is an executable suite — make test runs it, green, byte-identical in parallel and sequential runs. Being honest about the rough edges is part of the product.

The big one: it needs that custom branch of Harbour, not the stock compiler.

Still rough

  • Renaming a class data member covers plain VAR/DATA; members with explicit ACCESS/ASSIGN accessors, or inherited from a superclass, aren't covered yet.
  • Function parameters aren't annotated yet — only local variables and code-block parameters.
  • Some "maybe" is irreducible: a receiver whose class depends on runtime input honestly stays possible — the nature of the problem, not a bug. Library code keeps more possible than a closed program.
  • In a chained call (oM:Soma(1):Soma(2)) the label stays confirmed, not guaranteed, even under -kt.
  • A switch-off only frees a name when it sits in the module's own file. If the #uncommand arrives from an included header, the tool doesn't decide — it can't see from the compiler in what order your headers reached that module — and the refusal stays. Fail-closed, on purpose.
  • The per-call -kt check has a cost in very hot loops (so it's opt-in), and it doesn't cover pass-by-reference (@x) or legacy PARAMETERS x AS ... — those sites are never labeled guaranteed.
  • The tool and the compiler branch must be in step. It requires the exact dump version the branch emits today: build one without the other and every command refuses, telling you which version each side speaks and to rebuild. It will never degrade instead — a stale toolchain used to make it quietly report a possible where it had a confirmed, dressing a build problem up as a limit on what can be known about your code. There's no backward compatibility here, on purpose: the dump is produced fresh on every command, so an old dump cannot exist — only an out-of-step build, and that's an error to shout, not to work around.
  • You still wait for the project, though less than you did. Acting on facts instead of guesses has a price: to answer anything, the compiler has to export what it knows about your code. The tool now reuses the export that is still valid and redoes only what changed — on a real 42-module project a command went from 5.1 s to about 3.3 s. But renaming one variable still costs more than it should: exporting was only about a third of that wait, and the analysis that follows is redone in full every time. Making the cost proportional to what you touched needs that analysis to become incremental too, and that is the next thing worth doing here. (It used to be worse still: the export cost grew as the square of a module's expansions, and fixing that took about a third off every command.) What reuse never does is guess: it is decided by the content of your files, not by their clock — edit a file and restore its timestamp to the second, or delete the tool's own working copy, and the next command still answers about your code as it is right now. Don't take our word for the size of it: time any command against your own biggest project before you commit to the tool.

What's next, if it all goes well

  • Inference reborn as a suggester, never a verdict: it proposes an AS CLASS, the tool only writes it if a declaration can make it a fact, and the core then enforces it. The machine suggests; the compiler proves.
  • Per-instance usages — narrowing "find references" to the calls on one specific variable.
  • More maturation across real Harbour code — then real applications.

An honest ask

Help wanted: review the Harbour branch

I built the harbour-core branch that makes these compiler facts exist — the AST dump, the -kt enforcement, the parentage channel, the segfault and warning fixes. But I'm a Harbour application developer, not a compiler/VM specialist. I would genuinely value people who know Harbour's core taking a look at that branch.

If you know the compiler, the VM, or the class system — issues, corrections, and second opinions are all welcome.

Review feature/compiler-ast-dump →

Get it running

Install

Requirement, stated honestly: hbrefactor drives a special build of Harbour — the feature/compiler-ast-dump branch — via HB_BIN. Its verification won't run against stock Harbour.

  1. Build that Harbour branch and note its bin directory (that's your HB_BIN). See the fork linked above.
  2. Clone and build the tool:
    git clone https://github.com/diegopego/hbrefactor
    cd hbrefactor
    make build        # produces bin/hbrefactor
    make test         # optional: the behavior suite
  3. VSCode (optional): install the packaged extension vscode/hbrefactor-0.13.0.vsix and point hbrefactor.hbBin at your HB_BIN.

Honest limits

What it never does