fix(cpp-front): discover libclang on Linux, not only on Homebrew macOS #8

Merged
trevon merged 1 commit from fix/libclang-discovery-on-linux into main 2026-09-25 02:38:57 +00:00
2 changed files with 93 additions and 12 deletions
Showing only changes of commit cf8c4cccc1 - Show all commits

fix(cpp-front): discover libclang on Linux, not only on Homebrew macOS
Some checks failed
Lint Commits / commitlint (pull_request) Successful in 16s
Tests / Static Analysis (pull_request) Has been cancelled
Tests / Tests (pull_request) Has been cancelled

The front end hardcoded /opt/homebrew/opt/llvm and lib/libclang.dylib, so
on Linux it died before parsing anything:

    FileNotFoundError: [Errno 2] No such file or directory:
    '/opt/homebrew/opt/llvm/bin/clang'

PIPE_LLVM could move the prefix but not the file name, so no value of it
made the tool run there.

Discovery is now per platform: Homebrew's LLVM on macOS, the system prefix
elsewhere, PIPE_LLVM still winning over both. The library is looked up as
.dylib or .so, exact name first and then highest version, and under
lib/*-linux-gnu as well, because distributions ship
libclang.so.<major>.<minor> without always adding the unversioned symlink
and Debian keeps it in a multiarch subdirectory. -print-resource-dir falls
back to clang on PATH when the prefix ships only the library. When nothing
matches, the conventional name is handed to clang.cindex so its own error
names the library rather than a path this code invented.

_sdk_root already degrades to None off macOS, since xcrun is absent.

test/cpp_front_test.dart looked for the same Homebrew dylib, so on Linux
it reported "All tests skipped" rather than failing. That is why the gap
went unnoticed: the suite was green because it ran nothing. Its detection
now mirrors the front end's, and on this Linux machine the four cases go
from skipped to run and passing.

Verified end to end against an unrelated 87-translation-unit C++ project:
87/87 parsed, 0 TUs failed, 0 clang errors, 26 state enums and 879
emissions recovered.

Both suites fail test/stdout_json_test.dart under the parallel runner on
this machine, pristine main included, and it passes in isolation on both
branches; a serial run failed a different test instead. Pre-existing and
unrelated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
trvon 2026-09-24 19:54:23 -06:00
Signed by: trevon
GPG key ID: 083EB1C25C575E81

View file

@ -8,12 +8,48 @@ import 'package:test/test.dart';
/// state model, and the unchanged rules must fire on each positive control in
/// test/fixtures/cpp_state and stay silent on each negative one.
///
/// Skipped, not passed, when Homebrew LLVM (libclang + its Python bindings) or
/// python3 is unavailable.
/// Skipped, not passed, when libclang (plus its Python bindings) or python3 is
/// unavailable.
///
/// Discovery mirrors tool/cpp_front.py deliberately. This test used to look
/// only for Homebrew's `libclang.dylib`, so on Linux it skipped rather than
/// ran -- which is how the front end came to be unusable there without any
/// suite noticing.
String _llvmPrefix() {
final env = Platform.environment['PIPE_LLVM'];
if (env != null && env.isNotEmpty) return env;
return Platform.isMacOS ? '/opt/homebrew/opt/llvm' : '/usr';
}
/// The library, by platform and possibly versioned: Linux distributions ship
/// `libclang.so.<major>.<minor>` and do not always add the unversioned
/// symlink, and Debian/Ubuntu keep it under a multiarch subdirectory.
bool _libclangPresent(String prefix) {
final suffix = Platform.isMacOS ? 'dylib' : 'so';
final roots = <Directory>[Directory('$prefix/lib')];
final libDir = Directory('$prefix/lib');
if (libDir.existsSync()) {
roots.addAll(
libDir.listSync().whereType<Directory>().where(
(d) => d.path.endsWith('-linux-gnu'),
),
);
}
for (final root in roots) {
if (!root.existsSync()) continue;
if (File('${root.path}/libclang.$suffix').existsSync()) return true;
final versioned = root.listSync().whereType<File>().any(
(f) => f.uri.pathSegments.last.startsWith('libclang.$suffix'),
);
if (versioned) return true;
}
return false;
}
void main() {
final llvm = Platform.environment['PIPE_LLVM'] ?? '/opt/homebrew/opt/llvm';
final llvm = _llvmPrefix();
final available =
File('$llvm/lib/libclang.dylib').existsSync() &&
_libclangPresent(llvm) &&
Process.runSync('python3', ['--version']).exitCode == 0;
late FindingReport report;
@ -60,7 +96,7 @@ void main() {
hits(ruleEmittedNotRendered).map((f) => f.data['union']),
contains('fx::Unread'),
);
}, skip: available ? false : 'Homebrew LLVM / python3 not available');
}, skip: available ? false : 'libclang / python3 not available');
test('an enumerator examined but never produced is a dead branch', () {
final ghost = hits(
@ -69,7 +105,7 @@ void main() {
expect(ghost, hasLength(1));
expect(ghost.single.file, 'src/engine.cpp');
expect(ghost.single.line, isNotNull);
}, skip: available ? false : 'Homebrew LLVM / python3 not available');
}, skip: available ? false : 'libclang / python3 not available');
test('a cast to one enum does not mute "never emitted" for another', () {
// Casted::Y is produced only through static_cast, so it is unprovable
@ -79,7 +115,7 @@ void main() {
isNot(contains('fx::Casted')),
);
expect(report.suppressed.where((s) => s.contains('fx::Phase')), isEmpty);
}, skip: available ? false : 'Homebrew LLVM / python3 not available');
}, skip: available ? false : 'libclang / python3 not available');
test(
'a swallowed failure on the method\'s own busy state fires, and only it',
@ -90,6 +126,6 @@ void main() {
'startSwallowed',
]);
},
skip: available ? false : 'Homebrew LLVM / python3 not available',
skip: available ? false : 'libclang / python3 not available',
);
}

View file

@ -30,7 +30,9 @@ Consequences worth knowing before reading the findings:
is recorded as an indirect emission so the rule suppresses itself.
- The rule messages still speak of "UI"; for C++ read "nothing observes it".
Requires Homebrew LLVM's libclang and its Python bindings (found automatically).
Requires libclang and its Python bindings (found automatically): Homebrew's
LLVM on macOS, the system LLVM elsewhere. Set PIPE_LLVM to point at another
prefix.
"""
from __future__ import annotations
@ -41,13 +43,46 @@ import json
import os
import re
import shlex
import shutil
import subprocess
import sys
from collections import Counter, defaultdict
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
LLVM = Path(os.environ.get("PIPE_LLVM", "/opt/homebrew/opt/llvm"))
def _llvm_prefix() -> Path:
"""Where libclang lives. PIPE_LLVM wins; otherwise Homebrew's LLVM on macOS
and the system prefix elsewhere, because Linux has no /opt/homebrew."""
env = os.environ.get("PIPE_LLVM")
if env:
return Path(env)
return Path("/opt/homebrew/opt/llvm" if sys.platform == "darwin" else "/usr")
LLVM = _llvm_prefix()
def _libclang_path() -> str:
"""The shared library, by platform and possibly versioned.
Linux distributions name it libclang.so.<major>.<minor> and do not always
ship the unversioned symlink, and Debian/Ubuntu put it under a multiarch
subdirectory -- so a single fixed name finds nothing on most of them.
"""
suffix = "dylib" if sys.platform == "darwin" else "so"
roots = [LLVM / "lib", *(Path(p) for p in glob.glob(str(LLVM / "lib/*-linux-gnu")))]
for root in roots:
exact = root / f"libclang.{suffix}"
if exact.exists():
return str(exact)
for root in roots:
# Highest version first: libclang.so.22.1 before libclang.so.21.1.
versioned = sorted(glob.glob(str(root / f"libclang.{suffix}*")), reverse=True)
if versioned:
return versioned[0]
# Nothing found: hand clang.cindex the conventional name so its own error
# names the library rather than a path this function invented.
return str(LLVM / f"lib/libclang.{suffix}")
def _load_cindex():
@ -58,7 +93,7 @@ def _load_cindex():
# Pool workers are reused, and libclang may be configured only once.
if not cindex.Config.loaded:
cindex.Config.set_library_file(str(LLVM / "lib/libclang.dylib"))
cindex.Config.set_library_file(_libclang_path())
return cindex
@ -81,8 +116,18 @@ _TRANSPARENT = {
}
def _clang_binary() -> str:
"""The driver used only for -print-resource-dir. Under a system prefix
LLVM/bin/clang exists, but a prefix that ships just the library does not
have one, so fall back to whatever is on PATH."""
candidate = LLVM / "bin/clang"
if candidate.exists():
return str(candidate)
return shutil.which("clang") or shutil.which("clang++") or str(candidate)
def _resource_dir() -> str:
return subprocess.check_output([str(LLVM / "bin/clang"), "-print-resource-dir"], text=True).strip()
return subprocess.check_output([_clang_binary(), "-print-resource-dir"], text=True).strip()
def _sdk_root() -> str | None: