Skip to content
Open
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
9 changes: 8 additions & 1 deletion ScipDotnet.Tests/SnapshotTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public void Snapshot(string inputDirectory)
RecursivelyListFiles(outputDirectory, absoluteOutputPaths);
foreach (var absolutePath in absoluteOutputPaths)
{
if (!absolutePath.EndsWith(".cs"))
if (!IsSnapshotFile(absolutePath))
{
continue;
}
Expand Down Expand Up @@ -79,6 +79,13 @@ public void Snapshot(string inputDirectory)
}
}

/// <summary>
/// Razor views (.cshtml) and Blazor components (.razor) are never compiled from disk, the
/// Razor source generator feeds them to the compiler, so they have their own snapshots.
/// </summary>
private static bool IsSnapshotFile(string path) =>
path.EndsWith(".cs") || path.EndsWith(".cshtml") || path.EndsWith(".razor");

private static void RecursivelyListFiles(string path, List<string> result)
{
if (!Directory.Exists(path)) return;
Expand Down
17 changes: 16 additions & 1 deletion ScipDotnet/ScipDocumentIndexer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public class ScipDocumentIndexer
private readonly Dictionary<ISymbol, ScipSymbol> _globals;
private readonly Dictionary<ISymbol, ScipSymbol> _locals = new(SymbolEqualityComparer.Default);
private readonly string _markdownCodeFenceLanguage;
private readonly string? _originalFilePath;

// Custom formatting options to render symbol documentation. Feel free to tweak these parameters.
// The options were derived by multiple rounds of experimentation with the goal of striking a
Expand Down Expand Up @@ -56,14 +57,22 @@ public class ScipDocumentIndexer
SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers
);

/// <param name="originalFilePath">
/// When non-null, only record the occurrences that <code>#line</code> directives attribute to
/// this file. Source generated documents such as the C# that the Razor generator emits for a
/// .cshtml file are a mixture of generated boilerplate and code the developer wrote, and only
/// the latter belongs in the index.
/// </param>
public ScipDocumentIndexer(
Document doc,
IndexCommandOptions options,
Dictionary<ISymbol, ScipSymbol> globals)
Dictionary<ISymbol, ScipSymbol> globals,
string? originalFilePath = null)
{
_doc = doc;
_options = options;
_globals = globals;
_originalFilePath = originalFilePath;
_markdownCodeFenceLanguage = _doc.Language == "C#" ? "cs" : "vb";
}

Expand Down Expand Up @@ -220,6 +229,12 @@ public void VisitOccurrence(ISymbol? symbol, Location location, bool isDefinitio
return;
}

if (_originalFilePath != null &&
!string.Equals(location.GetMappedLineSpan().Path, _originalFilePath, StringComparison.Ordinal))
{
return;
}

var symbolRole = 0;
if (isDefinition)
{
Expand Down
140 changes: 125 additions & 15 deletions ScipDotnet/ScipProjectIndexer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,106 @@ await host.Services.GetRequiredService<MSBuildWorkspace>()
document.FilePath);
}
}

foreach (var document in await IndexSourceGeneratedDocuments(project, options, globals))
{
yield return document;
}
}
}

/// <summary>
/// Indexes the documents that the compiler synthesizes instead of reading from disk.
/// Razor views (.cshtml) and Blazor components (.razor) enter the compilation this way,
/// through the Razor source generator, so <code>project.Documents</code> never sees them.
///
/// The generated C# lives under <code>obj/</code> and usually does not exist on disk at all,
/// so reporting its path would produce an index full of files nobody can open. Instead we
/// follow the <code>#line</code> directives that the generator emits, group the occurrences
/// by the original file each one came from and report that file. Occurrences that map to
/// generated code rather than to a file the developer wrote are dropped.
/// </summary>
private async Task<IEnumerable<Scip.Document>> IndexSourceGeneratedDocuments(
Project project,
IndexCommandOptions options,
Dictionary<ISymbol, ScipSymbol> globals)
{
var documentsByOriginalPath = new Dictionary<string, Scip.Document>();
var generatedDocuments = await project.GetSourceGeneratedDocumentsAsync();
options.Logger.LogDebug($"Found {generatedDocuments.Count()} source generated documents in {project.FilePath}");
foreach (var document in generatedDocuments)
{
var tree = await document.GetSyntaxTreeAsync();
if (tree == null)
{
continue;
}

foreach (var originalPath in OriginalFilePaths(tree))
{
if (!options.Matcher.Match(options.WorkingDirectory.FullName, originalPath).HasMatches)
{
options.Logger.LogDebug(
"Excluded file path '{FilePath}' because it did not match the provided --include and --exclude arguments",
originalPath);
continue;
}

if (!documentsByOriginalPath.TryGetValue(originalPath, out var doc))
{
doc = new Scip.Document
{
Language = project.Language,
RelativePath = Path.GetRelativePath(options.WorkingDirectory.FullName, originalPath)
};
documentsByOriginalPath.Add(originalPath, doc);
}

await WalkDocument(doc, document, options, globals, project.Language, originalPath);
}
}

foreach (var doc in documentsByOriginalPath.Values)
{
RemoveDuplicates(doc);
}

return documentsByOriginalPath.Values;
}

/// <summary>
/// Removes the occurrences and symbols that we recorded more than once because several
/// generated files attribute the same region of the same original file to themselves.
/// </summary>
private static void RemoveDuplicates(Scip.Document doc)
{
var seenOccurrences = new HashSet<string>();
var occurrences = doc.Occurrences.Where(occurrence => seenOccurrences.Add(OccurrenceKey(occurrence))).ToList();
doc.Occurrences.Clear();
doc.Occurrences.AddRange(occurrences);

var seenSymbols = new HashSet<string>();
var symbols = doc.Symbols.Where(symbol => seenSymbols.Add(symbol.Symbol)).ToList();
doc.Symbols.Clear();
doc.Symbols.AddRange(symbols);
}

private static string OccurrenceKey(Scip.Occurrence occurrence) =>
$"{occurrence.Symbol} {occurrence.SymbolRoles} {string.Join(",", occurrence.Range)}";

/// <summary>
/// Returns the files that a generated syntax tree attributes its contents to via
/// <code>#line</code> directives. A single generated Razor file can point at more than one
/// original file because directives from <code>_ViewImports.cshtml</code> are copied into
/// every view that inherits them.
/// </summary>
private static IEnumerable<string> OriginalFilePaths(SyntaxTree tree) =>
tree.GetLineMappings()
.Where(mapping => !mapping.IsHidden && mapping.MappedSpan.HasMappedPath)
.Select(mapping => mapping.MappedSpan.Path)
.Where(path => !string.IsNullOrEmpty(path) && File.Exists(path))
.Distinct();

private async Task<Scip.Document> IndexDocument(Document document,
IndexCommandOptions options,
Dictionary<ISymbol, ScipSymbol> globals,
Expand All @@ -135,29 +232,42 @@ await host.Services.GetRequiredService<MSBuildWorkspace>()
? null
: Path.GetRelativePath(options.WorkingDirectory.FullName, document.FilePath)
};
await WalkDocument(doc, document, options, globals, language, originalFilePath: null);
return doc;
}

/// <summary>
/// Walks <paramref name="document"/> and adds what it finds to <paramref name="doc"/>. When
/// <paramref name="originalFilePath"/> is non-null only the occurrences that <code>#line</code>
/// directives attribute to that file are recorded.
/// </summary>
private async Task WalkDocument(Scip.Document doc,
Document document,
IndexCommandOptions options,
Dictionary<ISymbol, ScipSymbol> globals,
string language,
string? originalFilePath)
{
var semanticModel = await document.GetSemanticModelAsync();
if (semanticModel == null)
{
Logger.LogWarning(
"Skipping document {DocumentFilePath} because document.GetSemanticModelAsync() returned null",
document.FilePath);
return;
}
else

var symbolFormatter = new ScipDocumentIndexer(doc, options, globals, originalFilePath);
var root = await document.GetSyntaxRootAsync();
if (language == "C#")
{
var symbolFormatter = new ScipDocumentIndexer(doc, options, globals);
var root = await document.GetSyntaxRootAsync();
if (language == "C#")
{
var walker = new ScipCSharpSyntaxWalker(symbolFormatter, semanticModel);
walker.Visit(root);
}
else if (language == "Visual Basic")
{
var walker = new ScipVisualBasicSyntaxWalker(symbolFormatter, semanticModel);
walker.Visit(root);
}
var walker = new ScipCSharpSyntaxWalker(symbolFormatter, semanticModel);
walker.Visit(root);
}
else if (language == "Visual Basic")
{
var walker = new ScipVisualBasicSyntaxWalker(symbolFormatter, semanticModel);
walker.Visit(root);
}

return doc;
}
}
12 changes: 12 additions & 0 deletions snapshots/input/razor/RazorApp/Components/Counter.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<p>Current count: @CurrentCount</p>

<button @onclick="Increment">Click me</button>

@code {
private int CurrentCount { get; set; }

private void Increment()
{
CurrentCount++;
}
}
1 change: 1 addition & 0 deletions snapshots/input/razor/RazorApp/Components/_Imports.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@using Microsoft.AspNetCore.Components.Web
14 changes: 14 additions & 0 deletions snapshots/input/razor/RazorApp/Pages/Index.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@page
@model IndexModel
@{
ViewData["Title"] = Model.Greeting;
}

<h1>@Model.Greeting</h1>
<p>@Model.Shout("world")</p>

@functions {
private static string Loud(string text) => text.ToUpperInvariant();
}

<p>@Loud(Model.Greeting)</p>
15 changes: 15 additions & 0 deletions snapshots/input/razor/RazorApp/Pages/Index.cshtml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace RazorApp.Pages;

public class IndexModel : PageModel
{
public string Greeting { get; set; } = "Hello";

public string Shout(string name) => $"{Greeting}, {name}!";

public void OnGet()
{
Greeting = "Welcome";
}
}
2 changes: 2 additions & 0 deletions snapshots/input/razor/RazorApp/Pages/_ViewImports.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@using RazorApp.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
5 changes: 5 additions & 0 deletions snapshots/input/razor/RazorApp/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
var app = builder.Build();
app.MapRazorPages();
app.Run();
9 changes: 9 additions & 0 deletions snapshots/input/razor/RazorApp/RazorApp.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFrameworks>net10.0;net9.0;net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>
34 changes: 34 additions & 0 deletions snapshots/input/razor/razor.sln
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RazorApp", "RazorApp\RazorApp.csproj", "{2E0B95A8-D543-4F39-8618-4E1BE8AE024B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{2E0B95A8-D543-4F39-8618-4E1BE8AE024B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2E0B95A8-D543-4F39-8618-4E1BE8AE024B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2E0B95A8-D543-4F39-8618-4E1BE8AE024B}.Debug|x64.ActiveCfg = Debug|Any CPU
{2E0B95A8-D543-4F39-8618-4E1BE8AE024B}.Debug|x64.Build.0 = Debug|Any CPU
{2E0B95A8-D543-4F39-8618-4E1BE8AE024B}.Debug|x86.ActiveCfg = Debug|Any CPU
{2E0B95A8-D543-4F39-8618-4E1BE8AE024B}.Debug|x86.Build.0 = Debug|Any CPU
{2E0B95A8-D543-4F39-8618-4E1BE8AE024B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2E0B95A8-D543-4F39-8618-4E1BE8AE024B}.Release|Any CPU.Build.0 = Release|Any CPU
{2E0B95A8-D543-4F39-8618-4E1BE8AE024B}.Release|x64.ActiveCfg = Release|Any CPU
{2E0B95A8-D543-4F39-8618-4E1BE8AE024B}.Release|x64.Build.0 = Release|Any CPU
{2E0B95A8-D543-4F39-8618-4E1BE8AE024B}.Release|x86.ActiveCfg = Release|Any CPU
{2E0B95A8-D543-4F39-8618-4E1BE8AE024B}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
21 changes: 21 additions & 0 deletions snapshots/output-net10.0/razor/RazorApp/Components/Counter.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<p>Current count: @CurrentCount</p>
// ^^^^^^^^^^^^ reference scip-dotnet nuget . . Components/Counter#CurrentCount.
// ^^^^^^^^^^^^^ reference scip-dotnet nuget . . Components/Counter#BuildRenderTree().(__builder)
// ^^^^^^^^^^^^^ reference scip-dotnet nuget Microsoft.AspNetCore.Components 10.0.0.0 Rendering/RenderTreeBuilder#AddContent(+5).

<button @onclick="Increment">Click me</button>
// ^^^^^^^^^ reference scip-dotnet nuget . . Components/Counter#Increment().

@code {
private int CurrentCount { get; set; }
// ^^^^^^^^^^^^ definition scip-dotnet nuget . . Components/Counter#CurrentCount.
// documentation ```cs\nprivate int Counter.CurrentCount { get; set; }\n```

private void Increment()
// ^^^^^^^^^ definition scip-dotnet nuget . . Components/Counter#Increment().
// documentation ```cs\nprivate void Counter.Increment()\n```
{
CurrentCount++;
// ^^^^^^^^^^^^ reference scip-dotnet nuget . . Components/Counter#CurrentCount.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@using Microsoft.AspNetCore.Components.Web
// ^^^^^^^^^ reference scip-dotnet nuget . . Microsoft/
// ^^^^^^^^^^ reference scip-dotnet nuget . . AspNetCore/
// ^^^^^^^^^^ reference scip-dotnet nuget . . Components/
// ^^^ reference scip-dotnet nuget . . Web/
31 changes: 31 additions & 0 deletions snapshots/output-net10.0/razor/RazorApp/Pages/Index.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
@page
@model IndexModel
// ^^^^^^^^^^ reference scip-dotnet nuget . . Pages/IndexModel#
@{
ViewData["Title"] = Model.Greeting;
// ^^^^^^^^ reference scip-dotnet nuget . . AspNetCoreGeneratedDocument/Pages_Index#ViewData.
// ^^^^^ reference scip-dotnet nuget . . AspNetCoreGeneratedDocument/Pages_Index#Model.
// ^^^^^^^^ reference scip-dotnet nuget . . Pages/IndexModel#Greeting.
}

<h1>@Model.Greeting</h1>
// ^^^^^ reference scip-dotnet nuget . . AspNetCoreGeneratedDocument/Pages_Index#Model.
// ^^^^^^^^ reference scip-dotnet nuget . . Pages/IndexModel#Greeting.
<p>@Model.Shout("world")</p>
// ^^^^^ reference scip-dotnet nuget . . AspNetCoreGeneratedDocument/Pages_Index#Model.
// ^^^^^ reference scip-dotnet nuget . . Pages/IndexModel#Shout().

@functions {
private static string Loud(string text) => text.ToUpperInvariant();
// ^^^^ definition scip-dotnet nuget . . AspNetCoreGeneratedDocument/Pages_Index#Loud().
// documentation ```cs\nprivate static string Pages_Index.Loud(string text)\n```
// ^^^^ definition scip-dotnet nuget . . AspNetCoreGeneratedDocument/Pages_Index#Loud().(text)
// documentation ```cs\nstring text\n```
// ^^^^ reference scip-dotnet nuget . . AspNetCoreGeneratedDocument/Pages_Index#Loud().(text)
// ^^^^^^^^^^^^^^^^ reference scip-dotnet nuget System.Runtime 10.0.0.0 System/String#ToUpperInvariant().
}

<p>@Loud(Model.Greeting)</p>
// ^^^^ reference scip-dotnet nuget . . AspNetCoreGeneratedDocument/Pages_Index#Loud().
// ^^^^^ reference scip-dotnet nuget . . AspNetCoreGeneratedDocument/Pages_Index#Model.
// ^^^^^^^^ reference scip-dotnet nuget . . Pages/IndexModel#Greeting.
Loading