From 391cd7e96edbc6667f283b14a90d17580a4603c5 Mon Sep 17 00:00:00 2001 From: ramadanahmed Date: Mon, 6 Jul 2026 02:08:08 +0300 Subject: [PATCH 1/2] Add --dirs-proj support (MSBuild traversal dirs.proj), mirroring --solution Discovers every referenced .vcxproj from an MSBuild traversal project (Sdk=Microsoft.Build.Traversal), recursing into nested dirs.proj and skipping non-VC references (.csproj, etc.). Wired through in-process and out-of-process extraction, multi-input, all-configurations, config validation, and output-path resolution. Honors --cl-path/--msbuild-path/--vc-targets-path like --solution. --- CommandLineOptions.cs | 15 +++- InProcessExtractor.cs | 22 ++++++ OutOfProcessExtractor.cs | 36 +++++++++ Program.cs | 95 +++++++++++++++++++++++- ProjectDiscovery.cs | 153 +++++++++++++++++++++++++++++++++++++++ README.md | 16 +++- 6 files changed, 329 insertions(+), 8 deletions(-) diff --git a/CommandLineOptions.cs b/CommandLineOptions.cs index de87e2e..3f89b37 100644 --- a/CommandLineOptions.cs +++ b/CommandLineOptions.cs @@ -6,9 +6,11 @@ public class CommandLineOptions { public string[] Projects { get; set; } = []; public string[] Solutions { get; set; } = []; + public string[] DirsProjs { get; set; } = []; public string? Project => Projects.Length == 1 ? Projects[0] : null; public string? Solution => Solutions.Length == 1 ? Solutions[0] : null; + public string? DirsProj => DirsProjs.Length == 1 ? DirsProjs[0] : null; public string Configuration { get; set; } = "Debug"; public string Platform { get; set; } = "x64"; public string? VsPath { get; set; } @@ -54,6 +56,12 @@ public static CommandLineOptions Parse(string[] args) AllowMultipleArgumentsPerToken = true }; + var dirsProjOption = new Option("--dirs-proj") + { + Description = "Path to an MSBuild traversal dirs.proj (Sdk=\"Microsoft.Build.Traversal\"); recurses into nested dirs.proj and extracts every referenced .vcxproj. Can be specified multiple times.", + AllowMultipleArgumentsPerToken = true + }; + var configOption = new Option("--configuration", "-c") { Description = "Build configuration", @@ -219,6 +227,7 @@ public static CommandLineOptions Parse(string[] args) var rootCommand = new RootCommand("Extract compile_commands.json from Visual C++ MSBuild projects"); rootCommand.Options.Add(projectOption); rootCommand.Options.Add(solutionOption); + rootCommand.Options.Add(dirsProjOption); rootCommand.Options.Add(configOption); rootCommand.Options.Add(platformOption); rootCommand.Options.Add(vsPathOption); @@ -256,9 +265,10 @@ public static CommandLineOptions Parse(string[] args) var projects = commandResult.GetValue(projectOption) ?? []; var solutions = commandResult.GetValue(solutionOption) ?? []; + var dirsProjs = commandResult.GetValue(dirsProjOption) ?? []; - if (projects.Length == 0 && solutions.Length == 0) - commandResult.AddError("At least one --project or --solution must be specified."); + if (projects.Length == 0 && solutions.Length == 0 && dirsProjs.Length == 0) + commandResult.AddError("At least one --project, --solution, or --dirs-proj must be specified."); if (commandResult.GetValue(cCppPropertiesOption) && commandResult.GetValue(formatOption) == "rich") commandResult.AddError("--c-cpp-properties cannot be used with --format rich (rich format does not produce compile_commands.json)."); @@ -270,6 +280,7 @@ public static CommandLineOptions Parse(string[] args) { Projects = parseResult.GetValue(projectOption) ?? [], Solutions = parseResult.GetValue(solutionOption) ?? [], + DirsProjs = parseResult.GetValue(dirsProjOption) ?? [], Configuration = parseResult.GetValue(configOption)!, Platform = parseResult.GetValue(platformOption)!, VsPath = parseResult.GetValue(vsPathOption), diff --git a/InProcessExtractor.cs b/InProcessExtractor.cs index e1874c1..04128f6 100644 --- a/InProcessExtractor.cs +++ b/InProcessExtractor.cs @@ -1073,6 +1073,28 @@ public static List ExtractCompileCommandsFromSolution( { var solutionDir = Path.GetDirectoryName(Path.GetFullPath(solutionPath))!; var projects = ProjectDiscovery.GetVcProjectsFromSolution(solutionPath); + return ExtractCompileCommandsFromProjects( + projects, solutionDir, configuration, platform, enableLogger, + vcToolsInstallDir, emitDefaults, mergeDefaults); + } + + public static List ExtractCompileCommandsFromDirsProj( + string dirsProjPath, string configuration = "Debug", string platform = "x64", + bool enableLogger = false, string? vcToolsInstallDir = null, + bool emitDefaults = false, bool mergeDefaults = false) + { + // The dirs.proj directory plays the same role as SolutionDir for $(SolutionDir). + var baseDir = Path.GetDirectoryName(Path.GetFullPath(dirsProjPath))!; + var projects = ProjectDiscovery.GetVcProjectsFromDirsProj(dirsProjPath); + return ExtractCompileCommandsFromProjects( + projects, baseDir, configuration, platform, enableLogger, + vcToolsInstallDir, emitDefaults, mergeDefaults); + } + + private static List ExtractCompileCommandsFromProjects( + List projects, string solutionDir, string configuration, string platform, + bool enableLogger, string? vcToolsInstallDir, bool emitDefaults, bool mergeDefaults) + { var allCommands = new List(); foreach (var project in projects) diff --git a/OutOfProcessExtractor.cs b/OutOfProcessExtractor.cs index 485af8e..7b1079b 100644 --- a/OutOfProcessExtractor.cs +++ b/OutOfProcessExtractor.cs @@ -1130,6 +1130,42 @@ public static List ExtractCompileCommandsFromSolution( { var solutionDir = Path.GetDirectoryName(Path.GetFullPath(solutionPath))!; var projects = ProjectDiscovery.GetVcProjectsFromSolution(solutionPath); + return ExtractCompileCommandsFromProjects( + msbuildPath, projects, solutionDir, configuration, platform, enableLogger, + vcToolsInstallDir, vcTargetsPath, clPath, msbuildProperties, msbuildEnv, + launcher, includePathOrder, emitDefaults, mergeDefaults); + } + + public static List ExtractCompileCommandsFromDirsProj( + string msbuildPath, string dirsProjPath, string configuration = "Debug", + string platform = "x64", bool enableLogger = false, + string? vcToolsInstallDir = null, string? vcTargetsPath = null, + string? clPath = null, + IReadOnlyDictionary? msbuildProperties = null, + IReadOnlyDictionary? msbuildEnv = null, + MsBuildLauncher launcher = MsBuildLauncher.Auto, + IncludePathOrder includePathOrder = IncludePathOrder.Auto, + bool emitDefaults = false, + bool mergeDefaults = false) + { + // The dirs.proj directory plays the same role as SolutionDir for $(SolutionDir). + var baseDir = Path.GetDirectoryName(Path.GetFullPath(dirsProjPath))!; + var projects = ProjectDiscovery.GetVcProjectsFromDirsProj(dirsProjPath); + return ExtractCompileCommandsFromProjects( + msbuildPath, projects, baseDir, configuration, platform, enableLogger, + vcToolsInstallDir, vcTargetsPath, clPath, msbuildProperties, msbuildEnv, + launcher, includePathOrder, emitDefaults, mergeDefaults); + } + + private static List ExtractCompileCommandsFromProjects( + string msbuildPath, List projects, string solutionDir, + string configuration, string platform, bool enableLogger, + string? vcToolsInstallDir, string? vcTargetsPath, string? clPath, + IReadOnlyDictionary? msbuildProperties, + IReadOnlyDictionary? msbuildEnv, + MsBuildLauncher launcher, IncludePathOrder includePathOrder, + bool emitDefaults, bool mergeDefaults) + { var allCommands = new List(); foreach (var project in projects) diff --git a/Program.cs b/Program.cs index 5f9567d..44f783e 100644 --- a/Program.cs +++ b/Program.cs @@ -107,7 +107,7 @@ static void Main(string[] args) if (!options.AllConfigurations) ValidateConfigurations(options); - bool isMultiInput = options.Solutions.Length + options.Projects.Length > 1; + bool isMultiInput = options.Solutions.Length + options.Projects.Length + options.DirsProjs.Length > 1; // Handle --all-configurations if (options.AllConfigurations) @@ -184,6 +184,37 @@ static void ValidateConfigurations(CommandLineOptions options) } } + foreach (var dirsProj in options.DirsProjs) + { + var projects = ProjectDiscovery.GetVcProjectsFromDirsProj(dirsProj); + var errors = new List(); + + foreach (var project in projects) + { + // dirs.proj projects derive their configs from the .vcxproj; only validate + // when configs were discoverable to avoid spurious warnings. + if (project.ConfigurationPlatforms.Length > 0 && + !project.HasConfigurationPlatform(options.Configuration, options.Platform)) + { + var available = string.Join(", ", project.ConfigurationPlatforms.Select(cp => cp.ToString())); + errors.Add($"Configuration '{options.Configuration}|{options.Platform}' not found in {project.Name}. Available: {available}"); + } + } + + if (errors.Count > 0) + { + foreach (var error in errors) + { + Console.Error.WriteLine(options.Strict ? $"Error: {error}" : $"Warning: {error}"); + } + if (options.Strict) + { + Console.Error.WriteLine($"Aborting: {errors.Count} project(s) do not support the requested configuration."); + Environment.Exit(1); + } + } + } + foreach (var proj in options.Projects) { var validationError = ProjectDiscovery.ValidateConfigurationPlatform( @@ -227,7 +258,7 @@ static void RunAllConfigurations(CommandLineOptions options) try { - bool isMultiInput = options.Solutions.Length + options.Projects.Length > 1; + bool isMultiInput = options.Solutions.Length + options.Projects.Length + options.DirsProjs.Length > 1; List commands; if (isMultiInput) commands = ExtractMultipleInputs(options); @@ -287,6 +318,12 @@ static List DiscoverAllConfigurations(CommandLineOptions .SelectMany(p => p.ConfigurationPlatforms)); } + foreach (var dirsProj in options.DirsProjs) + { + all.AddRange(ProjectDiscovery.GetVcProjectsFromDirsProj(dirsProj) + .SelectMany(p => p.ConfigurationPlatforms)); + } + foreach (var proj in options.Projects) { all.AddRange(ProjectDiscovery.GetProjectConfigurations(proj)); @@ -299,7 +336,7 @@ static string GetOutputDirectory(CommandLineOptions options) { if (options.Output != null) return Path.GetDirectoryName(Path.GetFullPath(options.Output)) ?? "."; - var inputPath = options.Solutions.FirstOrDefault() ?? options.Projects.First(); + var inputPath = options.Solutions.FirstOrDefault() ?? options.DirsProjs.FirstOrDefault() ?? options.Projects.First(); return Path.GetDirectoryName(Path.GetFullPath(inputPath)) ?? "."; } @@ -312,6 +349,13 @@ static List ExtractInProcess(CommandLineOptions options) options.EnableLogger, options.VcToolsInstallDir, options.EmitDefaults, options.MergeDefaults); } + else if (options.DirsProjs.Length > 0 && options.DirsProj != null) + { + return InProcessExtractor.ExtractCompileCommandsFromDirsProj( + options.DirsProj, options.Configuration, options.Platform, + options.EnableLogger, options.VcToolsInstallDir, + options.EmitDefaults, options.MergeDefaults); + } else { var extractor = new InProcessExtractor( @@ -332,6 +376,18 @@ static List ExtractOutOfProcess(CommandLineOptions options) clPath: options.ClPath, emitDefaults: options.EmitDefaults, mergeDefaults: options.MergeDefaults); } + else if (options.DirsProjs.Length > 0 && options.DirsProj != null) + { + return OutOfProcessExtractor.ExtractCompileCommandsFromDirsProj( + options.MsBuildPath!, options.DirsProj, options.Configuration, options.Platform, + options.EnableLogger, options.VcToolsInstallDir, options.VcTargetsPath, + clPath: options.ClPath, + msbuildProperties: options.MsBuildProperties, + msbuildEnv: options.MsBuildEnv, + launcher: ParseLauncher(options.MsBuildLauncher), + includePathOrder: ParseIncludePathOrder(options.IncludePathOrder), + emitDefaults: options.EmitDefaults, mergeDefaults: options.MergeDefaults); + } else { var extractor = new OutOfProcessExtractor( @@ -384,6 +440,37 @@ static List ExtractMultipleInputs(CommandLineOptions options) } } + foreach (var dirsProj in options.DirsProjs) + { + Console.WriteLine($"Extracting from dirs.proj: {Path.GetFileName(dirsProj)}..."); + try + { + List commands; + if (isOutOfProcess) + commands = OutOfProcessExtractor.ExtractCompileCommandsFromDirsProj( + options.MsBuildPath!, dirsProj, options.Configuration, options.Platform, + options.EnableLogger, options.VcToolsInstallDir, options.VcTargetsPath, + clPath: options.ClPath, + msbuildProperties: options.MsBuildProperties, + msbuildEnv: options.MsBuildEnv, + launcher: ParseLauncher(options.MsBuildLauncher), + includePathOrder: ParseIncludePathOrder(options.IncludePathOrder), + emitDefaults: options.EmitDefaults, + mergeDefaults: options.MergeDefaults); + else + commands = InProcessExtractor.ExtractCompileCommandsFromDirsProj( + dirsProj, options.Configuration, options.Platform, + options.EnableLogger, options.VcToolsInstallDir, + options.EmitDefaults, options.MergeDefaults); + Console.WriteLine($" Got {commands.Count} entries"); + allCommands.AddRange(commands); + } + catch (Exception ex) + { + Console.Error.WriteLine($" Warning: Failed for {dirsProj}: {ex.Message}"); + } + } + foreach (var proj in options.Projects) { Console.WriteLine($"Extracting from project: {Path.GetFileName(proj)}..."); @@ -493,7 +580,7 @@ static void SetVcTargetsPath(string? vcTargetsPath) static string GetDefaultOutputPath(CommandLineOptions options) { - var inputPath = options.Solutions.FirstOrDefault() ?? options.Projects.First(); + var inputPath = options.Solutions.FirstOrDefault() ?? options.DirsProjs.FirstOrDefault() ?? options.Projects.First(); var dir = Path.GetDirectoryName(Path.GetFullPath(inputPath))!; var filename = options.Format == "rich" ? "compile_database.json" : "compile_commands.json"; return Path.Combine(dir, filename); diff --git a/ProjectDiscovery.cs b/ProjectDiscovery.cs index e33331b..9c970e6 100644 --- a/ProjectDiscovery.cs +++ b/ProjectDiscovery.cs @@ -173,5 +173,158 @@ public static ConfigurationPlatform[] GetProjectConfigurations(string vcxprojPat var available = string.Join(", ", configs.Select(cp => cp.ToString())); return $"Configuration '{configuration}|{platform}' not found in {Path.GetFileName(projectPath)}. Available: {available}"; } + + /// + /// Discovers VC++ projects referenced by an MSBuild "dirs.proj" traversal + /// project (Sdk="Microsoft.Build.Traversal"), recursing into nested traversal + /// projects. Non-VC references (.csproj, .props, etc.) are skipped. Mirrors + /// for .sln/.slnx inputs. + /// + public static List GetVcProjectsFromDirsProj(string dirsProjPath) + { + dirsProjPath = Path.GetFullPath(dirsProjPath); + + if (!File.Exists(dirsProjPath)) + throw new FileNotFoundException($"dirs project file not found: {dirsProjPath}"); + + var projects = new List(); + var visitedTraversals = new HashSet(StringComparer.OrdinalIgnoreCase); + var seenVcxproj = new HashSet(StringComparer.OrdinalIgnoreCase); + CollectVcProjectsFromTraversal(dirsProjPath, projects, visitedTraversals, seenVcxproj); + return projects; + } + + public static List GetVcProjectPathsFromDirsProj(string dirsProjPath) => + GetVcProjectsFromDirsProj(dirsProjPath).Select(p => p.Path).ToList(); + + private static void CollectVcProjectsFromTraversal( + string traversalPath, + List projects, + HashSet visitedTraversals, + HashSet seenVcxproj) + { + traversalPath = Path.GetFullPath(traversalPath); + + // Guard against cycles and repeated traversal of the same dirs.proj. + if (!visitedTraversals.Add(traversalPath)) + return; + + if (!File.Exists(traversalPath)) + { + Console.Error.WriteLine($"Warning: referenced traversal project not found: {traversalPath}"); + return; + } + + XDocument doc; + try + { + doc = XDocument.Load(traversalPath); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: failed to parse traversal project {traversalPath}: {ex.Message}"); + return; + } + + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + var baseDir = Path.GetDirectoryName(traversalPath)!; + + // Microsoft.Build.Traversal uses . Also accept + // used by some legacy traversal projects. + var includes = doc.Descendants(ns + "ProjectReference") + .Concat(doc.Descendants(ns + "ProjectFile")) + .Select(e => e.Attribute("Include")?.Value) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Cast(); + + foreach (var include in includes) + { + foreach (var resolved in ResolveTraversalInclude(baseDir, include)) + { + var ext = Path.GetExtension(resolved); + + if (ext.Equals(".vcxproj", StringComparison.OrdinalIgnoreCase)) + { + if (File.Exists(resolved) && seenVcxproj.Add(resolved)) + { + projects.Add(new VcProject( + Path: resolved, + Name: Path.GetFileNameWithoutExtension(resolved), + ConfigurationPlatforms: GetProjectConfigurations(resolved))); + } + } + else if (ext.Equals(".proj", StringComparison.OrdinalIgnoreCase)) + { + // Nested traversal project (e.g. a subdirectory dirs.proj). Recurse. + CollectVcProjectsFromTraversal(resolved, projects, visitedTraversals, seenVcxproj); + } + // Other project types (.csproj, .props, etc.) are not C/C++ and are skipped. + } + } + } + + // Resolves a traversal ProjectReference Include value into concrete file paths. + // Handles ';'-separated lists and simple '*'/'**' wildcard globs. Entries that + // contain unresolved MSBuild property/item expansions ($(..)/%(..)) are skipped. + private static IEnumerable ResolveTraversalInclude(string baseDir, string include) + { + foreach (var raw in include.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (raw.Contains("$(") || raw.Contains("%(")) + continue; + + var combined = Path.IsPathRooted(raw) ? raw : Path.Combine(baseDir, raw); + + if (raw.Contains('*')) + { + foreach (var match in ExpandWildcard(combined)) + yield return Path.GetFullPath(match); + } + else + { + yield return Path.GetFullPath(combined); + } + } + } + + // Minimal wildcard expander for traversal Include globs. Supports a literal + // directory prefix followed by '*'/'**' and a trailing filename pattern + // (e.g. "src\\**\\*.vcxproj"). Best-effort: unmatched or malformed patterns yield nothing. + private static IEnumerable ExpandWildcard(string pattern) + { + var separators = new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }; + var firstWildcard = pattern.IndexOf('*'); + if (firstWildcard < 0) + yield break; + + var lastSep = pattern.LastIndexOfAny(separators, firstWildcard); + if (lastSep < 0) + yield break; + + var globBaseDir = pattern.Substring(0, lastSep); + var remainder = pattern.Substring(lastSep + 1); + if (!Directory.Exists(globBaseDir)) + yield break; + + var recursive = remainder.Contains("**"); + var searchPattern = Path.GetFileName(remainder); + if (string.IsNullOrEmpty(searchPattern) || searchPattern == "**") + searchPattern = "*"; + + List files; + try + { + files = Directory.EnumerateFiles( + globBaseDir, searchPattern, + recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly).ToList(); + } + catch + { + yield break; + } + + foreach (var f in files) + yield return f; + } } } diff --git a/README.md b/README.md index 817e238..957b271 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MSBuild Compile Commands Extractor -This repo contains a sample that demonstrates how to extract [`compile_commands.json`](https://clang.llvm.org/docs/JSONCompilationDatabase.html) from Visual C++ MSBuild projects (.vcxproj, .sln, .slnx) using the MSBuild API. +This repo contains a sample that demonstrates how to extract [`compile_commands.json`](https://clang.llvm.org/docs/JSONCompilationDatabase.html) from Visual C++ MSBuild projects (.vcxproj, .sln, .slnx, dirs.proj) using the MSBuild API. The generated compilation database can be used with VS Code and C++ LSP tools for IntelliSense, code navigation, and static analysis. @@ -18,6 +18,16 @@ msbuild-extractor-sample --project myapp.vcxproj -c Debug -a x64 msbuild-extractor-sample --solution myapp.sln -c Debug -a x64 -o compile_commands.json ``` +### Traversal dirs.proj + +Extract every `.vcxproj` referenced by an MSBuild traversal project +(`Sdk="Microsoft.Build.Traversal"`), recursing into nested `dirs.proj`. Non-VC +references (`.csproj`, etc.) are skipped. + +```bash +msbuild-extractor-sample --dirs-proj dirs.proj -c Debug -a x64 -o compile_commands.json +``` + ### Auto-detect Visual Studio (no manual paths needed) ```bash @@ -91,6 +101,7 @@ msbuild-extractor-sample --solution myapp.sln -c Debug -a x64 \ - **VS instance selection** - `--list-instances` shows all VS installations; `--vs-instance ` selects by instance ID - **LSP compatible** - uses the real `cl.exe` path and adds `-ferror-limit=0` and `--target` so C++ LSP tools work out of the box - **Solution formats** - `.sln`, `.slnx`, and GN-generated `.sln` files +- **Traversal projects** - `--dirs-proj` extracts every `.vcxproj` referenced by an MSBuild `dirs.proj` (`Sdk="Microsoft.Build.Traversal"`), recursing into nested `dirs.proj` - **C++/WinRT support** - resolves `WindowsTargetPlatformVersion` (e.g., `10.0` → `10.0.26100.0`) so UWP and WinRT projects like Windows Terminal and Calculator extract correctly - **Response file expansion** - transparently inlines `@response.rsp` files during tokenization - **VS Code integration** - `--c-cpp-properties` emits a matching `.vscode/c_cpp_properties.json` for the VS Code C/C++ extension @@ -125,6 +136,7 @@ msbuild-extractor-sample [options] Options: -p, --project Path to .vcxproj file (repeatable) -s, --solution Path to .sln or .slnx file (repeatable) + --dirs-proj Path to an MSBuild traversal dirs.proj (repeatable; recurses into nested dirs.proj) -c, --configuration Build configuration [default: Debug] -a, --platform Build platform [default: x64] -o, --output Output path [default: compile_commands.json] @@ -150,7 +162,7 @@ Options: --logger Enable MSBuild console logger output ``` -At least one `--project` or `--solution` must be specified. Both can be repeated and combined. +At least one `--project`, `--solution`, or `--dirs-proj` must be specified. Both can be repeated and combined. The output includes a sentinel entry (`.msbuild-extractor-sample`) as the first element so consumers can identify the generator. Standard C++ LSP tools silently skip it since the file does not exist on disk. From fbc8b73f9ab5b9b12191189f6d00172bb04ec42b Mon Sep 17 00:00:00 2001 From: ramadanahmed Date: Tue, 7 Jul 2026 02:22:19 +0300 Subject: [PATCH 2/2] Add --follow-project-references to capture wrapper+Lib source Adds an opt-in flag that extends discovery to the transitive closure of .vcxproj edges. Only plain relative references are followed; references using MSBuild property expansions like $(inetroot) are skipped, so cross-tree / package references do not pull in the whole dependency graph. This captures the wrapper+Lib layout where a source-less wrapper .vcxproj references a *Lib.vcxproj that carries the ClCompile items. Threaded through --solution, --dirs-proj, and --project paths (in- and out-of-process). --- CommandLineOptions.cs | 11 +++++- InProcessExtractor.cs | 18 +++++---- OutOfProcessExtractor.cs | 19 ++++++---- Program.cs | 76 +++++++++++++++++++++++++------------ ProjectDiscovery.cs | 82 ++++++++++++++++++++++++++++++++++++++++ README.md | 2 + 6 files changed, 169 insertions(+), 39 deletions(-) diff --git a/CommandLineOptions.cs b/CommandLineOptions.cs index 3f89b37..10be850 100644 --- a/CommandLineOptions.cs +++ b/CommandLineOptions.cs @@ -35,6 +35,7 @@ public class CommandLineOptions public bool EmitCCppProperties { get; set; } public bool EmitDefaults { get; set; } public bool MergeDefaults { get; set; } + public bool FollowProjectReferences { get; set; } public Dictionary MsBuildProperties { get; set; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary MsBuildEnv { get; set; } = new(StringComparer.OrdinalIgnoreCase); public string MsBuildLauncher { get; set; } = "auto"; @@ -224,6 +225,12 @@ public static CommandLineOptions Parse(string[] args) }; includePathOrderOption.AcceptOnlyFromAmong("auto", "prepend", "append"); + var followProjectReferencesOption = new Option("--follow-project-references") + { + Description = "Also extract projects reachable via .vcxproj edges (transitive). Only plain relative references are followed; MSBuild-property references ($(..)) are skipped, so cross-tree/package references do not pull in the whole dependency graph. Useful for wrapper+Lib layouts where the referenced *Lib project carries the source.", + DefaultValueFactory = _ => false + }; + var rootCommand = new RootCommand("Extract compile_commands.json from Visual C++ MSBuild projects"); rootCommand.Options.Add(projectOption); rootCommand.Options.Add(solutionOption); @@ -256,6 +263,7 @@ public static CommandLineOptions Parse(string[] args) rootCommand.Options.Add(msbuildEnvOption); rootCommand.Options.Add(msbuildLauncherOption); rootCommand.Options.Add(includePathOrderOption); + rootCommand.Options.Add(followProjectReferencesOption); rootCommand.Validators.Add(commandResult => { @@ -308,7 +316,8 @@ public static CommandLineOptions Parse(string[] args) MsBuildProperties = ParseKeyValuePairs(parseResult.GetValue(msbuildPropertyOption), "--msbuild-property"), MsBuildEnv = ParseKeyValuePairs(parseResult.GetValue(msbuildEnvOption), "--msbuild-env"), MsBuildLauncher = parseResult.GetValue(msbuildLauncherOption)!, - IncludePathOrder = parseResult.GetValue(includePathOrderOption)! + IncludePathOrder = parseResult.GetValue(includePathOrderOption)!, + FollowProjectReferences = parseResult.GetValue(followProjectReferencesOption) }; }); diff --git a/InProcessExtractor.cs b/InProcessExtractor.cs index 04128f6..0e081cb 100644 --- a/InProcessExtractor.cs +++ b/InProcessExtractor.cs @@ -1069,32 +1069,36 @@ private string ResolveToolPath(string toolPath) public static List ExtractCompileCommandsFromSolution( string solutionPath, string configuration = "Debug", string platform = "x64", bool enableLogger = false, string? vcToolsInstallDir = null, - bool emitDefaults = false, bool mergeDefaults = false) + bool emitDefaults = false, bool mergeDefaults = false, bool followProjectReferences = false) { var solutionDir = Path.GetDirectoryName(Path.GetFullPath(solutionPath))!; var projects = ProjectDiscovery.GetVcProjectsFromSolution(solutionPath); return ExtractCompileCommandsFromProjects( projects, solutionDir, configuration, platform, enableLogger, - vcToolsInstallDir, emitDefaults, mergeDefaults); + vcToolsInstallDir, emitDefaults, mergeDefaults, followProjectReferences); } public static List ExtractCompileCommandsFromDirsProj( string dirsProjPath, string configuration = "Debug", string platform = "x64", bool enableLogger = false, string? vcToolsInstallDir = null, - bool emitDefaults = false, bool mergeDefaults = false) + bool emitDefaults = false, bool mergeDefaults = false, bool followProjectReferences = false) { // The dirs.proj directory plays the same role as SolutionDir for $(SolutionDir). var baseDir = Path.GetDirectoryName(Path.GetFullPath(dirsProjPath))!; var projects = ProjectDiscovery.GetVcProjectsFromDirsProj(dirsProjPath); return ExtractCompileCommandsFromProjects( projects, baseDir, configuration, platform, enableLogger, - vcToolsInstallDir, emitDefaults, mergeDefaults); + vcToolsInstallDir, emitDefaults, mergeDefaults, followProjectReferences); } - private static List ExtractCompileCommandsFromProjects( - List projects, string solutionDir, string configuration, string platform, - bool enableLogger, string? vcToolsInstallDir, bool emitDefaults, bool mergeDefaults) + public static List ExtractCompileCommandsFromProjects( + List projects, string? solutionDir, string configuration, string platform, + bool enableLogger, string? vcToolsInstallDir, bool emitDefaults, bool mergeDefaults, + bool followProjectReferences = false) { + if (followProjectReferences) + projects = ProjectDiscovery.ExpandWithProjectReferences(projects); + var allCommands = new List(); foreach (var project in projects) diff --git a/OutOfProcessExtractor.cs b/OutOfProcessExtractor.cs index 7b1079b..6326eed 100644 --- a/OutOfProcessExtractor.cs +++ b/OutOfProcessExtractor.cs @@ -1126,14 +1126,15 @@ public static List ExtractCompileCommandsFromSolution( MsBuildLauncher launcher = MsBuildLauncher.Auto, IncludePathOrder includePathOrder = IncludePathOrder.Auto, bool emitDefaults = false, - bool mergeDefaults = false) + bool mergeDefaults = false, + bool followProjectReferences = false) { var solutionDir = Path.GetDirectoryName(Path.GetFullPath(solutionPath))!; var projects = ProjectDiscovery.GetVcProjectsFromSolution(solutionPath); return ExtractCompileCommandsFromProjects( msbuildPath, projects, solutionDir, configuration, platform, enableLogger, vcToolsInstallDir, vcTargetsPath, clPath, msbuildProperties, msbuildEnv, - launcher, includePathOrder, emitDefaults, mergeDefaults); + launcher, includePathOrder, emitDefaults, mergeDefaults, followProjectReferences); } public static List ExtractCompileCommandsFromDirsProj( @@ -1146,7 +1147,8 @@ public static List ExtractCompileCommandsFromDirsProj( MsBuildLauncher launcher = MsBuildLauncher.Auto, IncludePathOrder includePathOrder = IncludePathOrder.Auto, bool emitDefaults = false, - bool mergeDefaults = false) + bool mergeDefaults = false, + bool followProjectReferences = false) { // The dirs.proj directory plays the same role as SolutionDir for $(SolutionDir). var baseDir = Path.GetDirectoryName(Path.GetFullPath(dirsProjPath))!; @@ -1154,18 +1156,21 @@ public static List ExtractCompileCommandsFromDirsProj( return ExtractCompileCommandsFromProjects( msbuildPath, projects, baseDir, configuration, platform, enableLogger, vcToolsInstallDir, vcTargetsPath, clPath, msbuildProperties, msbuildEnv, - launcher, includePathOrder, emitDefaults, mergeDefaults); + launcher, includePathOrder, emitDefaults, mergeDefaults, followProjectReferences); } - private static List ExtractCompileCommandsFromProjects( - string msbuildPath, List projects, string solutionDir, + public static List ExtractCompileCommandsFromProjects( + string msbuildPath, List projects, string? solutionDir, string configuration, string platform, bool enableLogger, string? vcToolsInstallDir, string? vcTargetsPath, string? clPath, IReadOnlyDictionary? msbuildProperties, IReadOnlyDictionary? msbuildEnv, MsBuildLauncher launcher, IncludePathOrder includePathOrder, - bool emitDefaults, bool mergeDefaults) + bool emitDefaults, bool mergeDefaults, bool followProjectReferences = false) { + if (followProjectReferences) + projects = ProjectDiscovery.ExpandWithProjectReferences(projects); + var allCommands = new List(); foreach (var project in projects) diff --git a/Program.cs b/Program.cs index 44f783e..4a3e17c 100644 --- a/Program.cs +++ b/Program.cs @@ -347,14 +347,24 @@ static List ExtractInProcess(CommandLineOptions options) return InProcessExtractor.ExtractCompileCommandsFromSolution( options.Solution, options.Configuration, options.Platform, options.EnableLogger, options.VcToolsInstallDir, - options.EmitDefaults, options.MergeDefaults); + options.EmitDefaults, options.MergeDefaults, options.FollowProjectReferences); } else if (options.DirsProjs.Length > 0 && options.DirsProj != null) { return InProcessExtractor.ExtractCompileCommandsFromDirsProj( options.DirsProj, options.Configuration, options.Platform, options.EnableLogger, options.VcToolsInstallDir, - options.EmitDefaults, options.MergeDefaults); + options.EmitDefaults, options.MergeDefaults, options.FollowProjectReferences); + } + else if (options.FollowProjectReferences) + { + var seed = new VcProject( + Path.GetFullPath(options.Projects[0]), + Path.GetFileNameWithoutExtension(options.Projects[0]), + ProjectDiscovery.GetProjectConfigurations(options.Projects[0])); + return InProcessExtractor.ExtractCompileCommandsFromProjects( + new List { seed }, options.SolutionDir, options.Configuration, options.Platform, + options.EnableLogger, options.VcToolsInstallDir, options.EmitDefaults, options.MergeDefaults, true); } else { @@ -374,7 +384,8 @@ static List ExtractOutOfProcess(CommandLineOptions options) options.MsBuildPath!, options.Solution, options.Configuration, options.Platform, options.EnableLogger, options.VcToolsInstallDir, options.VcTargetsPath, clPath: options.ClPath, - emitDefaults: options.EmitDefaults, mergeDefaults: options.MergeDefaults); + emitDefaults: options.EmitDefaults, mergeDefaults: options.MergeDefaults, + followProjectReferences: options.FollowProjectReferences); } else if (options.DirsProjs.Length > 0 && options.DirsProj != null) { @@ -386,7 +397,22 @@ static List ExtractOutOfProcess(CommandLineOptions options) msbuildEnv: options.MsBuildEnv, launcher: ParseLauncher(options.MsBuildLauncher), includePathOrder: ParseIncludePathOrder(options.IncludePathOrder), - emitDefaults: options.EmitDefaults, mergeDefaults: options.MergeDefaults); + emitDefaults: options.EmitDefaults, mergeDefaults: options.MergeDefaults, + followProjectReferences: options.FollowProjectReferences); + } + else if (options.FollowProjectReferences) + { + var seed = new VcProject( + Path.GetFullPath(options.Projects[0]), + Path.GetFileNameWithoutExtension(options.Projects[0]), + ProjectDiscovery.GetProjectConfigurations(options.Projects[0])); + return OutOfProcessExtractor.ExtractCompileCommandsFromProjects( + options.MsBuildPath!, new List { seed }, options.SolutionDir, + options.Configuration, options.Platform, options.EnableLogger, + options.VcToolsInstallDir, options.VcTargetsPath, options.ClPath, + options.MsBuildProperties, options.MsBuildEnv, + ParseLauncher(options.MsBuildLauncher), ParseIncludePathOrder(options.IncludePathOrder), + options.EmitDefaults, options.MergeDefaults, true); } else { @@ -425,12 +451,13 @@ static List ExtractMultipleInputs(CommandLineOptions options) launcher: ParseLauncher(options.MsBuildLauncher), includePathOrder: ParseIncludePathOrder(options.IncludePathOrder), emitDefaults: options.EmitDefaults, - mergeDefaults: options.MergeDefaults); + mergeDefaults: options.MergeDefaults, + followProjectReferences: options.FollowProjectReferences); else commands = InProcessExtractor.ExtractCompileCommandsFromSolution( sln, options.Configuration, options.Platform, options.EnableLogger, options.VcToolsInstallDir, - options.EmitDefaults, options.MergeDefaults); + options.EmitDefaults, options.MergeDefaults, options.FollowProjectReferences); Console.WriteLine($" Got {commands.Count} entries"); allCommands.AddRange(commands); } @@ -456,12 +483,13 @@ static List ExtractMultipleInputs(CommandLineOptions options) launcher: ParseLauncher(options.MsBuildLauncher), includePathOrder: ParseIncludePathOrder(options.IncludePathOrder), emitDefaults: options.EmitDefaults, - mergeDefaults: options.MergeDefaults); + mergeDefaults: options.MergeDefaults, + followProjectReferences: options.FollowProjectReferences); else commands = InProcessExtractor.ExtractCompileCommandsFromDirsProj( dirsProj, options.Configuration, options.Platform, options.EnableLogger, options.VcToolsInstallDir, - options.EmitDefaults, options.MergeDefaults); + options.EmitDefaults, options.MergeDefaults, options.FollowProjectReferences); Console.WriteLine($" Got {commands.Count} entries"); allCommands.AddRange(commands); } @@ -477,27 +505,27 @@ static List ExtractMultipleInputs(CommandLineOptions options) try { List commands; + var seed = new VcProject( + Path.GetFullPath(proj), + Path.GetFileNameWithoutExtension(proj), + ProjectDiscovery.GetProjectConfigurations(proj)); + var seedList = new List { seed }; if (isOutOfProcess) { - var extractor = new OutOfProcessExtractor( - options.MsBuildPath!, proj, options.Configuration, options.Platform, - options.EnableLogger, options.SolutionDir, options.VcToolsInstallDir, options.VcTargetsPath, - options.ClPath, - msbuildProperties: options.MsBuildProperties, - msbuildEnv: options.MsBuildEnv, - launcher: ParseLauncher(options.MsBuildLauncher), - includePathOrder: ParseIncludePathOrder(options.IncludePathOrder), - emitDefaults: options.EmitDefaults, - mergeDefaults: options.MergeDefaults); - commands = extractor.ExtractCompileCommands(); + commands = OutOfProcessExtractor.ExtractCompileCommandsFromProjects( + options.MsBuildPath!, seedList, options.SolutionDir, + options.Configuration, options.Platform, options.EnableLogger, + options.VcToolsInstallDir, options.VcTargetsPath, options.ClPath, + options.MsBuildProperties, options.MsBuildEnv, + ParseLauncher(options.MsBuildLauncher), ParseIncludePathOrder(options.IncludePathOrder), + options.EmitDefaults, options.MergeDefaults, options.FollowProjectReferences); } else { - var extractor = new InProcessExtractor( - proj, options.Configuration, options.Platform, - options.EnableLogger, options.SolutionDir, options.VcToolsInstallDir, - options.EmitDefaults, options.MergeDefaults); - commands = extractor.ExtractCompileCommands(); + commands = InProcessExtractor.ExtractCompileCommandsFromProjects( + seedList, options.SolutionDir, options.Configuration, options.Platform, + options.EnableLogger, options.VcToolsInstallDir, + options.EmitDefaults, options.MergeDefaults, options.FollowProjectReferences); } Console.WriteLine($" Got {commands.Count} entries"); allCommands.AddRange(commands); diff --git a/ProjectDiscovery.cs b/ProjectDiscovery.cs index 9c970e6..856312c 100644 --- a/ProjectDiscovery.cs +++ b/ProjectDiscovery.cs @@ -326,5 +326,87 @@ private static IEnumerable ExpandWildcard(string pattern) foreach (var f in files) yield return f; } + + /// + /// Reads the .vcxproj-to-.vcxproj <ProjectReference> edges of a .vcxproj and returns + /// absolute paths to existing .vcxproj files. References that use unresolved MSBuild + /// property/item expansions ($(..)/%(..)) are skipped (so cross-tree / package references + /// that require full MSBuild evaluation do not pull in the entire dependency graph). + /// + public static List GetProjectReferences(string vcxprojPath) + { + var result = new List(); + if (string.IsNullOrWhiteSpace(vcxprojPath) || !File.Exists(vcxprojPath)) + return result; + + XDocument doc; + try { doc = XDocument.Load(vcxprojPath); } + catch { return result; } + + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + var baseDir = Path.GetDirectoryName(Path.GetFullPath(vcxprojPath))!; + + var includes = doc.Descendants(ns + "ProjectReference") + .Select(e => e.Attribute("Include")?.Value) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Cast(); + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var include in includes) + { + foreach (var resolved in ResolveTraversalInclude(baseDir, include)) + { + if (resolved.EndsWith(".vcxproj", StringComparison.OrdinalIgnoreCase) && + File.Exists(resolved) && seen.Add(resolved)) + { + result.Add(resolved); + } + } + } + + return result; + } + + /// + /// Expands a seed set of VC projects with the transitive closure of their + /// .vcxproj-to-.vcxproj <ProjectReference> edges (see ). + /// The seeds are always included; the result is deduped by full path. Useful for wrapper+Lib + /// layouts where the referenced *Lib.vcxproj carries the ClCompile source while the referencing + /// wrapper project has none. + /// + public static List ExpandWithProjectReferences(IEnumerable seeds) + { + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var queue = new Queue(); + + foreach (var seed in seeds) + { + var full = Path.GetFullPath(seed.Path); + if (seen.Add(full)) + { + result.Add(seed); + queue.Enqueue(full); + } + } + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + foreach (var referenced in GetProjectReferences(current)) + { + if (seen.Add(referenced)) + { + result.Add(new VcProject( + Path: referenced, + Name: Path.GetFileNameWithoutExtension(referenced), + ConfigurationPlatforms: GetProjectConfigurations(referenced))); + queue.Enqueue(referenced); + } + } + } + + return result; + } } } diff --git a/README.md b/README.md index 957b271..e90d33a 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ msbuild-extractor-sample --solution myapp.sln -c Debug -a x64 \ - **LSP compatible** - uses the real `cl.exe` path and adds `-ferror-limit=0` and `--target` so C++ LSP tools work out of the box - **Solution formats** - `.sln`, `.slnx`, and GN-generated `.sln` files - **Traversal projects** - `--dirs-proj` extracts every `.vcxproj` referenced by an MSBuild `dirs.proj` (`Sdk="Microsoft.Build.Traversal"`), recursing into nested `dirs.proj` +- **Follow project references** - `--follow-project-references` additionally extracts projects reachable via `.vcxproj` `` edges (transitive). Only plain relative references are followed; MSBuild-property references (`$(..)`) are skipped, so it captures wrapper+Lib layouts (where a source-less wrapper references a `*Lib.vcxproj`) without pulling in the entire dependency graph - **C++/WinRT support** - resolves `WindowsTargetPlatformVersion` (e.g., `10.0` → `10.0.26100.0`) so UWP and WinRT projects like Windows Terminal and Calculator extract correctly - **Response file expansion** - transparently inlines `@response.rsp` files during tokenization - **VS Code integration** - `--c-cpp-properties` emits a matching `.vscode/c_cpp_properties.json` for the VS Code C/C++ extension @@ -137,6 +138,7 @@ Options: -p, --project Path to .vcxproj file (repeatable) -s, --solution Path to .sln or .slnx file (repeatable) --dirs-proj Path to an MSBuild traversal dirs.proj (repeatable; recurses into nested dirs.proj) + --follow-project-references Also extract projects reachable via .vcxproj edges (transitive; skips $(..) property references) -c, --configuration Build configuration [default: Debug] -a, --platform Build platform [default: x64] -o, --output Output path [default: compile_commands.json]