From ab6dc8de27fac2af47f403c58db40f595f78ed55 Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Mon, 13 Jul 2026 19:34:05 -0700 Subject: [PATCH] Auto-detect an installed clang resource directory on Unix libClang ships only the native library and no resource directory, so builtin headers like stddef.h are unavailable and Unix parses fail with 'stddef.h' file not found. Probe for a version-matched clang resource directory and inject -resource-dir when one is found, warning (never failing) otherwise. Detection is Unix-only; Windows resolves these through the MSVC/SDK headers as a fallback. Adds --resource-directory to specify one explicitly and --no-resource-directory-detection to opt out. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 + docs/generating-bindings-best-practices.md | 7 + .../Program.Options.cs | 6 + .../Program.ResourceDirectory.cs | 281 ++++++++++++++++++ sources/ClangSharpPInvokeGenerator/Program.cs | 1 + .../ResourceDirectoryTests.cs | 116 ++++++++ 6 files changed, 413 insertions(+) create mode 100644 sources/ClangSharpPInvokeGenerator/Program.ResourceDirectory.cs create mode 100644 tests/ClangSharpPInvokeGenerator.UnitTests/ResourceDirectoryTests.cs diff --git a/README.md b/README.md index c4f96ab8..418e7819 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,8 @@ Options: -r, --remap A declaration name to be remapped to another name during binding generation. [] -rt, --remap-type A type (record or enum) declaration name to be remapped to another name during binding generation. Takes precedence over --remap and is useful when a type and field share a name. [] -rf, --remap-field A field declaration name to be remapped to another name during binding generation. Takes precedence over --remap and is useful when a type and field share a name. [] + -rd, --resource-directory The Clang resource directory containing the builtin headers (such as stddef.h). When omitted, an installed and version-matched Clang's resource directory is automatically detected. [] + --no-resource-directory-detection Disable the automatic detection of the Clang resource directory. -std, --std Language standard to compile for. [] -to, --test-output The output location to write the generated tests to. [] -t, --traverse A file name included either directly or indirectly by -f that should be traversed during binding generation. [] diff --git a/docs/generating-bindings-best-practices.md b/docs/generating-bindings-best-practices.md index 8635723c..41dcb7f1 100644 --- a/docs/generating-bindings-best-practices.md +++ b/docs/generating-bindings-best-practices.md @@ -147,6 +147,13 @@ authoritative. This section groups the options by intent and notes when to reach `-Wno-*` warning suppression. * **`-x, --language` / `-std, --std`** — force C vs C++ and the language standard when the headers need it. +* **`-rd, --resource-directory`** — the Clang resource directory holding the builtin headers + (`stddef.h`, `stdarg.h`, the intrinsics, ...). You normally don't need this: on Unix the tool + auto-detects an installed, version-matched Clang the same way the `clang` driver locates its own + builtin headers, and only warns (never fails) when nothing is found. Auto-detection is skipped on + Windows, where the MSVC/Windows SDK toolchain ships compatible copies of these headers so parsing + works without one. Set it explicitly to pin a specific toolchain (honored on every platform), or + pass `--no-resource-directory-detection` to opt out. ### Naming and remapping diff --git a/sources/ClangSharpPInvokeGenerator/Program.Options.cs b/sources/ClangSharpPInvokeGenerator/Program.Options.cs index 1d41e0e6..7b13701e 100644 --- a/sources/ClangSharpPInvokeGenerator/Program.Options.cs +++ b/sources/ClangSharpPInvokeGenerator/Program.Options.cs @@ -24,6 +24,8 @@ internal static partial class Program private static readonly string[] s_remapOptionAliases = ["--remap", "-r"]; private static readonly string[] s_remapTypeOptionAliases = ["--remap-type", "-rt"]; private static readonly string[] s_remapFieldOptionAliases = ["--remap-field", "-rf"]; + private static readonly string[] s_resourceDirectoryOptionAliases = ["--resource-directory", "-rd"]; + private static readonly string[] s_resourceDirectoryDetectionOptionAliases = ["--no-resource-directory-detection"]; private static readonly string[] s_stdOptionAliases = ["--std", "-std"]; private static readonly string[] s_testOutputOptionAliases = ["--test-output", "-to"]; private static readonly string[] s_traverseOptionAliases = ["--traverse", "-t"]; @@ -66,6 +68,8 @@ internal static partial class Program private static readonly CommandLineOption s_remappedNameValuePairs = Multi(s_remapOptionAliases, "A declaration name to be remapped to another name during binding generation."); private static readonly CommandLineOption s_remappedTypeNameValuePairs = Multi(s_remapTypeOptionAliases, "A type (record or enum) declaration name to be remapped to another name during binding generation. Takes precedence over --remap and is useful when a type and field share a name."); private static readonly CommandLineOption s_remappedFieldNameValuePairs = Multi(s_remapFieldOptionAliases, "A field declaration name to be remapped to another name during binding generation. Takes precedence over --remap and is useful when a type and field share a name."); + private static readonly CommandLineOption s_resourceDirectory = Single(s_resourceDirectoryOptionAliases, "The Clang resource directory containing the builtin headers (such as stddef.h). When omitted, an installed and version-matched Clang's resource directory is automatically detected.", valueName: "directory"); + private static readonly CommandLineOption s_resourceDirectoryDetectionDisabled = Flag(s_resourceDirectoryDetectionOptionAliases, "Disable the automatic detection of the Clang resource directory."); private static readonly CommandLineOption s_std = Single(s_stdOptionAliases, "Language standard to compile for."); private static readonly CommandLineOption s_testOutputLocation = Single(s_testOutputOptionAliases, "The output location to write the generated tests to."); private static readonly CommandLineOption s_traversalNames = Multi(s_traverseOptionAliases, "A file name included either directly or indirectly by -f that should be traversed during binding generation."); @@ -110,6 +114,8 @@ internal static partial class Program s_remappedNameValuePairs, s_remappedTypeNameValuePairs, s_remappedFieldNameValuePairs, + s_resourceDirectory, + s_resourceDirectoryDetectionDisabled, s_std, s_testOutputLocation, s_traversalNames, diff --git a/sources/ClangSharpPInvokeGenerator/Program.ResourceDirectory.cs b/sources/ClangSharpPInvokeGenerator/Program.ResourceDirectory.cs new file mode 100644 index 00000000..f918a85c --- /dev/null +++ b/sources/ClangSharpPInvokeGenerator/Program.ResourceDirectory.cs @@ -0,0 +1,281 @@ +// Copyright (c) .NET Foundation and Contributors. All Rights Reserved. Licensed under the MIT License (MIT). See License.md in the repository root for more information. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using ClangSharp.Interop; + +namespace ClangSharp; + +internal static partial class Program +{ + // The Clang major version the tool is built against. The resource directory is versioned by + // this (lib/clang/) and the builtin headers are only guaranteed to be compatible with a + // matching major version, so any detected directory must line up with it. + private static int ClangMajorVersion => clang.MajorVersion; + + // Mirrors what the clang driver does automatically: locate the resource directory holding the + // builtin headers (stddef.h, stdarg.h, the intrinsics, ...) and pass it via -resource-dir so + // users don't have to wire it up by hand. Unlike the driver we cannot derive it relative to our + // own binary -- the headers are not shipped with libClang -- so we probe for an installed and + // version-matched Clang instead. + private static string[] AddResourceDirectory(string[] clangCommandLineArgs, IReadOnlyList additionalArgs) + { + // Only auto-detect on Unix. There, nothing but the compiler provides the freestanding + // headers (stddef.h, stdarg.h, the intrinsics, ...), so a missing resource dir is fatal. + // Windows has a fallback: the MSVC/Windows SDK toolchain ships compatible copies, so clang + // resolves them even without a resource dir. An explicit --resource-directory is still + // honored on every platform. + var detect = (!s_resourceDirectoryDetectionDisabled.IsPresent && !OperatingSystem.IsWindows()) + ? DetectClangResourceDirectory + : (Func?)null; + + if (TryResolveResourceDirectory(s_resourceDirectory.IsPresent ? s_resourceDirectory.SingleValue : null, + additionalArgs, + detect, + out var resourceDirectory, + out var warning)) + { + clangCommandLineArgs = [.. clangCommandLineArgs, "-resource-dir", resourceDirectory]; + } + + if (warning is not null) + { + Console.Error.WriteLine(warning); + } + + return clangCommandLineArgs; + } + + // Decides which resource directory (if any) should be injected. Kept free of any environment + // access -- the probe is supplied via `detect` (null when auto-detection doesn't apply) -- so + // the precedence rules can be unit tested. + internal static bool TryResolveResourceDirectory(string? explicitResourceDirectory, IReadOnlyList additionalArgs, Func? detect, [NotNullWhen(true)] out string? resourceDirectory, out string? warning) + { + warning = null; + + // An explicit --resource-directory always wins and is honored as-is. We deliberately don't + // fail when it looks wrong so the caller stays in control of their own toolchain. + if (!string.IsNullOrWhiteSpace(explicitResourceDirectory)) + { + resourceDirectory = explicitResourceDirectory; + return true; + } + + // Respect a -resource-dir passed through --additional; don't second-guess it or add another. + if (AdditionalArgsSpecifyResourceDirectory(additionalArgs)) + { + resourceDirectory = null; + return false; + } + + // Auto-detection is off (disabled by the user or not applicable to this platform). + if (detect is null) + { + resourceDirectory = null; + return false; + } + + resourceDirectory = detect(); + + if (resourceDirectory is not null) + { + return true; + } + + warning = $"Warning: No Clang resource directory was found; builtin headers such as 'stddef.h' may fail to resolve. " + + $"Specify one with --resource-directory , install LLVM/Clang {ClangMajorVersion}, or pass --no-resource-directory-detection to silence this."; + return false; + } + + private static bool AdditionalArgsSpecifyResourceDirectory(IReadOnlyList additionalArgs) + { + foreach (var arg in additionalArgs) + { + if (arg.Equals("-resource-dir", StringComparison.Ordinal) || + arg.Equals("--resource-dir", StringComparison.Ordinal) || + arg.StartsWith("-resource-dir=", StringComparison.Ordinal) || + arg.StartsWith("--resource-dir=", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static string? DetectClangResourceDirectory() + { + foreach (var candidate in EnumerateResourceDirectoryCandidates()) + { + if (IsValidResourceDirectory(candidate) && HasMatchingMajorVersion(candidate)) + { + return candidate; + } + } + + return null; + } + + private static IEnumerable EnumerateResourceDirectoryCandidates() + { + var major = ClangMajorVersion.ToString(CultureInfo.InvariantCulture); + + // 1. Next to the tool / native libClang, in case a resource directory is shipped side-by-side. + foreach (var baseDirectory in EnumerateApplicationDirectories()) + { + yield return Path.Combine(baseDirectory, "lib", "clang", major); + } + + // 2. Ask a Clang on PATH where its resource directory lives. + if (TryGetResourceDirectoryFromClang(out var fromClang)) + { + yield return fromClang; + } + + // 3. Well-known install locations for the current operating system. + foreach (var candidate in EnumerateWellKnownResourceDirectories(major)) + { + yield return candidate; + } + } + + private static IEnumerable EnumerateApplicationDirectories() + { + var seen = new HashSet(StringComparer.Ordinal); + + // Environment.ProcessPath is the launching executable; AppContext.BaseDirectory is the + // assembly directory. They differ when the tool is hosted, so probe both. + var processDirectory = (Environment.ProcessPath is string processPath) ? Path.GetDirectoryName(processPath) : null; + + if (!string.IsNullOrEmpty(processDirectory) && seen.Add(processDirectory)) + { + yield return processDirectory; + } + + var baseDirectory = AppContext.BaseDirectory; + + if (!string.IsNullOrEmpty(baseDirectory)) + { + baseDirectory = baseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + if (seen.Add(baseDirectory)) + { + yield return baseDirectory; + } + } + } + + // Only reached on Unix; auto-detection is not wired up on Windows (see AddResourceDirectory). + private static IEnumerable EnumerateWellKnownResourceDirectories(string major) + { + if (OperatingSystem.IsMacOS()) + { + // Homebrew is the common source of an upstream (non-Apple) Clang on macOS; Apple's own + // Clang uses a different versioning scheme and is filtered out by the major check. + yield return Path.Combine("/opt/homebrew/opt/llvm/lib/clang", major); + yield return Path.Combine("/usr/local/opt/llvm/lib/clang", major); + } + else + { + yield return Path.Combine($"/usr/lib/llvm-{major}/lib/clang", major); + yield return Path.Combine("/usr/lib/clang", major); + yield return Path.Combine("/usr/lib64/clang", major); + yield return Path.Combine("/usr/local/lib/clang", major); + } + } + + private static bool TryGetResourceDirectoryFromClang([NotNullWhen(true)] out string? resourceDirectory) + { + string[] clangExecutables = [$"clang-{ClangMajorVersion.ToString(CultureInfo.InvariantCulture)}", "clang"]; + + foreach (var clangExecutable in clangExecutables) + { + if (TryReadProcessOutput(clangExecutable, "-print-resource-dir", out var output) && !string.IsNullOrWhiteSpace(output)) + { + resourceDirectory = output.Trim(); + return true; + } + } + + resourceDirectory = null; + return false; + } + + // A directory is only usable if it actually contains the builtin headers we care about. + internal static bool IsValidResourceDirectory(string? resourceDirectory) + { + return !string.IsNullOrEmpty(resourceDirectory) + && File.Exists(Path.Combine(resourceDirectory, "include", "stddef.h")); + } + + // The builtin headers can reference builtins that only exist in a matching compiler, so we only + // accept a directory whose lib/clang/ segment matches the libClang we ship against. + internal static bool HasMatchingMajorVersion(string? resourceDirectory) + { + if (string.IsNullOrEmpty(resourceDirectory)) + { + return false; + } + + var name = Path.GetFileName(resourceDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var dotIndex = name.IndexOf('.', StringComparison.Ordinal); + + if (dotIndex >= 0) + { + name = name[..dotIndex]; + } + + return int.TryParse(name, NumberStyles.None, CultureInfo.InvariantCulture, out var major) + && (major == ClangMajorVersion); + } + + private static bool TryReadProcessOutput(string fileName, string arguments, out string output) + { + output = ""; + + try + { + using var process = new Process { + StartInfo = new ProcessStartInfo(fileName, arguments) { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }, + }; + + if (!process.Start()) + { + return false; + } + + // The output is a single short path, so reading it fully before waiting cannot deadlock. + output = process.StandardOutput.ReadToEnd(); + + if (!process.WaitForExit(milliseconds: 5000)) + { + return false; + } + + return process.ExitCode == 0; + } + catch (Win32Exception) + { + // The executable was not found on PATH. + return false; + } + catch (InvalidOperationException) + { + return false; + } + catch (IOException) + { + return false; + } + } +} diff --git a/sources/ClangSharpPInvokeGenerator/Program.cs b/sources/ClangSharpPInvokeGenerator/Program.cs index e9f2dd4a..4496e58a 100644 --- a/sources/ClangSharpPInvokeGenerator/Program.cs +++ b/sources/ClangSharpPInvokeGenerator/Program.cs @@ -531,6 +531,7 @@ public static int Run() clangCommandLineArgs = [.. clangCommandLineArgs, .. includeDirectories.Select(x => $"--include-directory={x}")]; clangCommandLineArgs = [.. clangCommandLineArgs, .. defineMacros.Select(x => $"--define-macro={x}")]; clangCommandLineArgs = [.. clangCommandLineArgs, .. additionalArgs]; + clangCommandLineArgs = AddResourceDirectory(clangCommandLineArgs, additionalArgs); var translationFlags = CXTranslationUnit_None; diff --git a/tests/ClangSharpPInvokeGenerator.UnitTests/ResourceDirectoryTests.cs b/tests/ClangSharpPInvokeGenerator.UnitTests/ResourceDirectoryTests.cs new file mode 100644 index 00000000..1e618b39 --- /dev/null +++ b/tests/ClangSharpPInvokeGenerator.UnitTests/ResourceDirectoryTests.cs @@ -0,0 +1,116 @@ +// Copyright (c) .NET Foundation and Contributors. All Rights Reserved. Licensed under the MIT License (MIT). See License.md in the repository root for more information. + +using System; +using System.IO; +using NUnit.Framework; + +namespace ClangSharp.UnitTests; + +public sealed class ResourceDirectoryTests +{ + [Test] + public void ExplicitResourceDirectoryIsHonored() + { + var resolved = Program.TryResolveResourceDirectory("/some/resource-dir", additionalArgs: [], detect: ShouldNotDetect, out var resourceDirectory, out var warning); + + Assert.That(resolved, Is.True); + Assert.That(resourceDirectory, Is.EqualTo("/some/resource-dir")); + Assert.That(warning, Is.Null); + } + + [Test] + public void ExplicitResourceDirectoryWinsOverDetection() + { + var resolved = Program.TryResolveResourceDirectory("/explicit", additionalArgs: [], detect: static () => "/detected", out var resourceDirectory, out var warning); + + Assert.That(resolved, Is.True); + Assert.That(resourceDirectory, Is.EqualTo("/explicit")); + Assert.That(warning, Is.Null); + } + + [TestCase("-resource-dir")] + [TestCase("--resource-dir")] + [TestCase("-resource-dir=/x")] + [TestCase("--resource-dir=/x")] + public void AdditionalResourceDirArgIsRespected(string additionalArg) + { + var resolved = Program.TryResolveResourceDirectory(explicitResourceDirectory: null, additionalArgs: [additionalArg], detect: ShouldNotDetect, out var resourceDirectory, out var warning); + + Assert.That(resolved, Is.False); + Assert.That(resourceDirectory, Is.Null); + Assert.That(warning, Is.Null); + } + + // A null detector models auto-detection being off: the user passed + // --no-resource-directory-detection, or the platform (Windows) relies on the MSVC fallback. + [Test] + public void NoDetectorDoesNotProbeOrWarn() + { + var resolved = Program.TryResolveResourceDirectory(explicitResourceDirectory: null, additionalArgs: [], detect: null, out var resourceDirectory, out var warning); + + Assert.That(resolved, Is.False); + Assert.That(resourceDirectory, Is.Null); + Assert.That(warning, Is.Null); + } + + [Test] + public void DetectedDirectoryIsUsed() + { + var resolved = Program.TryResolveResourceDirectory(explicitResourceDirectory: null, additionalArgs: [], detect: static () => "/detected", out var resourceDirectory, out var warning); + + Assert.That(resolved, Is.True); + Assert.That(resourceDirectory, Is.EqualTo("/detected")); + Assert.That(warning, Is.Null); + } + + [Test] + public void MissingDirectoryWarnsButDoesNotFail() + { + var resolved = Program.TryResolveResourceDirectory(explicitResourceDirectory: null, additionalArgs: [], detect: static () => null, out var resourceDirectory, out var warning); + + Assert.That(resolved, Is.False); + Assert.That(resourceDirectory, Is.Null); + Assert.That(warning, Is.Not.Null); + } + + [Test] + public void IsValidResourceDirectoryRequiresBuiltinHeader() + { + var root = Path.Combine(Path.GetTempPath(), $"clangsharp-res-{Guid.NewGuid():N}"); + + try + { + Assert.That(Program.IsValidResourceDirectory(root), Is.False); + + _ = Directory.CreateDirectory(Path.Combine(root, "include")); + Assert.That(Program.IsValidResourceDirectory(root), Is.False); + + File.WriteAllText(Path.Combine(root, "include", "stddef.h"), ""); + Assert.That(Program.IsValidResourceDirectory(root), Is.True); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + [Test] + public void HasMatchingMajorVersionComparesTheClangMajor() + { + var major = ClangSharp.Interop.clang.MajorVersion; + + Assert.That(Program.HasMatchingMajorVersion($"/usr/lib/llvm-{major}/lib/clang/{major}"), Is.True); + Assert.That(Program.HasMatchingMajorVersion($"/usr/lib/clang/{major}.0.0"), Is.True); + Assert.That(Program.HasMatchingMajorVersion($"/usr/lib/clang/{major - 1}"), Is.False); + Assert.That(Program.HasMatchingMajorVersion("/usr/lib/clang/not-a-version"), Is.False); + } + + private static string? ShouldNotDetect() + { + Assert.Fail("Detection should not have run for this configuration."); + return null; + } +}