feat(link): pass external-library deps to the linker as -l<name>

The libraries collected from `external 'lib'` declarations (the program's
and every used unit's TUnitInterface.LinkLibs) are unioned into
TBackendOpts.LinkLibs and emitted on the toolchain link line as one
-l<name> each, additive to the always-needed -lm/-lpthread.  Both the QBE
and native (external-linker) paths route through LinkViaToolchain and so
gain the flags.

The in-process internal linker (--linker internal) links only Blaise ELF
objects + the source-built RTL and has no concept of -l<name> system
libraries.  Rather than silently drop a declared dependency and emit a
binary with unresolved symbols, LinkViaInternalLinker now fails loudly
when LinkLibs is non-empty, pointing the user at --linker external.

Tests: TestExternalLib_MissingLib_FailsLink_{QBE,Native} drive the real
compiler binary end to end (the e2e harness links with a hardcoded
`cc ... -lm -lpthread` and never calls LinkViaToolchain, so it cannot
exercise this).  A program declaring `external 'nosuchlib...'` — never
called, but collected into LinkLibs at parse time — must fail the link
with the linker's "cannot find -lnosuchlib...", which only happens if the
driver actually emitted the flag.
This commit is contained in:
Graeme Geldenhuys 2026-06-30 17:51:42 +01:00
parent c1f14b5c78
commit edf4ffe374
4 changed files with 92 additions and 0 deletions

View file

@ -502,6 +502,7 @@ var
any ExportUnitInterface bugs against real
codebases. }
I: Integer;
J: Integer;
IR: string;
IRFile: string;
LinkErr: string; { Driver.LinkProgram result ('' on success) }
@ -1055,6 +1056,22 @@ begin
if PrebuiltObjPaths.IndexOf(Loader.LinkOnlyObjects.Strings[I]) < 0 then
PrebuiltObjPaths.Add(Loader.LinkOnlyObjects.Strings[I]);
end;
{ Capture link-library deps (-l<name>) off the program AST and every used
unit's interface before Prog/Loader are freed below the link step runs
after this finally block. Unioned into Opts.LinkLibs; the driver emits one
-l<name> each, additive to the RTL's -lm/-lpthread. }
if Opts.LinkLibs = nil then Opts.LinkLibs := TStringList.Create();
if (Prog <> nil) and (Prog.LinkLibs <> nil) then
for I := 0 to Prog.LinkLibs.Count - 1 do
if Opts.LinkLibs.IndexOf(TLinkLibDecl(Prog.LinkLibs.Items[I]).LibName) < 0 then
Opts.LinkLibs.Add(TLinkLibDecl(Prog.LinkLibs.Items[I]).LibName);
if Loader <> nil then
for I := 0 to Loader.PrebuiltIfaces.Count - 1 do
for J := 0 to TUnitInterface(Loader.PrebuiltIfaces.Items[I]).LinkLibs.Count - 1 do
if Opts.LinkLibs.IndexOf(
TUnitInterface(Loader.PrebuiltIfaces.Items[I]).LinkLibs.Strings[J]) < 0 then
Opts.LinkLibs.Add(
TUnitInterface(Loader.PrebuiltIfaces.Items[I]).LinkLibs.Strings[J]);
Units.Free();
Loader.Free();
SearchPaths.Free();

View file

@ -98,6 +98,11 @@ type
runtime.start.static.<os>) replaces libc; the
linker emits a non-PIE ET_EXEC with no
PT_INTERP. docs/linux-syscall-migration.adoc }
LinkLibs: TStringList; { extra libraries to link (-l<name>), unioned by
the frontend from the program's and its used
units' 'external ''lib''' declarations
(TUnitInterface.LinkLibs). nil = none. Owned;
ARC-released with the opts object. }
end;
TBackendDriver = class
@ -631,6 +636,12 @@ begin
Args.Add(RTLObjs.Strings[I]);
Args.Add('-lm'); { math functions (sqrt, sin, cos, etc.) }
Args.Add('-lpthread'); { POSIX threads (blaise_thread unit) }
{ Libraries declared via 'external ''lib''' in the program or any used unit
(unioned by the frontend into AOpts.LinkLibs). Emitted as -l<name> ld
expands to lib<name>.so/.a. Additive to the always-needed -lm/-lpthread. }
if AOpts.LinkLibs <> nil then
for I := 0 to AOpts.LinkLibs.Count - 1 do
Args.Add('-l' + AOpts.LinkLibs.Strings[I]);
ExitCode := RunProcess(Self.ToolPath('linker', AOpts.Target), Args, Msg);
finally
RTLObjs.Free();

View file

@ -197,6 +197,16 @@ var
begin
Result := '';
{ The internal linker links only Blaise ELF objects (+ the source-built RTL);
it has no concept of -l<name> system libraries. A program that declares an
`external 'lib'` dependency therefore cannot be linked this way fail loudly
rather than silently drop the library and emit a binary with unresolved
symbols. Use the external toolchain linker (the default) for such programs. }
if (AOpts.LinkLibs <> nil) and (AOpts.LinkLibs.Count > 0) then
Exit('internal linker cannot resolve external library ''' +
AOpts.LinkLibs.Strings[0] +
''' (from an ''external ''''lib'''''' declaration); use --linker external');
{ Build the linker with the resolved target's TLinkTarget so the emitted ELF
carries the right EI_OSABI / machine / load base for AOpts.Target without
this the internal linker always produced a Linux-shaped ELF regardless of

View file

@ -75,6 +75,14 @@ type
procedure TestEmitAsm_WithExplicitQbeBackend_Rejected;
procedure TestEmitIr_WithoutBackend_StillWorks;
procedure TestEmitIr_WithExplicitQbeBackend_StillWorks;
{ ---- external 'lib' propagates a -l<name> to the real link command ---- }
{ A program declaring `external 'lib'` for a NON-EXISTENT library must fail
the link with the linker's "cannot find -l<lib>" which only happens if
the driver actually emitted -l<lib>. The e2e harness can't test this (it
links with a hardcoded `cc ... -lm -lpthread` and never calls
LinkViaToolchain), so this drives the real compiler binary end to end. }
procedure TestExternalLib_MissingLib_FailsLink_QBE;
procedure TestExternalLib_MissingLib_FailsLink_Native;
{ ---- div/mod by zero raises a catchable EDivByZero (needs stdlib) ---- }
procedure TestDivByZeroCaught_QBE;
procedure TestDivByZeroCaught_Native;
@ -557,6 +565,52 @@ const
' end' + LineEnding +
'end.';
const
{ Declares an external from a library that does not exist. The function is
never called, but the parser collects the library into Prog.LinkLibs at
parse time, so the driver must still emit -lnosuchlib_blaise_xyz on the link
line which the system linker then rejects. If the -l<name> propagation
were broken (no flag emitted), the link would SUCCEED. }
SrcMissingExternalLib =
'program P;' + LineEnding +
'function f(X: Integer): Integer;' +
' external ''nosuchlib_blaise_xyz'' name ''nope'';' + LineEnding +
'begin' + LineEnding +
' WriteLn(''unreached'')' + LineEnding +
'end.';
procedure TCLIContractTests.TestExternalLib_MissingLib_FailsLink_QBE;
var SrcPath, BinPath, Out_: string; EC: Integer;
begin
if not CompilerAvailable() then begin Ignore('<toolchain-missing>'); Exit; end;
SrcPath := WriteScratchSource(SrcMissingExternalLib);
BinPath := FScratch + 'cli_misslib_qbe_' + IntToStr(FCounter);
EC := RunCompiler(['--source', SrcPath, '--backend', 'qbe',
'--unit-path', FRTLPath, '--unit-path', FStdlibPath,
'--output', BinPath], Out_);
AssertTrue('link must fail (missing library)', EC <> 0);
AssertTrue('linker reports the -l<name> it could not find',
Pos('nosuchlib_blaise_xyz', Out_) >= 0);
end;
procedure TCLIContractTests.TestExternalLib_MissingLib_FailsLink_Native;
var SrcPath, BinPath, Out_: string; EC: Integer;
begin
if not CompilerAvailable() then begin Ignore('<toolchain-missing>'); Exit; end;
SrcPath := WriteScratchSource(SrcMissingExternalLib);
BinPath := FScratch + 'cli_misslib_nat_' + IntToStr(FCounter);
{ --linker external: the internal linker cannot resolve -l<name> system
libraries (it errors out separately); the toolchain linker is what emits
and consumes -l<name>. }
EC := RunCompiler(['--source', SrcPath, '--backend', 'native',
'--linker', 'external',
'--unit-path', FRTLPath, '--unit-path', FStdlibPath,
'--output', BinPath], Out_);
AssertTrue('link must fail (missing library)', EC <> 0);
AssertTrue('linker reports the -l<name> it could not find',
Pos('nosuchlib_blaise_xyz', Out_) >= 0);
end;
procedure TCLIContractTests.TestDivByZeroCaught_QBE;
var Out_: string; EC: Integer;
begin