From cf8c4cccc18412d9989b435e14b09df6ca40a60e Mon Sep 17 00:00:00 2001 From: trvon Date: Thu, 24 Sep 2026 19:54:23 -0600 Subject: [PATCH] fix(cpp-front): discover libclang on Linux, not only on Homebrew macOS 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.. 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) --- test/cpp_front_test.dart | 52 +++++++++++++++++++++++++++++++++------ tool/cpp_front.py | 53 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 93 insertions(+), 12 deletions(-) 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: -- 2.52.0