diff --git a/CommandLineOptions.cs b/CommandLineOptions.cs index de87e2e..10be850 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; } @@ -33,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"; @@ -54,6 +57,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", @@ -216,9 +225,16 @@ 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); + rootCommand.Options.Add(dirsProjOption); rootCommand.Options.Add(configOption); rootCommand.Options.Add(platformOption); rootCommand.Options.Add(vsPathOption); @@ -247,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 => { @@ -256,9 +273,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 +288,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), @@ -297,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 e1874c1..0e081cb 100644 --- a/InProcessExtractor.cs +++ b/InProcessExtractor.cs @@ -1069,10 +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, 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 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, followProjectReferences); + } + + 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 485af8e..6326eed 100644 --- a/OutOfProcessExtractor.cs +++ b/OutOfProcessExtractor.cs @@ -1126,10 +1126,51 @@ 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, followProjectReferences); + } + + 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, + 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( + msbuildPath, projects, baseDir, configuration, platform, enableLogger, + vcToolsInstallDir, vcTargetsPath, clPath, msbuildProperties, msbuildEnv, + launcher, includePathOrder, emitDefaults, mergeDefaults, followProjectReferences); + } + + 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 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 5f9567d..4a3e17c 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)) ?? "."; } @@ -310,7 +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.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 { @@ -330,7 +384,35 @@ 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) + { + 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, + 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 { @@ -369,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); } @@ -384,33 +467,65 @@ static List ExtractMultipleInputs(CommandLineOptions options) } } - foreach (var proj in options.Projects) + foreach (var dirsProj in options.DirsProjs) { - Console.WriteLine($"Extracting from project: {Path.GetFileName(proj)}..."); + Console.WriteLine($"Extracting from dirs.proj: {Path.GetFileName(dirsProj)}..."); try { List commands; if (isOutOfProcess) - { - var extractor = new OutOfProcessExtractor( - options.MsBuildPath!, proj, options.Configuration, options.Platform, - options.EnableLogger, options.SolutionDir, options.VcToolsInstallDir, options.VcTargetsPath, - options.ClPath, + 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); - commands = extractor.ExtractCompileCommands(); + mergeDefaults: options.MergeDefaults, + followProjectReferences: options.FollowProjectReferences); + else + commands = InProcessExtractor.ExtractCompileCommandsFromDirsProj( + dirsProj, options.Configuration, options.Platform, + options.EnableLogger, options.VcToolsInstallDir, + options.EmitDefaults, options.MergeDefaults, options.FollowProjectReferences); + 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)}..."); + try + { + List commands; + var seed = new VcProject( + Path.GetFullPath(proj), + Path.GetFileNameWithoutExtension(proj), + ProjectDiscovery.GetProjectConfigurations(proj)); + var seedList = new List { seed }; + if (isOutOfProcess) + { + 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); @@ -493,7 +608,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..856312c 100644 --- a/ProjectDiscovery.cs +++ b/ProjectDiscovery.cs @@ -173,5 +173,240 @@ 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; + } + + /// + /// 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 817e238..e90d33a 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,8 @@ 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` +- **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 @@ -125,6 +137,8 @@ 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) + --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] @@ -150,7 +164,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.