pipe/test/cpp_front_test.dart
trvon cf8c4cccc1
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
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.<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>
2026-09-24 19:54:23 -06:00

131 lines
4.5 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:pipe/pipe.dart';
import 'package:test/test.dart';
/// Contract for the experimental C++ front end (tool/cpp_front.py): it writes a
/// 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 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 = _llvmPrefix();
final available =
_libclangPresent(llvm) &&
Process.runSync('python3', ['--version']).exitCode == 0;
late FindingReport report;
setUpAll(() async {
if (!available) return;
final root = Directory.systemTemp.createTempSync('pipe-cpp');
Directory('${root.path}/src').createSync();
File(
'test/fixtures/cpp_state/src/engine.cpp',
).copySync('${root.path}/src/engine.cpp');
File('${root.path}/compile_commands.json').writeAsStringSync(
jsonEncode([
{
'directory': root.path,
'file': 'src/engine.cpp',
'arguments': ['c++', '-std=c++20', '-c', 'src/engine.cpp'],
},
]),
);
final model = '${root.path}/model.json';
final result = Process.runSync('python3', [
'tool/cpp_front.py',
root.path,
'--include',
'src',
'-o',
model,
]);
expect(result.exitCode, 0, reason: '${result.stderr}');
expect('${result.stderr}', contains('0 clang error(s)'));
report = runChecks(
flow: stateFlowFromJson(
jsonDecode(File(model).readAsStringSync()) as Map<String, Object?>,
),
);
});
Iterable<Finding> hits(String rule) =>
report.findings.where((finding) => finding.rule == rule);
test('an enum produced but never examined is reported', () {
expect(
hits(ruleEmittedNotRendered).map((f) => f.data['union']),
contains('fx::Unread'),
);
}, skip: available ? false : 'libclang / python3 not available');
test('an enumerator examined but never produced is a dead branch', () {
final ghost = hits(
ruleRenderedNotEmitted,
).where((f) => f.data['variant'] == 'Ghost');
expect(ghost, hasLength(1));
expect(ghost.single.file, 'src/engine.cpp');
expect(ghost.single.line, isNotNull);
}, 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
// and suppressed. Phase lives in the same class and must still be judged.
expect(
hits(ruleRenderedNotEmitted).map((f) => f.data['union']),
isNot(contains('fx::Casted')),
);
expect(report.suppressed.where((s) => s.contains('fx::Phase')), isEmpty);
}, skip: available ? false : 'libclang / python3 not available');
test(
'a swallowed failure on the method\'s own busy state fires, and only it',
() {
// startGuarded resolves the state, startNested's try is a nested local
// fallback, and Reporter::run sets a field of a local event object.
expect(hits(ruleLoadingNotReset).map((f) => f.data['member']).toList(), [
'startSwallowed',
]);
},
skip: available ? false : 'libclang / python3 not available',
);
}