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; + } +}