diff --git a/ScipDotnet.Tests/SnapshotTests.cs b/ScipDotnet.Tests/SnapshotTests.cs
index fc66e41..43f8630 100644
--- a/ScipDotnet.Tests/SnapshotTests.cs
+++ b/ScipDotnet.Tests/SnapshotTests.cs
@@ -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;
}
@@ -79,6 +79,13 @@ public void Snapshot(string inputDirectory)
}
}
+ ///
+ /// 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.
+ ///
+ private static bool IsSnapshotFile(string path) =>
+ path.EndsWith(".cs") || path.EndsWith(".cshtml") || path.EndsWith(".razor");
+
private static void RecursivelyListFiles(string path, List result)
{
if (!Directory.Exists(path)) return;
diff --git a/ScipDotnet/ScipDocumentIndexer.cs b/ScipDotnet/ScipDocumentIndexer.cs
index 57a6e6d..e1ee02d 100644
--- a/ScipDotnet/ScipDocumentIndexer.cs
+++ b/ScipDotnet/ScipDocumentIndexer.cs
@@ -16,6 +16,7 @@ public class ScipDocumentIndexer
private readonly Dictionary _globals;
private readonly Dictionary _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
@@ -56,14 +57,22 @@ public class ScipDocumentIndexer
SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers
);
+ ///
+ /// When non-null, only record the occurrences that #line 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.
+ ///
public ScipDocumentIndexer(
Document doc,
IndexCommandOptions options,
- Dictionary globals)
+ Dictionary globals,
+ string? originalFilePath = null)
{
_doc = doc;
_options = options;
_globals = globals;
+ _originalFilePath = originalFilePath;
_markdownCodeFenceLanguage = _doc.Language == "C#" ? "cs" : "vb";
}
@@ -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)
{
diff --git a/ScipDotnet/ScipProjectIndexer.cs b/ScipDotnet/ScipProjectIndexer.cs
index 1f1debf..fc6eff8 100644
--- a/ScipDotnet/ScipProjectIndexer.cs
+++ b/ScipDotnet/ScipProjectIndexer.cs
@@ -120,9 +120,106 @@ await host.Services.GetRequiredService()
document.FilePath);
}
}
+
+ foreach (var document in await IndexSourceGeneratedDocuments(project, options, globals))
+ {
+ yield return document;
+ }
+ }
+ }
+
+ ///
+ /// 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 project.Documents never sees them.
+ ///
+ /// The generated C# lives under obj/ 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 #line 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.
+ ///
+ private async Task> IndexSourceGeneratedDocuments(
+ Project project,
+ IndexCommandOptions options,
+ Dictionary globals)
+ {
+ var documentsByOriginalPath = new Dictionary();
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ private static void RemoveDuplicates(Scip.Document doc)
+ {
+ var seenOccurrences = new HashSet();
+ var occurrences = doc.Occurrences.Where(occurrence => seenOccurrences.Add(OccurrenceKey(occurrence))).ToList();
+ doc.Occurrences.Clear();
+ doc.Occurrences.AddRange(occurrences);
+
+ var seenSymbols = new HashSet();
+ 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)}";
+
+ ///
+ /// Returns the files that a generated syntax tree attributes its contents to via
+ /// #line directives. A single generated Razor file can point at more than one
+ /// original file because directives from _ViewImports.cshtml are copied into
+ /// every view that inherits them.
+ ///
+ private static IEnumerable 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 IndexDocument(Document document,
IndexCommandOptions options,
Dictionary globals,
@@ -135,29 +232,42 @@ await host.Services.GetRequiredService()
? null
: Path.GetRelativePath(options.WorkingDirectory.FullName, document.FilePath)
};
+ await WalkDocument(doc, document, options, globals, language, originalFilePath: null);
+ return doc;
+ }
+
+ ///
+ /// Walks and adds what it finds to . When
+ /// is non-null only the occurrences that #line
+ /// directives attribute to that file are recorded.
+ ///
+ private async Task WalkDocument(Scip.Document doc,
+ Document document,
+ IndexCommandOptions options,
+ Dictionary 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;
}
}
\ No newline at end of file
diff --git a/snapshots/input/razor/RazorApp/Components/Counter.razor b/snapshots/input/razor/RazorApp/Components/Counter.razor
new file mode 100644
index 0000000..dc7f2c3
--- /dev/null
+++ b/snapshots/input/razor/RazorApp/Components/Counter.razor
@@ -0,0 +1,12 @@
+