diff --git a/test/cpp_front_test.dart b/test/cpp_front_test.dart index dfa1ba0..bf88a9b 100644 --- a/test/cpp_front_test.dart +++ b/test/cpp_front_test.dart @@ -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..` 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('$prefix/lib')]; + final libDir = Directory('$prefix/lib'); + if (libDir.existsSync()) { + roots.addAll( + libDir.listSync().whereType().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().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', ); } diff --git a/tool/cpp_front.py b/tool/cpp_front.py index 48e57fd..e12d3ba 100644 --- a/tool/cpp_front.py +++ b/tool/cpp_front.py @@ -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.. 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: