Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/SourceBrowser/TestCode/RepoB/RepoB2.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<Solution>
<Project Path="RepoBLib2/RepoBLib2.csproj" />
</Solution>
8 changes: 8 additions & 0 deletions src/SourceBrowser/TestCode/RepoB/RepoBLib2/RepoBLib2.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
</PropertyGroup>

</Project>
21 changes: 21 additions & 0 deletions src/SourceBrowser/TestCode/RepoB/RepoBLib2/Widget.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace RepoBLib2
{
/// <summary>
/// Lives in a second solution (RepoB2.slnx) under the same RepoB /repoPath, so the merged
/// site groups both RepoB solutions under a Solution Explorer solutionTitle node -- the only
/// place the multi-solution grouping (and its theming) is exercised.
/// </summary>
public class Widget
{
public string Describe() => WidgetInfo.Label;
}

/// <summary>
/// Referenced by <see cref="Widget"/> so RepoBLib2 has an indexed cross-symbol reference; without
/// one, Pass2's DiscoverProjects skips the assembly (no R folder) and it never reaches the site.
/// </summary>
public static class WidgetInfo
{
public static string Label => "RepoBLib2.Widget";
}
}
5 changes: 5 additions & 0 deletions src/SourceBrowser/TestCode/Vmr/Vmr.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<Solution>
<Project Path="VmrCore/VmrCore.csproj" />
<Project Path="src/SubRepoX/SubRepoX.csproj" />
<Project Path="src/SubRepoY/SubRepoY.csproj" />
</Solution>
16 changes: 16 additions & 0 deletions src/SourceBrowser/TestCode/Vmr/VmrCore/Core.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace VmrCore
{
/// <summary>
/// Sits directly under the Vmr root (not under any src/SubRepo* /repoPath), so per-project repo
/// resolution falls back to the whole input's tag (dotnet/vmr) -- the fallback arm of the fix.
/// </summary>
public class Core
{
public string Describe() => CoreInfo.Label;
}

public static class CoreInfo
{
public static string Label => "VmrCore.Core";
}
}
8 changes: 8 additions & 0 deletions src/SourceBrowser/TestCode/Vmr/VmrCore/VmrCore.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
</PropertyGroup>

</Project>
16 changes: 16 additions & 0 deletions src/SourceBrowser/TestCode/Vmr/src/SubRepoX/Alpha.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace SubRepoX
{
/// <summary>
/// Lives under Vmr/src/SubRepoX, tagged via a nested /repoPath to dotnet/subx even though it's part
/// of the single Vmr.slnx input -- the sub-repo case that used to inherit the parent's tag.
/// </summary>
public class Alpha
{
public string Describe() => AlphaInfo.Label;
}

public static class AlphaInfo
{
public static string Label => "SubRepoX.Alpha";
}
}
8 changes: 8 additions & 0 deletions src/SourceBrowser/TestCode/Vmr/src/SubRepoX/SubRepoX.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
</PropertyGroup>

</Project>
16 changes: 16 additions & 0 deletions src/SourceBrowser/TestCode/Vmr/src/SubRepoY/Beta.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace SubRepoY
{
/// <summary>
/// Lives under Vmr/src/SubRepoY, tagged via a nested /repoPath to dotnet/suby -- a second sub-repo
/// under the same input, so the merged site distinguishes subx/suby/vmr rather than one repo.
/// </summary>
public class Beta
{
public string Describe() => BetaInfo.Label;
}

public static class BetaInfo
{
public static string Label => "SubRepoY.Beta";
}
}
8 changes: 8 additions & 0 deletions src/SourceBrowser/TestCode/Vmr/src/SubRepoY/SubRepoY.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
</PropertyGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,21 @@ public void Repo_meta_option_sets_both_repo_path_and_server_path_mappings()
serverMapping.Value.ShouldBe("https://example.com/");
}

[TestMethod]
public void Duplicate_repo_folder_with_different_urls_keeps_the_last_one()
{
// The VMR is listed as both dotnet/dotnet and dotnet-dotnet-windows, which resolve to one
// local checkout -- so the same folder gets two /repo: URLs. Last writer wins (a warning is
// logged naming the kept vs. ignored URL); the folder still maps to exactly one URL.
var options = CommandLineOptions.Parse(
"/repo:\"a\"=\"dotnet/dotnet\"=\"https://example.com/first/\"",
"/repo:\"a\"=\"dotnet/dotnet\"=\"https://example.com/second/\"");

var serverMapping = options.ServerPathMappings.ShouldHaveSingleItem();
serverMapping.Key.ShouldBe(Path.GetFullPath("a"));
serverMapping.Value.ShouldBe("https://example.com/second/");
}

[TestMethod]
public void Repo_meta_option_requires_all_three_segments()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,9 +335,9 @@ public void Finalize_GroupsSolutionExplorer_UnderRepoFolders_WhenTheMergedSiteSp

var solutionExplorerHtml = File.ReadAllText(Path.Combine(websiteDestinationFolder, Constants.SolutionExplorer + ".html"));

solutionExplorerHtml.Contains("<div class=\"folderTitle repoTitle\" data-repo=\"ClangSharp\">ClangSharp</div>").ShouldBeTrue(
solutionExplorerHtml.Contains("<div class=\"folderTitle repoTitle\" data-repo=\"ClangSharp\" data-repo-path=\"ClangSharp\">ClangSharp</div>").ShouldBeTrue(
"Utils must be nested under a ClangSharp repo folder now that the merged site spans two repos. Actual: " + solutionExplorerHtml);
solutionExplorerHtml.Contains("<div class=\"folderTitle repoTitle\" data-repo=\"LLVMSharp\">LLVMSharp</div>").ShouldBeTrue(
solutionExplorerHtml.Contains("<div class=\"folderTitle repoTitle\" data-repo=\"LLVMSharp\" data-repo-path=\"LLVMSharp\">LLVMSharp</div>").ShouldBeTrue(
"App must be nested under an LLVMSharp repo folder now that the merged site spans two repos. Actual: " + solutionExplorerHtml);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ public void WriteProjectMap_adds_repo_and_solution_fields_when_any_assembly_is_t
var lineA = lines.Single(l => l.StartsWith("A;", StringComparison.Ordinal));
var lineB = lines.Single(l => l.StartsWith("B;", StringComparison.Ordinal));

Assert.AreEqual("A;0;1;clangsharp;ClangSharp", lineA);
Assert.AreEqual("B;1;0;;", lineB);
Assert.AreEqual("A;0;1;clangsharp;ClangSharp;clangsharp", lineA);
Assert.AreEqual("B;1;0;;;", lineB);
}
finally
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,90 @@ public void Repeated_calls_for_the_same_repo_reuse_the_same_folder_node()
first.ShouldBeSameAs(second);
root.Folders.Count.ShouldBe(1);
}

[TestMethod]
public void ResolveRepoName_prefers_the_most_specific_nested_mapping()
{
// A single VMR-style input rooted at the parent folder, with nested sub-repo mappings: each
// project must tag to the deepest mapping containing its own folder, not the parent's.
var mappings = new Dictionary<string, string>
{
[@"C:\vmr"] = "dotnet/vmr",
[@"C:\vmr\src\arcade"] = "dotnet/arcade",
[@"C:\vmr\src\roslyn"] = "dotnet/roslyn",
};

Program.ResolveRepoName(@"C:\vmr\src\arcade\src\Foo\Foo.csproj", mappings, "dotnet/vmr").ShouldBe("dotnet/arcade");
Program.ResolveRepoName(@"C:\vmr\src\roslyn\Bar\Bar.csproj", mappings, "dotnet/vmr").ShouldBe("dotnet/roslyn");
}

[TestMethod]
public void ResolveRepoName_falls_back_to_the_input_tag_outside_any_nested_mapping()
{
var mappings = new Dictionary<string, string>
{
[@"C:\vmr"] = "dotnet/vmr",
[@"C:\vmr\src\arcade"] = "dotnet/arcade",
};

// Directly under the VMR root but not under any sub-repo -> the whole input's tag.
Program.ResolveRepoName(@"C:\vmr\eng\Build\Build.csproj", mappings, "dotnet/vmr").ShouldBe("dotnet/vmr");
// No mapping contains it and no fallback -> untagged.
Program.ResolveRepoName(@"C:\other\Baz\Baz.csproj", mappings, "").ShouldBe("");
}

[TestMethod]
public void ResolveRepoChain_returns_the_full_ancestry_outermost_first()
{
var mappings = new Dictionary<string, string>
{
[@"C:\vmr"] = "dotnet/vmr",
[@"C:\vmr\src\arcade"] = "dotnet/arcade",
};

// A sub-repo project carries both its parent (vmr) and its own repo (arcade), parent first.
Program.ResolveRepoChain(@"C:\vmr\src\arcade\src\Foo\Foo.csproj", mappings, "dotnet/vmr")
.ShouldBe(new[] { "dotnet/vmr", "dotnet/arcade" });

// A project only under the VMR root is a single-element chain of just the parent.
Program.ResolveRepoChain(@"C:\vmr\eng\Build\Build.csproj", mappings, "dotnet/vmr")
.ShouldBe(new[] { "dotnet/vmr" });
}

[TestMethod]
public void ResolveRepoChain_falls_back_to_the_input_tag_or_empty()
{
var mappings = new Dictionary<string, string>
{
[@"C:\vmr\src\arcade"] = "dotnet/arcade",
};

// No mapping contains it -> single-element fallback chain.
Program.ResolveRepoChain(@"C:\other\Baz\Baz.csproj", mappings, "dotnet/vmr")
.ShouldBe(new[] { "dotnet/vmr" });
// No mapping and no fallback -> untagged (empty chain).
Program.ResolveRepoChain(@"C:\other\Baz\Baz.csproj", mappings, "").ShouldBeEmpty();
}

[TestMethod]
public void Nested_repo_chain_groups_a_sub_repo_under_its_parent_repo_folder()
{
var root = new Folder<ProjectSkeleton>();
var solutionCounts = new Dictionary<string, int> { ["dotnet/subx"] = 1 };

var folder = Program.GetSolutionExplorerGroupingFolder(
root, new[] { "dotnet/vmr", "dotnet/subx" }, "SubX", distinctRepoCount: 3, solutionCounts);

// The sub-repo folder is nested under its parent repo folder, each carrying its own ancestry.
var vmrFolder = root.Folders["dotnet/vmr"];
vmrFolder.Kind.ShouldBe(FolderKind.Repo);
vmrFolder.RepoChain.ShouldBe(new[] { "dotnet/vmr" });

var subxFolder = vmrFolder.Folders["dotnet/subx"];
subxFolder.ShouldBeSameAs(folder);
subxFolder.Kind.ShouldBe(FolderKind.Repo);
subxFolder.RepoName.ShouldBe("dotnet/subx");
subxFolder.RepoChain.ShouldBe(new[] { "dotnet/vmr", "dotnet/subx" });
}
}
}
20 changes: 18 additions & 2 deletions src/SourceBrowser/src/HtmlGenerator/CommandLineOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,22 @@ public static CommandLineOptions Parse(params string[] args)
var configAxes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var mergeConfigsOnly = false;

// Both /serverPath: and /repo: map a local repo folder to a source-link base URL, keyed by
// that folder. Mapping the same folder twice with different URLs (e.g. the VMR listed as both
// dotnet/dotnet and dotnet-dotnet-windows, resolving to one checkout) is last-writer-wins, so
// warn which URL is kept and which is dropped rather than silently losing one.
void MapServerPath(string folder, string url)
{
if (serverPathMappings.TryGetValue(folder, out var existing) && existing != url)
{
Log.Write(
$"Duplicate server URL for repo folder '{folder}': keeping '{url}' (last wins), ignoring '{existing}'.",
ConsoleColor.Yellow);
}

serverPathMappings[folder] = url;
}

foreach (var arg in args)
{
if (arg.StartsWith("/out:", StringComparison.Ordinal))
Expand Down Expand Up @@ -190,7 +206,7 @@ public static CommandLineOptions Parse(params string[] args)
continue;
}

serverPathMappings[Path.GetFullPath(match.Groups["from"].Value)] = match.Groups["to"].Value;
MapServerPath(Path.GetFullPath(match.Groups["from"].Value), match.Groups["to"].Value);
continue;
}

Expand Down Expand Up @@ -240,7 +256,7 @@ public static CommandLineOptions Parse(params string[] args)

var repoFolder = Path.GetFullPath(match.Groups["from"].Value);
repoPathMappings[repoFolder] = match.Groups["name"].Value;
serverPathMappings[repoFolder] = match.Groups["to"].Value;
MapServerPath(repoFolder, match.Groups["to"].Value);
continue;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ public static async Task GenerateInvocationAsync(CompilerInvocation invocation,
Dictionary<(string, string), string> typeForwards = null,
bool includeSourceGeneratedDocuments = true,
string repoName = "",
string solutionName = "")
string solutionName = "",
IReadOnlyDictionary<string, string> repoPathMappings = null,
int distinctRepoCount = 0,
IReadOnlyDictionary<string, int> solutionCountsByRepo = null)
{
try
{
Expand All @@ -48,6 +51,9 @@ public static async Task GenerateInvocationAsync(CompilerInvocation invocation,
solutionGenerator.GlobalAssemblyList = assemblyNames;
solutionGenerator.RepoName = repoName ?? string.Empty;
solutionGenerator.SolutionName = solutionName ?? string.Empty;
solutionGenerator.RepoPathMappings = repoPathMappings;
solutionGenerator.DistinctRepoCount = distinctRepoCount;
solutionGenerator.SolutionCountsByRepo = solutionCountsByRepo;
await solutionGenerator.GenerateAsync(cancellationToken, processedAssemblyList, solutionExplorerRoot);
}
else
Expand All @@ -60,6 +66,9 @@ public static async Task GenerateInvocationAsync(CompilerInvocation invocation,
typeForwards: typeForwards);
solutionGenerator.RepoName = repoName ?? string.Empty;
solutionGenerator.SolutionName = solutionName ?? string.Empty;
solutionGenerator.RepoPathMappings = repoPathMappings;
solutionGenerator.DistinctRepoCount = distinctRepoCount;
solutionGenerator.SolutionCountsByRepo = solutionCountsByRepo;
await solutionGenerator.GenerateAsync(cancellationToken);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ private void GenerateProjectInfo()
sb.Append("RepoName=").AppendLine(RepoName);
}

// Full repo ancestry (outermost first, own repo last), '|'-joined -- lets a parent repo
// include its nested sub-repos in filtering/grouping. '|' can't occur in an owner/repo name,
// so it stays a safe field separator here and in Assemblies.txt.
if (RepoChain is { Count: > 0 })
{
sb.Append("RepoChain=").AppendLine(string.Join("|", RepoChain));
}

if (!string.IsNullOrEmpty(SolutionName))
{
sb.Append("SolutionName=").AppendLine(SolutionName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,14 @@ public partial class ProjectGenerator
public string ProjectSourcePath { get; set; }
public string ProjectFilePath { get; private set; }

/// <summary>Repo/solution tags inherited from the owning SolutionGenerator, if any.</summary>
public string RepoName => SolutionGenerator?.RepoName ?? string.Empty;
/// <summary>Repo tag resolved per project from the owning SolutionGenerator's /repoPath
/// mappings (falling back to its per-input tag); solution tag is inherited as-is.</summary>
public string RepoName => SolutionGenerator?.ResolveRepoName(ProjectFilePath) ?? string.Empty;

/// <summary>Repo ancestry resolved per project (outermost repo first, own repo last), so a
/// parent repo can include its nested sub-repos in filtering/grouping. See
/// <see cref="SolutionGenerator.ResolveRepoChain"/>.</summary>
public IReadOnlyList<string> RepoChain => SolutionGenerator?.ResolveRepoChain(ProjectFilePath) ?? (IReadOnlyList<string>)System.Array.Empty<string>();
public string SolutionName => SolutionGenerator?.SolutionName ?? string.Empty;
public List<string> OtherFiles { get; set; }
public IEnumerable<MEF.ISymbolVisitor> PluginSymbolVisitors { get; private set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ private void AddProjectToFolder(Folder folder, Project project, IEnumerable<stri
{
var folderList = folders?.ToArray() ?? Array.Empty<string>();

// Repo/Solution grouping is per project: a single input can span several repos (e.g. a VMR
// whose sub-repos are tagged via nested /repoPath), so descend this project's full repo
// ancestry (parent repo -> sub-repo) before laying down the .sln folder chain underneath it.
var repoChain = ResolveRepoChain(project.FilePath);
var repoName = repoChain.Count > 0 ? repoChain[repoChain.Count - 1] : string.Empty;
folder = Program.GetSolutionExplorerGroupingFolder(folder, repoChain, SolutionName, DistinctRepoCount, SolutionCountsByRepo);

AddProjectToFolderCore(folder, project, folderList, repoName, repoChain);
}

private void AddProjectToFolderCore(Folder folder, Project project, string[] folderList, string repoName, IReadOnlyList<string> repoChain)
{
// Additive persistence only -- see Constants.SolutionFolderFileName. Written regardless of
// whether the project ends up nested or at the root (empty file = root), and is best-effort:
// a project this Pass1 run didn't actually generate output for (e.g. it was filtered out
Expand All @@ -64,12 +76,12 @@ private void AddProjectToFolder(Folder folder, Project project, IEnumerable<stri

if (folderList.Length == 0)
{
folder.Add(new ProjectSkeleton(project.AssemblyName, project.Name, RepoName));
folder.Add(new ProjectSkeleton(project.AssemblyName, project.Name, repoName, repoChain));
}
else
{
var subfolder = folder.GetOrCreateFolder(folderList[0]);
AddProjectToFolder(subfolder, project, folderList.Skip(1));
AddProjectToFolderCore(subfolder, project, folderList.Skip(1).ToArray(), repoName, repoChain);
}
}

Expand Down
Loading
Loading