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 /// state model, and the unchanged rules must fire on each positive control in
/// test/fixtures/cpp_state and stay silent on each negative one. /// test/fixtures/cpp_state and stay silent on each negative one.
/// ///
/// Skipped, not passed, when Homebrew LLVM (libclang + its Python bindings) or /// Skipped, not passed, when libclang (plus its Python bindings) or python3 is
/// python3 is unavailable. /// 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() { void main() {
final llvm = Platform.environment['PIPE_LLVM'] ?? '/opt/homebrew/opt/llvm'; final llvm = _llvmPrefix();
final available = final available =
File('$llvm/lib/libclang.dylib').existsSync() && _libclangPresent(llvm) &&
Process.runSync('python3', ['--version']).exitCode == 0; Process.runSync('python3', ['--version']).exitCode == 0;
late FindingReport report; late FindingReport report;
@ -60,7 +96,7 @@ void main() {
hits(ruleEmittedNotRendered).map((f) => f.data['union']), hits(ruleEmittedNotRendered).map((f) => f.data['union']),
contains('fx::Unread'), 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', () { test('an enumerator examined but never produced is a dead branch', () {
final ghost = hits( final ghost = hits(
@ -69,7 +105,7 @@ void main() {
expect(ghost, hasLength(1)); expect(ghost, hasLength(1));
expect(ghost.single.file, 'src/engine.cpp'); expect(ghost.single.file, 'src/engine.cpp');
expect(ghost.single.line, isNotNull); 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', () { 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 // Casted::Y is produced only through static_cast, so it is unprovable
@ -79,7 +115,7 @@ void main() {
isNot(contains('fx::Casted')), isNot(contains('fx::Casted')),
); );
expect(report.suppressed.where((s) => s.contains('fx::Phase')), isEmpty); 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( test(
'a swallowed failure on the method\'s own busy state fires, and only it', 'a swallowed failure on the method\'s own busy state fires, and only it',
@ -90,6 +126,6 @@ void main() {
'startSwallowed', '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. is recorded as an indirect emission so the rule suppresses itself.
- The rule messages still speak of "UI"; for C++ read "nothing observes it". - 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 from __future__ import annotations
@ -41,13 +43,46 @@ import json
import os import os
import re import re
import shlex import shlex
import shutil
import subprocess import subprocess
import sys import sys
from collections import Counter, defaultdict from collections import Counter, defaultdict
from concurrent.futures import ProcessPoolExecutor, as_completed from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path 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(): def _load_cindex():
@ -58,7 +93,7 @@ def _load_cindex():
# Pool workers are reused, and libclang may be configured only once. # Pool workers are reused, and libclang may be configured only once.
if not cindex.Config.loaded: if not cindex.Config.loaded:
cindex.Config.set_library_file(str(LLVM / "lib/libclang.dylib")) cindex.Config.set_library_file(_libclang_path())
return cindex 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: 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: def _sdk_root() -> str | None: