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

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: