From 62da612303cc7cc6c732e4611fdc44b210158806 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:37:24 +1100 Subject: [PATCH 1/4] Razor phase 4 (in progress): MVC tag helper names, scope and attribute completion Legacy tag helper elements and attributes become markup facts, with names read where the compiler gives no span; @addTagHelper, @removeTagHelper and @tagHelperPrefix scope MVC attribute completion. Builds; tests not yet run. --- .../RazorMvcTests.cs | 269 ++++++++++++++++++ .../Razor/HtmlMarkupContext.cs | 10 + .../Razor/RazorHtmlProjection.cs | 167 ++--------- .../Razor/RazorIr.Attributes.cs | 230 +++++++++++++++ .../SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs | 128 ++++----- .../Razor/RazorMarkup.cs | 7 +- .../Razor/RazorMarkupCapture.cs | 12 +- .../Razor/RazorMarkupCompletion.cs | 90 ++++-- .../Razor/RazorTagHelperScope.cs | 121 ++++++++ 9 files changed, 790 insertions(+), 244 deletions(-) create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMvcTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorTagHelperScope.cs diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMvcTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMvcTests.cs new file mode 100644 index 00000000..3abce99e --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMvcTests.cs @@ -0,0 +1,269 @@ +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; +using SharpLsp.Sidecar.CSharp.Workspace; + +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable RS1035 // Real-file reads are intentional in this end-to-end suite +#pragma warning disable IDE0042 // Positions read better as (Line, Character) pairs + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// Razor Pages and MVC views against the real fixture's PagesApp: @model, +/// @inject, @functions and @section through the projection, tag helper +/// elements and attributes from their descriptors, and _ViewImports.cshtml as an +/// import. Implements [RAZOR-TESTING] for [RAZOR-MVC]. +/// +public sealed class RazorMvcTests : IClassFixture +{ + private readonly RazorFixture _fixture; + + public RazorMvcTests(RazorFixture fixture) + { + _fixture = fixture; + } + + private string Edit => _fixture.Path("PagesApp", "Pages", "Edit.cshtml"); + + private string Index => _fixture.Path("PagesApp", "Views", "Home", "Index.cshtml"); + + [Fact] + public async Task Model_inject_and_functions_answer_through_the_projection() + { + using var manager = await _fixture.OpenManagerAsync(); + + // `@model`'s type is C#, and so is `Model.X`. + var model = await HoverAsync( + manager, + Edit, + RazorFixture.Locate(Edit, "@model EditModel", "EditModel") + ); + Assert.Contains("Edits one item.", model, StringComparison.Ordinal); + var title = await HoverAsync( + manager, + Edit, + RazorFixture.Locate(Edit, "@Shout(Model.Title)", "Title") + ); + Assert.Contains("The item's title.", title, StringComparison.Ordinal); + + // `@inject` declares a property of its type, and `@functions` members are C#. + var log = await HoverAsync( + manager, + Edit, + RazorFixture.Locate(Edit, "@inject ILogger", "Log") + ); + Assert.Contains("ILogger<", log, StringComparison.Ordinal); + var call = RazorFixture.Locate(Edit, "@Shout(Model.Title)", "Shout"); + var shout = Assert.Single( + RazorFixture + .Ok(await manager.GetDefinitionAsync(Edit, call.Line, call.Character)) + .Locations + ); + Assert.True(NativePaths.AreEqual(Edit, shout.FilePath), shout.FilePath); + Assert.Equal( + RazorFixture.Locate(Edit, "string Shout", "Shout"), + (shout.Line, shout.Character) + ); + } + + [Fact] + public async Task ViewImports_imports_reach_the_views_below_them() + { + using var manager = await _fixture.OpenManagerAsync(); + + // Views/_ViewImports.cshtml's `@using PagesApp.Models` resolves `@model Item`... + var item = await HoverAsync( + manager, + Index, + RazorFixture.Locate(Index, "@model Item", "Item") + ); + Assert.Contains("An item the home page shows.", item, StringComparison.Ordinal); + Assert.DoesNotContain( + RazorFixture.Ok(await manager.GetDiagnosticsAsync(Index)), + diagnostic => diagnostic.Severity == "Error" + ); + + // ...and its `@addTagHelper` binds the anchor tag helper. + var markup = await manager.FindRazorMarkupAsync(Index, default); + Assert.NotNull(markup); + var anchor = Assert.Single(markup.Tags, tag => tag.Name == "a"); + Assert.Contains( + anchor.MvcTagHelpers, + helper => helper.Name.EndsWith("AnchorTagHelper", StringComparison.Ordinal) + ); + } + + [Fact] + public async Task Tag_helper_elements_and_attributes_answer_hover_from_their_descriptors() + { + using var manager = await _fixture.OpenManagerAsync(); + + // An element a tag helper binds answers the tag helper's type. + var input = await HoverAsync( + manager, + Edit, + RazorFixture.Locate(Edit, " item.Label == "asp-page"); + Assert.Contains(items, item => item.Label == "asp-controller"); + Assert.Contains(items, item => item.Label == "asp-route-"); + var page = Assert.Single(items, item => item.Label == "asp-page"); + Assert.Equal("Property", page.Kind); + } + + [Fact] + public async Task Section_blocks_fold_and_outline() + { + using var manager = await _fixture.OpenManagerAsync(); + var text = await File.ReadAllTextAsync(Index); + const string section = "\n@section Scripts {\n \n}\n"; + _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Index, text + section)); + var opened = text.Split('\n').Length; + + var folds = RazorFixture.Ok(await manager.GetFoldingRangesAsync(Index, default)); + Assert.Contains((opened, opened + 1), folds.Select(fold => (fold.StartLine, fold.EndLine))); + var symbols = RazorFixture.Ok(await manager.GetDocumentSymbolsAsync(Index, default)); + Assert.Contains(symbols, symbol => symbol.Name == "@section" && symbol.Kind == "Key"); + } + + [Fact] + public async Task Tag_helper_elements_keep_their_markup_and_blank_their_csharp_values() + { + using var manager = await _fixture.OpenManagerAsync(); + var markup = await manager.FindRazorMarkupAsync(Edit, default); + Assert.NotNull(markup); + + // The elements are HTML, so the HTML projection keeps their tags. [RAZOR-ARCHITECTURE-HTML] + var html = markup.HtmlText; + Assert.Contains("
", html, StringComparison.Ordinal); + Assert.Contains("
", html, StringComparison.Ordinal); + Assert.Contains("class=\"form-control\" />", html, StringComparison.Ordinal); + Assert.Contains(">Reload", html, StringComparison.Ordinal); + Assert.Contains("", + html, + StringComparison.Ordinal + ); + Assert.Contains( + "Reload", + html, + StringComparison.Ordinal + ); + Assert.DoesNotContain("Title", html, StringComparison.Ordinal); + Assert.DoesNotContain("Model", html, StringComparison.Ordinal); + + // Each element a tag helper binds is a tag of the file, with its tag helpers. + string[] bound = ["form", "label", "input", "span", "a"]; + Assert.All( + bound, + name => Assert.Contains(markup.Tags, tag => tag.Name == name && tag.MvcTagHelpers.Any()) + ); + } + + [Fact] + public async Task Tag_helper_directives_scope_completion_and_binding() + { + using var manager = await _fixture.OpenManagerAsync(); + var original = await File.ReadAllTextAsync(Edit); + + // `@removeTagHelper` takes the anchor tag helper out of scope, wherever it is written: + // anchors stop binding and completing, and the form tag helper stays. [RAZOR-MVC-SCOPE] + const string anchor = + "@removeTagHelper Microsoft.AspNetCore.Mvc.TagHelpers.AnchorTagHelper, " + + "Microsoft.AspNetCore.Mvc.TagHelpers"; + var removed = await EditedAsync(manager, original + "\n" + anchor + "\n item.Label.StartsWith("asp-", StringComparison.Ordinal) + ); + Assert.DoesNotContain(removed.Markup.Tags, tag => tag.Name == "a"); + var form = Assert.Single(removed.Markup.Tags, tag => tag.Name == "form"); + Assert.Contains( + form.MvcTagHelpers, + helper => helper.Name.EndsWith(".FormTagHelper", StringComparison.Ordinal) + ); + + // `@tagHelperPrefix` makes only prefixed elements tag helpers: they bind, complete and + // answer hover under their prefixed name, and unprefixed ones do none of it. + var prefixed = await EditedAsync( + manager, + "@tagHelperPrefix th:\n" + original + "\nGo\n item.Label == "asp-route-"); + Assert.DoesNotContain(prefixed.Markup.Tags, tag => tag.Name == "form"); + var go = Assert.Single(prefixed.Markup.Tags, tag => tag.Name == "a"); + Assert.Equal("th:a", prefixed.Markup.Text.ToString(go.StartName)); + Assert.Equal("th:a", prefixed.Markup.Text.ToString(go.EndName!.Value)); + var at = prefixed.Markup.Text.Lines.GetLinePosition(go.StartName.Start + 3); + var hover = RazorFixture.Ok(await manager.GetHoverAsync(Edit, at.Line, at.Character)); + Assert.NotNull(hover); + Assert.Contains("AnchorTagHelper", hover.Contents, StringComparison.Ordinal); + var unprefixed = await EditedAsync( + manager, + "@tagHelperPrefix th:\n" + original + "\n item.Label.StartsWith("asp-", StringComparison.Ordinal) + ); + } + + /// The markup facts and the completion at the end of , once it is Edit.cshtml's. + private async Task<(RazorMarkup Markup, List Items)> EditedAsync( + WorkspaceManager manager, + string text + ) + { + _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Edit, text)); + var lines = text.Split('\n'); + var items = RazorFixture.Ok( + await manager.GetCompletionsAsync(Edit, lines.Length - 1, lines[^1].Length) + ); + var markup = await manager.FindRazorMarkupAsync(Edit, default); + Assert.NotNull(markup); + return (markup, items); + } + + private static async Task HoverAsync( + WorkspaceManager manager, + string file, + (int Line, int Character) at, + int offset = 0 + ) + { + var hover = RazorFixture.Ok( + await manager.GetHoverAsync(file, at.Line, at.Character + offset) + ); + Assert.NotNull(hover); + return hover.Contents; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs index a7b9633f..d1b67517 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs @@ -33,6 +33,16 @@ public static MarkupContext At(string html, int offset) return Context(ScanTo(html, Math.Clamp(offset, 0, html.Length))); } + /// + /// The name of the start tag whose attributes hold , lowercased as + /// the tokenizer reads it; null outside a start tag's attributes. [RAZOR-FEATURES-COMPLETION] + /// + public static string? StartTagAt(string html, int offset) + { + var scan = ScanTo(html, Math.Clamp(offset, 0, html.Length)); + return Context(scan) == MarkupContext.AttributeName && !scan.EndTag ? scan.Tag : null; + } + /// The tokenizer states the caret context derives from. private enum Scanner { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs index 2a961a57..fe0e3dd3 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Intermediate; using Microsoft.CodeAnalysis.Text; @@ -10,31 +9,37 @@ namespace SharpLsp.Sidecar.CSharp.Razor; /// internal static class RazorHtmlProjection { - public static string Create(SourceText text, DocumentIntermediateNode root) + /// The projection of , whose tag helper elements carry . + public static string Create(SourceText text, DocumentIntermediateNode root, string prefix) { var chars = text.ToString().Select(Blank).ToArray(); - Write(root, text, chars); + Write(root, text, chars, prefix); return new string(chars); } - private static void Write(IntermediateNode node, SourceText text, char[] chars) + private static void Write(IntermediateNode node, SourceText text, char[] chars, string prefix) { // A directive attribute's value is never HTML, whatever the compiler holds under it. - if (node.IsImported || IsDirective(node)) + if (node.IsImported || RazorIr.IsDirective(node)) { return; } - WriteNode(node, text, chars); + WriteNode(node, text, chars, prefix); foreach (var child in node.Children) { - Write(child, text, chars); + Write(child, text, chars, prefix); } } - private static void WriteNode(IntermediateNode node, SourceText text, char[] chars) + private static void WriteNode( + IntermediateNode node, + SourceText text, + char[] chars, + string prefix + ) { - if (RazorIr.Tag(node, text) is { } tag) + if (RazorIr.Tag(node, text, prefix) is { } tag) { WriteTag(node, tag, text, chars); } @@ -96,11 +101,12 @@ char[] chars chars, TextSpan.FromBounds(tag.Element.Start, OpeningEnd(text, tag.Element, attributes)) ); - // The IR lists attributes in source order, so each one starts after the one before it. - int? after = tag.StartName.End; - foreach (var attribute in node.Children.Where(IsAttribute)) + foreach (var attribute in RazorIr.NamedAttributes(node, tag, text)) { - after = ClearAttribute(chars, text, attribute, after); + if (Cleared(attribute) is { } span) + { + Clear(chars, span); + } } if (tag.EndName is { } end) @@ -110,126 +116,25 @@ char[] chars } /// - /// Blanks an attribute's IR span and a directive's whole @name="value", and answers - /// where the attribute ends, or null when the IR does not say. + /// What an attribute blanks: a directive's whole @name="value", else its IR span, under + /// which HTML is copied back as written. A tag helper attribute's name is markup, so a legacy + /// file's (asp-for, whose span is its value's) stays. [RAZOR-ARCHITECTURE-HTML] /// - private static int? ClearAttribute( - char[] chars, - SourceText text, - IntermediateNode node, - int? after - ) - { - var last = LastEnd(node); - if (DirectiveName(node, text, after) is { } name) - { - var end = Closing(Structure(node), last ?? name.End); - Clear(chars, TextSpan.FromBounds(name.Start, end)); - return end; - } - - if (node.Source is { } source) - { - Clear(chars, new TextSpan(source.AbsoluteIndex, source.Length)); - } - - return last is { } found - ? Closing(Structure(node), found) - : MinimizedEnd(node, text, after); - } - - /// A directive attribute's name, its @ included. - private static TextSpan? DirectiveName(IntermediateNode node, SourceText text, int? after) + private static TextSpan? Cleared(NamedAttribute attribute) { - return node switch - { - TagHelperDirectiveAttributeIntermediateNode directive => Located( - directive.OriginalAttributeSpan, - directive.OriginalAttributeName, - directive.AttributeStructure, - text, - after - ), - TagHelperDirectiveAttributeParameterIntermediateNode parameter => Located( - parameter.OriginalAttributeSpan, - parameter.OriginalAttributeName, - parameter.AttributeStructure, - text, - after - ), - _ => null, - }; - } - - /// - /// A directive attribute's name from the compiler's span. The compiler gives a minimized - /// directive parameter (@onclick:stopPropagation) no span, so its name is read where - /// the attribute before it ends, and only when the text there holds it. - /// - private static TextSpan? Located( - SourceSpan? original, - string name, - AttributeStructure structure, - SourceText text, - int? after - ) - { - return original is null && structure == AttributeStructure.Minimized && after is { } start - ? RazorIr.NameAfter(text, start, name) - : RazorIr.DirectiveName(original, name, text); - } - - /// The end of an attribute's last IR span, its own or a value's under it. - private static int? LastEnd(IntermediateNode node) - { - var ends = RazorIr - .Descendants(node) - .Select(child => child.Source) - .OfType() - .Select(source => source.AbsoluteIndex + source.Length) - .ToArray(); - return ends.Length > 0 ? ends.Max() : null; - } - - /// A minimized attribute has no span, so it ends after its name, read where the one before it ends. - private static int? MinimizedEnd(IntermediateNode node, SourceText text, int? after) - { - return - node is TagHelperHtmlAttributeIntermediateNode html - && html.AttributeStructure == AttributeStructure.Minimized - && after is { } start - ? RazorIr.NameAfter(text, start, html.AttributeName)?.End + return RazorIr.IsDirective(attribute.Node) && attribute is { Name: { } name, End: { } end } + ? TextSpan.FromBounds(name.Start, end) + : attribute.Node.Source is { } source + ? new TextSpan(source.AbsoluteIndex, source.Length) : null; } - /// How the compiler read a bound attribute's value; a plain attribute's span holds its quotes. - private static AttributeStructure Structure(IntermediateNode node) - { - return node switch - { - TagHelperHtmlAttributeIntermediateNode html => html.AttributeStructure, - TagHelperPropertyIntermediateNode property => property.AttributeStructure, - TagHelperDirectiveAttributeIntermediateNode directive => directive.AttributeStructure, - TagHelperDirectiveAttributeParameterIntermediateNode parameter => - parameter.AttributeStructure, - _ => AttributeStructure.Minimized, - }; - } - - /// An attribute's end: its value's end, plus the closing quote the compiler parsed. - private static int Closing(AttributeStructure structure, int valueEnd) - { - return structure is AttributeStructure.DoubleQuotes or AttributeStructure.SingleQuotes - ? valueEnd + 1 - : valueEnd; - } - private static TextSpan[] Attributes(IntermediateNode node) { return [ .. node - .Children.Where(IsAttribute) + .Children.Where(RazorIr.IsAttribute) .Where(child => child.Source is not null) .Select(child => new TextSpan( child.Source!.Value.AbsoluteIndex, @@ -238,22 +143,6 @@ .. node ]; } - private static bool IsAttribute(IntermediateNode node) - { - return node - is HtmlAttributeIntermediateNode - or TagHelperHtmlAttributeIntermediateNode - or TagHelperPropertyIntermediateNode - || IsDirective(node); - } - - private static bool IsDirective(IntermediateNode node) - { - return node - is TagHelperDirectiveAttributeIntermediateNode - or TagHelperDirectiveAttributeParameterIntermediateNode; - } - /// Tokenizes only an IR-owned opening tag; compiler attribute spans are opaque. private static int OpeningEnd(SourceText text, TextSpan element, TextSpan[] attributes) { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs new file mode 100644 index 00000000..7a7131a7 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs @@ -0,0 +1,230 @@ +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// A tag's attribute node, with its name's span and its end where the compiler's spans and the text say. +internal readonly record struct NamedAttribute(IntermediateNode Node, TextSpan? Name, int? End); + +/// +/// Attribute facts read from a tag's IR nodes, in source order. Implements +/// [RAZOR-ARCHITECTURE-ENGINE]. +/// +internal static partial class RazorIr +{ + /// The attributes of a tag node, each with what binds it. + public static IEnumerable Attributes( + IntermediateNode node, + MarkupTag tag, + SourceText text + ) + { + return NamedAttributes(node, tag, text) + .Select(named => + Bound(named.Node) is { } bound + ? new MarkupAttribute( + bound.Kind, + bound.Name, + named.Name, + tag, + bound.TagHelper, + bound.Attribute, + bound.Parameter + ) + : null + ) + .OfType(); + } + + /// + /// A tag node's attributes, each with its name's span and its end. A name the compiler gives + /// no span (a legacy file's attribute, a minimized directive parameter) is read where the + /// attribute before it ends, and only where the text holds it. The nodes that several tag + /// helpers bind for one attribute share its value, and so its name. + /// + public static IEnumerable NamedAttributes( + IntermediateNode node, + MarkupTag tag, + SourceText text + ) + { + int? after = tag.StartName.End; + var from = after; + IntermediateNode? previous = null; + foreach (var child in node.Children.Where(IsAttribute)) + { + from = previous is not null && SameAttribute(previous, child) ? from : after; + var name = NameOf(child, text, from); + var end = EndOf(child, name); + yield return new NamedAttribute(child, name, end); + (after, previous) = (end, child); + } + } + + /// Whether a node is one of a tag's attributes. + public static bool IsAttribute(IntermediateNode node) + { + return node + is HtmlAttributeIntermediateNode + or TagHelperHtmlAttributeIntermediateNode + or TagHelperPropertyIntermediateNode + || IsDirective(node); + } + + /// Whether a node is a directive attribute (@onclick) or one of its parameters. + public static bool IsDirective(IntermediateNode node) + { + return node + is TagHelperDirectiveAttributeIntermediateNode + or TagHelperDirectiveAttributeParameterIntermediateNode; + } + + /// What an attribute node is to the compiler, by the name it was written with. + private static ( + MarkupAttributeKind Kind, + string Name, + TagHelperDescriptor? TagHelper, + BoundAttributeDescriptor? Attribute, + BoundAttributeParameterDescriptor? Parameter + )? Bound(IntermediateNode node) + { + return node switch + { + TagHelperPropertyIntermediateNode property => ( + MarkupAttributeKind.Property, + property.AttributeName, + property.TagHelper, + property.BoundAttribute, + null + ), + TagHelperDirectiveAttributeIntermediateNode directive => ( + MarkupAttributeKind.Directive, + directive.OriginalAttributeName, + directive.TagHelper, + directive.BoundAttribute, + null + ), + TagHelperDirectiveAttributeParameterIntermediateNode parameter => ( + MarkupAttributeKind.DirectiveParameter, + parameter.OriginalAttributeName, + parameter.TagHelper, + parameter.BoundAttribute, + parameter.BoundAttributeParameter + ), + TagHelperHtmlAttributeIntermediateNode html => Html(html.AttributeName), + HtmlAttributeIntermediateNode html => Html(html.AttributeName), + _ => null, + }; + } + + private static ( + MarkupAttributeKind, + string, + TagHelperDescriptor?, + BoundAttributeDescriptor?, + BoundAttributeParameterDescriptor? + ) Html(string name) + { + return (MarkupAttributeKind.Html, name, null, null, null); + } + + /// An attribute's name: the compiler's span, else read where the attribute starts. + private static TextSpan? NameOf(IntermediateNode node, SourceText text, int? from) + { + return node switch + { + TagHelperDirectiveAttributeIntermediateNode directive => Located( + directive.OriginalAttributeSpan, + directive.OriginalAttributeName, + directive.AttributeStructure, + text, + from + ), + TagHelperDirectiveAttributeParameterIntermediateNode parameter => Located( + parameter.OriginalAttributeSpan, + parameter.OriginalAttributeName, + parameter.AttributeStructure, + text, + from + ), + TagHelperPropertyIntermediateNode { OriginalAttributeSpan: { } span } => Span(span), + _ => Bound(node) is { } bound && from is { } start + ? NameAfter(text, start, bound.Name) + : null, + }; + } + + /// + /// A directive attribute's name from the compiler's span. The compiler gives a minimized + /// directive parameter (@onclick:stopPropagation) no span, so its name is read where + /// the attribute before it ends. + /// + private static TextSpan? Located( + SourceSpan? original, + string name, + AttributeStructure structure, + SourceText text, + int? from + ) + { + return original is null && structure == AttributeStructure.Minimized && from is { } start + ? NameAfter(text, start, name) + : DirectiveName(original, name, text); + } + + /// + /// Where an attribute ends: its value's end and closing quote, else, for a directive or a + /// minimized attribute, its name's end; null when neither the IR nor the text says. + /// + private static int? EndOf(IntermediateNode node, TextSpan? name) + { + var structure = Structure(node); + var value = + LastEnd(node) + ?? (IsDirective(node) || structure == AttributeStructure.Minimized ? name?.End : null); + return value is { } found ? Closing(structure, found) : null; + } + + /// Whether two property nodes are one attribute that two tag helpers bind. + private static bool SameAttribute(IntermediateNode first, IntermediateNode second) + { + return first is TagHelperPropertyIntermediateNode { Source: { } value } one + && second is TagHelperPropertyIntermediateNode { Source: { } other } two + && one.AttributeName == two.AttributeName + && value.Equals(other); + } + + /// The end of an attribute's last IR span, its own or a value's under it. + private static int? LastEnd(IntermediateNode node) + { + var ends = Descendants(node) + .Select(child => child.Source) + .OfType() + .Select(source => source.AbsoluteIndex + source.Length) + .ToArray(); + return ends.Length > 0 ? ends.Max() : null; + } + + /// How the compiler read a bound attribute's value; a plain attribute's span holds its quotes. + private static AttributeStructure Structure(IntermediateNode node) + { + return node switch + { + TagHelperHtmlAttributeIntermediateNode html => html.AttributeStructure, + TagHelperPropertyIntermediateNode property => property.AttributeStructure, + TagHelperDirectiveAttributeIntermediateNode directive => directive.AttributeStructure, + TagHelperDirectiveAttributeParameterIntermediateNode parameter => + parameter.AttributeStructure, + _ => AttributeStructure.Minimized, + }; + } + + /// An attribute's end: its value's end, plus the closing quote the compiler parsed. + private static int Closing(AttributeStructure structure, int valueEnd) + { + return structure is AttributeStructure.DoubleQuotes or AttributeStructure.SingleQuotes + ? valueEnd + 1 + : valueEnd; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs index cd428a40..2d94cae7 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs @@ -14,7 +14,7 @@ namespace SharpLsp.Sidecar.CSharp.Razor; /// verified against the text, so a span the compiler did not shape that way is never /// reported. Implements [RAZOR-ARCHITECTURE-ENGINE]. /// -internal static class RazorIr +internal static partial class RazorIr { /// Every node under , parents before children. public static IEnumerable Descendants(IntermediateNode root) @@ -79,6 +79,22 @@ public static IEnumerable CodeBlocks(IntermediateNode root) }; } + /// The tag helper directives under , its imports' first, in order. + public static IEnumerable TagHelperDirectives(IntermediateNode root) + { + return Descendants(root) + .OfType() + .Where(directive => RazorTagHelperScope.Declares(directive.DirectiveName)) + .Select(directive => new TagHelperDirective( + directive.DirectiveName, + directive + .Children.OfType() + .FirstOrDefault() + ?.Content + ?? "" + )); + } + /// The namespace an @using imports, without a global:: qualifier; null for an alias or static using. public static string? Namespace(UsingDirectiveIntermediateNode use) { @@ -94,21 +110,20 @@ public static IEnumerable CodeBlocks(IntermediateNode root) return (global is null ? name : name.ReplaceNode(global, global.Name)).ToString(); } - /// The tag a node is: an element bound to tag helpers, or a plain component-file element. - public static MarkupTag? Tag(IntermediateNode node, SourceText text) + /// + /// The tag a node is: an element bound to tag helpers, or a plain component-file element. + /// A legacy file's tag helper element is spelled with the file's @tagHelperPrefix. + /// + public static MarkupTag? Tag(IntermediateNode node, SourceText text, string prefix) { return node switch { - TagHelperIntermediateNode { Source: { } source, StartTagSpan: { } start } bound => - new MarkupTag( - bound.TagName, - Span(source), - Span(start), - bound.TagMode == TagMode.StartTagAndEndTag - ? EndName(Span(source), bound.TagName, text) - : null, - [.. bound.TagHelpers] - ), + TagHelperIntermediateNode { Source: { } source } bound => BoundTag( + bound, + Span(source), + text, + prefix + ), MarkupElementIntermediateNode { Source: { } source } element => PlainTag( element.TagName, Span(source), @@ -118,16 +133,6 @@ [.. bound.TagHelpers] }; } - /// The attributes of a tag node, each with what binds it. - public static IEnumerable Attributes( - IntermediateNode node, - MarkupTag tag, - SourceText text - ) - { - return node.Children.Select(child => Attribute(child, tag, text)).OfType(); - } - /// Whether a diagnostic belongs to the processed file, not to one of its imports. public static bool IsIn(RazorDiagnostic diagnostic, string? path, SourceText text) { @@ -137,58 +142,35 @@ public static bool IsIn(RazorDiagnostic diagnostic, string? path, SourceText tex && (string.IsNullOrEmpty(span.FilePath) || NativePaths.AreEqual(span.FilePath, path)); } - private static MarkupAttribute? Attribute( - IntermediateNode child, - MarkupTag tag, - SourceText text + /// + /// An element tag helpers bind. A component's IR carries its start tag name's span; a + /// legacy file's does not, so its name, prefix first, is read just after the element's + /// <, as HTML spells it in any case, and only where the text holds it. + /// + private static MarkupTag? BoundTag( + TagHelperIntermediateNode bound, + TextSpan element, + SourceText text, + string prefix ) { - return child switch - { - TagHelperPropertyIntermediateNode property => new MarkupAttribute( - MarkupAttributeKind.Property, - property.AttributeName, - property.OriginalAttributeSpan is { } span ? Span(span) : null, - tag, - property.TagHelper, - property.BoundAttribute, - null - ), - TagHelperDirectiveAttributeIntermediateNode directive => new MarkupAttribute( - MarkupAttributeKind.Directive, - directive.OriginalAttributeName, - DirectiveName( - directive.OriginalAttributeSpan, - directive.OriginalAttributeName, - text - ), - tag, - directive.TagHelper, - directive.BoundAttribute, - null - ), - TagHelperDirectiveAttributeParameterIntermediateNode parameter => new MarkupAttribute( - MarkupAttributeKind.DirectiveParameter, - parameter.OriginalAttributeName, - DirectiveName( - parameter.OriginalAttributeSpan, - parameter.OriginalAttributeName, - text - ), - tag, - parameter.TagHelper, - parameter.BoundAttribute, - parameter.BoundAttributeParameter - ), - TagHelperHtmlAttributeIntermediateNode html => Html(html.AttributeName, tag), - HtmlAttributeIntermediateNode html => Html(html.AttributeName, tag), - _ => null, - }; - } - - private static MarkupAttribute Html(string name, MarkupTag tag) - { - return new MarkupAttribute(MarkupAttributeKind.Html, name, null, tag, null, null, null); + var legacy = bound.StartTagSpan is null; + var comparison = legacy ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var spelled = legacy ? prefix + bound.TagName : bound.TagName; + var start = bound.StartTagSpan is { } span + ? Span(span) + : new TextSpan(element.Start + 1, spelled.Length); + return Holds(text, start, spelled, comparison) + ? new MarkupTag( + bound.TagName, + element, + start, + bound.TagMode == TagMode.StartTagAndEndTag + ? EndName(element, spelled, text, comparison) + : null, + [.. bound.TagHelpers] + ) + : null; } private static MarkupTag? PlainTag(string name, TextSpan element, SourceText text) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs index f6232e69..29f0964e 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs @@ -39,9 +39,7 @@ ImmutableArray TagHelpers /// The MVC tag helpers bound to this tag. public IEnumerable MvcTagHelpers => - TagHelpers.Where(helper => - helper.Kind is TagHelperKind.ITagHelper or TagHelperKind.ViewComponent - ); + TagHelpers.Where(RazorTagHelperScope.IsMvc); /// Whether lies on the start or end tag's name. public bool NameContains(int offset) @@ -116,6 +114,9 @@ TagHelperCollection TagHelpers /// The C# code blocks (@{ … }) of the file, which fold. [RAZOR-FEATURES-STRUCTURE] public ImmutableArray CodeBlocks { get; init; } = []; + /// The MVC tag helpers the file's directives bring into scope. [RAZOR-MVC-SCOPE] + public RazorTagHelperScope Scope { get; init; } = RazorTagHelperScope.Empty; + /// The caret's HTML context, [RAZOR-ARCHITECTURE-HTML]. public MarkupContext ContextAt(int offset) { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs index 5a443dbb..3072ef54 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs @@ -6,7 +6,8 @@ namespace SharpLsp.Sidecar.CSharp.Razor; /// /// Reads markup facts out of the compiler's IR while it is built. Directives are read first -/// in the directive-classifier phase, before @code becomes plain C#; tags and +/// in the directive-classifier phase, before @code becomes plain C#, and with them the +/// tag helper directives that scope a legacy file's tag helpers ([RAZOR-MVC-SCOPE]); tags and /// attributes first in the optimization phase, before components are lowered and attribute /// names lose their spans; diagnostics last. The IR is mutated after each read, so each /// pass copies what it needs at once. Implements [RAZOR-ARCHITECTURE-ENGINE]. @@ -21,6 +22,8 @@ internal sealed class RazorMarkupCapture private RazorCodeDocument? _codeDocument; private string _htmlText = ""; private TextSpan[] _codeBlocks = []; + private TagHelperDirective[] _tagHelperDirectives = []; + private string _prefix = ""; /// Add the capture passes to an engine. public void Register(RazorProjectEngineBuilder builder) @@ -50,13 +53,16 @@ [.. _diagnostics.Distinct()], { HtmlText = _htmlText, CodeBlocks = [.. _codeBlocks], + Scope = RazorTagHelperScope.Of(_tagHelperDirectives, tagHelpers), }; } private void ReadDirectives(RazorCodeDocument document, DocumentIntermediateNode root) { var text = document.Source.Text; - _htmlText = RazorHtmlProjection.Create(text, root); + _tagHelperDirectives = [.. RazorIr.TagHelperDirectives(root)]; + _prefix = RazorTagHelperScope.Of(_tagHelperDirectives, []).Prefix; + _htmlText = RazorHtmlProjection.Create(text, root, _prefix); _codeBlocks = [.. RazorIr.CodeBlocks(root)]; foreach (var node in RazorIr.Descendants(root).Where(node => !node.IsImported)) { @@ -77,7 +83,7 @@ private void ReadTags(RazorCodeDocument document, DocumentIntermediateNode root) { _namespaces.Add(ns); } - else if (!node.IsImported && RazorIr.Tag(node, text) is { } tag) + else if (!node.IsImported && RazorIr.Tag(node, text, _prefix) is { } tag) { _tags.Add(tag); _attributes.AddRange(RazorIr.Attributes(node, tag, text)); diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs index 1bb56518..8156be5a 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs @@ -6,12 +6,12 @@ namespace SharpLsp.Sidecar.CSharp.Razor; /// -/// Completion in Blazor markup, from the compiler's tag helpers. After <: components, +/// Completion in Razor markup, from the compiler's tag helpers. After <: components, /// by their short name where their namespace is in scope and their full name otherwise, and /// inside a component its child-content parameters. In a start tag: the parameters not yet -/// set, [EditorRequired] ones first and marked, then @bind-X and the directive -/// attributes with their parameters. Each item replaces the name typed at the caret. -/// Implements [RAZOR-FEATURES-COMPLETION]. +/// set, [EditorRequired] ones first and marked, the attributes of the MVC tag helpers in +/// scope, then @bind-X and the directive attributes with their parameters. Each item +/// replaces the name typed at the caret. Implements [RAZOR-FEATURES-COMPLETION] and [RAZOR-MVC]. /// internal static class RazorMarkupCompletion { @@ -47,8 +47,8 @@ int Group var context = markup.ContextAt(offset); var entries = context == MarkupContext.TagName ? TagEntries(markup, offset) - : context == MarkupContext.AttributeName && OwnerAt(markup, offset) is { } tag - ? AttributeEntries(markup, compilation, tag) + : HtmlMarkupContext.StartTagAt(markup.HtmlText, offset) is { } spelled + ? AttributeEntries(markup, compilation, OwnerAt(markup, offset, spelled), spelled) : null; var replace = TextSpan.FromBounds(HtmlMarkupContext.NameStart(markup.Text, offset), offset); return entries is null @@ -134,28 +134,60 @@ private static IEnumerable ChildContents(RazorMarkup markup, MarkupTag pa )); } + /// + /// The attributes not yet set on the start tag spelled : a + /// component's parameters, the attributes its MVC tag helpers in scope bind, and in a + /// component file the directive attributes. + /// private static IEnumerable AttributeEntries( RazorMarkup markup, Compilation compilation, - MarkupTag tag + MarkupTag? tag, + string spelled ) { var set = markup .Attributes.Where(attribute => attribute.Owner == tag) .Select(attribute => attribute.Name) .ToHashSet(StringComparer.Ordinal); - var parameters = tag.Component is { } component ? Parameters(compilation, component) : []; - var directives = markup - .TagHelpers.Where(helper => - DirectiveKinds.Contains(helper.Kind) && Applies(helper, tag) - ) - .SelectMany(DirectiveEntries); + var parameters = tag?.Component is { } component ? Parameters(compilation, component) : []; + var helpers = markup.Scope.Targeting(spelled).SelectMany(TagHelperEntries); + var directives = + markup.FileKind == RazorFileKind.Component + ? markup + .TagHelpers.Where(helper => + DirectiveKinds.Contains(helper.Kind) + && Applies(helper, tag?.Name ?? spelled) + ) + .SelectMany(DirectiveEntries) + : []; return parameters + .Concat(helpers) .Concat(directives) .Where(entry => !set.Contains(entry.Label)) .DistinctBy(entry => entry.Label); } + /// An MVC tag helper's attributes, and each indexer's prefix (asp-route-). + private static IEnumerable TagHelperEntries(TagHelperDescriptor helper) + { + return helper.BoundAttributes.SelectMany(attribute => + new[] + { + (Label: attribute.Name, Type: attribute.TypeName), + (Label: attribute.IndexerNamePrefix, Type: attribute.IndexerTypeName), + } + .Where(named => !string.IsNullOrEmpty(named.Label)) + .Select(named => new Entry( + named.Label!, + "Property", + named.Type, + attribute.Documentation, + 1 + )) + ); + } + /// A component's parameters; one its property marks [EditorRequired] sorts first. private static IEnumerable Parameters( Compilation compilation, @@ -210,24 +242,30 @@ private static IEnumerable DirectiveEntries(TagHelperDescriptor helper) ); } - /// Whether a directive attribute's tag helper matches 's name. - private static bool Applies(TagHelperDescriptor helper, MarkupTag tag) + /// Whether a directive attribute's tag helper targets a tag named . + private static bool Applies(TagHelperDescriptor helper, string name) { - return helper.TagMatchingRules.Any(rule => - rule.TagName == "*" - || string.Equals( - rule.TagName, - tag.Name, - rule.CaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase - ) - ); + return helper.TagMatchingRules.Any(rule => RazorTagHelperScope.Targets(rule, name)); } - /// The tag whose start tag holds : the nearest one before it. - private static MarkupTag? OwnerAt(RazorMarkup markup, int offset) + /// + /// The tag whose start tag holds : the innermost one holding the + /// caret whose name is spelled as the tokenizer read it. A legacy file's plain HTML element + /// is no tag, so none owns its start tag. + /// + private static MarkupTag? OwnerAt(RazorMarkup markup, int offset, string spelled) { return markup - .Tags.Where(tag => tag.StartName.End <= offset && tag.Element.Start < offset) + .Tags.Where(tag => + tag.StartName.End <= offset + && tag.Element.Start < offset + && offset <= tag.Element.End + && string.Equals( + markup.Text.ToString(tag.StartName), + spelled, + StringComparison.OrdinalIgnoreCase + ) + ) .MaxBy(tag => tag.StartName.Start); } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorTagHelperScope.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorTagHelperScope.cs new file mode 100644 index 00000000..8cb5de17 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorTagHelperScope.cs @@ -0,0 +1,121 @@ +using System.Collections.Immutable; +using Microsoft.AspNetCore.Razor.Language; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// A tag helper directive of a legacy file or of an import: its name and its text. +internal sealed record TagHelperDirective(string Name, string Text); + +/// +/// The MVC tag helpers a legacy file can use and the prefix their tags carry: what its +/// @addTagHelper, @removeTagHelper and @tagHelperPrefix directives +/// declare, its imports' first, read as the compiler reads them. Implements [RAZOR-MVC-SCOPE]. +/// +internal sealed record RazorTagHelperScope( + string Prefix, + ImmutableArray TagHelpers +) +{ + private const string Add = "addTagHelper"; + private const string Remove = "removeTagHelper"; + private const string PrefixDirective = "tagHelperPrefix"; + private const string Global = "global::"; + + /// No tag helpers and no prefix: a component file's scope. + public static RazorTagHelperScope Empty { get; } = new("", []); + + /// The scope declare, in order, over . + public static RazorTagHelperScope Of( + IEnumerable directives, + IEnumerable all + ) + { + var mvc = all.Where(IsMvc).ToArray(); + return directives.Aggregate(Empty, (scope, directive) => scope.Apply(directive, mvc)); + } + + /// Whether a directive named declares the scope. + public static bool Declares(string name) + { + return name is Add or Remove or PrefixDirective; + } + + /// Whether a tag helper is an MVC one, which the directives scope, not a component document's. + public static bool IsMvc(TagHelperDescriptor helper) + { + return helper.Kind is TagHelperKind.ITagHelper or TagHelperKind.ViewComponent; + } + + /// Whether targets elements named . + public static bool Targets(TagMatchingRuleDescriptor rule, string tagName) + { + return rule.TagName is null or "*" + || string.Equals( + rule.TagName, + tagName, + rule.CaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase + ); + } + + /// The tag helpers in scope that target a tag spelled , prefix included. + public IEnumerable Targeting(string tagName) + { + return tagName.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase) + ? TagHelpers.Where(helper => + helper.TagMatchingRules.Any(rule => Targets(rule, tagName[Prefix.Length..])) + ) + : []; + } + + private RazorTagHelperScope Apply(TagHelperDirective directive, TagHelperDescriptor[] mvc) + { + var text = Unquoted(directive.Text.Trim()); + return directive.Name switch + { + Add => this with { TagHelpers = [.. TagHelpers.Union(Matching(text, mvc))] }, + Remove => this with { TagHelpers = [.. TagHelpers.Except(Matching(text, mvc))] }, + PrefixDirective when text.Length > 0 => this with { Prefix = text }, + _ => this, + }; + } + + /// A directive's text without the quotes it may be written in. + private static string Unquoted(string text) + { + return text.Length >= 2 && text[0] == '"' && text[^1] == '"' ? text[1..^1] : text; + } + + /// + /// The tag helpers a TypePattern, AssemblyName lookup names: all of the assembly's + /// for *, those whose full name starts with what precedes a trailing *, else + /// the one so named. A lookup the compiler rejects names none. + /// + private static IEnumerable Matching( + string lookup, + TagHelperDescriptor[] mvc + ) + { + var parts = lookup.Split(',', StringSplitOptions.TrimEntries); + var valid = + parts is [{ Length: > 0 }, { Length: > 0 }] + && !lookup.StartsWith('\'') + && !lookup.EndsWith('\''); + var pattern = + valid && parts[0].StartsWith(Global, StringComparison.Ordinal) + ? parts[0][Global.Length..] + : parts[0]; + return valid + ? mvc.Where(helper => helper.AssemblyName == parts[1] && Matches(pattern, helper.Name)) + : []; + } + + private static bool Matches(string pattern, string name) + { + return pattern == "*" + || ( + pattern.Length > 1 && pattern[^1] == '*' + ? name.StartsWith(pattern[..^1], StringComparison.Ordinal) + : name == pattern + ); + } +} From 052d32c3226b481ad00578cd271758c7836acf39 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:12:22 +1100 Subject: [PATCH 2/4] Razor: Pages and MVC, F# in Razor, editing features, collocated files, WebAssembly debugging and Hot Reload Completes the Razor plan: - Razor Pages and MVC views: tag helper hover, definition, completion and scope; @model, @inject and @functions through the projection. - F# in Razor solutions: hover with F# docs, definition into the F# source, and references and rename across F#, C# and Razor, found by assembly and XML doc identity. - Editing features from Razor positions: selection ranges, code actions (add @using, generate a handler, extract to code-behind or a component), inlay hints, code lens, and call and type hierarchy. C# hierarchy kinds are PascalCase, as the host maps them. - Collocated files: .razor.css classes complete as LSP values and define to their selectors, an "import" call defines to its .razor.js, and VS Code nests the files. The attribute at a caret is read by the HTML tokenizer. - Blazor WebAssembly launches through the sharplsp-blazorwasm debugger and js-debug. - Razor Hot Reload: a Razor save swaps the additional document in every framework copy. A re-save of committed text answers applied with nothing to apply, for .cs saves too. - Review fixes: the host maps the Value completion kind, the F# Razor e2e builds its F# library, and fixture copies and hierarchy runners are shared. --- docs/plans/RAZOR-PLAN.md | 64 ++- docs/specs/DEBUGGING-SPEC.md | 43 +- docs/specs/DEFINITION-SPEC.md | 47 +- docs/specs/RAZOR-SPEC.md | 190 ++++++- docs/specs/REFERENCES-SPEC.md | 11 +- docs/specs/RENAME-SPEC.md | 2 + src/editors/vscode/package.json | 71 ++- src/editors/vscode/src/blazor-wasm-debug.ts | 443 ++++++++++++++++ src/editors/vscode/src/blazor-wasm-launch.ts | 468 +++++++++++++++++ src/editors/vscode/src/dap-hot-reload.ts | 10 +- src/editors/vscode/src/debug.ts | 17 + src/editors/vscode/src/hot-reload.ts | 5 +- src/editors/vscode/src/launch-profiles.ts | 12 + src/editors/vscode/src/razor-html.ts | 14 +- .../blazor-wasm-debug-resolution.test.ts | 479 ++++++++++++++++++ .../src/test/suite/blazor-wasm-debug.test.ts | 286 +++++++++++ .../test/suite/debug-hot-reload-e2e.test.ts | 60 +++ .../src/test/suite/dotnet-project-kit.ts | 21 + .../test/suite/fsi-build-output-e2e.test.ts | 27 +- .../vscode/src/test/suite/razor-kit.ts | 29 +- .../vscode/src/test/suite/razor-lsp.test.ts | 160 ++++++ .../suite/run-debug-contributions.test.ts | 11 +- src/editors/vscode/test-chunks.json | 4 +- src/sharplsp/src/call_hierarchy.rs | 1 + src/sharplsp/src/code_actions.rs | 177 ++++++- src/sharplsp/src/cross_language.rs | 305 +++++++++++ src/sharplsp/src/main.rs | 8 +- src/sharplsp/src/razor.rs | 103 +++- src/sharplsp/src/semantic.rs | 141 ++++-- .../e2e_modules/definition_cross_language.rs | 62 ++- .../e2e_modules/fixtures_cross_language.rs | 2 +- .../tests/e2e_modules/fixtures_razor.rs | 18 + src/sharplsp/tests/e2e_modules/mod.rs | 5 + src/sharplsp/tests/e2e_modules/razor.rs | 29 +- .../tests/e2e_modules/razor_collocation.rs | 175 +++++++ .../tests/e2e_modules/razor_fsharp.rs | 341 +++++++++++++ .../tests/e2e_modules/razor_hierarchy.rs | 191 +++++++ src/sharplsp/tests/e2e_modules/razor_mvc.rs | 143 ++++++ .../tests/e2e_modules/razor_selection.rs | 179 +++++++ .../tests/fixtures/blazor-wasm/App.razor | 9 + .../fixtures/blazor-wasm/BlazorWasmApp.csproj | 17 + .../blazor-wasm/Directory.Build.props | 6 + .../blazor-wasm/Directory.Build.targets | 4 + .../fixtures/blazor-wasm/Pages/Counter.razor | 14 + .../tests/fixtures/blazor-wasm/Program.cs | 11 + .../Properties/launchSettings.json | 11 + .../tests/fixtures/blazor-wasm/_Imports.razor | 5 + .../fixtures/blazor-wasm/wwwroot/index.html | 23 + .../BlazorApp/Components/Pages/Leaders.razor | 4 +- .../BlazorApp/Components/Shared/Card.razor | 36 ++ .../BlazorApp/Components/Shared/Card.razor.cs | 28 + .../Components/Shared/Card.razor.css | 52 ++ .../BlazorApp/Components/Shared/Card.razor.js | 10 + .../Components/Shared/CardInterop.cs | 17 + .../BlazorApp/Components/Shared/Counting.cs | 14 + .../BlazorApp/Components/Shared/Ticker.razor | 19 + .../tests/fixtures/razor/Domain/Scores.fs | 18 +- .../CallHierarchyKindTests.cs | 49 ++ .../HeadlessOverrideGenerationTests.cs | 16 +- .../HotReloadSessionRegistryTests.cs | 27 + .../HtmlMarkupContextTests.cs | 66 +++ .../MergeDeclarationAssignmentTests.cs | 16 +- .../NavigationEndToEndTests.cs | 2 +- .../RazorCodeActionsTests.cs | 349 +++++++++++++ .../RazorCollocationTests.cs | 301 +++++++++++ .../RazorCompletionEditsTests.cs | 48 +- .../RazorFSharpTests.cs | 310 ++++++++++++ .../RazorHierarchyTests.cs | 191 +++++++ .../RazorHintsAndLensesTests.cs | 136 +++++ .../RazorHotReloadTests.cs | 366 +++++++++++++ .../RazorMvcTests.cs | 107 ++++ .../RazorSelectionRangesTests.cs | 288 +++++++++++ .../RazorSidecarTests.cs | 96 ++++ .../RazorStylesheetTests.cs | 196 +++++++ .../TextEditing.cs | 23 + .../WorkspaceManagerExtraCoverageTests.cs | 14 +- .../WorkspaceManagerFeatureCoverageTests.cs | 19 +- .../WorkspaceManagerForeignRenameTests.cs | 19 +- .../CSharpSidecar.ForeignRename.cs | 19 + .../CSharpSidecar.Razor.cs | 62 +++ .../SharpLsp.Sidecar.CSharp/CSharpSidecar.cs | 2 + .../Debugging/HotReloadSession.cs | 115 ++++- .../Debugging/HotReloadSignatureGuard.cs | 21 +- .../ForeignRenameMessages.cs | 14 + .../SharpLsp.Sidecar.CSharp/Messages.cs | 11 + .../Razor/CssTokenizer.cs | 377 ++++++++++++++ .../Razor/HtmlMarkupContext.Attributes.cs | 108 ++++ .../Razor/HtmlMarkupContext.cs | 23 +- .../Razor/RazorCollocation.cs | 240 +++++++++ .../Razor/RazorCompletionEdits.cs | 45 +- .../Razor/RazorExtractActions.cs | 330 ++++++++++++ .../Razor/RazorHandlerText.cs | 64 +++ .../Razor/RazorHints.cs | 94 ++++ .../Razor/RazorIr.Attributes.cs | 1 + .../Razor/RazorMarkup.cs | 7 +- .../Razor/RazorMarkupCompletion.cs | 34 +- .../Razor/RazorQuickFixes.cs | 244 +++++++++ .../Razor/RazorSelectionRanges.cs | 134 +++++ .../Razor/RazorStylesheet.cs | 111 ++++ .../Workspace/CallHierarchyResolver.cs | 71 ++- .../Workspace/CodeActionResolver.cs | 1 + .../Workspace/CodeLensResolver.cs | 43 +- .../Workspace/DefinitionResolver.cs | 25 +- .../Workspace/FSharpProjectSymbols.cs | 133 +++++ .../Workspace/InlayHintResolver.cs | 22 +- .../Workspace/TypeHierarchyResolver.cs | 10 +- .../Workspace/WorkspaceManager.Features.cs | 38 +- .../WorkspaceManager.ForeignReferences.cs | 67 +++ .../WorkspaceManager.ForeignRename.cs | 33 +- .../Workspace/WorkspaceManager.Helpers.cs | 6 +- .../Workspace/WorkspaceManager.Hierarchy.cs | 127 ++++- .../Workspace/WorkspaceManager.Navigation.cs | 79 ++- .../WorkspaceManager.RazorCodeActions.cs | 113 +++++ .../WorkspaceManager.RazorCollocation.cs | 157 ++++++ .../WorkspaceManager.RazorHierarchy.cs | 240 +++++++++ .../Workspace/WorkspaceManager.RazorMarkup.cs | 11 +- .../WorkspaceManager.RazorSelection.cs | 76 +++ .../Workspace/WorkspaceManager.Rename.cs | 55 +- .../Workspace/WorkspaceManager.cs | 20 +- .../NativePaths.Razor.cs | 43 ++ .../Solutions/ProjectReferences.cs | 4 +- .../FSharpForeignSymbolsTests.fs | 157 ++++++ .../FSharpForeignSymbolsWireTests.fs | 76 +++ .../SharpLsp.Sidecar.FSharp.Tests.fsproj | 2 + .../FSharpForeignSymbols.fs | 124 +++++ .../FSharpReferences.fs | 52 +- .../SharpLsp.Sidecar.FSharp/FSharpRename.fs | 64 +-- .../SharpLsp.Sidecar.FSharp/FSharpSidecar.fs | 5 + .../SharpLsp.Sidecar.FSharp/FSharpWire.fs | 22 + .../SharpLsp.Sidecar.FSharp.fsproj | 3 +- 130 files changed, 10735 insertions(+), 491 deletions(-) create mode 100644 src/editors/vscode/src/blazor-wasm-debug.ts create mode 100644 src/editors/vscode/src/blazor-wasm-launch.ts create mode 100644 src/editors/vscode/src/test/suite/blazor-wasm-debug-resolution.test.ts create mode 100644 src/editors/vscode/src/test/suite/blazor-wasm-debug.test.ts create mode 100644 src/sharplsp/src/cross_language.rs create mode 100644 src/sharplsp/tests/e2e_modules/razor_collocation.rs create mode 100644 src/sharplsp/tests/e2e_modules/razor_fsharp.rs create mode 100644 src/sharplsp/tests/e2e_modules/razor_hierarchy.rs create mode 100644 src/sharplsp/tests/e2e_modules/razor_mvc.rs create mode 100644 src/sharplsp/tests/e2e_modules/razor_selection.rs create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/App.razor create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/BlazorWasmApp.csproj create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.props create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.targets create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/Pages/Counter.razor create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/Program.cs create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/Properties/launchSettings.json create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/_Imports.razor create mode 100644 src/sharplsp/tests/fixtures/blazor-wasm/wwwroot/index.html create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.cs create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.css create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.js create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/CardInterop.cs create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Counting.cs create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Ticker.razor create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/CallHierarchyKindTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCodeActionsTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCollocationTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorFSharpTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHierarchyTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHintsAndLensesTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHotReloadTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorSelectionRangesTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorStylesheetTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/TextEditing.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/CssTokenizer.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.Attributes.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCollocation.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorExtractActions.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHandlerText.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHints.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorQuickFixes.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSelectionRanges.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorStylesheet.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FSharpProjectSymbols.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignReferences.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCodeActions.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCollocation.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHierarchy.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorSelection.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsTests.fs create mode 100644 src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsWireTests.fs create mode 100644 src/sidecars/SharpLsp.Sidecar.FSharp/FSharpForeignSymbols.fs diff --git a/docs/plans/RAZOR-PLAN.md b/docs/plans/RAZOR-PLAN.md index 561a3f3f..5197f243 100644 --- a/docs/plans/RAZOR-PLAN.md +++ b/docs/plans/RAZOR-PLAN.md @@ -20,7 +20,7 @@ The phases below are in dependency order. Each item is checked only when a test - [x] Fixture solution `src/sharplsp/tests/fixtures/razor/` with `BlazorApp`, `Widgets` (RCL), `PagesApp` (Pages + MVC) and `Domain` (F#), isolated by its own `Directory.Build.props` ([RAZOR-TESTING]) - [x] Host: add `LangId::Razor` for `.razor` and `.cshtml`, owned by the C# sidecar ([RAZOR-ARCHITECTURE-ROUTING]) -- [x] Host: route `documentSymbol`, `foldingRange` and `linkedEditingRange` for Razor to the sidecar, and answer `selectionRange` empty +- [x] Host: route `documentSymbol`, `foldingRange` and `linkedEditingRange` for Razor to the sidecar, and answer `selectionRange` empty (the Deferred selection-range item has since routed it to the sidecar too) - [x] Host: add `<` and `@` completion triggers, and short-circuit them for C# and F# documents - [x] Host: publish Razor diagnostics under the source `sharplsp-razor` - [x] Sidecar: bundle the Razor compiler from the build SDK, and list it in `THIRD-PARTY-NOTICES.md` ([RAZOR-ARCHITECTURE-ENGINE]) @@ -76,7 +76,7 @@ The phases below are in dependency order. Each item is checked only when a test - Renaming `Badge.Text` from its code-behind edits `@Text` and Board's `Text=`. - Renaming `Tally.Label` turns `@bind-Label` into `@bind-Title`. - prepareRename refuses on a component's start and end tags. - - The F# half of the rename is Phase 6 + - The F# half of the rename is Phase 5 ### Phase 3: Blazor Markup @@ -110,11 +110,30 @@ The phases below are in dependency order. Each item is checked only when a test ### Phase 4: Razor Pages and MVC -- [ ] `@model`, `@inject`, `@section` and `@functions` through the projection ([RAZOR-MVC]) -- [ ] Tag helper elements and attributes: hover and completion -- [ ] `_ViewImports.cshtml` imports resolved by the engine - -### Phase 5: Editors +- [x] `@model`, `@inject`, `@section` and `@functions` through the projection ([RAZOR-MVC-DIRECTIVES]) + - `RazorMvcTests.Model_inject_and_functions_answer_through_the_projection`: `@model EditModel` and `Model.Title` hover their docs, `@inject` hovers `ILogger<…>`, and `@Shout` goes to its `@functions` declaration. + - `Model_expressions_and_section_code_get_csharp_features`: `asp-for="Title"` hovers and goes to `EditModel.Title`, finds its 4 references in `Edit.cshtml`, and renames them all with no generated-file edit; `@Model.Price` inside `@section` hovers. `Section_blocks_fold_and_outline` folds and outlines `@section`. + - Full stack: `test_full_stack_razor_pages_and_mvc_views` (`razor_mvc.rs`). +- [x] Tag helper elements and attributes: hover and completion ([RAZOR-MVC-TAGHELPERS], [RAZOR-MVC-SCOPE]) + - Hover: `Tag_helper_elements_and_attributes_answer_hover_from_their_descriptors` (`` answers `InputTagHelper`, `asp-for` its `ModelExpression`). + - Completion: `Tag_helper_attributes_complete_in_an_element_start_tag` (`asp-page`, `asp-controller`, `asp-route-`) and `Tag_helper_elements_complete_after_an_open_angle_bracket` (`partial`, `environment`, `a` once, the `th:` prefix, `@removeTagHelper`). That test found components completing after `<` in `.cshtml` files, where no component binds by tag; they no longer do. + - Scope: `Tag_helper_directives_scope_completion_and_binding`. VS Code: `a Razor Page completes tag helper elements once, before the HTML service` (an element that both HTML and a tag helper name is listed once). +- [x] `_ViewImports.cshtml` imports resolved by the engine ([RAZOR-MVC-IMPORTS]) — `ViewImports_imports_reach_the_views_below_them`: `@model Item` resolves through `Views/_ViewImports.cshtml`'s `@using`, the view reports no error, and its `@addTagHelper` binds `` + +### Phase 5: F# + +- [x] Hover, completion and definition from Razor into F# types of a referenced F# project ([RAZOR-FSHARP-METADATA], [RAZOR-FSHARP-DEFINITION]) + - Hover shows the F# XML docs: the design-time build references Domain's `refint` assembly, which has no XML, so the sidecar swaps in the built DLL with its documentation. `RazorFSharpTests.Hover_and_signature_help_read_FSharp_symbols_from_their_assembly` and `Completion_offers_FSharp_members_in_markup_and_code`. + - Definition lands in `Scores.fs`, not decompiled source: the C# sidecar names each F# symbol by assembly and XML doc ID, and the F# sidecar finds its declaration (`workspace/declarationForeign`, [DEFINITION-CROSSLANG-IDENTITY]). `Definition_names_each_FSharp_symbol_for_the_FSharp_sidecar`; F#: `FSharpForeignSymbolsTests`. The C#→F# definition e2e now lands in `Shapes.fs` too. + - References merge both sides by the same identity (`workspace/referencesForeign`, [REFERENCES-CROSS-LANGUAGE]): `Foreign_references_and_rename_reach_Razor_uses_of_an_FSharp_symbol`. + - F# references and highlights placed a dotted use (`player.Score`) over its whole path. They now sit on the name, the same span the foreign path reports (`F# references and foreign uses place each use on its name alike`). +- [x] F#-origin rename that includes Razor usages, mapped back from the projections ([RAZOR-FSHARP-RENAME]) + - From F#: `Foreign_references_and_rename_reach_Razor_uses_of_an_FSharp_symbol` renames `Leaders.razor`'s `@player.Score`. + - From Razor or C#: the rename resolves to the F# symbol and renames it everywhere (`Rename_from_Razor_renames_an_FSharp_symbol_in_CSharp_and_Razor`). + - Full stack: `test_full_stack_fsharp_in_razor_session` (hover, definition into `Scores.fs`, references with and without the declaration, rename from Razor and from F#). VS Code: `F# symbols in Razor navigate to, find and rename in their F# source`. + - That full-stack test only restored the fixture, so C# had no `Domain.dll` to read and every F# symbol stayed unresolved ([DEFINITION-CROSSLANG]: the F# project must be built). Its workspace now builds the F# library (`create_razor_workspace_with_fsharp_built`). + +### Phase 6: Editors - [x] VS Code: sync `razor` and `aspnetcorerazor` documents, and activate on `onLanguage:razor` ([RAZOR-EDITORS]) — `razor-lsp.test.ts`: a Razor file opens as Razor, and its unsaved edits reach the server - [x] VS Code: forward HTML completion and hover over `sharplsp/razor/htmlProjection` ([RAZOR-FEATURES-HTML]) — `razor-lsp.test.ts`: element names and HTML hovers in markup, C# completion and hover in code @@ -122,16 +141,21 @@ The phases below are in dependency order. Each item is checked only when a test - [x] VS Code: add a `razor` end-to-end chunk on both platforms — `test-chunks.json`, in both matrices - [x] Zed: Razor language registration (Zed has no built-in Razor grammar) — `the_server_attaches_to_the_csharp_fsharp_and_razor_languages` -### Phase 6: F# - -- [ ] Hover, completion and definition from Razor into F# types of a referenced F# project ([RAZOR-FSHARP]) -- [ ] F#-origin rename that includes Razor usages, mapped back from the projections - -### Deferred ([RAZOR-FEATURES-DEFERRED]) - -- [ ] Razor formatting -- [ ] Selection ranges -- [ ] Code actions: extract a block to code-behind, extract a component, generate an event handler, add `@using` -- [ ] Inlay hints, code lens, and call and type hierarchy from Razor positions -- [ ] `.razor.css` and `.razor.js` collocation awareness -- [ ] Blazor WebAssembly debugging and Razor Hot Reload +### Deferred + +- [x] Razor formatting — resolved as out of scope: SharpLsp formats no language ([SHARPLSP-FEATURES-FORMATTING]), so the spec now says so for Razor ([RAZOR-SCOPE-FORMATTING]) and nothing is advertised +- [x] Selection ranges ([RAZOR-FEATURES-SELECTION]) — `RazorSelectionRangesTests` (6 cases: C# chains through syntax nodes, tag names through enclosing elements, attribute names and values, implicit expressions, legacy files, unlisted files); `test_full_stack_razor_selection_ranges`; the routing session's `selectionRange` now asserts the exact chain. An attribute's whole span comes from the compiler's spans (a new `MarkupAttribute.End` fact), not a scan of the text +- [x] Code actions: extract a block to code-behind, extract a component, generate an event handler, add `@using` ([RAZOR-CODE-ACTIONS]) — `RazorCodeActionsTests` (5 cases). A new `@using` goes after a Razor Page's `@page` line, never above it (`A_new_import_follows_the_page_directive_a_Razor_Page_opens_with`). New files arrive as LSP `CreateFile` operations (`map_workspace_edit_creates_new_files_before_editing_them`) +- [x] Inlay hints, code lens, and call and type hierarchy from Razor positions ([RAZOR-FEATURES-INLAY-HINTS], [RAZOR-FEATURES-CODE-LENS], [RAZOR-FEATURES-HIERARCHY]) — `RazorHintsAndLensesTests` (2), `RazorHierarchyTests` (3), `test_full_stack_razor_hierarchy_and_hints` + - Outgoing calls from a Razor projection threw: the resolver read the declaration through `Solution.GetDocument`, which is null for a source-generated document. It now reads each declaration through its own tree + - Markup callers are the component, named by its file with kind `Class`, never the generated `BuildRenderTree` + - The C# sidecar named hierarchy kinds in lowercase, while the host maps PascalCase (the F# sidecar's casing). So every C# class, property or interface item reached the client as a method or class. The C# kinds are now PascalCase, and the e2e asserts the LSP kinds +- [x] `.razor.css` and `.razor.js` collocation awareness ([RAZOR-COLLOCATION]) — `RazorCollocationTests` (6), `RazorStylesheetTests` (10), `HtmlMarkupContextTests` (attribute at a caret), `test_full_stack_razor_collocated_styles_and_scripts`, VS Code `a component completes and defines its stylesheet classes, and nests its collocated files` + - Classes come from a CSS Syntax Level 3 tokenizer over rule preludes; a class completes as LSP `Value` and defines to every selector that declares it. A module literal in an `"import"` call defines to its `.razor.js`, from Razor, a code-behind and plain C# + - The attribute at a caret is read by the HTML tokenizer, continued past the caret. Review found three bugs in a text-scanning first version: an attribute after a bare one (``, `
`, ``) is a tag of the file's markup facts, with the tag helpers bound to it. Its attributes that the tag helpers bind (`asp-for`, `asp-page`, `asp-route-*`) are `Property` attributes. +- **Hover:** a bound element answers the tag helper type's hover, and a bound attribute the hover of the property it sets. +- **Completion after `<`:** each element a tag helper in scope targets by name, spelled with the scope's prefix, once, as a `Class` item whose detail names the tag helpers that bind it. A rule that targets every element (`*`) names no element. The client drops its own HTML item with the same label. +- **Completion in a start tag:** the attributes that the tag helpers targeting the element bind, not yet set, and each indexer's prefix (`asp-route-`). +- **Model expressions:** an attribute whose property is a `ModelExpression` (`asp-for="Title"`) holds a C# model expression, so it gets C# hover, definition, references and rename through the projection. Its value is blank in the HTML projection; every other literal value stays. + +### [RAZOR-MVC-SCOPE] Tag Helper Scope + +The tag helpers a legacy file can use are those its imports' and its own `@addTagHelper`, `@removeTagHelper` and `@tagHelperPrefix` directives declare, applied in order, as the compiler applies them: + +- `@addTagHelper TypePattern, Assembly` adds the assembly's tag helpers whose full name the pattern names: all of them for `*`, those whose name starts with what precedes a trailing `*`, or the one so named. A `global::` prefix is ignored. A lookup the compiler rejects adds none. +- `@removeTagHelper` removes the tag helpers its lookup names, wherever in the file it is written. +- `@tagHelperPrefix p:` makes only elements spelled `p:name` tag helper elements. They bind, complete and answer hover under the prefixed name, and unprefixed elements do none of it. +- A component file has no tag helper scope: its MVC tag helpers never bind. A legacy file binds no component by its tag, so components never complete after `<` there. + +### [RAZOR-MVC-IMPORTS] Imports + +- `_ViewImports.cshtml` is an import. Its `@using`, `@addTagHelper` and other directives reach every view and page in its folder and below, read from the workspace's current text ([RAZOR-ARCHITECTURE-ENGINE]). +- `_ViewStart.cshtml`, layouts and partials are ordinary legacy files. ## [RAZOR-FSHARP] F# in Razor Solutions -Blazor front ends often sit over F# domain libraries. A Razor project that references an F# project reads it as its built DLL, like any C# project does ([DEFINITION-CROSSLANG]). +Blazor front ends often sit over F# domain libraries. A Razor project that references an F# project reads it as its built DLL, like any C# project does ([DEFINITION-CROSSLANG]). Every feature below reaches the F# project through the symbol's identity, its assembly and XML doc signature, so F# symbols in Razor files are as navigable and renameable as C# ones ([DEFINITION-CROSSLANG-IDENTITY]). + +### [RAZOR-FSHARP-METADATA] Hover, Completion and Signature Help + +- These work on F# types and members used in Razor, through Domain's metadata: records and their fields, unions and their cases' tests (`IsLeading`), and module functions. +- Hover shows the F# documentation, read from the F# project's XML documentation file. +- A curried F# function is a method of all its parameters to C#, so signature help lists them all. + +### [RAZOR-FSHARP-DEFINITION] Definition + +- Definition, type definition and declaration on an F# symbol from Razor land on its F# declaration in the `.fs` file, ranged on its name. +- A record's or union's constructor (`new Player(…)`) lands on its type. +- Until the F# sidecar has loaded, the answer is the decompiled source, and it is not cached. + +### [RAZOR-FSHARP-REFERENCES] References + +- References from Razor on an F# symbol hold its Razor and C# uses, its F# uses and, when asked for, its F# declaration ([REFERENCES-CROSS-LANGUAGE]). +- References that start in F# hold the symbol's C# and Razor uses too. Each Razor use is mapped back to its Razor file and never reported in generated code. + +### [RAZOR-FSHARP-RENAME] Rename + +- **From F#:** renaming an F# symbol renames its Razor uses too. The Rust host asks the C# sidecar for the C# uses of the symbol ([RENAME-CROSSLANGUAGE]). Those uses include the projections, and each one is mapped back to its Razor file, never edited in generated code. +- **From Razor or C#:** `prepareRename` accepts an F# symbol on its name. The C# sidecar edits its C# and Razor uses, and the F# sidecar renames its declaration and F# uses, in one workspace edit. +- **A C# symbol from Razor:** the identity a rename sends to the F# sidecar resolves through the projection, so the symbol's F# uses are renamed too. + +### [RAZOR-FSHARP-LANGUAGE] No F# Razor -- **Hover, completion and signature help:** these work on F# types and members used in Razor through that metadata. -- **Definition:** definition on an F# symbol from Razor lands where it lands from C#: in decompiled metadata-as-source for the symbol's type ([DEFINITION-CROSSLANG]). -- **Rename from F#:** renaming an F# symbol renames its Razor usages too. The Rust host asks the C# sidecar for the C# usages of the symbol ([RENAME-CROSSLANGUAGE]). Those usages include the projections, and each one is mapped back to its Razor file, never edited in generated code. -- **References from F#:** references that start in F# reach C# and Razor usages only where they reach C# usages today ([DEFINITION-CROSSLANG]). -- **No F# Razor:** F# is not a Razor code language. A Razor `@code` block is C#. +F# is not a Razor code language, because the Razor compiler generates only C#. A Razor `@code` block is C#. ## [RAZOR-EDITORS] Editors diff --git a/docs/specs/REFERENCES-SPEC.md b/docs/specs/REFERENCES-SPEC.md index 68a0c758..8188dc22 100644 --- a/docs/specs/REFERENCES-SPEC.md +++ b/docs/specs/REFERENCES-SPEC.md @@ -131,7 +131,7 @@ F# references MUST search every compile item in the loaded project; a file-local 3. From the `FSharpSymbolUse.Symbol`, call [`GetUsesOfSymbolInFile()`](https://fsharp.github.io/fsharp-compiler-docs/) for document-scoped results. 4. For project-wide results, iterate all project files and call `GetAllUsesOfAllSymbolsInFile()` on each, filtering to the target symbol by `FSharpSymbol` equality. 5. If `context.includeDeclaration` is true, include the symbol's declaration range. -6. Map each `FSharpSymbolUse.Range` to LSP `Location`. +6. Map each use to an LSP `Location` on the symbol's name. FCS ranges a dotted use (`player.Score`) over the whole path, so the name is the identifier token at the range's end that spells the symbol's name; a use no token spells keeps FCS's range. Document highlights use the same name ranges. ### textDocument/documentHighlight `[REFERENCES-FSHARP-HIGHLIGHT]` @@ -158,10 +158,13 @@ When a C# project references an F# project (or vice versa), find-all-references | Scenario | Approach | |---|---| -| C# symbol used in F# code | C# sidecar finds references in C# projects → Rust host also dispatches to F# sidecar for F# projects | -| F# symbol used in C# code | F# sidecar finds references in F# projects → Rust host also dispatches to C# sidecar for C# projects | +| C# symbol used in F# code | C# sidecar finds references in C# projects and Razor files → the Rust host asks it for the symbol's identity, then asks the F# sidecar for the F# uses of that identity | +| F# symbol used in C# or Razor code | F# sidecar finds references in F# projects → the Rust host asks it for the symbol's identity, then asks the C# sidecar for the C# and Razor uses of that identity | +| F# symbol, asked from a C# or Razor use | C# sidecar finds the C# and Razor uses of the metadata symbol → the Rust host asks the F# sidecar for its F# declaration and uses by the same identity | -The Rust host merges results from both sidecars and deduplicates by location. +- **Identity:** the symbol's assembly and XML doc signature, from `textDocument/renameIdentity` ([DEFINITION-CROSSLANG-IDENTITY]). A Razor position resolves through its projection. +- **Request:** `workspace/referencesForeign` (`{ assemblyName, xmlDocSig, includeDeclaration }`) answers a location list. The C# sidecar searches every C# project and maps Razor uses back to their Razor files, dropping hidden scaffolding. The F# sidecar searches only the project that builds the assembly and its in-memory readers, or, for an assembly no F# project builds, the F# projects that reference it. It never walks every file of the workspace. +- **Merge:** the Rust host appends the other side's locations to the primary's, each place once. A missing, loading or failing other sidecar adds nothing, so the primary answer always stands. ## Extended Results `[REFERENCES-EXTENSIONS]` diff --git a/docs/specs/RENAME-SPEC.md b/docs/specs/RENAME-SPEC.md index a678b7c9..c260eba2 100644 --- a/docs/specs/RENAME-SPEC.md +++ b/docs/specs/RENAME-SPEC.md @@ -119,6 +119,8 @@ Public symbols used across C# and F# project boundaries MUST be renamed across b - C# symbol renamed with F# references: C# sidecar computes C# edits; Rust host asks F# sidecar for F# usages and merges edits. - F# symbol renamed with C# references: F# sidecar computes F# edits; Rust host asks C# sidecar for C# usages and merges edits. +- F# symbol renamed from a C# or Razor use: C# reads it from the F# project's DLL. `prepareRename` accepts it on its name, and the C# sidecar computes the C# and Razor edits by the symbol's identity ([DEFINITION-CROSSLANG-IDENTITY]); the Rust host asks the F# sidecar to rename the declaration and its F# uses, and merges the edits. +- A rename asked from a Razor file resolves the symbol's identity through the file's projection, so its other-language uses are renamed too ([RAZOR-FSHARP-RENAME]). - Conflicting or overlapping edits MUST fail the rename with a clear error instead of returning a partial edit. ## [RENAME-ERRORS] Error Handling diff --git a/src/editors/vscode/package.json b/src/editors/vscode/package.json index 7b08a348..e45472dd 100644 --- a/src/editors/vscode/package.json +++ b/src/editors/vscode/package.json @@ -42,7 +42,9 @@ "workspaceContains:**/*.fsproj", "onLanguage:razor", "onDebugResolve:sharplsp-coreclr", - "onDebugDynamicConfigurations:sharplsp-coreclr" + "onDebugDynamicConfigurations:sharplsp-coreclr", + "onDebugResolve:sharplsp-blazorwasm", + "onDebugDynamicConfigurations:sharplsp-blazorwasm" ], "main": "./dist/extension.js", "capabilities": { @@ -365,6 +367,12 @@ } } }, + "configurationDefaults": { + "explorer.fileNesting.patterns": { + "*.razor": "${capture}.razor.cs, ${capture}.razor.css, ${capture}.razor.js", + "*.cshtml": "${capture}.cshtml.cs, ${capture}.cshtml.css, ${capture}.cshtml.js" + } + }, "debuggers": [ { "type": "sharplsp-coreclr", @@ -573,6 +581,67 @@ "stopAtEntry": false } ] + }, + { + "type": "sharplsp-blazorwasm", + "label": "Blazor WebAssembly", + "configurationAttributes": { + "launch": { + "required": [], + "properties": { + "project": { + "type": "string", + "description": "The Blazor WebAssembly .csproj/.fsproj to run. Omit to use the project the active document belongs to." + }, + "launchProfile": { + "type": "string", + "description": "The Properties/launchSettings.json profile to run, by name. Omit to use the project's only Project profile." + }, + "browser": { + "type": "string", + "enum": [ + "chrome", + "edge" + ], + "default": "chrome", + "description": "The browser to debug, through VS Code's built-in JavaScript debugger." + }, + "url": { + "type": "string", + "description": "The page to open. Omit to use the address the DevServer announces." + }, + "webRoot": { + "type": "string", + "description": "The directory served URLs map onto. Omit to use the project's wwwroot." + }, + "timeout": { + "type": "number", + "default": 30000, + "description": "How long the browser debugger waits for the page to load." + } + } + } + }, + "configurationSnippets": [ + { + "label": "Blazor WebAssembly: Launch", + "description": "Run a Blazor WebAssembly project and debug it in the browser", + "body": { + "type": "sharplsp-blazorwasm", + "request": "launch", + "name": "Launch Blazor WebAssembly", + "browser": "chrome" + } + } + ], + "initialConfigurations": [ + { + "type": "sharplsp-blazorwasm", + "request": "launch", + "name": "Launch Blazor WebAssembly", + "browser": "chrome" + } + ] } ], "viewsContainers": { diff --git a/src/editors/vscode/src/blazor-wasm-debug.ts b/src/editors/vscode/src/blazor-wasm-debug.ts new file mode 100644 index 00000000..da38c0a7 --- /dev/null +++ b/src/editors/vscode/src/blazor-wasm-debug.ts @@ -0,0 +1,443 @@ +// Blazor WebAssembly launch resolution: everything a `sharplsp-blazorwasm` launch +// decides that never touches a process. +// +// Implements [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]. +// +// A Blazor WebAssembly app is served by `Microsoft.AspNetCore.Components.WebAssembly +// .DevServer` (or `app.UseWebAssemblyDebugging()`), which exposes the .NET debug proxy +// at `/_framework/debug/ws-proxy`. VS Code's built-in JavaScript debugger reaches .NET +// breakpoints through that proxy when it is handed an `inspectUri`, so this tier's whole +// job is to turn a launch configuration into the `pwa-chrome`/`pwa-msedge` one js-debug +// understands. Process control lives in blazor-wasm-launch.ts. +import * as fs from 'node:fs'; +import { XMLParser } from 'fast-xml-parser'; +import type * as vscode from 'vscode'; +import { + directoryOf, + extensionOf, + fileNameOf, + isAbsolutePath, + joinPath, + resolvePath, +} from './paths'; +import { resolveLaunchTarget } from './launch-resolver'; +import { projectProfiles, readProfiles, type LaunchProfile } from './launch-profiles'; +import { err, ok, type Result } from './result'; +import { isRecord } from './utils'; + +/** The name a launch that states none is listed under. */ +export const DEFAULT_LAUNCH_NAME = 'Launch Blazor WebAssembly'; + +/** The debug type this extension contributes for Blazor WebAssembly. */ +export const BLAZOR_WASM_DEBUG_TYPE = 'sharplsp-blazorwasm'; + +/** The SDK a standalone Blazor WebAssembly project names on its `Project` element. */ +export const BLAZOR_WASM_SDK = 'Microsoft.NET.Sdk.BlazorWebAssembly'; + +/** The browsers js-debug ships a debug type for. */ +export type BlazorWasmBrowser = 'chrome' | 'edge'; + +/** Chromium: the browser a `blazorwasm` template profile is written for. */ +export const DEFAULT_BLAZOR_WASM_BROWSER: BlazorWasmBrowser = 'chrome'; + +/** + * The `inspectUri` the `blazorwasm` template writes into `launchSettings.json`. + * + * A launch profile that states none still has to be debuggable, so this is the + * fallback. `{browserInspectUri}` is deliberately LEFT in place: js-debug + * substitutes it with the address of the browser tab it opened, which is + * information only it has. + */ +export const DEFAULT_INSPECT_URI = + '{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}'; + +/** How long js-debug waits for the page before it gives up. */ +export const DEFAULT_BROWSER_TIMEOUT_MS = 30_000; + +/** The two `contributes.debuggers` labels the browsers map onto. */ +const BROWSER_DEBUG_TYPES: Readonly> = { + chrome: 'pwa-chrome', + edge: 'pwa-msedge', +}; + +/** The prefix the ASP.NET Core host logs a bound address behind. */ +const LISTENING_PREFIX = 'Now listening on:'; + +/** The tokens a launch profile's `inspectUri` carries. */ +const WS_PROTOCOL_TOKEN = '{wsProtocol}'; +const HOSTNAME_TOKEN = '{url.hostname}'; +const PORT_TOKEN = '{url.port}'; + +/** Attributes are read with an `@_` prefix, the spelling dependencies.ts uses. */ +const ATTRIBUTE_PREFIX = '@_'; + +/** One parser for every project file this module reads. */ +const projectParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: ATTRIBUTE_PREFIX, +}); + +/** + * A browser name from user input, defaulting to Chromium. + * + * `browser` arrives from a hand-written `launch.json`, so it can be absent or + * misspelled; an unknown value MUST NOT refuse the launch, because the only + * thing a wrong browser name changes is which of two identical debug types is + * started. + */ +export function browserOf(value: unknown): BlazorWasmBrowser { + return typeof value === 'string' && value.trim().toLowerCase() === 'edge' ? 'edge' : 'chrome'; +} + +/** The js-debug type that drives `browser`. */ +export function browserDebugType(browser: BlazorWasmBrowser): string { + return BROWSER_DEBUG_TYPES[browser]; +} + +/** A URL from process output, or undefined when the text is not one. */ +function httpUrlOf(token: string): string | undefined { + try { + const parsed = new URL(token); + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? token : undefined; + } catch { + return undefined; + } +} + +/** The first space-delimited token of `text`. */ +function firstToken(text: string): string { + const end = text.indexOf(' '); + return end === -1 ? text : text.slice(0, end); +} + +/** + * The URL a `Now listening on: ` line announces. + * + * `dotnet run` interleaves that line with build output, restore warnings and + * the SDK's telemetry notice, so the line is found by its prefix and the URL by + * parsing the token after it — text that merely *contains* a URL is not an + * announcement, which is what keeps `Content root path:` and the dev + * certificate warning out of the answer. + */ +export function listeningUrlOf(line: string): string | undefined { + const trimmed = line.trim(); + const at = trimmed.indexOf(LISTENING_PREFIX); + if (at === -1) return undefined; + return httpUrlOf(firstToken(trimmed.slice(at + LISTENING_PREFIX.length).trim())); +} + +/** Every URL announced in a whole block of output, in order. */ +export function listeningUrlsOf(output: string): string[] { + return output.split('\n').flatMap((line) => { + const url = listeningUrlOf(line); + return url === undefined ? [] : [url]; + }); +} + +/** Output split into whole lines, plus the unfinished tail of the last one. */ +export interface LineBuffer { + /** The tail of a line whose newline has not arrived yet. */ + readonly pending: string; + /** The lines completed by the chunk. */ + readonly lines: readonly string[]; +} + +/** + * Split one output chunk into whole lines. + * + * A child's output arrives in arbitrary chunks, so `Now listening on:` lands + * split across two of them often enough to matter; keeping the tail makes the + * announcement findable wherever the pipe happened to cut it. + */ +export function takeLines(pending: string, chunk: string): LineBuffer { + const parts = `${pending}${chunk}`.split('\n'); + const tail = parts.pop() ?? ''; + return { pending: tail, lines: parts }; +} + +/** + * `pattern` with the host, port and socket protocol of `appUrl` filled in. + * + * `{browserInspectUri}` is left alone: js-debug owns it. An unparseable + * `appUrl` leaves the pattern untouched rather than producing a half-expanded + * URL the browser would refuse to load. + */ +export function expandInspectUri(pattern: string, appUrl: string): string { + const parsed = urlOf(appUrl); + if (parsed === undefined) return pattern; + return pattern + .replaceAll(WS_PROTOCOL_TOKEN, parsed.protocol === 'https:' ? 'wss' : 'ws') + .replaceAll(HOSTNAME_TOKEN, parsed.hostname) + .replaceAll(PORT_TOKEN, parsed.port); +} + +/** `value` as a URL, or undefined when it is not one. */ +function urlOf(value: string): URL | undefined { + try { + return new URL(value); + } catch { + return undefined; + } +} + +/** The `inspectUri` for a launch: the profile's own, or the template's default. */ +export function inspectUriFor(pattern: string | undefined, appUrl: string): string { + const usable = + pattern === undefined || pattern.trim().length === 0 ? DEFAULT_INSPECT_URI : pattern; + return expandInspectUri(usable, appUrl); +} + +/** Everything the browser session needs, once the app is listening. */ +export interface BrowserSessionOptions { + /** The configuration name the session is listed under. */ + readonly name: string; + /** The URL to open. */ + readonly url: string; + /** The .NET debug proxy URL, already expanded. */ + readonly inspectUri: string; + readonly browser: BlazorWasmBrowser; + /** Where js-debug maps served URLs back onto source files. */ + readonly webRoot: string; + /** js-debug's own wait for the page. */ + readonly timeout: number; +} + +/** + * The js-debug launch configuration a Blazor WebAssembly session is handed to. + * + * `inspectUri` is the whole bridge: without it js-debug attaches to the browser + * only, and every .NET breakpoint in a `.razor` file or a C# class silently + * never binds. + */ +export function browserConfiguration(options: BrowserSessionOptions): vscode.DebugConfiguration { + return { + type: browserDebugType(options.browser), + request: 'launch', + name: options.name, + url: options.url, + inspectUri: options.inspectUri, + webRoot: options.webRoot, + timeout: options.timeout, + }; +} + +/** The `wwwroot` directory a Blazor WebAssembly project serves from. */ +export function webRootOf(projectFile: string): string { + return joinPath(directoryOf(projectFile), 'wwwroot'); +} + +/** The `dotnet run` arguments for `projectFile`, without a shell in between. */ +export function runArguments( + projectFile: string, + launchProfile: string | undefined, +): readonly string[] { + const named = launchProfile === undefined ? '' : launchProfile.trim(); + const args = ['run', '--project', projectFile]; + return named.length === 0 ? args : [...args, '--launch-profile', named]; +} + +/** + * The launch profile a launch runs under. + * + * The profile is named when the configuration says so; otherwise the project's + * SOLE `Project` profile applies, exactly as [DEBUG-FEATURES-LAUNCH-PROFILES] + * makes it apply to every other launch. Several profiles and no name is not an + * ambiguity to guess at here: the dev server announces whatever URL it binds, + * so the launch still works, it just carries no `inspectUri` of its own. + */ +export function launchProfileOf( + projectFile: string, + named: string | undefined, +): LaunchProfile | undefined { + const eligible = projectProfiles(readProfiles(projectFile)); + if (named === undefined || named.trim().length === 0) { + return eligible.length === 1 ? eligible[0] : undefined; + } + return eligible.find((profile) => profile.name === named); +} + +/** SDK names an MSBuild `Project` element declares, in order. */ +function sdkNamesOf(element: unknown): string[] { + const sdk = isRecord(element) ? element[`${ATTRIBUTE_PREFIX}Sdk`] : undefined; + return typeof sdk === 'string' ? sdk.split(';') : []; +} + +/** True when `element` is a `Project` carrying the Blazor WebAssembly SDK. */ +function isWebAssemblySdk(element: unknown): boolean { + return sdkNamesOf(element).some( + (name) => name.trim().toLowerCase() === BLAZOR_WASM_SDK.toLowerCase(), + ); +} + +/** + * True when project XML declares `Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"`. + * + * Read through a real XML parser, never by matching text: a commented-out + * `` element, an `` item inside an `ItemGroup` and a + * property whose VALUE happens to name the SDK all look identical to a string + * search and are three different facts. + */ +export function isBlazorWasmProject(xml: string): boolean { + if (xml.trim().length === 0) return false; + try { + const parsed: unknown = projectParser.parse(xml); + const project = isRecord(parsed) ? parsed.Project : undefined; + const elements = Array.isArray(project) ? project : [project]; + return elements.some(isWebAssemblySdk); + } catch { + return false; + } +} + +/** True when the project file on disk is a Blazor WebAssembly project. */ +export function isBlazorWasmProjectFile(file: string): boolean { + try { + return isBlazorWasmProject(fs.readFileSync(file, 'utf-8')); + } catch { + return false; + } +} + +/** The Blazor WebAssembly projects among `files`, keeping their order. */ +export function blazorWasmProjects(files: readonly string[]): string[] { + return files.filter(isBlazorWasmProjectFile); +} + +/** The name a generated configuration carries for `projectFile`. */ +export function configurationName(projectFile: string): string { + return `Blazor WebAssembly: ${fileNameOf(projectFile, extensionOf(projectFile))}`; +} + +/** One generated configuration, carrying nothing but what a user must choose. */ +export function blazorWasmConfiguration(projectFile: string): vscode.DebugConfiguration { + return { + type: BLAZOR_WASM_DEBUG_TYPE, + request: 'launch', + name: configurationName(projectFile), + project: projectFile, + browser: DEFAULT_BLAZOR_WASM_BROWSER, + }; +} + +/** One configuration per Blazor WebAssembly project, without prompting. */ +export function blazorWasmConfigurations(files: readonly string[]): vscode.DebugConfiguration[] { + return blazorWasmProjects(files).map(blazorWasmConfiguration); +} + +/** + * How long the app gets to announce a listening URL. + * + * A cold `dotnet run` restores and builds before it binds, which on a first run + * with an empty package cache takes far longer than the bind itself; the wait is + * generous for that reason and still bounded, so a project that never serves + * reports itself instead of leaving a session open on nothing. + */ +export const STARTUP_TIMEOUT_MS = 180_000; + +/** How a launch's startup ended. */ +export type Startup = + | { readonly kind: 'listening'; readonly url: string } + | { readonly kind: 'exited'; readonly outcome: Result } + | { readonly kind: 'timeout' }; + +/** Everything one launch needs, once the project is known. */ +export interface BlazorWasmRequest { + readonly projectFile: string; + readonly launchProfile: LaunchProfile | undefined; + readonly browser: BlazorWasmBrowser; + readonly url: string | undefined; + readonly webRoot: string; + readonly name: string; + readonly timeout: number; +} + +/** Why a launch has no project to run, in the words of a user. */ +const NO_PROJECT_MESSAGE = + 'Blazor WebAssembly launch found no runnable project for this workspace.'; +const NOT_A_PROJECT_MESSAGE = + 'Blazor WebAssembly launches need a .csproj/.fsproj project, not a script or a single file.'; + +/** A `string` field of the configuration, or undefined when absent or empty. */ +export function stringField(config: vscode.DebugConfiguration, key: string): string | undefined { + const value: unknown = config[key]; + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +} + +/** A positive `number` field of the configuration, or undefined. */ +export function numberField(config: vscode.DebugConfiguration, key: string): number | undefined { + const value: unknown = config[key]; + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined; +} + +/** + * The project a configuration names, or the one the workspace resolves to. + * + * A stated `project` is the user's decision and is used as given; only a + * MISSING one consults the resolver every other launch surface shares + * ([DEBUG-FEATURES-LAUNCH-TARGET]). + */ +export async function resolveProject( + folder: vscode.WorkspaceFolder | undefined, + stated: unknown, +): Promise> { + const root = folder?.uri.fsPath; + const named = typeof stated === 'string' ? stated.trim() : ''; + if (named.length > 0) return projectAt(root, named); + if (folder === undefined) return err(NO_PROJECT_MESSAGE); + const resolved = await resolveLaunchTarget(undefined, folder); + if (!resolved.ok) + return err(resolved.error.trim().length === 0 ? NO_PROJECT_MESSAGE : resolved.error); + if (resolved.value.kind !== 'project') return err(NOT_A_PROJECT_MESSAGE); + return ok(resolved.value.projectFile); +} + +/** `named` as an existing project path, resolved against `root` when relative. */ +export function projectAt(root: string | undefined, named: string): Result { + if (root === undefined && !isAbsolutePath(named)) { + return err(`Cannot resolve '${named}' without a workspace folder.`); + } + const file = isAbsolutePath(named) || root === undefined ? named : resolvePath(root, named); + return fs.existsSync(file) ? ok(file) : err(`Blazor WebAssembly project not found: ${file}`); +} + +/** The request a configuration describes, or why it cannot be launched. */ +export async function requestOf( + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, +): Promise> { + const project = await resolveProject(folder, config.project); + if (!project.ok) return project; + const named = stringField(config, 'launchProfile'); + const profile = launchProfileOf(project.value, named); + if (named !== undefined && profile === undefined) { + return err( + `Launch profile '${named}' is not declared by ${fileNameOf(project.value)}. ` + + 'Add it to Properties/launchSettings.json, or omit launchProfile.', + ); + } + return ok({ + projectFile: project.value, + launchProfile: profile, + browser: browserOf(config.browser), + url: stringField(config, 'url'), + webRoot: stringField(config, 'webRoot') ?? webRootOf(project.value), + name: stringField(config, 'name') ?? DEFAULT_LAUNCH_NAME, + timeout: numberField(config, 'timeout') ?? DEFAULT_BROWSER_TIMEOUT_MS, + }); +} + +/** The sentence a startup that never announced an address earns. */ +export function startupFailure(outcome: Startup, projectFile: string): string { + const named = fileNameOf(projectFile); + if (outcome.kind === 'timeout') { + return ( + `dotnet run for ${named} announced no listening URL within ` + + `${String(STARTUP_TIMEOUT_MS / 1000)}s. Check that the project is a Blazor ` + + 'WebAssembly app and that Properties/launchSettings.json binds a URL.' + ); + } + if (outcome.kind === 'exited' && !outcome.outcome.ok) { + return `dotnet run for ${named} stopped before it served anything: ${outcome.outcome.error}`; + } + return `dotnet run for ${named} exited before it announced a listening URL.`; +} diff --git a/src/editors/vscode/src/blazor-wasm-launch.ts b/src/editors/vscode/src/blazor-wasm-launch.ts new file mode 100644 index 00000000..65b5f1cb --- /dev/null +++ b/src/editors/vscode/src/blazor-wasm-launch.ts @@ -0,0 +1,468 @@ +// Launching a Blazor WebAssembly app and handing it to js-debug. +// +// Implements [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]. +// +// Three moving parts, in this order: `dotnet run` (which starts the DevServer that +// serves both the app and the .NET debug proxy), the announcement of the address it +// bound, and the browser session that receives the expanded `inspectUri`. Everything +// the launch decides WITHOUT a process lives in blazor-wasm-debug.ts; everything here +// is process control and the handoff. +// +// The process is started through dotnet-process.ts rather than a bare `spawn`, so the +// CLI's UI language is pinned (the announcement is English text) and the stop reaps the +// whole tree: `dotnet run` launches the dev server as a CHILD, so signalling only the +// parent leaves the port bound by a process nothing owns. +import * as vscode from 'vscode'; +import { + BLAZOR_WASM_DEBUG_TYPE, + DEFAULT_LAUNCH_NAME, + STARTUP_TIMEOUT_MS, + blazorWasmConfigurations, + browserConfiguration, + browserDebugType, + browserOf, + inspectUriFor, + listeningUrlOf, + requestOf, + runArguments, + startupFailure, + stringField, + takeLines, + type BlazorWasmRequest, + type Startup, +} from './blazor-wasm-debug'; +import { runDotnet, type DotnetRun } from './dotnet-process'; +import { info, traceInfo, warn } from './log'; +import { directoryOf, fileNameOf } from './paths'; +import { err, ok, type Result } from './result'; + +/** + * A backstop on the whole session, not a policy. + * + * dotnet-process.ts kills an invocation that outlives its own ceiling; a dev + * server legitimately lives as long as the browser session does, so the ceiling + * is set far past any debugging session rather than at the run timeout. + */ +const SESSION_CEILING_MS = 24 * 60 * 60 * 1_000; + +/** A `dotnet run` process a launch owns. */ +export interface BlazorWasmProcess { + /** Resolves once the process, and everything it started, is gone. */ + readonly ended: Promise>; + /** Terminates the process and everything it started. */ + stop(): void; +} + +/** Starts `dotnet run`; `onOutput` sees every chunk as it arrives. */ +export type BlazorWasmProcessStarter = ( + args: readonly string[], + cwd: string, + onOutput: (chunk: string) => void, +) => BlazorWasmProcess; + +/** A started browser debug session, as far as the launch cares. */ +export interface BrowserSession { + /** Resolves when the user's browser session ends, however it ends. */ + readonly ended: Promise; +} + +/** Hands a configuration to js-debug, or reports that it refused. */ +export type BrowserSessionStarter = ( + folder: vscode.WorkspaceFolder | undefined, + configuration: vscode.DebugConfiguration, +) => Promise; + +/** + * What a launch needs from outside itself. + * + * A seam, not a testing afterthought: a browser debug session cannot be started + * in a headless test host, and a real dev server takes minutes to build, so the + * suite that proves the handoff replaces exactly these three and nothing else. + */ +export interface BlazorWasmHost { + readonly startProcess: BlazorWasmProcessStarter; + readonly startBrowserSession: BrowserSessionStarter; + /** Every project file under a workspace folder. */ + readonly listProjects: (root: string) => Promise; +} + +/** A launch that is currently running, and how to end it. */ +export interface RunningLaunch { + readonly projectFile: string; + /** The address the DevServer announced. */ + readonly url: string; + /** The configuration js-debug was handed. */ + readonly configuration: vscode.DebugConfiguration; + /** Resolves once the `dotnet run` process is gone. */ + readonly ended: Promise; + /** Ends the launch; the browser session ends with the app. */ + stop(): void; +} + +/** The launches in flight, and the process each one owns. */ +interface LaunchRegistry { + readonly running: Map; + /** The process each launch owns, so only its OWN exit un-registers it. */ + readonly processes: Map; +} + +declare global { + /** The live launches; global for the reason {@link useBlazorWasmHost} is. */ + var __sharplspBlazorWasmLaunches: LaunchRegistry | undefined; +} + +/** The one registry, wherever this module was loaded from. */ +function registry(): LaunchRegistry { + globalThis.__sharplspBlazorWasmLaunches ??= { running: new Map(), processes: new Map() }; + return globalThis.__sharplspBlazorWasmLaunches; +} + +/** The launch in flight for `projectFile`, if any. */ +export function runningBlazorWasmLaunch(projectFile: string): RunningLaunch | undefined { + return registry().running.get(projectFile); +} + +/** + * `dotnet run`, supervised by the one module that knows how to stop a tree. + * + * `runDotnet` never rejects: a spawn that failed, a non-zero exit and a kill all + * arrive as a {@link DotnetRun} the caller can report. + */ +const dotnetRunProcess: BlazorWasmProcessStarter = (args, cwd, onOutput) => { + const controller = new AbortController(); + const ended = runDotnet(args, cwd, SESSION_CEILING_MS, controller.signal, { onOutput }).then( + exitOf, + ); + return { + ended, + stop: () => { + controller.abort(); + }, + }; +}; + +/** What a finished invocation means to a launch: OK, or the reason it is not. */ +function exitOf(run: DotnetRun): Result { + if (!run.failed && !run.killed) return ok(undefined); + const reason = run.errorMessage?.trim(); + return err(reason !== undefined && reason.length > 0 ? reason : 'dotnet run stopped'); +} + +/** + * The shipped browser handoff: `vscode.debug.startDebugging` onto js-debug. + * + * The session is matched by NAME because `startDebugging` answers whether the + * session started, not which one it was; the name is the one this launch wrote + * into the configuration, so the pair is unambiguous. + */ +async function startBrowserSession( + folder: vscode.WorkspaceFolder | undefined, + configuration: vscode.DebugConfiguration, +): Promise { + const started = await vscode.debug.startDebugging(folder, configuration); + if (!started) return undefined; + return { ended: sessionLifetime(configuration.name) }; +} + +/** Resolves when the session named `name` terminates. */ +async function sessionLifetime(name: string): Promise { + await new Promise((resolve) => { + const starting = vscode.debug.onDidStartDebugSession((session) => { + if (session.name !== name) return; + starting.dispose(); + const ending = vscode.debug.onDidTerminateDebugSession((gone) => { + if (gone.id !== session.id) return; + ending.dispose(); + resolve(); + }); + }); + }); +} + +/** Every project file beneath a folder, minus build output and dependencies. */ +async function listProjectFiles(root: string): Promise { + const found = await vscode.workspace.findFiles( + new vscode.RelativePattern(root, '**/*.{csproj,fsproj}'), + '**/{node_modules,bin,obj,target}/**', + ); + return found.map((uri) => uri.fsPath); +} + +/** The shipped launch environment. */ +const shippedHost: BlazorWasmHost = { + startProcess: dotnetRunProcess, + startBrowserSession, + listProjects: listProjectFiles, +}; + +declare global { + /** Overrides on the launch environment; see {@link useBlazorWasmHost}. */ + var __sharplspBlazorWasmHost: Partial | undefined; +} + +/** + * Overrides live on `globalThis`, not in a module variable. + * + * The suite runs from `out/` while the extension runs from the bundled + * `dist/extension.js`, so those are two module instances that share no module + * state; a module-scoped seam would be replaced in a copy nothing calls, and + * the test would pass or fail for reasons unrelated to the launch. hot-reload.ts + * keeps its flag in the same place for the same reason. + */ +function host(): BlazorWasmHost { + return { ...shippedHost, ...(globalThis.__sharplspBlazorWasmHost ?? {}) }; +} + +/** + * Replace part of the launch environment, returning the undo. + * + * Callers MUST restore: a suite that leaves an override in place changes every + * launch the extension makes afterwards, in this window and any later one. + */ +export function useBlazorWasmHost(overrides: Partial): () => void { + const previous = globalThis.__sharplspBlazorWasmHost; + globalThis.__sharplspBlazorWasmHost = { ...previous, ...overrides }; + return () => { + globalThis.__sharplspBlazorWasmHost = previous; + }; +} + +/** A promise, and the one call that settles it. */ +interface Deferred { + readonly promise: Promise; + settle(value: T): void; +} + +/** A {@link Deferred}, resolved exactly once by whoever gets there first. */ +function deferred(): Deferred { + let settle: (value: T) => void = () => undefined; + const promise = new Promise((resolve) => { + settle = resolve; + }); + return { + promise, + settle: (value) => { + settle(value); + }, + }; +} + +/** The app's address, or the reason there is none. */ +async function waitForAddress(startup: Deferred): Promise { + const timer = setTimeout(() => { + startup.settle({ kind: 'timeout' }); + }, STARTUP_TIMEOUT_MS); + try { + return await startup.promise; + } finally { + clearTimeout(timer); + } +} + +/** Streams `dotnet run` output to the log and reports the first bound address. */ +interface OutputScanner { + readonly push: (chunk: string) => void; +} + +/** A scanner that logs whole lines and reports the first listening URL. */ +function outputScanner(onUrl: (url: string) => void): OutputScanner { + let pending = ''; + const feed = (line: string): void => { + const trimmed = line.trimEnd(); + if (trimmed.length > 0) traceInfo(`dotnet run: ${trimmed}`); + const url = listeningUrlOf(trimmed); + if (url !== undefined) onUrl(url); + }; + return { + push: (chunk) => { + const buffer = takeLines(pending, chunk); + pending = buffer.pending; + buffer.lines.forEach(feed); + }, + }; +} + +/** + * One `dotnet run` process, and what became of it. + * + * `exit` is recorded and queryable because a dev server can die between the + * moment it announces its address and the moment the browser session opens; + * registering a launch whose process is already gone would leave the next + * launch believing a DevServer it does not own is still serving the port. + */ +interface ProcessRun { + readonly process: BlazorWasmProcess; + /** Resolves once the process is gone and this launch is un-registered. */ + readonly finished: Promise; + /** How the process ended, or undefined while it is still alive. */ + readonly exit: () => Result | undefined; +} + +/** + * Forget a launch ONLY when the process that ended is the one it owns. + * + * A stopped app takes a moment to exit, and a newer launch of the same project + * can register in that window; deleting by key alone un-registered the LIVE + * launch and left a second DevServer nobody would ever stop. + */ +function forget(projectFile: string, process: BlazorWasmProcess): void { + const live = registry(); + if (live.processes.get(projectFile) !== process) return; + live.processes.delete(projectFile); + live.running.delete(projectFile); +} + +/** Start the app, wait for its address, and hand it to the browser. */ +async function startSession( + folder: vscode.WorkspaceFolder | undefined, + request: BlazorWasmRequest, +): Promise> { + const startup = deferred(); + const scanner = outputScanner((url) => { + startup.settle({ kind: 'listening', url }); + }); + info(`Starting Blazor WebAssembly launch for ${fileNameOf(request.projectFile)}`); + const process = host().startProcess( + runArguments(request.projectFile, request.launchProfile?.name), + directoryOf(request.projectFile), + scanner.push, + ); + let exit: Result | undefined; + const finished = process.ended.then((outcome) => { + exit = outcome; + forget(request.projectFile, process); + startup.settle({ kind: 'exited', outcome }); + }); + const outcome = await waitForAddress(startup); + if (outcome.kind !== 'listening') { + process.stop(); + return err(startupFailure(outcome, request.projectFile)); + } + const run: ProcessRun = { process, finished, exit: () => exit }; + return await handOff(folder, request, { ...run, url: request.url ?? outcome.url }); +} + +/** A process that has announced its address, ready for a browser session. */ +type ServedApp = ProcessRun & { readonly url: string }; + +/** Hand the served app to js-debug, and tie the app's life to that session's. */ +async function handOff( + folder: vscode.WorkspaceFolder | undefined, + request: BlazorWasmRequest, + app: ServedApp, +): Promise> { + const configuration = browserConfiguration({ + name: request.name, + url: app.url, + inspectUri: inspectUriFor(request.launchProfile?.inspectUri, app.url), + browser: request.browser, + webRoot: request.webRoot, + timeout: request.timeout, + }); + info( + `Blazor WebAssembly app for ${fileNameOf(request.projectFile)} is listening on ${app.url}; ` + + `handing it to ${browserDebugType(request.browser)}`, + ); + const session = await host().startBrowserSession(folder, configuration); + const died = app.exit(); + if (session === undefined) { + app.process.stop(); + return err(`Could not start ${browserDebugType(request.browser)} for ${app.url}.`); + } + if (died !== undefined) { + app.process.stop(); + return err(startupFailure({ kind: 'exited', outcome: died }, request.projectFile)); + } + const viaBrowser = session.ended.then(async () => { + info( + `Blazor WebAssembly browser session for ${fileNameOf(request.projectFile)} ended; ` + + 'stopping dotnet run', + ); + app.process.stop(); + await app.finished; + }); + // The launch's whole lifetime: the browser session ends (which stops the app), + // or the app dies on its own. Both paths have reaped the process by the time it + // settles, so a caller that awaits `ended` may rely on the port being free. + const ended = Promise.race([viaBrowser, app.finished]); + const launch: RunningLaunch = { + projectFile: request.projectFile, + url: app.url, + configuration, + ended, + stop: () => { + app.process.stop(); + }, + }; + const live = registry(); + live.processes.set(request.projectFile, app.process); + live.running.set(request.projectFile, launch); + return ok(launch); +} + +/** + * Launch the Blazor WebAssembly app a configuration describes. + * + * The returned launch owns the `dotnet run` process: when the browser session + * that received its `inspectUri` ends, that process — and only the tree it + * started — is terminated. + */ +export async function launchBlazorWasm( + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, +): Promise> { + const request = await requestOf(folder, config); + if (!request.ok) return request; + const previous = registry().running.get(request.value.projectFile); + if (previous !== undefined) { + warn( + `Stopping the Blazor WebAssembly launch already running for ${fileNameOf(request.value.projectFile)}`, + ); + previous.stop(); + } + return await startSession(folder, request.value); +} + +/** + * Fills in, and later launches, a `sharplsp-blazorwasm` session. + * + * Implements [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM] and + * [DEBUG-FEATURES-LAUNCH-DYNAMIC]. + */ +export class BlazorWasmDebugProvider implements vscode.DebugConfigurationProvider { + /** Nothing to resolve before `${...}` are expanded: this tier is not a debugger. */ + public resolveDebugConfiguration( + _folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, + ): vscode.DebugConfiguration { + config.type = BLAZOR_WASM_DEBUG_TYPE; + config.request = config.request === 'attach' ? 'launch' : config.request; + config.browser = browserOf(config.browser); + config.name = stringField(config, 'name') ?? DEFAULT_LAUNCH_NAME; + return config; + } + + /** + * Start the app and hand it to js-debug. + * + * `undefined` AFTER the browser session has started is the handoff, not a + * refusal: VS Code must start no session for `sharplsp-blazorwasm` itself, + * because the session the user debugs is the `pwa-chrome`/`pwa-msedge` one. + */ + public async resolveDebugConfigurationWithSubstitutedVariables( + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, + ): Promise { + const outcome = await launchBlazorWasm(folder, config); + if (!outcome.ok) void vscode.window.showErrorMessage(outcome.error); + return undefined; + } + + /** One configuration per Blazor WebAssembly project, generated without a prompt. */ + public async provideDebugConfigurations( + folder: vscode.WorkspaceFolder | undefined, + ): Promise { + if (folder === undefined) return []; + return blazorWasmConfigurations(await host().listProjects(folder.uri.fsPath)); + } +} diff --git a/src/editors/vscode/src/dap-hot-reload.ts b/src/editors/vscode/src/dap-hot-reload.ts index 9f2a0416..5805eced 100644 --- a/src/editors/vscode/src/dap-hot-reload.ts +++ b/src/editors/vscode/src/dap-hot-reload.ts @@ -11,8 +11,12 @@ import * as vscode from 'vscode'; import type { DapMessage } from './dap-emulate'; import { error, traceInfo } from './log'; import * as state from './state'; +import { RAZOR_LANGUAGES } from './razor-html'; import { getErrorMessage, isRecord, removeDirRecursive } from './utils'; +/** The languages whose saves this pipeline owns: C# and Razor ([RAZOR-HOT-RELOAD]). */ +const OWNED_LANGUAGES: readonly string[] = ['csharp', ...RAZOR_LANGUAGES]; + interface HotReloadHost { request(command: string, args: Record): Promise; /** Per-type debugger metadata caches go stale once new IL is committed. */ @@ -353,7 +357,11 @@ export class DapHotReload implements vscode.Disposable { } private owns(document: vscode.TextDocument): boolean { - if (!this.enabled || document.languageId !== 'csharp' || this.projectPath === undefined) { + if ( + !this.enabled || + !OWNED_LANGUAGES.includes(document.languageId) || + this.projectPath === undefined + ) { return false; } return isLexicallyWithin(directoryOf(this.projectPath), document.uri.fsPath); diff --git a/src/editors/vscode/src/debug.ts b/src/editors/vscode/src/debug.ts index 6e598177..95ab36b1 100644 --- a/src/editors/vscode/src/debug.ts +++ b/src/editors/vscode/src/debug.ts @@ -20,6 +20,8 @@ import { findNetcoredbg, getNetcoredbgCandidates } from './netcoredbg'; import { info, warn } from './log'; import { DapRouter } from './dap-router'; import { resolveAttachTarget } from './attach-target'; +import { BlazorWasmDebugProvider } from './blazor-wasm-launch'; +import { BLAZOR_WASM_DEBUG_TYPE } from './blazor-wasm-debug'; import { NO_TARGET_MESSAGE, folderFor, @@ -509,6 +511,7 @@ export function registerDebugAdapter( ): void { activeFrameworkReader = readActiveFramework; const provider = new SharpLspLaunchProvider(); + const blazorWasm = new BlazorWasmDebugProvider(); context.subscriptions.push( // Both trigger kinds: `Initial` fills a generated launch.json, `Dynamic` // puts SharpLsp in the "Show all automatic debug configurations" dropdown. @@ -526,6 +529,20 @@ export function registerDebugAdapter( DEBUG_TYPE, new SharpLspDebugAdapterFactory(context.extensionPath), ), + // The Blazor WebAssembly debugger has no adapter of its own: it starts `dotnet run` + // and hands the app to js-debug ([DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]), so only its + // configuration providers are registered — both trigger kinds, as + // [DEBUG-FEATURES-LAUNCH-DYNAMIC] requires of every SharpLsp debugger. + vscode.debug.registerDebugConfigurationProvider( + BLAZOR_WASM_DEBUG_TYPE, + blazorWasm, + vscode.DebugConfigurationProviderTriggerKind.Initial, + ), + vscode.debug.registerDebugConfigurationProvider( + BLAZOR_WASM_DEBUG_TYPE, + blazorWasm, + vscode.DebugConfigurationProviderTriggerKind.Dynamic, + ), vscode.commands.registerCommand(CMD_DEBUG_PROGRAM, async (argument?: LaunchArgument) => { await launch(argument, false); }), diff --git a/src/editors/vscode/src/hot-reload.ts b/src/editors/vscode/src/hot-reload.ts index 050a1ba2..3545658b 100644 --- a/src/editors/vscode/src/hot-reload.ts +++ b/src/editors/vscode/src/hot-reload.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { hotReloadOnSave } from './config.js'; import { info } from './log.js'; +import { RAZOR_LANGUAGES } from './razor-html.js'; declare global { var __sharplspHotReloadRunning: boolean | undefined; @@ -106,9 +107,9 @@ function handleDocumentSave(doc: vscode.TextDocument): void { info(`Hot Reload triggered on save: ${doc.uri.fsPath}`); } -/** Check if the language is C# or F#. */ +/** Check if the language is C#, F#, or Razor ([RAZOR-HOT-RELOAD]). */ export function isRelevantLanguage(languageId: string): boolean { - return languageId === 'csharp' || languageId === 'fsharp'; + return languageId === 'csharp' || languageId === 'fsharp' || RAZOR_LANGUAGES.includes(languageId); } /** Stop hot reload when a debug session ends. */ diff --git a/src/editors/vscode/src/launch-profiles.ts b/src/editors/vscode/src/launch-profiles.ts index 9bd20fc4..9344de51 100644 --- a/src/editors/vscode/src/launch-profiles.ts +++ b/src/editors/vscode/src/launch-profiles.ts @@ -19,6 +19,17 @@ export interface LaunchProfile { readonly commandLineArgs?: string; readonly environmentVariables?: Readonly>; readonly applicationUrl?: string; + /** + * The .NET debug proxy URL a Blazor WebAssembly profile carries. + * + * `Microsoft.AspNetCore.Components.WebAssembly.DevServer` writes the template's + * `{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser= + * {browserInspectUri}` here, and that value is the whole bridge to .NET + * breakpoints ([DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]). It is parsed with the rest + * of the profile rather than re-read at the launch site, so one reader owns the + * profile document. + */ + readonly inspectUri?: string; } /** ASP.NET reads its listening URLs from this variable. */ @@ -65,6 +76,7 @@ function toProfile(name: string, raw: unknown): LaunchProfile | undefined { commandName, ...optional('commandLineArgs', stringOf(raw, 'commandLineArgs')), ...optional('applicationUrl', stringOf(raw, 'applicationUrl')), + ...optional('inspectUri', stringOf(raw, 'inspectUri')), ...optional('environmentVariables', envOf(raw, 'environmentVariables')), }; return profile; diff --git a/src/editors/vscode/src/razor-html.ts b/src/editors/vscode/src/razor-html.ts index 8051010b..5cfc4c07 100644 --- a/src/editors/vscode/src/razor-html.ts +++ b/src/editors/vscode/src/razor-html.ts @@ -153,9 +153,19 @@ async function htmlHover(uri: Uri, position: Position): Promise !offered.has(labelOf(item))); + return new CompletionList([...items, ...added], incomplete); +} + +/** A completion item's label text. */ +function labelOf(item: CompletionItem): string { + return typeof item.label === 'string' ? item.label : item.label.label; } diff --git a/src/editors/vscode/src/test/suite/blazor-wasm-debug-resolution.test.ts b/src/editors/vscode/src/test/suite/blazor-wasm-debug-resolution.test.ts new file mode 100644 index 00000000..98bdd1a1 --- /dev/null +++ b/src/editors/vscode/src/test/suite/blazor-wasm-debug-resolution.test.ts @@ -0,0 +1,479 @@ +// Blazor WebAssembly launch RESOLUTION — every part of a `sharplsp-blazorwasm` +// launch that decides an answer without starting a real process. +// Spec: [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]. +// +// Nothing here mocks the extension's own surface: the pure functions, the real +// `launchSettings.json` of the repository fixture and the shipped provider are +// driven directly. Only the two things a headless host cannot own — a `dotnet +// run` child and a browser debug session — are replaced, through the shipped +// `useBlazorWasmHost` seam. +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type * as vscode from 'vscode'; +import { + BLAZOR_WASM_DEBUG_TYPE, + DEFAULT_INSPECT_URI, + blazorWasmConfigurations, + browserConfiguration, + browserDebugType, + browserOf, + expandInspectUri, + inspectUriFor, + isBlazorWasmProject, + isBlazorWasmProjectFile, + launchProfileOf, + listeningUrlOf, + listeningUrlsOf, + projectAt, + resolveProject, + runArguments, + startupFailure, + takeLines, +} from '../../blazor-wasm-debug'; +import { + BlazorWasmDebugProvider, + runningBlazorWasmLaunch, + launchBlazorWasm, + useBlazorWasmHost, + type BlazorWasmProcess, +} from '../../blazor-wasm-launch'; +import { err, ok, type Result } from '../../result'; +import { fakeFolder } from './run-debug-kit'; + +/** The Blazor WebAssembly fixture in the repository. */ +const FIXTURE = path.resolve(__dirname, '../../../../../sharplsp/tests/fixtures/blazor-wasm'); +/** The one project it declares, and the one the launch is pointed at. */ +const PROJECT = path.join(FIXTURE, 'BlazorWasmApp.csproj'); +/** A real project of the same repository that is NOT a Blazor WebAssembly app. */ +const OTHER_PROJECT = path.resolve( + __dirname, + '../../../../../sharplsp/tests/fixtures/razor/BlazorApp/BlazorApp.csproj', +); +/** The template's own `inspectUri`, as written into launchSettings.json. */ +const TEMPLATE_INSPECT_URI = + '{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}'; +/** What the DevServer prints on a loopback HTTP bind, in its real shape. */ +const HTTP_ADDRESS = 'http://127.0.0.1:58480'; +/** A profile that binds HTTPS: the socket must be `wss`, not `ws`. */ +const HTTPS_ADDRESS = 'https://localhost:7297'; +/** The expanded forms the two addresses must produce. */ +const HTTP_INSPECT = `ws://127.0.0.1:58480/_framework/debug/ws-proxy?browser={browserInspectUri}`; +const HTTPS_INSPECT = `wss://localhost:7297/_framework/debug/ws-proxy?browser={browserInspectUri}`; + +/** + * `dotnet run` output, captured from a real run of the repository fixture: + * build chatter first, then the host's announcement, then lifecycle noise. + */ +const REAL_OUTPUT = [ + 'Using launch settings from Properties/launchSettings.json...', + 'info: Microsoft.Hosting.Lifetime[14]', + ` Now listening on: ${HTTPS_ADDRESS}`, + 'info: Microsoft.Hosting.Lifetime[14]', + ` Now listening on: ${HTTP_ADDRESS}`, + 'info: Microsoft.Hosting.Lifetime[0]', + ' Application started. Press Ctrl+C to shut down.', + ` Content root path: ${FIXTURE}`, +].join('\n'); + +/** A `dotnet run` child the test drives by hand. */ +interface FakeRun { + readonly process: BlazorWasmProcess; + /** The argv the launch built, in full. */ + readonly args: readonly string[]; + /** The directory the launch ran it in. */ + readonly cwd: string; + /** How many times the launch asked for it to stop. */ + readonly stops: () => number; + /** Emit child output exactly as a pipe would. */ + readonly announce: (lines: readonly string[]) => void; + /** End the child on its own terms, before it announces anything. */ + readonly crash: (reason: string) => void; +} + +/** A fake `dotnet run` that records everything the launch does to it. */ +function fakeRun(args: readonly string[], cwd: string, onOutput: (chunk: string) => void): FakeRun { + let settle: (value: Result) => void = () => undefined; + let stops = 0; + const ended = new Promise>((resolve) => { + settle = resolve; + }); + return { + process: { + ended, + stop: () => { + stops += 1; + settle(ok(undefined)); + }, + }, + args, + cwd, + stops: () => stops, + announce: (lines) => { + onOutput(`${lines.join('\n')}\n`); + }, + crash: (reason) => { + settle(err(reason)); + }, + }; +} + +/** A launch configuration pointing at the fixture project. */ +function launchConfig(overrides: Record = {}): vscode.DebugConfiguration { + return { + type: BLAZOR_WASM_DEBUG_TYPE, + request: 'launch', + name: 'Launch Blazor WebAssembly', + project: PROJECT, + ...overrides, + }; +} + +suite('Blazor WebAssembly launch resolution', () => { + test('expands the inspectUri onto the address the DevServer announced', () => { + // 1. The loopback HTTP address the fixture's own profile binds. + assert.strictEqual(expandInspectUri(DEFAULT_INSPECT_URI, HTTP_ADDRESS), HTTP_INSPECT); + assert.strictEqual(inspectUriFor(undefined, HTTP_ADDRESS), HTTP_INSPECT); + + // 2. An HTTPS profile must produce a SECURE socket: `ws` refuses to speak + // to a TLS listener, and js-debug then never reaches .NET. + assert.strictEqual(expandInspectUri(DEFAULT_INSPECT_URI, HTTPS_ADDRESS), HTTPS_INSPECT); + assert.strictEqual(expandInspectUri(TEMPLATE_INSPECT_URI, HTTPS_ADDRESS), HTTPS_INSPECT); + + // 3. A profile that states no pattern at all falls back to the template's, + // and `{browserInspectUri}` is left for js-debug to substitute. + assert.strictEqual( + inspectUriFor(undefined, 'http://localhost:5297'), + 'ws://localhost:5297/_framework/debug/ws-proxy?browser={browserInspectUri}', + ); + assert.strictEqual(inspectUriFor(' ', HTTP_ADDRESS), HTTP_INSPECT); + assert.ok(inspectUriFor(undefined, HTTP_ADDRESS).endsWith('?browser={browserInspectUri}')); + + // 4. An address that is not a URL leaves the pattern whole rather than + // producing a half-expanded proxy the browser would refuse. + assert.strictEqual( + expandInspectUri(DEFAULT_INSPECT_URI, 'not an address'), + DEFAULT_INSPECT_URI, + ); + }); + + test('reads the bound address out of real dotnet run output', () => { + // 1. A real capture: two announcements, and only they are URLs. + assert.deepStrictEqual(listeningUrlsOf(REAL_OUTPUT), [HTTPS_ADDRESS, HTTP_ADDRESS]); + const twice = `${REAL_OUTPUT}\n Now listening on: ${HTTP_ADDRESS}`; + assert.deepStrictEqual(listeningUrlsOf(twice), [HTTPS_ADDRESS, HTTP_ADDRESS, HTTP_ADDRESS]); + // A bare URL on its own line is output, not an announcement of one. + assert.deepStrictEqual(listeningUrlsOf(HTTP_ADDRESS), []); + + // 2. Noise that merely CONTAINS a URL is not an announcement. + assert.strictEqual(listeningUrlOf(` Content root path: ${FIXTURE}`), undefined); + assert.strictEqual( + listeningUrlOf( + 'warn: The ASP.NET Core developer certificate is not trusted: https://aka.ms/aspnet/https-trust-dev-cert', + ), + undefined, + ); + assert.deepStrictEqual(listeningUrlsOf('info: Microsoft.Hosting.Lifetime[14]'), []); + + // 3. A pipe cuts the line wherever it likes; the tail is kept until the + // newline arrives, so the announcement is still found whole. + const first = takeLines('', ` Now lis`); + assert.strictEqual(first.pending, ' Now lis'); + assert.deepStrictEqual(first.lines, []); + const second = takeLines(first.pending, `tening on: ${HTTP_ADDRESS}\n`); + assert.strictEqual(second.pending, ''); + assert.strictEqual(second.lines.length, 1); + assert.deepStrictEqual(listeningUrlsOf(second.lines.join('\n')), [HTTP_ADDRESS]); + }); + + test('maps a browser onto the debug type VS Code ships for it', () => { + // 1. Both browsers js-debug ships a type for. + assert.strictEqual(browserDebugType('chrome'), 'pwa-chrome'); + assert.strictEqual(browserDebugType('edge'), 'pwa-msedge'); + + // 2. A browser name is hand-written in launch.json, so it arrives in any + // case and can be absent; neither may refuse a launch. + assert.strictEqual(browserDebugType(browserOf('EDGE')), 'pwa-msedge'); + assert.strictEqual(browserDebugType(browserOf('Chrome')), 'pwa-chrome'); + assert.strictEqual(browserDebugType(browserOf(undefined)), 'pwa-chrome'); + assert.strictEqual(browserDebugType(browserOf(42)), 'pwa-chrome'); + + // 3. The configuration js-debug receives names the browser type, and the + // proxy URL is the expanded one. + const configuration = browserConfiguration({ + name: 'Launch Blazor WebAssembly', + url: HTTP_ADDRESS, + inspectUri: HTTP_INSPECT, + browser: 'edge', + webRoot: path.join(FIXTURE, 'wwwroot'), + timeout: 30_000, + }); + assert.strictEqual(configuration.type, 'pwa-msedge'); + assert.strictEqual(configuration.request, 'launch'); + assert.strictEqual(configuration.url, HTTP_ADDRESS); + assert.strictEqual(configuration.inspectUri, HTTP_INSPECT); + assert.strictEqual(configuration.name, 'Launch Blazor WebAssembly'); + }); + + test('generates one configuration per Blazor WebAssembly project, without prompting', async () => { + // 1. Discovery is the shipped XML reader against the real fixture files. + assert.strictEqual(isBlazorWasmProject(fs.readFileSync(PROJECT, 'utf-8')), true); + assert.strictEqual(isBlazorWasmProject(fs.readFileSync(OTHER_PROJECT, 'utf-8')), false); + assert.strictEqual( + isBlazorWasmProject( + '', + ), + false, + ); + assert.strictEqual(isBlazorWasmProject('not xml at all'), false); + + // 2. The provider answers from the project list it is given, checking the + // Sdk attribute of each — the non-WebAssembly project is dropped. + const provider = new BlazorWasmDebugProvider(); + const restore = useBlazorWasmHost({ + listProjects: () => Promise.resolve([PROJECT, OTHER_PROJECT]), + }); + try { + const generated = await provider.provideDebugConfigurations(fakeFolder(FIXTURE)); + assert.deepStrictEqual(generated, [ + { + type: BLAZOR_WASM_DEBUG_TYPE, + request: 'launch', + name: 'Blazor WebAssembly: BlazorWasmApp', + project: PROJECT, + browser: 'chrome', + }, + ]); + const fromFiles = blazorWasmConfigurations([OTHER_PROJECT, PROJECT]); + assert.strictEqual(fromFiles.length, 1); + assert.strictEqual(fromFiles[0]?.project, PROJECT); + + // 3. With no folder there is nothing to enumerate, and nothing is offered. + assert.deepStrictEqual(await provider.provideDebugConfigurations(undefined), []); + } finally { + restore(); + } + + // 4. The shipped listing finds the folder's project files itself, build + // output excluded, and offers the WebAssembly one. + const listed = await provider.provideDebugConfigurations(fakeFolder(FIXTURE)); + assert.deepStrictEqual( + listed.map((configuration) => configuration.project), + [PROJECT], + 'the fixture folder holds one Blazor WebAssembly project', + ); + }); + + test('reads the profile, its inspectUri and the run arguments from the project', () => { + // 1. The fixture's sole `Project` profile applies without being named. + const profile = launchProfileOf(PROJECT, undefined); + assert.strictEqual(profile?.name, 'http'); + assert.strictEqual(profile?.inspectUri, TEMPLATE_INSPECT_URI); + + // 2. A named profile that the project does not declare is NOT invented. + assert.strictEqual(launchProfileOf(PROJECT, 'nope'), undefined); + assert.strictEqual(launchProfileOf(PROJECT, 'http')?.name, 'http'); + + // 3. The argv is a real argument vector — no shell, so no quoting. + assert.deepStrictEqual(runArguments(PROJECT, 'http'), [ + 'run', + '--project', + PROJECT, + '--launch-profile', + 'http', + ]); + assert.deepStrictEqual(runArguments(PROJECT, undefined), ['run', '--project', PROJECT]); + assert.deepStrictEqual(runArguments(PROJECT, ' '), ['run', '--project', PROJECT]); + }); + + test('kills the dotnet run process when the browser session ends', async () => { + const runs: FakeRun[] = []; + const handed: vscode.DebugConfiguration[] = []; + let endBrowser: () => void = () => undefined; + const restore = useBlazorWasmHost({ + startProcess: (args, cwd, onOutput) => { + const run = fakeRun(args, cwd, onOutput); + runs.push(run); + run.announce([` Now listening on: ${HTTP_ADDRESS}`]); + return run.process; + }, + startBrowserSession: (_folder, configuration) => { + handed.push(configuration); + return Promise.resolve({ + ended: new Promise((resolve) => { + endBrowser = resolve; + }), + }); + }, + }); + try { + // 1. The launch runs the project's own profile and hands js-debug the + // expanded proxy — and touches no process while the session is live. + const first = await launchBlazorWasm(fakeFolder(FIXTURE), launchConfig()); + assert.ok(first.ok, `the fake app served an address; ${JSON.stringify(first)}`); + const firstRun = runs[0]; + const firstHanded = handed[0]; + assert.ok(firstRun !== undefined && firstHanded !== undefined); + assert.deepStrictEqual(firstRun.args, [ + 'run', + '--project', + PROJECT, + '--launch-profile', + 'http', + ]); + assert.strictEqual(firstRun.cwd, path.dirname(PROJECT)); + assert.strictEqual(firstHanded.type, 'pwa-chrome'); + assert.strictEqual(firstHanded.url, HTTP_ADDRESS); + assert.strictEqual(firstHanded.inspectUri, HTTP_INSPECT); + assert.strictEqual(firstHanded.webRoot, path.join(FIXTURE, 'wwwroot')); + assert.strictEqual(first.value.url, HTTP_ADDRESS); + assert.strictEqual(firstRun.stops(), 0); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), first.value); + + // 2. A second launch of the SAME project replaces the first: the old app + // is stopped once, so two DevServers never race for one port. + const second = await launchBlazorWasm(fakeFolder(FIXTURE), launchConfig()); + assert.ok(second.ok); + const secondRun = runs[1]; + assert.ok(secondRun !== undefined); + assert.strictEqual(firstRun.stops(), 1); + assert.strictEqual(handed.length, 2); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), second.value); + + // 3. The browser session ends, and with it the app it was attached to — + // and only that app, and only once. + endBrowser(); + await second.value.ended; + assert.strictEqual(secondRun.stops(), 1); + assert.strictEqual(firstRun.stops(), 1); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), undefined); + } finally { + restore(); + } + }); + + test('stops the served app when the browser never starts, or the app dies during the handoff', async () => { + const runs: FakeRun[] = []; + let browser: () => Promise<{ readonly ended: Promise } | undefined> = () => + Promise.resolve(undefined); + const restore = useBlazorWasmHost({ + startProcess: (args, cwd, onOutput) => { + const run = fakeRun(args, cwd, onOutput); + runs.push(run); + run.announce([` Now listening on: ${HTTP_ADDRESS}`]); + return run.process; + }, + startBrowserSession: () => browser(), + }); + try { + // 1. The app served its address, but js-debug never started: the app is + // stopped, and the refusal names the browser and the address. + const refused = await launchBlazorWasm(fakeFolder(FIXTURE), launchConfig()); + assert.strictEqual(refused.ok, false); + assert.strictEqual( + refused.ok ? '' : refused.error, + `Could not start pwa-chrome for ${HTTP_ADDRESS}.`, + ); + assert.strictEqual(runs[0]?.stops(), 1, 'the served app is stopped once'); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), undefined); + + // 2. The browser started, but the app had died meanwhile: its reason is + // carried into the refusal, and nothing is left running. + browser = async () => { + runs[1]?.crash('the DevServer crashed'); + await new Promise((resolve) => setTimeout(resolve, 0)); + return { ended: new Promise(() => undefined) }; + }; + const died = await launchBlazorWasm(fakeFolder(FIXTURE), launchConfig()); + assert.strictEqual(died.ok, false); + assert.strictEqual( + died.ok ? '' : died.error, + 'dotnet run for BlazorWasmApp.csproj stopped before it served anything: the DevServer crashed', + ); + assert.strictEqual(runs.length, 2); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), undefined); + } finally { + restore(); + } + }); + + test('names why a launch has no project, or why its startup failed', async () => { + // 1. With no folder, a relative project cannot be resolved, and a missing + // project is never assumed. + assert.deepStrictEqual( + projectAt(undefined, 'App.csproj'), + err("Cannot resolve 'App.csproj' without a workspace folder."), + ); + assert.deepStrictEqual( + await resolveProject(undefined, undefined), + err('Blazor WebAssembly launch found no runnable project for this workspace.'), + ); + + // 2. Without a stated project, the folder's only project is the one launched; + // a folder holding none is refused, never guessed at. + assert.deepStrictEqual(await resolveProject(fakeFolder(FIXTURE), ' '), ok(PROJECT)); + const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'sharplsp-wasm-empty-')); + try { + const none = await resolveProject(fakeFolder(empty), undefined); + assert.strictEqual(none.ok, false, 'an empty folder has no project to launch'); + } finally { + fs.rmSync(empty, { recursive: true, force: true }); + } + + // 3. A project file that cannot be read is not a Blazor WebAssembly app, and + // an address that is not a URL is not announced. + assert.strictEqual(isBlazorWasmProjectFile(path.join(FIXTURE, 'Missing.csproj')), false); + assert.strictEqual(isBlazorWasmProjectFile(PROJECT), true); + assert.strictEqual(listeningUrlOf(' Now listening on: http://[::1'), undefined); + + // 4. A startup that timed out or exited says which, by project name. + assert.match( + startupFailure({ kind: 'timeout' }, PROJECT), + /^dotnet run for BlazorWasmApp\.csproj announced no listening URL within 180s\./u, + ); + assert.strictEqual( + startupFailure({ kind: 'exited', outcome: ok(undefined) }, PROJECT), + 'dotnet run for BlazorWasmApp.csproj exited before it announced a listening URL.', + ); + }); + + test('refuses a project that stops before it serves anything', async () => { + const restore = useBlazorWasmHost({ + startProcess: (args, cwd, onOutput) => { + const run = fakeRun(args, cwd, onOutput); + run.crash('MSB1009: Project file does not exist.'); + return run.process; + }, + startBrowserSession: () => Promise.resolve(undefined), + }); + try { + // 1. dotnet's own reason is carried into the refusal, named by project. + const failed = await launchBlazorWasm(fakeFolder(FIXTURE), launchConfig()); + assert.strictEqual(failed.ok, false); + assert.match( + failed.ok ? '' : failed.error, + /dotnet run for BlazorWasmApp\.csproj stopped before it served anything: MSB1009/u, + ); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), undefined); + + // 2. A project path that does not exist is refused before a process runs. + const missing = await launchBlazorWasm( + fakeFolder(FIXTURE), + launchConfig({ project: path.join(FIXTURE, 'Nope.csproj') }), + ); + assert.strictEqual(missing.ok, false); + assert.match(missing.ok ? '' : missing.error, /project not found/iu); + + // 3. A profile the project does not declare is refused, not ignored. + const unknownProfile = await launchBlazorWasm( + fakeFolder(FIXTURE), + launchConfig({ launchProfile: 'nope' }), + ); + assert.strictEqual(unknownProfile.ok, false); + assert.match(unknownProfile.ok ? '' : unknownProfile.error, /'nope' is not declared/iu); + } finally { + restore(); + } + }); +}); diff --git a/src/editors/vscode/src/test/suite/blazor-wasm-debug.test.ts b/src/editors/vscode/src/test/suite/blazor-wasm-debug.test.ts new file mode 100644 index 00000000..1060b130 --- /dev/null +++ b/src/editors/vscode/src/test/suite/blazor-wasm-debug.test.ts @@ -0,0 +1,286 @@ +// Blazor WebAssembly debugging, end to end: a REAL `dotnet run` of a REAL +// Blazor WebAssembly app, handed to js-debug, and stopped with the session. +// Spec: [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]. +// +// The launch is driven exactly as a user drives it — `vscode.debug.startDebugging` +// with a `sharplsp-blazorwasm` configuration, through the provider the extension +// registered at activation. Only the browser session itself is replaced, by the +// shipped `useBlazorWasmHost` seam: a headless host has no js-debug to start, and +// what this suite must prove is the configuration js-debug would have received +// and the app that answers behind it. +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import { + runningBlazorWasmLaunch, + useBlazorWasmHost, + type RunningLaunch, +} from '../../blazor-wasm-launch.js'; +import { copyPinnedFixture, dotnet } from './dotnet-project-kit'; +import { authoredPackageJson } from './run-debug-kit'; +import { + comparablePath, + pollUntilResult, + removeDirRecursive, + requireWorkspaceRoot, +} from './test-helpers'; +import { DEBUG_SESSION_MS, FIXTURE_BUILD_MS, POLL_INTERVAL_MS } from './test-timeouts'; + +/** The Blazor WebAssembly fixture in the repository. */ +const SOURCE = path.resolve(__dirname, '../../../../../sharplsp/tests/fixtures/blazor-wasm'); + +/** The project the launch is pointed at, relative to the fixture root. */ +const PROJECT_NAME = 'BlazorWasmApp.csproj'; + +/** + * The manifest as AUTHORED, which is not what VS Code core hands back: core + * injects its own attributes into every debugger it loads + * ([DEBUG-FEATURES-LAUNCH-OUTPUT] rule 5). + */ +interface AuthoredDebugger { + readonly type?: string; + readonly configurationAttributes?: { readonly launch?: { properties?: object } }; + readonly configurationSnippets?: { readonly body?: { readonly type?: string } }[]; +} + +/** The parts of the authored manifest this suite judges. */ +interface AuthoredManifest { + readonly activationEvents?: string[]; + readonly contributes?: { readonly debuggers?: AuthoredDebugger[] }; +} + +/** The page the DevServer answers the debug proxy on. */ +const DEBUG_PROXY_PATH = '/_framework/debug'; + +/** Copy the fixture to `root` and build it, so the launch only has to run it. */ +async function writeFixture(root: string): Promise { + copyPinnedFixture(SOURCE, root); + await dotnet(['build', '--nologo'], root); +} + +/** An HTTP status for `url`, or undefined when nothing answers. */ +function statusOf(url: string): Promise { + return new Promise((resolve) => { + const request = http.get(url, (response) => { + response.resume(); + resolve(response.statusCode); + }); + request.on('error', () => { + resolve(undefined); + }); + request.setTimeout(5_000, () => { + request.destroy(); + resolve(undefined); + }); + }); +} + +/** + * The scratch copy of the fixture this suite works against. + * + * Inside the OPENED workspace folder, as every debug suite's scratch tree is: a + * session started against a temp directory has no `workspaceFolder`, which is a + * different refusal path from the one under test here. + */ +let root = ''; + +/** Everything the browser session received, in order. */ +const handed: vscode.DebugConfiguration[] = []; + +/** How many browser sessions were started. */ +let sessions = 0; + +/** Ends the browser session the launch is waiting on. */ +let endBrowser: () => void = () => undefined; + +/** The fixture, built, with the browser session replaced by a recorder. */ +suiteSetup(async function () { + this.timeout(FIXTURE_BUILD_MS); + root = fs.mkdtempSync(path.join(requireWorkspaceRoot(), 'blazor-wasm-')); + await writeFixture(root); + useBlazorWasmHost({ + startBrowserSession: (_folder, configuration) => { + handed.push(configuration); + sessions += 1; + return Promise.resolve({ + ended: new Promise((resolve) => { + endBrowser = resolve; + }), + }); + }, + }); +}); + +suiteTeardown(function () { + this.timeout(DEBUG_SESSION_MS); + removeDirRecursive(root); +}); + +/** The project file of the scratch copy. */ +function projectFile(): string { + return path.join(root, PROJECT_NAME); +} + +/** A `sharplsp-blazorwasm` configuration for the scratch project. */ +function launchConfiguration(): vscode.DebugConfiguration { + return { + type: 'sharplsp-blazorwasm', + request: 'launch', + name: 'Launch Blazor WebAssembly', + project: projectFile(), + browser: 'chrome', + }; +} + +/** Start the launch through the provider the extension registered, and wait for it. */ +async function startLaunch(): Promise { + const folder: vscode.WorkspaceFolder = { + uri: vscode.Uri.file(requireWorkspaceRoot()), + name: path.basename(requireWorkspaceRoot()), + index: 0, + }; + const wanted = sessions + 1; + // The provider hands off to js-debug and answers `undefined`, which is how a + // provider starts NO session of its own — so `false` here is success, and the + // seam is what says the launch reached the browser. + await vscode.debug.startDebugging(folder, launchConfiguration()); + const launch = await pollUntilResult( + () => Promise.resolve(runningBlazorWasmLaunch(projectFile())), + (found) => found !== undefined && sessions === wanted, + FIXTURE_BUILD_MS, + POLL_INTERVAL_MS, + 'the Blazor WebAssembly launch to serve and hand off', + ); + assert.ok(launch, 'the launch registers itself while it runs'); + return launch; +} + +suite('Blazor WebAssembly debugging', () => { + test('serves the app, hands js-debug the proxy and dies with the session', async function () { + this.timeout(FIXTURE_BUILD_MS + DEBUG_SESSION_MS); + + // 1. The user presses F5 on a `sharplsp-blazorwasm` launch. `dotnet run` + // builds and serves the app, and js-debug receives the expanded proxy. + const launch = await startLaunch(); + const configuration = handed[0]; + assert.ok(configuration !== undefined, 'js-debug was handed a configuration'); + assert.strictEqual(configuration.type, 'pwa-chrome', 'chrome maps onto pwa-chrome'); + assert.strictEqual(configuration.request, 'launch'); + assert.strictEqual(configuration.name, 'Launch Blazor WebAssembly'); + assert.ok( + /^http:\/\/127\.0\.0\.1:\d+$/u.test(launch.url), + `the DevServer's own address is launched: ${launch.url}`, + ); + assert.strictEqual(configuration.url, launch.url, 'the browser opens the real address'); + const expanded = `ws://${new URL(launch.url).host}${DEBUG_PROXY_PATH}/ws-proxy?browser={browserInspectUri}`; + assert.strictEqual(configuration.inspectUri, expanded, 'the proxy carries host and port'); + assert.ok( + !configuration.inspectUri.includes('{wsProtocol}'), + 'every token but {browserInspectUri} is expanded', + ); + assert.strictEqual( + comparablePath(configuration.webRoot), + comparablePath(path.join(root, 'wwwroot')), + 'webRoot defaults to the project wwwroot', + ); + + // 2. The proxy the configuration names is really served: a GET of the + // DevServer's `/_framework/debug` answers 200, which only the dev server + // package produces. TWICE, so a single lucky answer cannot pass. + const status = await statusOf(`${launch.url}${DEBUG_PROXY_PATH}`); + assert.strictEqual(status, 200, `the .NET debug proxy answers on ${launch.url}`); + const again = await statusOf(`${launch.url}${DEBUG_PROXY_PATH}`); + assert.strictEqual(again, 200, 'and keeps answering'); + const page = await statusOf(`${launch.url}/`); + assert.strictEqual(page, 200, 'the app itself is served too'); + + // 3. The user stops the browser session: the app it was attached to stops + // with it, and the port it held is released. Polled, because the stop is + // a signal and the process takes a moment to go. + endBrowser(); + await launch.ended; + assert.strictEqual( + runningBlazorWasmLaunch(projectFile()), + undefined, + 'a finished launch is not registered', + ); + const closed = await pollUntilResult( + () => statusOf(`${launch.url}${DEBUG_PROXY_PATH}`), + (status) => status !== 200, + DEBUG_SESSION_MS, + POLL_INTERVAL_MS, + 'the DevServer to stop answering once the browser session ended', + ); + assert.notStrictEqual(closed, 200, 'the dotnet run process is gone'); + }); + + test('stops the app it replaced when the same project launches twice', async function () { + this.timeout(FIXTURE_BUILD_MS + DEBUG_SESSION_MS); + + // 1. A launch of the project, serving. + const first = await startLaunch(); + assert.strictEqual(sessions, 2, 'the recorder saw the session'); + assert.strictEqual(await statusOf(`${first.url}${DEBUG_PROXY_PATH}`), 200); + + // 2. A second launch of the SAME project: one DevServer may hold the port, + // so the first is stopped and the second takes over the registry. + const second = await startLaunch(); + assert.notStrictEqual(second.url, first.url, 'the second app bound its own address'); + assert.strictEqual(runningBlazorWasmLaunch(projectFile()), second); + await pollUntilResult( + () => statusOf(`${first.url}${DEBUG_PROXY_PATH}`), + (status) => status !== 200, + DEBUG_SESSION_MS, + POLL_INTERVAL_MS, + 'the replaced DevServer to stop answering', + ); + + // 3. Ending the browser session ends only what it was attached to. + endBrowser(); + await second.ended; + assert.notStrictEqual(await statusOf(`${second.url}${DEBUG_PROXY_PATH}`), 200); + assert.strictEqual(runningBlazorWasmLaunch(projectFile()), undefined); + }); + + test('contributes the debugger, its snippet and its activation events', async function () { + this.timeout(DEBUG_SESSION_MS); + + // 1. The extension activated, so the providers a launch needs are registered. + const extension = vscode.extensions.getExtension('nimblesite.sharplsp'); + assert.ok(extension !== undefined, 'the SharpLsp extension is installed in the host'); + await extension.activate(); + assert.strictEqual(extension.isActive, true); + + // 2. Both debuggers are declared: the C#/F# one, and the Blazor WebAssembly + // one, whose launch properties are what `launch.json` IntelliSense reads. + // Read from the AUTHORED manifest, because the loaded one is merged with + // core's injected attributes and would report properties we never wrote. + const manifest = authoredPackageJson() as AuthoredManifest; + const debuggers = manifest.contributes?.debuggers ?? []; + const wasm = debuggers.find((entry) => entry.type === 'sharplsp-blazorwasm'); + assert.ok( + wasm !== undefined, + `contributed debuggers: ${debuggers.map((d) => d.type).join(', ')}`, + ); + const properties = Object.keys(wasm.configurationAttributes?.launch?.properties ?? {}).sort(); + assert.deepStrictEqual(properties, [ + 'browser', + 'launchProfile', + 'project', + 'timeout', + 'url', + 'webRoot', + ]); + const snippets = wasm.configurationSnippets?.map((snippet) => snippet.body?.type) ?? []; + assert.deepStrictEqual(snippets, ['sharplsp-blazorwasm'], 'one snippet, of this type'); + + // 3. The type makes the extension activate before it is used, and the + // manifest the host loaded agrees with the one in the source tree. + assert.ok(manifest.activationEvents?.includes('onDebugResolve:sharplsp-blazorwasm')); + assert.ok( + manifest.activationEvents?.includes('onDebugDynamicConfigurations:sharplsp-blazorwasm'), + ); + }); +}); diff --git a/src/editors/vscode/src/test/suite/debug-hot-reload-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-hot-reload-e2e.test.ts index ca847f7d..2ad47854 100644 --- a/src/editors/vscode/src/test/suite/debug-hot-reload-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-hot-reload-e2e.test.ts @@ -11,6 +11,8 @@ // their locals, and the state they spent minutes reaching. So the session id is // asserted unchanged across every edit. import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; import * as vscode from 'vscode'; import { MODE, type DebugFixture } from './debug-fixture-programs'; import { CMD_CONTINUE, assertStoppedAt, topFrame } from './debug-drive-kit'; @@ -238,4 +240,62 @@ suite('Debug hot reload — editing a method while the debuggee is paused', () = ); }, ); + + // Implements [RAZOR-HOT-RELOAD]: the DAP save pipeline owns Razor documents + // as well as C#. A saved .razor file inside the debugged project reaches the + // sidecar — which refuses an unlisted Razor file BY NAME — instead of + // vanishing as a foreign language, and the session survives the refusal. + debugTest( + 'a saved Razor document is owned by the pipeline and names itself when unlisted', + debuggee, + async ({ fixture, recorder, stubs }) => { + // Interaction 1 — run the debuggee with hot reload enabled. + const { session } = await runToFirstStop(debuggee(), 'accumulate-entry', { + mode: MODE.plain, + extra: { hotReload: true }, + }); + + // Interaction 2 — save a Razor document inside the debugged project. + // This console debuggee lists no Razor file, so the sidecar must refuse + // the save by name rather than apply anything. + const razorPath = path.join(fixture.dir, 'Owned.razor'); + fs.writeFileSync(razorPath, '

Owned

\n', 'utf8'); + const razorDoc = await vscode.workspace.openTextDocument(vscode.Uri.file(razorPath)); + eq(razorDoc.languageId, 'razor', 'VS Code opens a .razor file as the razor language'); + const razorEditor = await vscode.window.showTextDocument(razorDoc); + await razorEditor.edit((b) => { + b.insert(new vscode.Position(0, 0), '

touched

'); + }); + assert.ok(await razorDoc.save(), 'the Razor document must save — that is the trigger'); + await sleep(RELOAD_SETTLE_MS); + + // Interaction 3 — the save REACHED the pipeline: one refusal, naming the + // file. An unowned language would leave `refusalsOf` empty here. + const reported = refusalsOf(stubs); + eq( + reported.length, + 1, + `exactly one refusal for the unlisted Razor save: ${JSON.stringify(reported)}`, + ); + const message = requireAt(reported, 0, 'the unlisted-Razor refusal'); + assert.ok( + message.includes('Owned.razor'), + `the refusal must name the Razor file; it said: '${message}'`, + ); + + // Interaction 4 — the session SURVIVED the refusal, and the original IL + // still runs: a refused save is a verdict, not a lost debuggee. + eq( + vscode.debug.activeDebugSession?.id, + session.id, + 'an unlisted Razor save must not restart or kill the session', + ); + await vscode.commands.executeCommand(CMD_CONTINUE); + await assertRanToCompletion(recorder, 0, 'a session after an unlisted Razor save'); + assert.ok( + recorder.outputText().includes('total=8'), + 'nothing was applied, so the original result must appear', + ); + }, + ); }); diff --git a/src/editors/vscode/src/test/suite/dotnet-project-kit.ts b/src/editors/vscode/src/test/suite/dotnet-project-kit.ts index 57f4476a..fff2b171 100644 --- a/src/editors/vscode/src/test/suite/dotnet-project-kit.ts +++ b/src/editors/vscode/src/test/suite/dotnet-project-kit.ts @@ -10,10 +10,12 @@ // Solutions are produced by `dotnet new sln` + `dotnet sln add`, never authored: // .NET 10 emits the XML `.slnx` format by default and the extension has to cope // with whichever the CLI chose, so the fixture must not assume an extension. +import * as assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { execFile } from 'node:child_process'; import { XMLBuilder } from 'fast-xml-parser'; +import { findGlobalJson } from '../../global-json.js'; import { FIXTURE_BUILD_MS } from './test-timeouts'; /** A ``. */ @@ -433,3 +435,22 @@ export async function installedFrameworkPair(cwd: string): Promise { } return targets.slice(-2); } + +/** Build output a copied fixture must not bring along: a copied `obj` points back at the source. */ +const BUILD_OUTPUT = new Set(['bin', 'obj']); + +/** + * Copy the repository fixture at `source` to `root`, without its build output, pinned to + * the SDK the repository builds it with. A copy under the OS temp directory has no + * `global.json` above it, so it would build with whatever SDK is newest on the machine, + * whose Razor generator can need a newer Roslyn than the sidecar's. + */ +export function copyPinnedFixture(source: string, root: string): void { + fs.cpSync(source, root, { + recursive: true, + filter: (entry) => !BUILD_OUTPUT.has(path.basename(entry)), + }); + const pin = findGlobalJson(source); + assert.ok(pin, 'the repository pins the fixture SDK in a global.json'); + fs.copyFileSync(pin, path.join(root, 'global.json')); +} diff --git a/src/editors/vscode/src/test/suite/fsi-build-output-e2e.test.ts b/src/editors/vscode/src/test/suite/fsi-build-output-e2e.test.ts index 427f33de..1b764bdb 100644 --- a/src/editors/vscode/src/test/suite/fsi-build-output-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/fsi-build-output-e2e.test.ts @@ -420,11 +420,16 @@ suite('FSI / Build / Output-filter / Hot-reload E2E', () => { // ── hot-reload.ts ─────────────────────────────────────────────── - test('isRelevantLanguage classifies C#/F# vs other languages within a save flow', async function () { + test('isRelevantLanguage classifies C#/F#/Razor vs other languages within a save flow', async function () { this.timeout(COMMAND_MS); assert.ok(isRelevantLanguage('csharp')); assert.ok(isRelevantLanguage('fsharp')); + assert.ok(isRelevantLanguage('razor'), 'a .razor save feeds hot reload ([RAZOR-HOT-RELOAD])'); + assert.ok( + isRelevantLanguage('aspnetcorerazor'), + "the C# extension's Razor id feeds hot reload too ([RAZOR-HOT-RELOAD])", + ); assert.ok(!isRelevantLanguage('json')); assert.ok(!isRelevantLanguage('plaintext')); @@ -473,6 +478,26 @@ suite('FSI / Build / Output-filter / Hot-reload E2E', () => { assert.ok(!isRelevantLanguage(txtDoc.languageId)); assert.ok(isHotReloadRunning()); + // Saving a Razor document must also be harmless: its language is relevant, + // so the onSave path runs instead of early-returning ([RAZOR-HOT-RELOAD]). + const razorPath = path.join(tmpDir, 'Saved.razor'); + fs.writeFileSync(razorPath, '

Saved

\n', 'utf8'); + const razorDoc = await vscode.workspace.openTextDocument(vscode.Uri.file(razorPath)); + assert.strictEqual( + razorDoc.languageId, + 'razor', + 'VS Code opens a .razor file as the razor language', + ); + const razorEditor = await vscode.window.showTextDocument(razorDoc); + await razorEditor.edit((b) => { + b.insert(new vscode.Position(0, 0), '

touched

'); + }); + await assert.doesNotReject(async () => { + await razorDoc.save(); + }, 'Saving a Razor doc during hot reload must not throw'); + assert.ok(isRelevantLanguage(razorDoc.languageId)); + assert.ok(isHotReloadRunning(), 'Hot reload must remain running after a Razor save'); + await vscode.commands.executeCommand('sharplsp.hotReload.stop'); assert.ok(!isHotReloadRunning()); }); diff --git a/src/editors/vscode/src/test/suite/razor-kit.ts b/src/editors/vscode/src/test/suite/razor-kit.ts index d5f39aee..9d7fd63e 100644 --- a/src/editors/vscode/src/test/suite/razor-kit.ts +++ b/src/editors/vscode/src/test/suite/razor-kit.ts @@ -2,50 +2,33 @@ // repository's src/sharplsp/tests/fixtures/razor, copied without its build output // to a scratch root, built so the SDK's Razor generator has run, and loaded // through the real extension. -import * as assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import type * as vscode from 'vscode'; -import { findGlobalJson } from '../../global-json.js'; import { serverProjection } from '../../razor-html.js'; -import { dotnet } from './dotnet-project-kit'; +import { copyPinnedFixture, dotnet } from './dotnet-project-kit'; import { type LoadedFixture, useLoadedFixture } from './netfx-language-kit'; import { lspClient } from './profiler-kit'; /** The fixture in the repository. */ const SOURCE = path.resolve(__dirname, '../../../../../sharplsp/tests/fixtures/razor'); -/** Build output a copy leaves behind: a copied `obj` would point back at the source. */ -const BUILD_OUTPUT = new Set(['bin', 'obj']); - /** The Razor files the suite opens, relative to the fixture root. */ export const RAZOR_FILES = { counter: 'BlazorApp/Components/Pages/Counter.razor', board: 'BlazorApp/Components/Pages/Board.razor', + leaders: 'BlazorApp/Components/Pages/Leaders.razor', + card: 'BlazorApp/Components/Shared/Card.razor', + edit: 'PagesApp/Pages/Edit.cshtml', } as const; /** The Razor file the suite writes where no project lists it. */ export const STRAY_RAZOR = 'Stray.razor'; -/** - * Pin `root` to the SDK the fixture has in the repository. A scratch copy has no - * `global.json` above it, so it would build and load with the newest installed SDK, - * whose Razor generator can need a newer Roslyn than the sidecar's. - */ -function keepSdkPin(root: string): void { - const pin = findGlobalJson(SOURCE); - assert.ok(pin, 'the repository pins the fixture SDK in a global.json'); - fs.copyFileSync(pin, path.join(root, 'global.json')); -} - -/** Copy the fixture to `root`, with its SDK pin, and build it. */ +/** Copy the fixture to `root`, pinned to the repository SDK, and build it. */ async function writeRazorFixture(root: string): Promise { - fs.cpSync(SOURCE, root, { - recursive: true, - filter: (source) => !BUILD_OUTPUT.has(path.basename(source)), - }); - keepSdkPin(root); + copyPinnedFixture(SOURCE, root); const solution = path.join(root, 'RazorFixtures.slnx'); await dotnet(['build', solution], root); return { diff --git a/src/editors/vscode/src/test/suite/razor-lsp.test.ts b/src/editors/vscode/src/test/suite/razor-lsp.test.ts index f0188dbb..5d86d6c2 100644 --- a/src/editors/vscode/src/test/suite/razor-lsp.test.ts +++ b/src/editors/vscode/src/test/suite/razor-lsp.test.ts @@ -300,4 +300,164 @@ suite('Razor in VS Code', () => { 'every usage finds the same references', ); }); + + test('F# symbols in Razor navigate to, find and rename in their F# source', async function () { + this.timeout(LSP_RESPONSE_MS * 3); + const { doc, uri } = await openRepoFile(fixture().root, RAZOR_FILES.leaders); + const inScores = (target: vscode.Uri): boolean => target.path.endsWith('/Domain/Scores.fs'); + + // Interaction 1 — definition on an F# record lands on its F# declaration, not + // decompiled source. [RAZOR-FSHARP-DEFINITION] + const definitions = await pollProvider( + 'vscode.executeDefinitionProvider', + [uri, positionOf(doc, 'private readonly Player[]', 'Player')], + (found) => found.some((target) => inScores(targetUri(target))), + LSP_RESPONSE_MS * 2, + ); + const declared = definitions.find((target) => inScores(targetUri(target))); + assert.ok(declared, 'a definition in Scores.fs'); + const scores = await vscode.workspace.openTextDocument(targetUri(declared)); + assert.equal(scores.getText(targetRange(declared)), 'Player', 'ranged on the name'); + assert.ok( + scores.lineAt(targetRange(declared).start.line).text.startsWith('type Player'), + 'on the type declaration', + ); + + // Interaction 2 — references on an F# field span F# and Razor, each on its name. + // [RAZOR-FSHARP-REFERENCES] + const score = positionOf(doc, '@player.Score', 'Score'); + const references = await pollProvider( + 'vscode.executeReferenceProvider', + [uri, score], + (found) => + found.some((location) => inScores(location.uri)) && + found.some((location) => location.uri.toString() === uri.toString()), + ); + assert.equal(references.filter((location) => inScores(location.uri)).length, 5); + for (const location of references) { + const holder = inScores(location.uri) ? scores : doc; + assert.equal(holder.getText(location.range), 'Score', 'every reference spans the name'); + assert.ok(!location.uri.path.endsWith('.g.cs'), 'never generated code'); + } + + // Interaction 3 — renaming it from Razor renames its F# declaration and uses. + // [RAZOR-FSHARP-RENAME] + const edit = await vscode.commands.executeCommand( + 'vscode.executeDocumentRenameProvider', + uri, + score, + 'Points', + ); + const byFile = new Map(edit.entries().map(([target, edits]) => [target.path, edits])); + assert.equal(byFile.get(uri.path)?.length, 1, 'the Razor use'); + assert.equal(byFile.get(scores.uri.path)?.length, 5, 'the F# declaration and four uses'); + assert.ok( + [...byFile.values()].flat().every((change) => change.newText === 'Points'), + 'every edit renames to Points', + ); + assert.ok(![...byFile.keys()].some((file) => file.endsWith('.g.cs')), 'no generated file'); + }); + + test('a Razor Page completes tag helper elements once, before the HTML service', async function () { + this.timeout(LSP_RESPONSE_MS * 2); + const { doc, uri, editor } = await openRepoFile(fixture().root, RAZOR_FILES.edit); + const end = doc.lineAt(doc.lineCount - 1).range.end; + await editor.edit((builder) => { + builder.insert(end, '\n<'); + }); + const caret = doc.lineAt(doc.lineCount - 1).range.end; + + // The tag helper's element comes from the server, HTML's own from the HTML + // service, and `a`, which both offer, appears once. [RAZOR-MVC-TAGHELPERS] + const list = await completionList(uri, caret, 'partial'); + const labels = list.items.map((item) => + typeof item.label === 'string' ? item.label : item.label.label, + ); + assert.ok(labels.includes('environment'), 'every tag helper element'); + assert.ok( + HTML_ONLY.every((element) => labels.includes(element)), + "and the HTML service's", + ); + assert.equal(labels.filter((label) => label === 'a').length, 1, '`a` once'); + const anchor = list.items.find((item) => item.label === 'a'); + assert.match( + String(anchor?.detail), + /AnchorTagHelper/, + "the server's item, naming its tag helper", + ); + assert.ok(!labels.includes('ErrorBoundary'), 'no component completes in a Razor Page'); + }); + + test('a component completes and defines its stylesheet classes, and nests its collocated files', async function () { + this.timeout(LSP_RESPONSE_MS * 3); + const { doc, uri, editor } = await openRepoFile(fixture().root, RAZOR_FILES.card); + const inStylesheet = (target: vscode.Uri): boolean => + target.path.endsWith('/Shared/Card.razor.css'); + + // Interaction 1 — a class name defines to every selector that declares it, each on + // the name after its `.`. [RAZOR-COLLOCATION-CSS] + const definitions = await pollProvider( + 'vscode.executeDefinitionProvider', + [uri, positionOf(doc, 'class="card title"', 'title')], + (found) => found.length > 0 && found.every((target) => inStylesheet(targetUri(target))), + LSP_RESPONSE_MS * 2, + ); + assert.equal(definitions.length, 5, 'the five rules whose selectors hold .title'); + const [first] = definitions; + assert.ok(first, 'a definition'); + const stylesheet = await vscode.workspace.openTextDocument(targetUri(first)); + for (const definition of definitions) { + assert.equal( + stylesheet.getText(targetRange(definition)), + 'title', + 'ranged on the class name', + ); + } + + // Interaction 2 — inside a class value, the stylesheet's classes complete as values + // that replace the class name at the caret. [RAZOR-COLLOCATION-CSS] + const end = doc.lineAt(doc.lineCount - 1).range.end; + await editor.edit((builder) => { + builder.insert(end, '\n
{ assert.strictEqual(DEBUG_TYPE, DEBUG_TYPE_ID, 'constants and the kit name one debug type'); const debuggers: unknown = block.debuggers; assert.ok(Array.isArray(debuggers), 'contributes.debuggers must be an array'); - assert.strictEqual(debuggers.length, 1, 'a second debugger would split the F5 auto-pick'); - assert.strictEqual(debuggers[0], contribution, 'the only entry IS the sharplsp-coreclr one'); + // Judged on `languages`, not on the array's length: a debugger that declares + // NONE ([DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]'s `sharplsp-blazorwasm`, reached + // from launch.json and the Select Debugger list) takes no part in the F5 + // auto-pick, so only an entry that DOES declare them can split it. + const autoPicked = debuggers.filter( + (item) => Array.isArray(item?.languages) && item.languages.length > 0, + ); + assert.strictEqual(autoPicked.length, 1, 'a second debugger would split the F5 auto-pick'); + assert.strictEqual(autoPicked[0], contribution, 'the auto-picked entry IS sharplsp-coreclr'); const bodies = snippetBodies(); assert.strictEqual(bodies.length, 2, 'a launch and an attach snippet are both offered'); const types = bodies.map((body) => String(body.type)); diff --git a/src/editors/vscode/test-chunks.json b/src/editors/vscode/test-chunks.json index 3e274eba..ed0ba200 100644 --- a/src/editors/vscode/test-chunks.json +++ b/src/editors/vscode/test-chunks.json @@ -56,11 +56,13 @@ ] }, "debug": { - "description": "Getting a program to START, and nothing about what happens once it has: the F5 / no-launch.json resolve contract, launchSettings.json + .run.json profile parsing, the netcoredbg adapter factory, manifest conformance for the debugger, breakpoint, task-definition, command and menu contributions, the [SCRIPT-CONE] launch-target walk with MSBuild output resolution, and the F5 / Ctrl+F5 / sharplsp.runProgram gestures over real projects, C# file-based apps and .fsx scripts.", + "description": "Getting a program to START, and nothing about what happens once it has: the F5 / no-launch.json resolve contract, launchSettings.json + .run.json profile parsing, the netcoredbg adapter factory, manifest conformance for the debugger, breakpoint, task-definition, command and menu contributions, the [SCRIPT-CONE] launch-target walk with MSBuild output resolution, and the F5 / Ctrl+F5 / sharplsp.runProgram gestures over real projects, C# file-based apps and .fsx scripts. Also the Blazor WebAssembly launch ([DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]), which starts `dotnet run`, hands the DevServer's debug proxy to js-debug and dies with that browser session.", "files": [ "debug-e2e.test.js", "debug-adapter-e2e.test.js", "debug-adapter-startup.test.js", + "blazor-wasm-debug.test.js", + "blazor-wasm-debug-resolution.test.js", "run-debug-profiles.test.js", "run-debug-contributions.test.js", "run-debug-target.test.js", diff --git a/src/sharplsp/src/call_hierarchy.rs b/src/sharplsp/src/call_hierarchy.rs index e5ff5d9c..c92f0bd8 100644 --- a/src/sharplsp/src/call_hierarchy.rs +++ b/src/sharplsp/src/call_hierarchy.rs @@ -75,6 +75,7 @@ fn parse_symbol_kind(kind: &str) -> SymbolKind { "Property" => SymbolKind::PROPERTY, "Field" => SymbolKind::FIELD, "Class" => SymbolKind::CLASS, + "Event" => SymbolKind::EVENT, "Interface" => SymbolKind::INTERFACE, "Struct" => SymbolKind::STRUCT, "Enum" => SymbolKind::ENUM, diff --git a/src/sharplsp/src/code_actions.rs b/src/sharplsp/src/code_actions.rs index 6dea6e3f..a8d12c10 100644 --- a/src/sharplsp/src/code_actions.rs +++ b/src/sharplsp/src/code_actions.rs @@ -8,7 +8,10 @@ use std::sync::Arc; use anyhow::Result; use lsp_server::Request; use lsp_types::{ - CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionParams, TextEdit, Uri, WorkspaceEdit, + CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionParams, CreateFile, + CreateFileOptions, DocumentChangeOperation, DocumentChanges, OneOf, + OptionalVersionedTextDocumentIdentifier, ResourceOp, TextDocumentEdit, TextEdit, Uri, + WorkspaceEdit, }; use tracing::debug; @@ -104,11 +107,59 @@ fn map_action_kind(kind: &str) -> CodeActionKind { } /// Convert a sidecar workspace edit into an LSP `WorkspaceEdit`. +/// The LSP edit for a sidecar edit: a map of text edits by file, or, when it +/// creates a file, document changes that create each new file before editing +/// it, since a text edit cannot reach a file that does not exist yet. +/// [RAZOR-CODE-ACTIONS] +fn map_workspace_edit(edit: &SidecarWorkspaceEdit) -> WorkspaceEdit { + if edit.document_changes.iter().any(|change| change.create) { + WorkspaceEdit { + document_changes: Some(DocumentChanges::Operations( + edit.document_changes + .iter() + .flat_map(document_operations) + .collect(), + )), + ..WorkspaceEdit::default() + } + } else { + map_text_changes(edit) + } +} + +/// A document's operations: its creation when it is new, then its edits. +fn document_operations(change: &SidecarDocumentEdit) -> Vec { + let Ok(uri) = crate::paths::path_to_lsp_uri(&change.file_path) else { + return Vec::new(); + }; + let create = change.create.then(|| { + DocumentChangeOperation::Op(ResourceOp::Create(CreateFile { + uri: uri.clone(), + options: Some(CreateFileOptions { + overwrite: Some(false), + ignore_if_exists: Some(false), + }), + annotation_id: None, + })) + }); + let edits = DocumentChangeOperation::Edit(TextDocumentEdit { + text_document: OptionalVersionedTextDocumentIdentifier { uri, version: None }, + edits: change + .edits + .iter() + .map(map_text_edit) + .map(OneOf::Left) + .collect(), + }); + create.into_iter().chain([edits]).collect() +} + +/// A sidecar edit that creates no file, as per-document text changes. #[expect( clippy::mutable_key_type, reason = "Uri is the key type mandated by lsp-types WorkspaceEdit" )] -fn map_workspace_edit(edit: &SidecarWorkspaceEdit) -> WorkspaceEdit { +fn map_text_changes(edit: &SidecarWorkspaceEdit) -> WorkspaceEdit { let mut changes = std::collections::HashMap::new(); for doc_edit in &edit.document_changes { if let Ok(uri) = crate::paths::path_to_lsp_uri(&doc_edit.file_path) { @@ -166,6 +217,9 @@ struct SidecarDocumentEdit { file_path: String, /// Text edits to apply to this document. edits: Vec, + /// Whether the edit creates the file, whose whole text it then holds. + #[serde(default)] + create: bool, } /// A workspace-wide set of edits returned by the sidecar. @@ -238,6 +292,7 @@ mod tests { use crate::paths::test_paths::{NATIVE_FILE, NATIVE_FILE_URI}; let edit = SidecarWorkspaceEdit { document_changes: vec![SidecarDocumentEdit { + create: false, file_path: NATIVE_FILE.to_string(), edits: vec![ SidecarTextEdit { @@ -271,6 +326,7 @@ mod tests { fn map_workspace_edit_skips_unparseable_paths() { let edit = SidecarWorkspaceEdit { document_changes: vec![SidecarDocumentEdit { + create: false, file_path: "\u{0}".to_string(), edits: vec![], }], @@ -278,4 +334,121 @@ mod tests { let workspace_edit = map_workspace_edit(&edit); assert!(workspace_edit.changes.unwrap().is_empty()); } + + /// An edit that creates a file creates it before writing its text, and + /// edits the other files in the same document changes. [RAZOR-CODE-ACTIONS] + #[test] + fn map_workspace_edit_creates_new_files_before_editing_them() { + use crate::paths::test_paths::{NATIVE_FILE, NATIVE_FILE_URI}; + let text = |new_text: &str| SidecarTextEdit { + start_line: 0, + start_character: 0, + end_line: 0, + end_character: 0, + new_text: new_text.to_string(), + }; + let created = format!("{NATIVE_FILE}.cs"); + let edit = SidecarWorkspaceEdit { + document_changes: vec![ + SidecarDocumentEdit { + create: true, + file_path: created.clone(), + edits: vec![text("public partial class Counter {}")], + }, + SidecarDocumentEdit { + create: false, + file_path: NATIVE_FILE.to_string(), + edits: vec![text("")], + }, + ], + }; + let workspace_edit = map_workspace_edit(&edit); + assert!(workspace_edit.changes.is_none()); + let operations = match workspace_edit.document_changes { + Some(DocumentChanges::Operations(operations)) => operations, + _ => Vec::new(), + }; + let steps: Vec<(&str, &str, Option, usize)> = + operations.iter().map(operation_step).collect(); + let created_uri = crate::paths::path_to_lsp_uri(&created).unwrap(); + assert_eq!( + steps, + [ + ("create", created_uri.as_str(), Some(false), 0), + ("edit", created_uri.as_str(), None, 1), + ("edit", NATIVE_FILE_URI, None, 1), + ], + "the new file is created, then written, then the existing file is edited" + ); + assert!( + operations.iter().all(|operation| !matches!( + operation, + DocumentChangeOperation::Edit(TextDocumentEdit { + text_document: OptionalVersionedTextDocumentIdentifier { + version: Some(_), + .. + }, + .. + }) + )), + "no edit pins a document version" + ); + } + + /// A creating edit drops a document whose path is no URI, and keeps the + /// others: the new file is still created. [RAZOR-CODE-ACTIONS] + #[test] + fn map_workspace_edit_creating_files_skips_unparseable_paths() { + use crate::paths::test_paths::NATIVE_FILE; + let created = format!("{NATIVE_FILE}.cs"); + let edit = SidecarWorkspaceEdit { + document_changes: vec![ + SidecarDocumentEdit { + create: true, + file_path: created.clone(), + edits: vec![], + }, + SidecarDocumentEdit { + create: false, + file_path: "\u{0}".to_string(), + edits: vec![], + }, + ], + }; + let operations = match map_workspace_edit(&edit).document_changes { + Some(DocumentChanges::Operations(operations)) => operations, + _ => Vec::new(), + }; + let created_uri = crate::paths::path_to_lsp_uri(&created).unwrap(); + let steps: Vec<_> = operations.iter().map(operation_step).collect(); + assert_eq!( + steps, + [ + ("create", created_uri.as_str(), Some(false), 0), + ("edit", created_uri.as_str(), None, 0), + ] + ); + } + + /// An operation as `(kind, uri, overwrite, edit count)`. + fn operation_step(operation: &DocumentChangeOperation) -> (&str, &str, Option, usize) { + match operation { + DocumentChangeOperation::Op(ResourceOp::Create(create)) => ( + "create", + create.uri.as_str(), + create + .options + .as_ref() + .and_then(|options| options.overwrite), + 0, + ), + DocumentChangeOperation::Edit(edit) => ( + "edit", + edit.text_document.uri.as_str(), + None, + edit.edits.len(), + ), + DocumentChangeOperation::Op(_) => ("other", "", None, 0), + } + } } diff --git a/src/sharplsp/src/cross_language.rs b/src/sharplsp/src/cross_language.rs new file mode 100644 index 00000000..1915b660 --- /dev/null +++ b/src/sharplsp/src/cross_language.rs @@ -0,0 +1,305 @@ +//! Symbols that cross the C# ↔ F# boundary, found in the other sidecar by +//! their identity: the assembly that declares them and their XML doc +//! signature, the only name both compilers give a symbol. +//! +//! C# sees an F# project only as its built DLL, so definition from C# or a +//! Razor file onto an F# symbol lands in decompiled source. That location +//! carries the symbol's identity, and the F# sidecar answers its F# +//! declaration instead. References merge each side's uses the same way. +//! Implements [DEFINITION-CROSSLANG-IDENTITY], [REFERENCES-CROSS-LANGUAGE] +//! and [RAZOR-FSHARP]. + +use std::sync::Arc; + +use anyhow::Result; +use lsp_types::Location; +use tracing::debug; + +use crate::semantic::{ + request_symbol_identity, sidecar_location_to_lsp, sidecar_workspace_loaded, + SidecarLocationListResult, SidecarLocationResult, SidecarRenameIdentityResult, +}; +use crate::sidecar::manager::SidecarManager; +use crate::utils::{request_sidecar, SidecarPositionReq}; + +/// The request naming a symbol by identity, matching the sidecars' +/// `ForeignSymbolRequest` key layout. +#[derive(serde::Serialize)] +struct ForeignSymbolReq<'a> { + /// Simple name of the assembly that declares the symbol. + assembly_name: &'a str, + /// The symbol's XML doc signature. + xml_doc_sig: &'a str, + /// Whether references include the declaration. + include_declaration: bool, +} + +/// `result` with each decompiled location of an F# project's symbol replaced +/// by that symbol's F# declarations, where the F# sidecar finds them, and +/// whether the answer is settled. A location without an identity stays; one +/// the F# sidecar cannot place yet stays too, and leaves the answer unsettled, +/// since the F# sidecar may still be loading. With no F# sidecar, nothing can +/// place it, so the decompiled answer is final. +pub fn in_fsharp_source( + runtime: &tokio::runtime::Runtime, + fsharp: Option<&Arc>, + result: SidecarLocationListResult, +) -> (SidecarLocationListResult, bool) { + let Some(fsharp) = fsharp else { + return (result, true); + }; + let (placed, settled): (Vec<_>, Vec<_>) = result + .locations + .into_iter() + .map(|location| declarations_of(runtime, fsharp, location)) + .unzip(); + let locations = placed.into_iter().flatten().collect(); + ( + SidecarLocationListResult { locations }, + settled.into_iter().all(|done| done), + ) +} + +/// The F# declarations of the symbol `location` shows, else `location`, and +/// whether that is final: a location naming no F# symbol always is. +fn declarations_of( + runtime: &tokio::runtime::Runtime, + fsharp: &Arc, + location: SidecarLocationResult, +) -> (Vec, bool) { + let Some(symbol) = location.symbol.as_ref().filter(|symbol| symbol.found) else { + return (vec![location], true); + }; + by_identity( + runtime, + fsharp, + "workspace/declarationForeign", + symbol, + true, + ) + .inspect_err(|err| debug!(error = %format_args!("{err:#}"), "F# declaration lookup failed")) + .ok() + .filter(|declared| !declared.is_empty()) + .map_or_else(|| (vec![location], false), |declared| (declared, true)) +} + +/// The other language's uses of the symbol at `at`, found by its identity: +/// none when the other sidecar has no workspace, the symbol has no identity, +/// or either sidecar fails, so the primary answer always stands. +pub fn foreign_references( + runtime: &tokio::runtime::Runtime, + primary: &SidecarManager, + other: Option<&Arc>, + at: &SidecarPositionReq, + include_declaration: bool, +) -> Vec { + let Some(other) = other else { + return Vec::new(); + }; + references_by_identity(runtime, primary, other, at, include_declaration).unwrap_or_else(|err| { + debug!(error = %format_args!("{err:#}"), "cross-language references failed"); + Vec::new() + }) +} + +/// The other sidecar's references to the symbol at `at`, found by the identity +/// the primary sidecar names it with; none when it names no foreign symbol. +fn references_by_identity( + runtime: &tokio::runtime::Runtime, + primary: &SidecarManager, + other: &Arc, + at: &SidecarPositionReq, + include_declaration: bool, +) -> Result> { + if !sidecar_workspace_loaded(runtime, other)? { + return Ok(Vec::new()); + } + let identity = request_symbol_identity(runtime, primary, at)?; + if !identity.found { + return Ok(Vec::new()); + } + let method = "workspace/referencesForeign"; + let found = by_identity(runtime, other, method, &identity, include_declaration)?; + Ok(found.iter().filter_map(sidecar_location_to_lsp).collect()) +} + +/// Ask `sidecar` for the locations `method` finds for the symbol `identity`. +fn by_identity( + runtime: &tokio::runtime::Runtime, + sidecar: &Arc, + method: &str, + identity: &SidecarRenameIdentityResult, + include_declaration: bool, +) -> Result> { + let request = ForeignSymbolReq { + assembly_name: &identity.assembly_name, + xml_doc_sig: &identity.xml_doc_sig, + include_declaration, + }; + let found: Option = + request_sidecar(runtime, sidecar, method, &request)?; + Ok(found.map_or_else(Vec::new, |result| result.locations)) +} + +/// `primary` followed by each of `foreign` it does not already hold. +pub fn merged(primary: Vec, foreign: Vec) -> Vec { + foreign.into_iter().fold(primary, |mut all, location| { + if !all.contains(&location) { + all.push(location); + } + all + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use lsp_types::{Position, Range, Uri}; + use std::str::FromStr; + + /// A sidecar whose process cannot start, so every request to it fails. + fn unreachable_sidecar() -> Arc { + Arc::new(SidecarManager::new( + "unreachable", + "sharplsp-no-such-sidecar", + Vec::new(), + "sharplsp-no-such-sidecar.sock", + )) + } + + /// A sidecar location as the wire carries it: `symbol` names an F# + /// project's symbol as `(found, assembly, xml doc signature)`. + fn location(line: u32, symbol: Option<(bool, &str, &str)>) -> Option { + let wire = ("/a/Domain.dll.cs", line, 4, line, 10, symbol); + rmp_serde::from_slice(&rmp_serde::to_vec(&wire).ok()?).ok() + } + + fn lines(result: &SidecarLocationListResult) -> Vec { + result.locations.iter().map(|found| found.line).collect() + } + + /// With no F# sidecar nothing can place a decompiled location, so the + /// answer is final as it stands. [DEFINITION-CROSSLANG-IDENTITY] + #[test] + fn without_an_fsharp_sidecar_decompiled_locations_are_final() { + let runtime = tokio::runtime::Runtime::new().ok(); + let result = SidecarLocationListResult { + locations: [ + location(3, Some((true, "Domain", "T:Domain.Player"))), + location(7, None), + ] + .into_iter() + .flatten() + .collect(), + }; + let placed = runtime.map(|runtime| in_fsharp_source(&runtime, None, result)); + let (result, settled) = placed.unzip(); + assert_eq!(settled, Some(true), "nothing could ever place it"); + assert_eq!( + result.as_ref().map(lines), + Some(vec![3, 7]), + "every location stays" + ); + } + + /// A location the F# sidecar cannot place stays decompiled and leaves the + /// answer unsettled, so it is asked again; a location naming no F# symbol, + /// or one whose identity was not found, never asks. [DEFINITION-CROSSLANG-IDENTITY] + #[test] + fn an_unplaced_fsharp_symbol_stays_decompiled_and_unsettled() { + let runtime = tokio::runtime::Runtime::new().ok(); + let fsharp = unreachable_sidecar(); + let answer = |locations: Vec>| { + let result = SidecarLocationListResult { + locations: locations.into_iter().flatten().collect(), + }; + runtime + .as_ref() + .map(|runtime| in_fsharp_source(runtime, Some(&fsharp), result)) + .map(|(result, settled)| (lines(&result), settled)) + }; + assert_eq!( + answer(vec![ + location(3, Some((true, "Domain", "T:Domain.Player"))), + location(7, None) + ]), + Some((vec![3, 7], false)), + "the F# sidecar failed, so the decompiled answer stands for now" + ); + assert_eq!( + answer(vec![location(5, Some((false, "", ""))), location(7, None)]), + Some((vec![5, 7], true)), + "no F# symbol to place: the answer is final" + ); + } + + /// No other sidecar, or one that fails, adds no references: the primary + /// answer always stands. [REFERENCES-CROSS-LANGUAGE] + #[test] + fn foreign_references_are_none_without_a_working_other_sidecar() { + let runtime = tokio::runtime::Runtime::new().ok(); + let primary = unreachable_sidecar(); + let other = unreachable_sidecar(); + let at = SidecarPositionReq { + file_path: "/a/Board.razor".to_string(), + line: 3, + character: 9, + }; + let found = |other: Option<&Arc>| { + runtime + .as_ref() + .map(|runtime| foreign_references(runtime, &primary, other, &at, true)) + }; + assert_eq!(found(None), Some(Vec::new()), "no other sidecar"); + assert_eq!( + found(Some(&other)), + Some(Vec::new()), + "the other sidecar fails" + ); + } + + fn at(uri: &str, line: u32) -> Option { + Some(Location { + uri: Uri::from_str(uri).ok()?, + range: Range::new(Position::new(line, 0), Position::new(line, 4)), + }) + } + + /// Each side's answers once, the primary's first. [REFERENCES-CROSS-LANGUAGE] + #[test] + fn merged_keeps_the_primary_order_and_drops_duplicates() { + let primary: Vec = [ + at("file:///a/Leaders.razor", 6), + at("file:///a/Scores.fs", 3), + ] + .into_iter() + .flatten() + .collect(); + let foreign: Vec = [at("file:///a/Scores.fs", 3), at("file:///a/Scores.fs", 9)] + .into_iter() + .flatten() + .collect(); + let all = merged(primary.clone(), foreign.clone()); + assert_eq!(all.len(), 3); + assert_eq!(all.first(), primary.first()); + assert_eq!(all.last(), foreign.last()); + assert_eq!(merged(Vec::new(), foreign.clone()), foreign); + } + + /// The wire request is positional, in the sidecars' key order. + #[test] + fn foreign_symbol_request_is_positional() { + let request = ForeignSymbolReq { + assembly_name: "Domain", + xml_doc_sig: "T:Domain.Player", + include_declaration: true, + }; + let decoded: Option<(String, String, bool)> = rmp_serde::to_vec(&request) + .ok() + .and_then(|bytes| rmp_serde::from_slice(&bytes).ok()); + assert_eq!( + decoded, + Some(("Domain".into(), "T:Domain.Player".into(), true)) + ); + } +} diff --git a/src/sharplsp/src/main.rs b/src/sharplsp/src/main.rs index 99ba9ab8..0a710ab9 100644 --- a/src/sharplsp/src/main.rs +++ b/src/sharplsp/src/main.rs @@ -8,6 +8,7 @@ mod code_lens; mod config; mod config_debug; mod configuration; +mod cross_language; mod diagnostics; mod document_symbols; mod folding; @@ -810,8 +811,9 @@ fn handle_request( // than a syntax tree can give). The other syntax features — // foldingRange, selectionRange, linkedEditingRange — run on the F# // tree-sitter grammar for both languages. Razor has no grammar, so the - // C# sidecar answers its syntax requests, and selection ranges are not - // served yet. [SHARPLSP-FEATURES-NAVIGATION] [RAZOR-ARCHITECTURE-ROUTING] + // C# sidecar answers every one of its syntax requests, selection + // ranges chained out to the whole document. + // [SHARPLSP-FEATURES-NAVIGATION] [RAZOR-ARCHITECTURE-ROUTING] DocumentSymbolRequest::METHOD => match request_lang(&req) { Some(LangId::FSharp | LangId::Razor) => { let sidecar = pick_sidecar(&req, csharp_sidecar, fsharp_sidecar); @@ -823,7 +825,7 @@ fn handle_request( razor::handle_folding_ranges(req, runtime, csharp_sidecar) } SelectionRangeRequest::METHOD if request_lang(&req) == Some(LangId::Razor) => { - Ok(serde_json::json!([])) + razor::handle_selection_ranges(req, runtime, csharp_sidecar) } LinkedEditingRange::METHOD if request_lang(&req) == Some(LangId::Razor) => { razor::handle_linked_editing_range(req, runtime, csharp_sidecar) diff --git a/src/sharplsp/src/razor.rs b/src/sharplsp/src/razor.rs index 8a4099a8..46bc5107 100644 --- a/src/sharplsp/src/razor.rs +++ b/src/sharplsp/src/razor.rs @@ -1,7 +1,8 @@ //! Razor documents (`.razor`, `.cshtml`) in the host. //! //! The host has no Razor grammar, so the syntax requests it answers from -//! tree-sitter for C# go to the C# sidecar for Razor, whose Razor compiler is +//! tree-sitter for C# — documentSymbol, foldingRange, selectionRange and +//! linkedEditingRange — go to the C# sidecar for Razor, whose Razor compiler is //! the parser. The two Razor completion triggers are useless in C# and F# //! documents, so those answer at once. Implements [RAZOR-ARCHITECTURE-ROUTING]. @@ -11,7 +12,7 @@ use anyhow::Result; use lsp_server::Request; use lsp_types::{ CompletionParams, FoldingRange, FoldingRangeParams, LinkedEditingRangeParams, - LinkedEditingRanges, Position, Range, TextDocumentIdentifier, Uri, + LinkedEditingRanges, Position, Range, SelectionRangeParams, TextDocumentIdentifier, Uri, }; use crate::semantic::SidecarLocationListResult; @@ -129,6 +130,70 @@ fn linked_ranges(found: &SidecarLocationListResult) -> Result }) } +/// Handle `textDocument/selectionRange` for a Razor document via the C# sidecar: +/// each position's chain of Razor ranges, innermost first, nested into LSP +/// parents. Implements [RAZOR-FEATURES-SELECTION]. +pub fn handle_selection_ranges( + req: Request, + runtime: &tokio::runtime::Runtime, + sidecar: Option<&Arc>, +) -> Result { + let params: SelectionRangeParams = serde_json::from_value(req.params)?; + let request = SidecarSelectionRangesReq { + file_path: crate::paths::uri_to_path(params.text_document.uri.as_str())?, + positions: params + .positions + .iter() + .map(|at| (at.line, at.character)) + .collect(), + }; + let chains: Vec = + fetch(runtime, sidecar, "textDocument/selectionRange", &request)?; + Ok(serde_json::to_value(selection_ranges(&chains))?) +} + +/// The host's request for a Razor file's selection ranges: the file and its +/// carets, matching the sidecar's `SelectionRangeRequest` Key layout. +#[derive(serde::Serialize)] +struct SidecarSelectionRangesReq { + /// Absolute path of the Razor file. + file_path: String, + /// The carets, in request order. + positions: Vec<(u32, u32)>, +} + +/// One position's answer: its Razor ranges, innermost first. +#[derive(serde::Deserialize)] +struct SidecarRangeList { + /// The chain, innermost first, as `(start_line, start_character, end_line, + /// end_character)`. + ranges: Vec<(u32, u32, u32, u32)>, +} + +/// The sidecar's chains as LSP selection ranges, one per position. LSP 3.17 +/// types the answer `SelectionRange[] | null`, so a position with no chain — a +/// file no project lists — makes the whole answer none. +fn selection_ranges(chains: &[SidecarRangeList]) -> Option> { + chains + .iter() + .map(|chain| nest(chain.ranges.as_slice())) + .collect() +} + +/// Ranges innermost first as one [`lsp_types::SelectionRange`], each range its +/// successor's `parent`; none when the chain is empty. +fn nest(ranges: &[(u32, u32, u32, u32)]) -> Option { + ranges.iter().rev().fold(None, |parent, range| { + Some(lsp_types::SelectionRange { + range: Range::new( + Position::new(range.0, range.1), + Position::new(range.2, range.3), + ), + parent: parent.map(Box::new), + }) + }) +} + /// Send one request to the sidecar that owns Razor and decode its answer. fn fetch( runtime: &tokio::runtime::Runtime, @@ -268,4 +333,38 @@ mod tests { assert_eq!((fold.start_character, fold.end_character), (None, None)); assert!(fold.kind.is_none()); } + + /// A sidecar chain nests into LSP parents, innermost first; an empty chain + /// answers none. [RAZOR-FEATURES-SELECTION] + #[test] + fn selection_range_chains_nest_innermost_first() { + let nested = selection_ranges(&[SidecarRangeList { + ranges: vec![(1, 2, 1, 8), (1, 0, 3, 4)], + }]); + let inner = nested + .as_ref() + .and_then(|chains| chains.first()) + .expect("the innermost range"); + assert_eq!( + inner.range, + Range::new(Position::new(1, 2), Position::new(1, 8)) + ); + let outer = inner.parent.as_ref().expect("the outer range"); + assert_eq!( + outer.range, + Range::new(Position::new(1, 0), Position::new(3, 4)) + ); + assert!(outer.parent.is_none(), "the document has no parent"); + + let empty = selection_ranges(&[ + SidecarRangeList { + ranges: vec![(0, 0, 0, 1)], + }, + SidecarRangeList { ranges: vec![] }, + ]); + assert_eq!( + empty, None, + "a position with no chain makes the whole answer none" + ); + } } diff --git a/src/sharplsp/src/semantic.rs b/src/sharplsp/src/semantic.rs index 5e015293..8fc265b3 100644 --- a/src/sharplsp/src/semantic.rs +++ b/src/sharplsp/src/semantic.rs @@ -297,9 +297,9 @@ pub fn handle_implementation( fallback: Option<&Arc>, ) -> Result { let method = "textDocument/implementation"; - let value = handle_multi_location_nav(req.clone(), runtime, sidecar, method)?; + let (value, _) = handle_multi_location_nav(req.clone(), runtime, (sidecar, fallback), method)?; Ok(or_fallback(value, fallback, method, |fb| { - handle_multi_location_nav(req, runtime, Some(fb), method) + handle_multi_location_nav(req, runtime, (Some(fb), None), method).map(|(value, _)| value) })) } @@ -319,12 +319,12 @@ pub fn handle_references( "textDocument/references:nodecl" }; cached_at_position(req, vfs, nav_cache, cache_method, |req| { - let value = handle_references_nav(req.clone(), runtime, sidecar)?; + let value = handle_references_nav(req.clone(), runtime, (sidecar, fallback))?; Ok(or_fallback( value, fallback, "textDocument/references", - |fb| handle_references_nav(req, runtime, Some(fb)), + |fb| handle_references_nav(req, runtime, (Some(fb), None)), )) }) } @@ -393,10 +393,13 @@ fn to_document_highlight(h: &SidecarDocumentHighlightResult) -> DocumentHighligh } /// Inner handler for references (serializes `ReferencesRequest` with `include_declaration`). +/// References from the sidecar that owns the document, merged with the other +/// language's uses of the same symbol, found by its identity. +/// [REFERENCES-CROSS-LANGUAGE] fn handle_references_nav( req: Request, runtime: &tokio::runtime::Runtime, - sidecar: Option<&Arc>, + (sidecar, other): NavRoute<'_>, ) -> Result { with_sidecar(req, sidecar, |sidecar, params: ReferenceParams| { let at = SidecarPositionReq::at( @@ -412,7 +415,7 @@ fn handle_references_nav( "References request dispatching to sidecar" ); let request = SidecarReferencesReq { - file_path: at.file_path, + file_path: at.file_path.clone(), line: at.line, character: at.character, include_declaration, @@ -423,12 +426,21 @@ fn handle_references_nav( else { return Ok(serde_json::Value::Null); }; - let locations: Vec = result + let own: Vec = result .locations .iter() .filter_map(sidecar_location_to_lsp) .collect(); - Ok(serde_json::to_value(locations)?) + let foreign = crate::cross_language::foreign_references( + runtime, + sidecar, + other, + &at, + include_declaration, + ); + Ok(serde_json::to_value(crate::cross_language::merged( + own, foreign, + ))?) }) } @@ -440,9 +452,9 @@ fn handle_references_nav( fn dispatch_position_request( req: Request, runtime: &tokio::runtime::Runtime, - sidecar: Option<&Arc>, + (sidecar, foreign): NavRoute<'_>, method: &str, -) -> Result> { +) -> Result> { let Some(sidecar) = sidecar else { debug!("{method}: no sidecar available"); return Ok(None); @@ -457,19 +469,28 @@ fn dispatch_position_request( character = request.character, "request dispatching to sidecar" ); - request_sidecar(runtime, sidecar, method, &request) + let found: Option = + request_sidecar(runtime, sidecar, method, &request)?; + Ok(found.map(|result| crate::cross_language::in_fsharp_source(runtime, foreign, result))) } +/// The sidecar a navigation request goes to, and the F# sidecar that places +/// the F# symbols its decompiled answers show. [DEFINITION-CROSSLANG-IDENTITY] +type NavRoute<'a> = ( + Option<&'a Arc>, + Option<&'a Arc>, +); + /// Shared handler for multi-location navigation requests /// (definition, implementation). fn handle_multi_location_nav( req: Request, runtime: &tokio::runtime::Runtime, - sidecar: Option<&Arc>, + route: NavRoute<'_>, method: &str, -) -> Result { - let Some(result) = dispatch_position_request(req, runtime, sidecar, method)? else { - return Ok(serde_json::Value::Null); +) -> Result<(serde_json::Value, bool)> { + let Some((result, settled)) = dispatch_position_request(req, runtime, route, method)? else { + return Ok((serde_json::Value::Null, true)); }; let locations: Vec = result .locations @@ -478,7 +499,7 @@ fn handle_multi_location_nav( .collect(); let response = (!locations.is_empty()).then(|| GotoDefinitionResponse::Array(locations)); - Ok(serde_json::to_value(response)?) + Ok((serde_json::to_value(response)?, settled)) } /// Cached navigation with cross-language fallback. @@ -501,9 +522,18 @@ fn handle_cached_nav_with_fallback( method: &str, multi: bool, ) -> Result { - let value = handle_cached_nav(req.clone(), vfs, nav_cache, runtime, sidecar, method, multi)?; + let route = (sidecar, fallback); + let value = handle_cached_nav(req.clone(), vfs, nav_cache, runtime, route, method, multi)?; Ok(or_fallback(value, fallback, method, |fb| { - handle_cached_nav(req, vfs, nav_cache, runtime, Some(fb), method, multi) + handle_cached_nav( + req, + vfs, + nav_cache, + runtime, + (Some(fb), None), + method, + multi, + ) })) } @@ -542,6 +572,22 @@ fn cached_at_position( nav_cache: &mut crate::nav_cache::NavCache, method: &str, dispatch: impl FnOnce(Request) -> Result, +) -> Result { + cached_at_position_when_settled(req, vfs, nav_cache, method, |req| { + dispatch(req).map(|value| (value, true)) + }) +} + +/// [`cached_at_position`] for an answer that may not be settled yet: one that +/// still shows an F# symbol in decompiled source, because the F# sidecar could +/// not place it yet, is answered but not remembered, so the next request asks +/// again. [DEFINITION-CROSSLANG-IDENTITY] +fn cached_at_position_when_settled( + req: Request, + vfs: &crate::vfs::Vfs, + nav_cache: &mut crate::nav_cache::NavCache, + method: &str, + dispatch: impl FnOnce(Request) -> Result<(serde_json::Value, bool)>, ) -> Result { let at: TextDocumentPositionParams = serde_json::from_value(req.params.clone())?; let uri = at.text_document.uri.as_str(); @@ -550,8 +596,10 @@ fn cached_at_position( if let Some(cached) = cached_nav_hit(nav_cache, uri, version, line, character, method, method) { return Ok(cached); } - let value = dispatch(req)?; - nav_cache.insert(uri, version, line, character, method, value.clone()); + let (value, settled) = dispatch(req)?; + if settled { + nav_cache.insert(uri, version, line, character, method, value.clone()); + } Ok(value) } @@ -581,15 +629,15 @@ fn handle_cached_nav( vfs: &crate::vfs::Vfs, nav_cache: &mut crate::nav_cache::NavCache, runtime: &tokio::runtime::Runtime, - sidecar: Option<&Arc>, + route: NavRoute<'_>, method: &str, multi: bool, ) -> Result { - cached_at_position(req, vfs, nav_cache, method, |req| { + cached_at_position_when_settled(req, vfs, nav_cache, method, |req| { if multi { - handle_multi_location_nav(req, runtime, sidecar, method) + handle_multi_location_nav(req, runtime, route, method) } else { - handle_single_location_nav(req, runtime, sidecar, method) + handle_single_location_nav(req, runtime, route, method) } }) } @@ -599,22 +647,22 @@ fn handle_cached_nav( fn handle_single_location_nav( req: Request, runtime: &tokio::runtime::Runtime, - sidecar: Option<&Arc>, + route: NavRoute<'_>, method: &str, -) -> Result { - let Some(result) = dispatch_position_request(req, runtime, sidecar, method)? else { - return Ok(serde_json::Value::Null); +) -> Result<(serde_json::Value, bool)> { + let Some((result, settled)) = dispatch_position_request(req, runtime, route, method)? else { + return Ok((serde_json::Value::Null, true)); }; let response = result.locations.first().and_then(|loc| { let location = sidecar_location_to_lsp(loc)?; Some(GotoDefinitionResponse::Scalar(location)) }); - Ok(serde_json::to_value(response)?) + Ok((serde_json::to_value(response)?, settled)) } /// Convert a sidecar `LocationResult` to an LSP `Location`. -fn sidecar_location_to_lsp(loc: &SidecarLocationResult) -> Option { +pub(crate) fn sidecar_location_to_lsp(loc: &SidecarLocationResult) -> Option { let uri: Uri = path_to_lsp_uri(&loc.file_path).ok()?; Some(Location { uri, @@ -658,6 +706,7 @@ fn map_completion_kind(tag: &str) -> CompletionItemKind { "Constant" => CompletionItemKind::CONSTANT, "Delegate" => CompletionItemKind::FUNCTION, "TypeParameter" => CompletionItemKind::TYPE_PARAMETER, + "Value" => CompletionItemKind::VALUE, _ => CompletionItemKind::TEXT, } } @@ -776,6 +825,10 @@ pub(crate) struct SidecarLocationResult { pub(crate) end_line: u32, /// End character of the target range. pub(crate) end_character: u32, + /// For decompiled source of an F# project's symbol, the symbol's identity, + /// which the F# sidecar places. [DEFINITION-CROSSLANG-IDENTITY] + #[serde(default)] + pub(crate) symbol: Option, } /// Sidecar response containing a list of locations. @@ -956,7 +1009,7 @@ fn foreign_rename_edits( } /// Check whether the other-language sidecar has a project that can contain references. -fn sidecar_workspace_loaded( +pub(crate) fn sidecar_workspace_loaded( runtime: &tokio::runtime::Runtime, sidecar: &Arc, ) -> Result { @@ -997,7 +1050,18 @@ fn request_rename_identity( line: rename.line, character: rename.character, }; - let payload = rmp_serde::to_vec(&request)?; + request_symbol_identity(runtime, sidecar, &request) +} + +/// Ask the sidecar that owns `at`'s document for the identity of the symbol +/// there: a C# source symbol, an F# one, or an F# project's symbol that C# or +/// Razor reads from its DLL. [DEFINITION-CROSSLANG-IDENTITY] +pub(crate) fn request_symbol_identity( + runtime: &tokio::runtime::Runtime, + sidecar: &SidecarManager, + at: &SidecarPositionReq, +) -> Result { + let payload = rmp_serde::to_vec(at)?; let bytes = runtime.block_on(sidecar.request("textDocument/renameIdentity", payload))?; Ok(rmp_serde::from_slice(&bytes)?) } @@ -1211,13 +1275,13 @@ struct SidecarRenameRequest { /// Portable metadata identity returned by the symbol's owning sidecar. #[derive(serde::Deserialize)] -struct SidecarRenameIdentityResult { +pub(crate) struct SidecarRenameIdentityResult { /// Whether the source position resolved to a cross-language-visible symbol. - found: bool, + pub(crate) found: bool, /// Simple name of the assembly that owns the symbol. - assembly_name: String, + pub(crate) assembly_name: String, /// Standard .NET XML documentation signature for the symbol. - xml_doc_sig: String, + pub(crate) xml_doc_sig: String, } /// Request for references to a metadata symbol owned by the other sidecar. @@ -1281,6 +1345,10 @@ struct SidecarDocumentEditResult { file_path: String, /// Text edits to apply. edits: Vec, + /// Whether the edit creates the file: decoded to keep the positional wire + /// shape, since a rename never creates one. + #[serde(default)] + _create: bool, } /// A workspace-wide set of edits from the sidecar rename operation. @@ -1408,6 +1476,7 @@ mod rename_merge_tests { SidecarDocumentEditResult { file_path: path.to_string(), edits, + _create: false, } } diff --git a/src/sharplsp/tests/e2e_modules/definition_cross_language.rs b/src/sharplsp/tests/e2e_modules/definition_cross_language.rs index f58e1902..63e0fd5f 100644 --- a/src/sharplsp/tests/e2e_modules/definition_cross_language.rs +++ b/src/sharplsp/tests/e2e_modules/definition_cross_language.rs @@ -1,11 +1,11 @@ //! Full-stack cross-language go-to-definition: C# ↔ F#. //! //! A C# project and an F# project reference each other across the language -//! boundary. Each sidecar sees the *other* language only as a compiled -//! assembly, so navigating from a use site into the other-language definition -//! resolves through metadata-as-source (the referenced type is decompiled to a -//! navigable location) — the same behavior mature IDEs give for a compiled -//! cross-language reference. Implements [DEFINITION-CROSSLANG]. +//! boundary. Navigating from a use site into the other-language definition +//! lands in its source: F# compiles the C# project in memory, and C# names the +//! F# symbol it reads from the DLL by its identity, which the F# sidecar +//! places in the `.fs` file. Implements [DEFINITION-CROSSLANG] and +//! [DEFINITION-CROSSLANG-IDENTITY]. use super::*; @@ -51,8 +51,8 @@ fn poll_cross_language_definition( // ── C# → F# ────────────────────────────────────────────────────────── -// FULL-STACK: go-to-definition from C# onto an F#-defined type resolves into -// the F# assembly (decompiled metadata-as-source), not the C# consumer file. +// FULL-STACK: go-to-definition from C# onto an F#-defined type lands on its +// declaration in the F# source, not in decompiled source or the C# consumer. #[test] fn test_cross_language_definition_csharp_to_fsharp() { require_dotnet(); @@ -81,6 +81,54 @@ fn test_cross_language_definition_csharp_to_fsharp() { "C#→F# definition must land on the F#-defined `Shape` type, got: {uri}" ); + // Once the F# sidecar has loaded, the answer is the F# declaration: the + // decompiled answer before it is never cached. [DEFINITION-CROSSLANG-IDENTITY] + let source = poll_cross_language_definition( + &mut client, + &file_uri, + 8, + 22, + ".cs", + Duration::from_mins(3), + ); + let uri = source["uri"].as_str().unwrap(); + assert!( + uri.ends_with("/Shapes.fs"), + "C#→F# lands in the F# source, got: {uri}" + ); + // `type Shape(name: string, sides: int) =` (line 3) + // 0123456789 + assert_eq!( + source["range"]["start"], + json!({ "line": 3, "character": 5 }) + ); + assert_eq!( + source["range"]["end"], + json!({ "line": 3, "character": 10 }) + ); + + // With the F# sidecar loaded, a C# local has no cross-language identity, so + // its references are C#'s alone: the declaration and its one use. + // [REFERENCES-CROSS-LANGUAGE] + let local = references(&mut client, &file_uri, 8, 12, true); + let starts: Vec<(Value, Value)> = local["result"] + .as_array() + .map(|found| { + found + .iter() + .map(|at| (at["uri"].clone(), at["range"]["start"].clone())) + .collect() + }) + .unwrap_or_default(); + assert_eq!( + starts, + vec![ + (json!(file_uri), json!({ "line": 8, "character": 12 })), + (json!(file_uri), json!({ "line": 9, "character": 15 })), + ], + "the local's references stay in C#: {local}" + ); + client.shutdown_and_exit(); client.wait_with_timeout(); } diff --git a/src/sharplsp/tests/e2e_modules/fixtures_cross_language.rs b/src/sharplsp/tests/e2e_modules/fixtures_cross_language.rs index b3ad6a7c..5911ccec 100644 --- a/src/sharplsp/tests/e2e_modules/fixtures_cross_language.rs +++ b/src/sharplsp/tests/e2e_modules/fixtures_cross_language.rs @@ -18,7 +18,7 @@ use super::*; use std::path::Path; /// Run a `dotnet` subcommand in `cwd`, asserting success. -fn dotnet(args: &[&str], cwd: &Path) { +pub fn dotnet(args: &[&str], cwd: &Path) { let status = std::process::Command::new("dotnet") .args(args) .current_dir(cwd) diff --git a/src/sharplsp/tests/e2e_modules/fixtures_razor.rs b/src/sharplsp/tests/e2e_modules/fixtures_razor.rs index 3cdd4d0a..636cc169 100644 --- a/src/sharplsp/tests/e2e_modules/fixtures_razor.rs +++ b/src/sharplsp/tests/e2e_modules/fixtures_razor.rs @@ -44,6 +44,24 @@ pub fn create_razor_workspace() -> RazorWorkspace { create_razor_workspace_with_pin(true) } +/// [`create_razor_workspace`] with the F# `Domain` library built: C# reads an F# +/// project only as its built DLL, so a C# or Razor use of an F# symbol resolves +/// only once that DLL exists. [DEFINITION-CROSSLANG] [RAZOR-FSHARP] +pub fn create_razor_workspace_with_fsharp_built() -> RazorWorkspace { + let ws = create_razor_workspace(); + super::fixtures_cross_language::dotnet( + &[ + "build", + "Domain/Domain.fsproj", + "--verbosity", + "quiet", + "--nologo", + ], + ws.root(), + ); + ws +} + /// Restore under the newest installed SDK to catch compiler/sidecar version skew. /// [RAZOR-ARCHITECTURE-GENERATOR] pub fn create_razor_workspace_with_latest_sdk() -> RazorWorkspace { diff --git a/src/sharplsp/tests/e2e_modules/mod.rs b/src/sharplsp/tests/e2e_modules/mod.rs index 40451186..83dad1d7 100644 --- a/src/sharplsp/tests/e2e_modules/mod.rs +++ b/src/sharplsp/tests/e2e_modules/mod.rs @@ -68,6 +68,11 @@ pub mod nuget_unused_full_stack; pub mod profiler_shared; pub mod pull_diagnostics; pub mod razor; +pub mod razor_collocation; +pub mod razor_fsharp; +pub mod razor_hierarchy; +pub mod razor_mvc; +pub mod razor_selection; pub mod references; pub mod selection; pub mod semantic_coverage; diff --git a/src/sharplsp/tests/e2e_modules/razor.rs b/src/sharplsp/tests/e2e_modules/razor.rs index 9d7df02f..93187e35 100644 --- a/src/sharplsp/tests/e2e_modules/razor.rs +++ b/src/sharplsp/tests/e2e_modules/razor.rs @@ -81,8 +81,8 @@ fn assert_latest_sdk_razor_hover(client: &mut LspClient, ws: &RazorWorkspace) { assert_eq!(hover["range"]["start"], json!({"line": 7, "character": 33})); } -/// documentSymbol, foldingRange and linkedEditingRange come from the sidecar's -/// Razor compiler; selectionRange answers empty. [RAZOR-FEATURES-STRUCTURE] +/// documentSymbol, foldingRange, linkedEditingRange and selectionRange come from +/// the sidecar's Razor compiler. [RAZOR-FEATURES-STRUCTURE] [RAZOR-FEATURES-SELECTION] fn assert_syntax_requests_reach_the_sidecar(client: &mut LspClient, board: &str) { let symbols = document_request(client, "textDocument/documentSymbol", board); assert_rpc_ok(&symbols, "documentSymbol(Board.razor)"); @@ -160,7 +160,19 @@ fn assert_syntax_requests_reach_the_sidecar(client: &mut LspClient, board: &str) json!({ "textDocument": { "uri": board }, "positions": [{ "line": 6, "character": 3 }] }), ); assert_rpc_ok(&selection, "selectionRange(Board.razor)"); - assert_eq!(selection["result"], json!([])); + assert_eq!( + selection["result"], + json!([{ + "range": { "start": { "line": 6, "character": 1 }, "end": { "line": 6, "character": 6 } }, + "parent": { + "range": { "start": { "line": 6, "character": 0 }, "end": { "line": 10, "character": 8 } }, + "parent": { + "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 26, "character": 0 } } + } + } + }]), + "the tag name, then its element, then the document [RAZOR-FEATURES-SELECTION]" + ); } /// The HTML projection keeps markup at its own positions and blanks Razor; a @@ -372,8 +384,13 @@ fn assert_markup_completion_reaches_the_client(client: &mut LspClient, board: &s client.change_document(board, 8, text); } -/// The completion items at the end of `typed`, once Board holds it. -fn complete_after(client: &mut LspClient, board: &str, version: i32, typed: &str) -> Vec { +/// The completion items at the end of `typed`, once the Razor file holds it. +pub(super) fn complete_after( + client: &mut LspClient, + board: &str, + version: i32, + typed: &str, +) -> Vec { client.change_document(board, version, typed); let line = typed.lines().count() - 1; let character = typed @@ -389,7 +406,7 @@ fn complete_after(client: &mut LspClient, board: &str, version: i32, typed: &str "position": { "line": line, "character": character }, }), ); - assert_rpc_ok(&response, "completion(Board.razor, markup)"); + assert_rpc_ok(&response, "completion(Razor markup)"); let items = response["result"].as_array().cloned().unwrap_or_default(); if !items.is_empty() || std::time::Instant::now() > deadline { return items; diff --git a/src/sharplsp/tests/e2e_modules/razor_collocation.rs b/src/sharplsp/tests/e2e_modules/razor_collocation.rs new file mode 100644 index 00000000..a7ab340b --- /dev/null +++ b/src/sharplsp/tests/e2e_modules/razor_collocation.rs @@ -0,0 +1,175 @@ +//! Full-stack collocated files over the real fixture's `Card.razor`: its +//! `Card.razor.css` classes complete as LSP values inside a `class` value and +//! define to every selector that declares them, and an `"import"` call defines +//! to the start of the `Card.razor.js` module it names, from Razor and from C#. +//! [RAZOR-COLLOCATION] [RAZOR-COLLOCATION-CSS] [RAZOR-COLLOCATION-JS] + +use super::razor::complete_after; +use super::*; + +/// The component with a collocated stylesheet and module. +const CARD: &[&str] = &["BlazorApp", "Components", "Shared", "Card.razor"]; + +/// The component's scoped stylesheet. +const STYLESHEET: &[&str] = &["BlazorApp", "Components", "Shared", "Card.razor.css"]; + +/// Plain C# that imports the component's module. +const INTEROP: &[&str] = &["BlazorApp", "Components", "Shared", "CardInterop.cs"]; + +/// LSP `CompletionItemKind.Value`. +const VALUE: u64 = 12; + +#[test] +fn test_full_stack_razor_collocated_styles_and_scripts() { + require_dotnet(); + let ws = create_razor_workspace(); + let mut client = LspClient::start_verbose(); + let init = client.initialize_with_root(json!(path_to_file_uri(ws.root()))); + assert_rpc_ok(&init, "initialize the Razor fixture"); + + let card = ws.uri(CARD); + let text = ws.text(CARD); + client.open_document(&card, &text); + + assert_classes_define_to_their_selectors(&mut client, &ws, &card); + assert_modules_define_to_their_start(&mut client, &ws, &card); + assert_classes_complete_as_values(&mut client, &card, &text); + + client.shutdown_and_exit(); + client.wait_with_timeout(); +} + +/// `title` in `
` lands on the five selectors of `.title`, +/// each ranged on the name after its `.`. +fn assert_classes_define_to_their_selectors( + client: &mut LspClient, + ws: &RazorWorkspace, + card: &str, +) { + let first = poll_definition_until_ready(client, card, 4, 19, Duration::from_mins(3)); + assert!( + first["uri"] + .as_str() + .is_some_and(|uri| uri.ends_with("/Card.razor.css")), + "a class defines in the component's stylesheet: {first}" + ); + let all = definition(client, card, 4, 19); + assert_nav_ok(&all); + let locations = all["result"].as_array().cloned().unwrap_or_default(); + assert_eq!( + locations.len(), + 5, + "every selector of `.title`: {locations:?}" + ); + let stylesheet = ws.text(STYLESHEET); + let lines: Vec<&str> = stylesheet.lines().collect(); + for location in &locations { + assert!( + location["uri"] + .as_str() + .is_some_and(|uri| uri.ends_with("/Card.razor.css")), + "{location}" + ); + assert_eq!( + ranged_text(&lines, location), + "title", + "on the name: {location}" + ); + } +} + +/// The module literal in `@code` and in plain C# both define to the module's start. +fn assert_modules_define_to_their_start(client: &mut LspClient, ws: &RazorWorkspace, card: &str) { + let razor = definition(client, card, 22, 20); + assert_nav_ok(&razor); + assert_module_start(&razor["result"], "from the component's @code block"); + + let interop = ws.uri(INTEROP); + let source = ws.text(INTEROP); + client.open_document(&interop, &source); + let (line, character) = source + .lines() + .enumerate() + .find_map(|(line, text)| text.find("\"./Components").map(|at| (line, at + 4))) + .expect("CardInterop.cs imports the module"); + let csharp = definition( + client, + &interop, + u32::try_from(line).unwrap_or_default(), + u32::try_from(character).unwrap_or_default(), + ); + assert_nav_ok(&csharp); + assert_module_start(&csharp["result"], "from plain C#"); +} + +/// One location, at the very start of `Card.razor.js`. +fn assert_module_start(result: &Value, label: &str) { + let locations = result.as_array().cloned().unwrap_or_default(); + assert_eq!(locations.len(), 1, "{label}: one module: {result}"); + assert!( + locations[0]["uri"] + .as_str() + .is_some_and(|uri| uri.ends_with("/Card.razor.js")), + "{label}: the collocated module: {result}" + ); + assert_eq!( + locations[0]["range"], + json!({ "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 } }), + "{label}: at its start" + ); +} + +/// After `
= items + .iter() + .filter(|item| item["label"] == "title") + .collect(); + assert_eq!(titles.len(), 1, "each class once: {items:?}"); + let title = titles[0]; + assert_eq!( + title["kind"], VALUE, + "a stylesheet class is a value: {title}" + ); + assert_eq!( + title["detail"], "Card.razor.css", + "naming its stylesheet: {title}" + ); + let line = typed.lines().count() - 1; + let end = typed + .lines() + .last() + .map_or(0, |last| last.encode_utf16().count()); + assert_eq!( + title["textEdit"]["range"], + json!({ + "start": { "line": line, "character": end - 2 }, + "end": { "line": line, "character": end } + }), + "replacing the class name at the caret: {title}" + ); + assert!( + items + .iter() + .any(|item| item["label"] == "card--grid" && item["kind"] == VALUE), + "a class inside nested at-rules completes too: {items:?}" + ); + client.change_document(card, 3, text); +} + +/// The text a single-line location covers in `lines`. +fn ranged_text<'a>(lines: &[&'a str], location: &Value) -> &'a str { + let at = |key: &str, field: &str| { + location["range"][key][field] + .as_u64() + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or_default() + }; + lines + .get(at("start", "line")) + .and_then(|line| line.get(at("start", "character")..at("end", "character"))) + .unwrap_or_default() +} diff --git a/src/sharplsp/tests/e2e_modules/razor_fsharp.rs b/src/sharplsp/tests/e2e_modules/razor_fsharp.rs new file mode 100644 index 00000000..bf5feb79 --- /dev/null +++ b/src/sharplsp/tests/e2e_modules/razor_fsharp.rs @@ -0,0 +1,341 @@ +//! Full-stack F# in a Razor solution over the real fixture: `Leaders.razor` +//! uses the F# `Domain` library's record, union and module functions. The C# +//! sidecar reads Domain as its built DLL; the host finds each F# symbol in the +//! F# sidecar by its identity, so definition lands in `Scores.fs`, and +//! references and rename span F#, C# and Razor, from either side. +//! [RAZOR-FSHARP] [DEFINITION-CROSSLANG-IDENTITY] [REFERENCES-CROSS-LANGUAGE] + +use super::*; + +/// The page that uses the F# library. +const LEADERS: &[&str] = &["BlazorApp", "Components", "Pages", "Leaders.razor"]; + +/// The F# library's only file. +const SCORES: &[&str] = &["Domain", "Scores.fs"]; + +/// The zero-based position of `token` on the first line of `text` holding +/// `anchor`. The fixture is ASCII, so bytes are UTF-16 code units. +fn locate(text: &str, anchor: &str, token: &str) -> (u32, u32) { + let (line, content) = text + .lines() + .enumerate() + .find(|(_, content)| content.contains(anchor)) + .unwrap_or_else(|| panic!("missing anchor {anchor}")); + let character = content + .find(token) + .unwrap_or_else(|| panic!("missing token {token}")); + ( + u32::try_from(line).expect("line fits"), + u32::try_from(character).expect("character fits"), + ) +} + +/// The name position of every `.name` member use in `text`. +fn member_uses(text: &str, name: &str) -> Vec<(u32, u32)> { + let dotted = format!(".{name}"); + text.lines() + .enumerate() + .flat_map(|(line, content)| { + content + .match_indices(dotted.as_str()) + .map(move |(at, _)| (line, at + 1)) + .collect::>() + }) + .map(|(line, at)| { + ( + u32::try_from(line).expect("line fits"), + u32::try_from(at).expect("character fits"), + ) + }) + .collect() +} + +/// A location's file name and start. +fn placed(location: &Value) -> (String, u64, u64) { + let uri = location["uri"].as_str().unwrap_or_default(); + let name = uri.rsplit('/').next().unwrap_or_default().to_string(); + let start = &location["range"]["start"]; + ( + name, + start["line"].as_u64().unwrap_or(u64::MAX), + start["character"].as_u64().unwrap_or(u64::MAX), + ) +} + +/// A position in a named file, as [`placed`] reports it. +fn at(file: &str, (line, character): (u32, u32)) -> (String, u64, u64) { + (file.to_string(), u64::from(line), u64::from(character)) +} + +/// Poll definition at `position` until it lands in `Scores.fs`: until the F# +/// sidecar has loaded Domain, the C# sidecar's decompiled answer stands. +fn poll_fsharp_definition( + client: &mut LspClient, + uri: &str, + (line, character): (u32, u32), +) -> Value { + let deadline = std::time::Instant::now() + Duration::from_mins(3); + loop { + let response = definition(client, uri, line, character); + assert_nav_ok(&response); + let location = first_location(&response["result"]); + if location["uri"] + .as_str() + .is_some_and(|found| found.ends_with("Scores.fs")) + { + return location; + } + assert!( + std::time::Instant::now() < deadline, + "definition at {line}:{character} never reached Scores.fs: {response}" + ); + std::thread::sleep(Duration::from_secs(2)); + } +} + +#[test] +fn test_full_stack_fsharp_in_razor_session() { + require_dotnet(); + let ws = create_razor_workspace_with_fsharp_built(); + let mut client = LspClient::start_verbose(); + let init = client.initialize_with_root(json!(path_to_file_uri(ws.root()))); + assert_rpc_ok(&init, "initialize Razor fixture"); + let leaders = ws.uri(LEADERS); + let page = ws.text(LEADERS); + let scores = ws.text(SCORES); + client.open_document(&leaders, &page); + + // ── Hover: the F# record, with its F# documentation, from Domain's DLL ── + let (line, character) = locate(&page, "private readonly Player[]", "Player"); + let hover = poll_hover_until_ready( + &mut client, + &leaders, + line, + character, + Duration::from_mins(3), + ); + let markdown = hover["contents"]["value"].as_str().unwrap_or_default(); + assert!( + markdown.contains("A player on the leader board."), + "Player hover: {markdown}" + ); + assert_eq!( + hover["range"]["start"], + json!({ "line": line, "character": character }) + ); + + assert_definition_lands_in_fsharp_source(&mut client, &leaders, &page, &scores); + assert_references_span_fsharp_and_razor(&mut client, &leaders, &page, &scores); + assert_rename_from_razor_renames_fsharp(&mut client, &leaders, &page, &scores); + assert_rename_from_fsharp_renames_razor(&mut client, &ws, &page, &scores); + + client.shutdown_and_exit(); + client.wait_with_timeout(); +} + +/// Definition, type definition and declaration from Razor onto F# symbols land +/// on their F# declarations, never decompiled source. [DEFINITION-CROSSLANG-IDENTITY] +fn assert_definition_lands_in_fsharp_source( + client: &mut LspClient, + leaders: &str, + page: &str, + scores: &str, +) { + let player = poll_fsharp_definition( + client, + leaders, + locate(page, "private readonly Player[]", "Player"), + ); + assert_eq!( + placed(&player), + at("Scores.fs", locate(scores, "type Player", "Player")) + ); + assert_eq!( + player["range"]["end"]["character"].as_u64(), + Some(u64::from(locate(scores, "type Player", "Player").1) + 6), + "the range is the type's name" + ); + let score = poll_fsharp_definition(client, leaders, locate(page, "@player.Score", "Score")); + assert_eq!( + placed(&score), + at("Scores.fs", locate(scores, "Score: int", "Score")) + ); + let total = poll_fsharp_definition(client, leaders, locate(page, "@Scoring.total", "total")); + assert_eq!( + placed(&total), + at("Scores.fs", locate(scores, "let total", "total")) + ); + let constructed = + poll_fsharp_definition(client, leaders, locate(page, "[new Player(", "Player")); + assert_eq!( + placed(&constructed), + placed(&player), + "a record's constructor is its type" + ); + + let (line, character) = locate(page, "var player in players", "player"); + let type_definition = type_definition(client, leaders, line, character); + assert_nav_ok(&type_definition); + assert_eq!( + placed(&first_location(&type_definition["result"])), + placed(&player) + ); +} + +/// References from Razor on an F# record field hold its F# declaration and +/// every F# and Razor use; without the declaration, only the uses. +/// [REFERENCES-CROSS-LANGUAGE] [RAZOR-FSHARP-REFERENCES] +fn assert_references_span_fsharp_and_razor( + client: &mut LspClient, + leaders: &str, + page: &str, + scores: &str, +) { + let (line, character) = locate(page, "@player.Score", "Score"); + let mut expected: Vec<(String, u64, u64)> = member_uses(scores, "Score") + .into_iter() + .map(|position| at("Scores.fs", position)) + .chain([at("Leaders.razor", (line, character))]) + .collect(); + expected.sort(); + assert_eq!(expected.len(), 5, "four F# uses and one Razor use"); + + let uses = references(client, leaders, line, character, false); + assert_rpc_ok(&uses, "references(Score) without declaration"); + let mut found: Vec<_> = uses["result"] + .as_array() + .expect("locations") + .iter() + .map(placed) + .collect(); + found.sort(); + assert_eq!(found, expected); + + let all = references(client, leaders, line, character, true); + assert_rpc_ok(&all, "references(Score) with declaration"); + let mut with_declaration: Vec<_> = all["result"] + .as_array() + .expect("locations") + .iter() + .map(placed) + .collect(); + with_declaration.sort(); + expected.push(at("Scores.fs", locate(scores, "Score: int", "Score"))); + expected.sort(); + assert_eq!(with_declaration, expected); + assert!( + all["result"] + .as_array() + .expect("locations") + .iter() + .all(|location| { + location["uri"] + .as_str() + .is_some_and(|uri| !uri.ends_with(".g.cs")) + }), + "no generated file: {all}" + ); +} + +/// Renaming an F# record field from Razor renames its F# declaration and uses +/// and its Razor use, in one edit. [RAZOR-FSHARP-RENAME] +fn assert_rename_from_razor_renames_fsharp( + client: &mut LspClient, + leaders: &str, + page: &str, + scores: &str, +) { + let (line, character) = locate(page, "@player.Score", "Score"); + let prepared = client.request( + "textDocument/prepareRename", + json!({ "textDocument": { "uri": leaders }, "position": { "line": line, "character": character } }), + ); + assert_rpc_ok(&prepared, "prepareRename(Score) in Razor"); + assert_eq!(prepared["result"]["placeholder"], "Score"); + assert_eq!( + prepared["result"]["range"]["start"], + json!({ "line": line, "character": character }) + ); + + let renamed = rename_request(client, leaders, line, character, "Points"); + assert_rpc_ok(&renamed, "rename(Score) from Razor"); + let changes = renamed["result"]["documentChanges"] + .as_array() + .expect("documentChanges") + .clone(); + let razor = edits_for(&changes, "Leaders.razor"); + assert_eq!(razor.len(), 1, "the Razor use: {renamed}"); + assert_eq!( + razor[0]["range"]["start"], + json!({ "line": line, "character": character }) + ); + let fsharp = edits_for(&changes, "Scores.fs"); + assert_eq!( + fsharp.len(), + 5, + "the F# declaration and four uses: {renamed}" + ); + let applied = apply_text_edits(scores, &fsharp); + assert!( + applied.contains("Points: int }"), + "the field is renamed: {applied}" + ); + assert!( + !applied.contains(".Score"), + "every F# use is renamed: {applied}" + ); + assert!( + edits_for(&changes, ".g.cs").is_empty(), + "no generated file: {renamed}" + ); + assert!( + fsharp + .iter() + .chain(razor.iter()) + .all(|edit| edit["newText"] == "Points"), + "every edit renames to Points" + ); +} + +/// Renaming the F# field from its F# declaration renames the Razor use too. +/// [RAZOR-FSHARP-RENAME] [RENAME-CROSSLANGUAGE] +fn assert_rename_from_fsharp_renames_razor( + client: &mut LspClient, + ws: &RazorWorkspace, + page: &str, + scores: &str, +) { + let uri = ws.uri(SCORES); + client.open_document(&uri, scores); + let (line, character) = locate(scores, "Score: int", "Score"); + let deadline = std::time::Instant::now() + Duration::from_mins(3); + let changes = loop { + let renamed = rename_request(client, &uri, line, character, "Points"); + assert_rpc_ok(&renamed, "rename(Score) from F#"); + let changes = renamed["result"]["documentChanges"] + .as_array() + .cloned() + .unwrap_or_default(); + if !edits_for(&changes, "Leaders.razor").is_empty() { + break changes; + } + assert!( + std::time::Instant::now() < deadline, + "F# rename never reached Razor: {renamed}" + ); + std::thread::sleep(Duration::from_secs(2)); + }; + assert_eq!(edits_for(&changes, "Scores.fs").len(), 5); + let razor = edits_for(&changes, "Leaders.razor"); + assert_eq!(razor.len(), 1); + let (use_line, use_character) = locate(page, "@player.Score", "Score"); + assert_eq!( + razor[0]["range"]["start"], + json!({ "line": use_line, "character": use_character }) + ); + assert_eq!( + edits_for(&changes, ".g.cs"), + Vec::::new(), + "no edit touches a generated file" + ); +} diff --git a/src/sharplsp/tests/e2e_modules/razor_hierarchy.rs b/src/sharplsp/tests/e2e_modules/razor_hierarchy.rs new file mode 100644 index 00000000..68b572a3 --- /dev/null +++ b/src/sharplsp/tests/e2e_modules/razor_hierarchy.rs @@ -0,0 +1,191 @@ +//! Full-stack Razor inlay hints, code lenses, and call and type hierarchy over the real +//! fixture. Each runs on the Razor file's C# projection and answers in the file itself: +//! positions, items and call sites are Razor ranges, a component class is its own file. +//! [RAZOR-FEATURES-INLAY-HINTS] [RAZOR-FEATURES-HIERARCHY] [RAZOR-ARCHITECTURE-PROJECTION] + +use super::*; + +/// `Ticker.razor`: a component that inherits another, calls a plain C# method, and renders +/// markup that clicks its own handler. +const TICKER: &[&str] = &["BlazorApp", "Components", "Shared", "Ticker.razor"]; + +/// `Panel.razor`, the component `Ticker` inherits. +const PANEL: &[&str] = &["Widgets", "Panel.razor"]; + +#[test] +fn test_full_stack_razor_hierarchy_and_hints() { + require_dotnet(); + let ws = create_razor_workspace(); + let mut client = LspClient::start_verbose(); + let init = client.initialize_with_root(json!(path_to_file_uri(ws.root()))); + assert_rpc_ok(&init, "initialize the Razor fixture"); + + let ticker = ws.uri(TICKER); + client.open_document(&ticker, &ws.text(TICKER)); + + // ── Readiness: a hover inside the file's C# arrives through the projection ── + let hover = poll_hover_until_ready(&mut client, &ticker, 2, 24, Duration::from_mins(3)); + let markdown = hover["contents"]["value"].as_str().unwrap_or_default(); + assert!( + markdown.contains("Describe"), + "hover on `Describe` must show the method: {markdown}" + ); + + assert_call_hierarchy(&mut client, &ticker); + assert_type_hierarchy(&mut client, &ws, &ticker); + assert_inlay_hints(&mut client, &ticker); + + client.shutdown_and_exit(); + client.wait_with_timeout(); +} + +/// `prepareCallHierarchy` and `incomingCalls` from a Razor position: the item is the Razor +/// declaration, and the markup that clicks it is a caller in the same file. +fn assert_call_hierarchy(client: &mut LspClient, ticker: &str) { + let hit = prepared(client, "textDocument/prepareCallHierarchy", ticker, 8, 17); + assert_eq!(hit["name"], "Hit", "{hit}"); + assert_eq!(hit["kind"], 6, "a method: {hit}"); + assert_eq!(hit["uri"], json!(ticker), "{hit}"); + assert_eq!( + hit["range"], + json!({ + "start": { "line": 8, "character": 17 }, + "end": { "line": 8, "character": 20 }, + }), + "{hit}" + ); + + let incoming = client.request("callHierarchy/incomingCalls", json!({ "item": hit })); + assert_rpc_ok(&incoming, "incomingCalls(Hit)"); + let calls = incoming["result"].as_array().expect("incoming calls"); + assert_eq!(calls.len(), 1, "{incoming}"); + let call = &calls[0]; + assert_eq!( + call["from"]["name"], "Ticker.razor", + "the component's own markup calls it: {call}" + ); + assert_eq!(call["from"]["kind"], 5, "the component is a class: {call}"); + assert_eq!(call["from"]["uri"], json!(ticker), "{call}"); + assert_eq!( + call["from"]["selectionRange"]["start"], + json!({ "line": 0, "character": 0 }), + "a component is its file: {call}" + ); + assert_eq!( + call["fromRanges"], + json!([{ + "start": { "line": 3, "character": 18 }, + "end": { "line": 3, "character": 21 }, + }]), + "the click the markup writes: {call}" + ); +} + +/// `prepareTypeHierarchy` from `@inherits`, and its subtypes: the base component is its own +/// file, and the subtype is this one's. +fn assert_type_hierarchy(client: &mut LspClient, ws: &RazorWorkspace, ticker: &str) { + let panel = prepared(client, "textDocument/prepareTypeHierarchy", ticker, 0, 10); + assert_eq!(panel["name"], "Panel", "{panel}"); + assert_eq!(panel["kind"], 5, "a component is a class: {panel}"); + assert_eq!(panel["uri"], json!(ws.uri(PANEL)), "{panel}"); + assert_eq!( + panel["selectionRange"], + json!({ + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 }, + }), + "a component class is the start of its file: {panel}" + ); + + let subtypes = client.request("typeHierarchy/subtypes", json!({ "item": panel })); + assert_rpc_ok(&subtypes, "subtypes(Panel)"); + let items = subtypes["result"].as_array().expect("subtypes"); + assert_eq!(items.len(), 1, "{subtypes}"); + assert_eq!(items[0]["name"], "Ticker", "{subtypes}"); + assert_eq!(items[0]["uri"], json!(ticker), "{subtypes}"); +} + +/// `textDocument/inlayHint` over the file: the hints of its C# fragments sit at their Razor +/// positions, the generated builder's own arguments are dropped, and a range outside every +/// fragment answers none. +fn assert_inlay_hints(client: &mut LspClient, ticker: &str) { + let hints = inlay_hints(client, ticker, (0, 0), (18, 0)); + assert!( + hints.iter().any( + |hint| hint["position"] == json!({ "line": 10, "character": 11 }) + && hint["label"] == ": int" + && hint["kind"] == 1 + ), + "the type of a `var` in `@code`: {hints:?}" + ); + assert!( + hints.iter().any( + |hint| hint["position"] == json!({ "line": 10, "character": 33 }) + && hint["label"] == "by:" + && hint["kind"] == 2 + ), + "the name of the argument `Bump` takes: {hints:?}" + ); + assert!( + hints.iter().all(|hint| hint["position"]["line"] + .as_u64() + .is_some_and(|line| line <= 18)), + "every hint is in the file, not in its projection: {hints:?}" + ); + + assert_eq!( + inlay_hints(client, ticker, (4, 0), (4, 0)), + Vec::::new(), + "a blank line outside every C# fragment has no hints" + ); +} + +/// The hints the server answers over `range`, asserted well-formed. +fn inlay_hints( + client: &mut LspClient, + uri: &str, + start: (u64, u64), + end: (u64, u64), +) -> Vec { + let response = client.request( + "textDocument/inlayHint", + json!({ + "textDocument": { "uri": uri }, + "range": { + "start": { "line": start.0, "character": start.1 }, + "end": { "line": end.0, "character": end.1 }, + }, + }), + ); + assert_rpc_ok(&response, "inlayHint"); + let hints = response["result"] + .as_array() + .expect("an array of hints") + .clone(); + for hint in &hints { + assert!(hint["position"].is_object(), "a position: {hint}"); + assert!( + hint["label"] + .as_str() + .is_some_and(|label| !label.is_empty()), + "a label: {hint}" + ); + } + hints +} + +/// The single item `method` prepares at a position. +fn prepared(client: &mut LspClient, method: &str, uri: &str, line: u64, character: u64) -> Value { + let response = client.request( + method, + json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character }, + }), + ); + assert_rpc_ok(&response, method); + let none = Vec::new(); + let items = response["result"].as_array().unwrap_or(&none); + assert_eq!(items.len(), 1, "{method} prepares one item: {response}"); + items[0].clone() +} diff --git a/src/sharplsp/tests/e2e_modules/razor_mvc.rs b/src/sharplsp/tests/e2e_modules/razor_mvc.rs new file mode 100644 index 00000000..35a0a14d --- /dev/null +++ b/src/sharplsp/tests/e2e_modules/razor_mvc.rs @@ -0,0 +1,143 @@ +//! Full-stack Razor Pages and MVC views over the real fixture's `PagesApp`: +//! `@model`, `@inject` and `@functions` answer through the projection, tag +//! helper elements and attributes answer from their descriptors and complete +//! in scope, and a view resolves what its `_ViewImports.cshtml` imports. +//! [RAZOR-MVC] [RAZOR-MVC-DIRECTIVES] [RAZOR-MVC-TAGHELPERS] [RAZOR-MVC-IMPORTS] + +use super::razor::complete_after; +use super::*; + +/// The Razor Page with a model, an injection, tag helpers and `@functions`. +const EDIT: &[&str] = &["PagesApp", "Pages", "Edit.cshtml"]; + +/// The MVC view whose `@model` only `Views/_ViewImports.cshtml` resolves. +const INDEX: &[&str] = &["PagesApp", "Views", "Home", "Index.cshtml"]; + +#[test] +fn test_full_stack_razor_pages_and_mvc_views() { + require_dotnet(); + let ws = create_razor_workspace(); + let mut client = LspClient::start_verbose(); + let init = client.initialize_with_root(json!(path_to_file_uri(ws.root()))); + assert_rpc_ok(&init, "initialize the Razor fixture"); + + let edit = ws.uri(EDIT); + let text = ws.text(EDIT); + client.open_document(&edit, &text); + + assert_directives_answer_through_the_projection(&mut client, &edit); + assert_tag_helpers_answer_from_their_descriptors(&mut client, &edit, &text); + assert_view_imports_reach_the_view(&mut client, &ws); + + client.shutdown_and_exit(); + client.wait_with_timeout(); +} + +/// `@model`'s type, `@inject`'s property and a `@functions` method are C#. +fn assert_directives_answer_through_the_projection(client: &mut LspClient, edit: &str) { + let model = poll_hover_until_ready(client, edit, 1, 8, Duration::from_mins(3)); + let markdown = model["contents"]["value"].as_str().unwrap_or_default(); + assert!( + markdown.contains("Edits one item."), + "`@model EditModel` hovers the page model's summary: {markdown}" + ); + assert_eq!( + model["range"], + json!({ "start": { "line": 1, "character": 7 }, "end": { "line": 1, "character": 16 } }), + "ranged on the type name" + ); + + let log = hover(client, edit, 2, 28); + assert_hover_ok(&log); + let markdown = log["result"]["contents"]["value"] + .as_str() + .unwrap_or_default(); + assert!( + markdown.contains("ILogger<"), + "`@inject` declares a property of its type: {markdown}" + ); + + let shout = definition(client, edit, 10, 5); + assert_nav_ok(&shout); + let target = first_location(&shout["result"]); + assert_eq!( + target["uri"], + json!(edit), + "the method is the page's: {shout}" + ); + assert_location_line(&target, 13, "`@Shout` goes to its `@functions` declaration"); +} + +/// An element a tag helper binds, and the attribute it sets, hover from their +/// descriptors; tag helper elements and attributes complete in scope. +fn assert_tag_helpers_answer_from_their_descriptors( + client: &mut LspClient, + edit: &str, + text: &str, +) { + let input = hover(client, edit, 6, 6); + assert_hover_ok(&input); + let markdown = input["result"]["contents"]["value"] + .as_str() + .unwrap_or_default(); + assert!( + markdown.contains("InputTagHelper"), + "`` hovers its tag helper: {markdown}" + ); + + let title = definition(client, edit, 6, 20); + assert_nav_ok(&title); + let entries = location_entries(&title["result"]); + assert_has_location_in( + &entries, + "Edit.cshtml.cs", + "`asp-for=\"Title\"` names the model's property", + ); + + let elements = complete_after(client, edit, 2, &format!("{text}\n<")); + let labels: Vec<&str> = elements + .iter() + .filter_map(|item| item["label"].as_str()) + .collect(); + for expected in ["partial", "environment"] { + assert!( + labels.contains(&expected), + "`{expected}` completes after `<`: {labels:?}" + ); + } + assert_eq!( + labels.iter().filter(|label| **label == "a").count(), + 1, + "an element HTML also names completes once: {labels:?}" + ); + + let attributes = complete_after(client, edit, 3, &format!("{text}\n::new(), "the view compiles: {errors:?}"); +} diff --git a/src/sharplsp/tests/e2e_modules/razor_selection.rs b/src/sharplsp/tests/e2e_modules/razor_selection.rs new file mode 100644 index 00000000..a5eff9b8 --- /dev/null +++ b/src/sharplsp/tests/e2e_modules/razor_selection.rs @@ -0,0 +1,179 @@ +//! Full-stack Razor selection ranges over the real fixture: the host routes +//! `textDocument/selectionRange` to the C# sidecar, which answers each position's +//! chain from its innermost span out to the whole document, nested as LSP parents. +//! [RAZOR-FEATURES-SELECTION] + +use super::*; + +/// `Board.razor`, the fixture's richest component. +const BOARD: &[&str] = &["BlazorApp", "Components", "Pages", "Board.razor"]; + +/// The whole file's span: the fixture's files end with a line break. +fn document() -> Value { + json!({ + "start": {"line": 0, "character": 0}, + "end": {"line": 26, "character": 0} + }) +} + +#[test] +fn test_full_stack_razor_selection_ranges() { + require_dotnet(); + let ws = create_razor_workspace(); + let mut client = LspClient::start_verbose(); + let _init = client.initialize_with_root(json!(path_to_file_uri(ws.root()))); + + let board = ws.uri(BOARD); + client.open_document(&board, &ws.text(BOARD)); + + // ── A caret on the Tally tag name: tag → element → Panel → document ── + let chains = poll_selection_until_ready(&mut client, &board, 7, 5); + let tally = chains + .as_array() + .and_then(|all| all.first()) + .expect("one chain per position"); + let levels = selection_levels(tally); + assert!( + levels.len() >= 3, + "a tag name chains at least three levels: {levels:?}" + ); + assert_eq!( + levels[0], + json!({"start": {"line": 7, "character": 5}, "end": {"line": 7, "character": 10}}), + "the innermost range is the tag name: {levels:?}" + ); + assert_eq!( + levels[1], + json!({"start": {"line": 7, "character": 4}, "end": {"line": 9, "character": 12}}), + "then the element: {levels:?}" + ); + assert_eq!( + levels[2], + json!({"start": {"line": 6, "character": 0}, "end": {"line": 10, "character": 8}}), + "then the enclosing Panel: {levels:?}" + ); + assert_eq!( + levels.last(), + Some(&document()), + "the document closes the chain" + ); + assert!( + selection_levels(tally) + .last() + .and_then(|level| level.get("parent")) + .is_none(), + "the document has no parent: {tally}" + ); + assert_parents_hold(tally); + + // ── A caret inside a C# identifier in markup chains through the attribute ── + let answer = client.request( + "textDocument/selectionRange", + json!({ + "textDocument": { "uri": board }, + "positions": [ + { "line": 8, "character": 25 }, + { "line": 19, "character": 20 } + ] + }), + ); + assert_rpc_ok(&answer, "selectionRange(Board.razor, two carets)"); + let chains = answer["result"].as_array().expect("one chain per position"); + assert_eq!(chains.len(), 2, "one chain per position: {answer}"); + + let attribute = selection_levels(&chains[0]); + assert_eq!( + attribute[0], + json!({"start": {"line": 8, "character": 22}, "end": {"line": 8, "character": 29}}), + "the innermost range is the C# identifier in the value: {attribute:?}" + ); + assert!( + attribute.len() >= 5, + "the identifier chains through the attribute and its elements: {attribute:?}" + ); + assert_eq!( + attribute.last(), + Some(&document()), + "the document closes the chain" + ); + assert_parents_hold(&chains[0]); + + let field = selection_levels(&chains[1]); + assert_eq!( + field[0], + json!({"start": {"line": 19, "character": 19}, "end": {"line": 19, "character": 26}}), + "the innermost range is the field's identifier: {field:?}" + ); + assert!(field.len() >= 4, "the identifier chains outward: {field:?}"); + assert_eq!( + field.last(), + Some(&document()), + "the document closes the chain" + ); + assert_parents_hold(&chains[1]); + + client.shutdown_and_exit(); + client.wait_with_timeout(); +} + +/// Ask for `textDocument/selectionRange` until the sidecar has loaded the +/// workspace and answers a chain with a parent, then return the chains. +fn poll_selection_until_ready( + client: &mut LspClient, + uri: &str, + line: u32, + character: u32, +) -> Value { + let deadline = std::time::Instant::now() + Duration::from_mins(3); + loop { + let response = client.request( + "textDocument/selectionRange", + json!({ + "textDocument": { "uri": uri }, + "positions": [{ "line": line, "character": character }] + }), + ); + assert_rpc_ok(&response, "selectionRange(Board.razor)"); + let chains = response["result"].as_array().cloned().unwrap_or_default(); + if chains + .first() + .is_some_and(|chain| chain["parent"].is_object()) + { + return Value::Array(chains); + } + assert!( + std::time::Instant::now() < deadline, + "selection ranges never nested within the deadline: {response}" + ); + std::thread::sleep(Duration::from_secs(2)); + } +} + +/// The chain as its ranges, innermost first. +fn selection_levels(chain: &Value) -> Vec { + let mut levels = Vec::new(); + let mut at = Some(chain); + while let Some(item) = at { + levels.push(item["range"].clone()); + at = item.get("parent").filter(|parent| parent.is_object()); + } + levels +} + +/// A `line`/`character` object as its numbers, so positions compare. +fn point(value: &Value) -> (u64, u64) { + ( + value["line"].as_u64().unwrap_or_default(), + value["character"].as_u64().unwrap_or_default(), + ) +} + +/// Every level holds the one before it, so an editor can grow the selection. +fn assert_parents_hold(chain: &Value) { + let levels = selection_levels(chain); + for (inner, outer) in levels.iter().zip(levels.iter().skip(1)) { + let (start, end) = (point(&inner["start"]), point(&inner["end"])); + let holds = point(&outer["start"]) <= start && point(&outer["end"]) >= end; + assert!(holds, "each parent must hold its child: {inner} in {outer}"); + } +} diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/App.razor b/src/sharplsp/tests/fixtures/blazor-wasm/App.razor new file mode 100644 index 00000000..e6585e93 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/App.razor @@ -0,0 +1,9 @@ +@* The root component the host mounts into `#app`. [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM] *@ + + + + + +

Sorry, there is nothing at this address.

+
+
diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/BlazorWasmApp.csproj b/src/sharplsp/tests/fixtures/blazor-wasm/BlazorWasmApp.csproj new file mode 100644 index 00000000..a90cdc56 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/BlazorWasmApp.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + BlazorWasmApp + enable + enable + + + + + + + + diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.props b/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.props new file mode 100644 index 00000000..d320e322 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.props @@ -0,0 +1,6 @@ + + + diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.targets b/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.targets new file mode 100644 index 00000000..df1b2315 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.targets @@ -0,0 +1,4 @@ + + + diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/Pages/Counter.razor b/src/sharplsp/tests/fixtures/blazor-wasm/Pages/Counter.razor new file mode 100644 index 00000000..f342a792 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/Pages/Counter.razor @@ -0,0 +1,14 @@ +@page "/counter" + +@* The one page: enough that a breakpoint in a Razor file has something to hit. *@ +

Counter

+ +

Current count: @currentCount

+ + + +@code { + private int currentCount; + + private void Increment() => currentCount++; +} diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/Program.cs b/src/sharplsp/tests/fixtures/blazor-wasm/Program.cs new file mode 100644 index 00000000..d6cf22a5 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/Program.cs @@ -0,0 +1,11 @@ +using BlazorWasmApp; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +// The template's entry point: build the WebAssembly host in the browser, then let it +// take over the page. [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM] +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +await builder.Build().RunAsync(); diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/Properties/launchSettings.json b/src/sharplsp/tests/fixtures/blazor-wasm/Properties/launchSettings.json new file mode 100644 index 00000000..ba7323af --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://127.0.0.1:0", + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}" + } + } +} diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/_Imports.razor b/src/sharplsp/tests/fixtures/blazor-wasm/_Imports.razor new file mode 100644 index 00000000..5f14b71b --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/_Imports.razor @@ -0,0 +1,5 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using BlazorWasmApp +@using BlazorWasmApp.Pages diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/wwwroot/index.html b/src/sharplsp/tests/fixtures/blazor-wasm/wwwroot/index.html new file mode 100644 index 00000000..069ab110 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/wwwroot/index.html @@ -0,0 +1,23 @@ + + + + + + + + BlazorWasmApp + + + +
+

Loading…

+
+ +
+ An unhandled error has occurred. +
+ + + + + diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Leaders.razor b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Leaders.razor index 46d61fd9..cb4e91c8 100644 --- a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Leaders.razor +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Leaders.razor @@ -4,11 +4,13 @@
    @foreach (var player in players) { -
  • @player.Name: @player.Score
  • +
  • @player.Name: @player.Score (@Describe(Scoring.standing(players, player)))
  • }

Total: @Scoring.total(players)

@code { private readonly Player[] players = [new Player("Ada", 3), new Player("Grace", 5)]; + + private static string Describe(Standing standing) => standing.IsLeading ? "leading" : "chasing"; } diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor new file mode 100644 index 00000000..fb14970c --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor @@ -0,0 +1,36 @@ +@using Microsoft.JSInterop +@inject IJSRuntime JS +@implements IAsyncDisposable + +
+ @Title +
+ +@code { + private IJSObjectReference? module; + + /// The card's title. + [Parameter] + public string Title { get; set; } = ""; + + /// Loads the card's collocated JavaScript module. + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + module = await JS.InvokeAsync( + "import", + "./Components/Shared/Card.razor.js" + ); + } + } + + /// Releases the collocated module. + public async ValueTask DisposeAsync() + { + if (module is not null) + { + await module.DisposeAsync(); + } + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.cs b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.cs new file mode 100644 index 00000000..a4d06b5f --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.cs @@ -0,0 +1,28 @@ +using Microsoft.JSInterop; + +namespace BlazorApp.Components.Shared; + +/// +/// The code-behind half of Card.razor. Implements [RAZOR-COLLOCATION-JS] for the +/// sidecar's tests: a collocated module import is answered from a code-behind file too. +/// +public partial class Card +{ + /// Loads the component's collocated module from a code-behind method. + public static ValueTask LoadAsync(IJSRuntime js) + { + return js.InvokeAsync("import", "./Components/Shared/Card.razor.js"); + } + + /// Loads whatever module the caller names, which no definition can resolve. + public static ValueTask LoadAnyAsync(IJSRuntime js, string path) + { + return js.InvokeAsync("import", path); + } + + /// Runs a JavaScript function; the first argument is not an import. + public static ValueTask InvokeAsync(IJSRuntime js, string identifier) + { + return js.InvokeAsync(identifier, "./Components/Shared/Card.razor.js"); + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.css b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.css new file mode 100644 index 00000000..f2ed2e8a --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.css @@ -0,0 +1,52 @@ +/* + * Card.razor.css — the collocated stylesheet of Card.razor. + * A comment holds braces and class names: } .commented { .alsoCommented { } + */ + +.card { + color: #1a1a1a; + margin: .5rem; + padding: 0.25rem 0.5rem; + background: url(card.bg.png); +} + +.card .title { + font-weight: 600; +} + +.card::deep .card__title { + text-transform: uppercase; + grid-area: zone; +} + +.title:is(.accent, .muted) { + letter-spacing: 0.05em; +} + +a[href$=".pdf"] .title { + text-decoration: underline; +} + +.panel .title { + font-size: 1.25rem; +} + +.p-1\.5 { + padding: 0.375rem; +} + +.labeled { + content: "a.b"; +} + +@media (min-width: 641px) { + .title { + font-size: 2rem; + } + + @supports (display: grid) { + .card--grid .meta { + display: grid; + } + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.js b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.js new file mode 100644 index 00000000..efc042ff --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.js @@ -0,0 +1,10 @@ +/** + * The collocated JavaScript module of Card.razor, imported by + * `JS.InvokeAsync("import", "./Components/Shared/Card.razor.js")`. + */ +export function render(elementId) { + const card = document.getElementById(elementId); + if (card) { + card.dataset.enhanced = "true"; + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/CardInterop.cs b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/CardInterop.cs new file mode 100644 index 00000000..12e58eba --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/CardInterop.cs @@ -0,0 +1,17 @@ +using Microsoft.JSInterop; + +namespace BlazorApp.Components.Shared; + +/// +/// Loads a component's collocated JavaScript module from plain C#. Implements +/// [RAZOR-COLLOCATION-JS]: the definition of the module a "import" call names is +/// answered from a .cs file as well as from Razor. +/// +public static class CardInterop +{ + /// Loads Card's collocated module. + public static ValueTask LoadCardAsync(IJSRuntime js) + { + return js.InvokeAsync("import", "./Components/Shared/Card.razor.js"); + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Counting.cs b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Counting.cs new file mode 100644 index 00000000..85f4a5d1 --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Counting.cs @@ -0,0 +1,14 @@ +namespace BlazorApp.Components.Shared; + +/// +/// A plain C# type the fixture's Razor components call into, so that a call from Razor code and +/// markup reaches a method declared outside the projection. [RAZOR-TESTING] +/// +internal static class Counting +{ + /// Doubles a count. + internal static int Bump(int by) + { + return by * 2; + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Ticker.razor b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Ticker.razor new file mode 100644 index 00000000..5518e7f3 --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Ticker.razor @@ -0,0 +1,19 @@ +@inherits Panel + +@Describe(count) + + +@code { + private int count; + + private void Hit() + { + var next = Counting.Bump(count); + count = next; + } + + private static string Describe(int value) + { + return $"Count: {value}"; + } +} diff --git a/src/sharplsp/tests/fixtures/razor/Domain/Scores.fs b/src/sharplsp/tests/fixtures/razor/Domain/Scores.fs index 11578364..05c2a042 100644 --- a/src/sharplsp/tests/fixtures/razor/Domain/Scores.fs +++ b/src/sharplsp/tests/fixtures/razor/Domain/Scores.fs @@ -1,9 +1,25 @@ namespace Domain /// A player on the leader board. -type Player = { Name: string; Score: int } +type Player = + { /// The player's display name. + Name: string + /// The points the player has scored. + Score: int } + +/// Where a player stands against the field. +type Standing = + /// No player has scored more. + | Leading + /// Behind the leader by some points. + | Chasing of gap: int /// Scoring rules for the leader board. module Scoring = /// The sum of every player's score. let total (players: seq) = players |> Seq.sumBy (fun player -> player.Score) + + /// Where `player` stands against `field`. + let standing (field: seq) (player: Player) = + let best = field |> Seq.map (fun other -> other.Score) |> Seq.max + if player.Score = best then Leading else Chasing(best - player.Score) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/CallHierarchyKindTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/CallHierarchyKindTests.cs new file mode 100644 index 00000000..374abba2 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/CallHierarchyKindTests.cs @@ -0,0 +1,49 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SharpLsp.Sidecar.CSharp.Workspace; + +#pragma warning disable CA1515 // Public xUnit discovery type + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// The kinds call hierarchy names its items with, in the PascalCase the host maps to LSP +/// SymbolKind, for the symbols a hierarchy request can land on that no workspace test +/// reaches. Implements [RAZOR-TESTING] for [RAZOR-FEATURES-HIERARCHY]. +/// +public sealed class CallHierarchyKindTests +{ + private const string Source = """ + namespace Kinds + { + public class Box + { + public object Items() => from item in new[] { 1 } select item; + } + } + """; + + [Fact] + public void Namespaces_type_parameters_and_kinds_lsp_lacks_are_named_for_the_host() + { + var tree = CSharpSyntaxTree.ParseText(Source); + var compilation = CSharpCompilation.Create("Kinds", [tree]); + var model = compilation.GetSemanticModel(tree); + var nodes = tree.GetRoot().DescendantNodes().ToList(); + + var space = model.GetDeclaredSymbol(nodes.OfType().Single()); + var item = model.GetDeclaredSymbol(nodes.OfType().Single()); + var range = model.GetDeclaredSymbol(nodes.OfType().Single()); + Assert.Equal( + ("Namespace", "TypeParameter", "Function", "Function"), + ( + CallHierarchyResolver.MapSymbolKind(space!), + CallHierarchyResolver.MapSymbolKind(item!), + CallHierarchyResolver.MapSymbolKind(range!), + CallHierarchyResolver.MapSymbolKind(compilation.Assembly) + ) + ); + Assert.Equal(SymbolKind.RangeVariable, range!.Kind); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HeadlessOverrideGenerationTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HeadlessOverrideGenerationTests.cs index e5ef6399..5043e01a 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HeadlessOverrideGenerationTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HeadlessOverrideGenerationTests.cs @@ -1,8 +1,8 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Text; using SharpLsp.Sidecar.Common; using SharpLsp.Sidecar.CSharp.Workspace; +using static SharpLsp.Sidecar.CSharp.Tests.TextEditing; #pragma warning disable CA1307 // StringComparison overloads add no value to xUnit assertions #pragma warning disable CA1515 // Public xUnit discovery type @@ -411,18 +411,4 @@ private static T Unwrap(Outcome.Result result) Assert.False(result.IsError, result.Match(_ => "ok", error => error)); return +result; } - - private static string ApplyEdits(string source, IEnumerable edits) - { - var text = SourceText.From(source); - var changes = edits.Select(edit => new TextChange(EditSpan(text, edit), edit.NewText)); - return text.WithChanges(changes).ToString(); - } - - private static TextSpan EditSpan(SourceText text, TextEditResult edit) - { - var start = text.Lines.GetPosition(new LinePosition(edit.StartLine, edit.StartCharacter)); - var end = text.Lines.GetPosition(new LinePosition(edit.EndLine, edit.EndCharacter)); - return TextSpan.FromBounds(start, end); - } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HotReloadSessionRegistryTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HotReloadSessionRegistryTests.cs index 6d30a4f8..756ad8b0 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HotReloadSessionRegistryTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HotReloadSessionRegistryTests.cs @@ -372,6 +372,33 @@ public async Task Two_phase_updates_commit_discard_and_span_documents() .ConfigureAwait(true); Assert.Equal("committed", committed.Status); + // A re-save of the committed text changes nothing at runtime: Roslyn has nothing to + // apply, so it is applied with no deltas and leaves nothing to commit. + var quiet = await SendAsync( + registry, + new HotReloadRequest + { + Action = "update", + SessionId = started.SessionId, + FilePath = sourcePath, + NewText = UpdatedSource, + } + ) + .ConfigureAwait(true); + Assert.Equal( + ("applied", 0, 0), + (quiet.Status, quiet.Updates.Count, quiet.Diagnostics.Count) + ); + _ = await Assert + .ThrowsAsync(async () => + _ = await SendAsync( + registry, + new HotReloadRequest { Action = "commit", SessionId = started.SessionId } + ) + .ConfigureAwait(true) + ) + .ConfigureAwait(true); + // A cross-file-consistent batch: the call in Calculator.cs needs the // method added in Helper.cs — only a single candidate spanning both // documents can emit it. diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs index c266559e..b8859d3b 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs @@ -255,6 +255,72 @@ string expected Assert.Equal(expected, Of(html).ToString()); } + [Fact] + public void The_attribute_at_a_caret_is_the_one_the_tokenizer_reads_there() + { + // In a value: the attribute's name, and its value without the quotes. + Assert.Equal(("class", "class", "a b"), AttributeOf("")); + Assert.Equal(("class", "class", "a"), AttributeOf("")); + Assert.Equal(("data-x", "data-x", "1"), AttributeOf("

")); + Assert.Equal(("x", "x", "ab"), AttributeOf("

")); + Assert.Equal(("class", "class", ""), AttributeOf("

")); + + // On a name: the whole name, and the value the tokenizer reads after the caret. + Assert.Equal(("class", "class", "x"), AttributeOf("

")); + Assert.Equal(("b", "b", "x"), AttributeOf("")); + Assert.Equal(("hidden", "hidden", null), AttributeOf("")); + Assert.Equal(("x", "x", null), AttributeOf("

"); + var action = Assert.Single( + await ActionsAsync(manager, Counter, (section.Line, 0), (close.Line, 0)), + a => a.Title == "Extract element to new component 'Component'" + ); + var edit = await ResolveAsync(manager, action); + Assert.Equal( + "
\n

Nested

\n
\n", + Assert.Single(edit.DocumentChanges[0].Edits).NewText + ); + Assert.Contains( + "
\n \n
\n", + ApplyEdits(text, edit.DocumentChanges[1].Edits), + StringComparison.Ordinal + ); + + // A local declared outside the selection is out of the new component's reach. + var span = Find(text, "@item"); + Assert.DoesNotContain( + await ActionsAsync( + manager, + Counter, + span, + (span.Line, span.Character + "@item".Length) + ), + a => a.Title.StartsWith("Extract element", StringComparison.Ordinal) + ); + + // One declared inside it, a lambda's parameter, moves with it. + const string emphasis = "@(new[] { 1 }.Select(n => n + 1).First())"; + var em = Find(text, emphasis); + _ = Assert.Single( + await ActionsAsync(manager, Counter, em, (em.Line, em.Character + emphasis.Length)), + a => a.Title == "Extract element to new component 'Component'" + ); + } + + private static (int Line, int Character) Find(string text, string token) + { + var lines = text.Split('\n'); + var line = Array.FindIndex(lines, l => l.Contains(token, StringComparison.Ordinal)); + Assert.True(line >= 0, $"missing {token}"); + return (line, lines[line].IndexOf(token, StringComparison.Ordinal)); + } + + private static async Task> ActionsAsync( + WorkspaceManager manager, + string file, + (int Line, int Character) start, + (int Line, int Character)? end = null + ) + { + var (endLine, endCharacter) = end ?? start; + return RazorFixture.Ok( + await manager.GetCodeActionsAsync( + file, + start.Line, + start.Character, + endLine, + endCharacter + ) + ); + } + + private static async Task ResolveAsync( + WorkspaceManager manager, + CodeActionItem action + ) + { + Assert.True(action.Id < 0, "a Razor action has its own id"); + return RazorFixture.Ok(await manager.ResolveCodeActionAsync(action.Id)); + } + + private static async Task> ErrorsAsync(WorkspaceManager manager, string file) + { + return + [ + .. RazorFixture + .Ok(await manager.GetDiagnosticsAsync(file)) + .Where(diagnostic => diagnostic.Severity == "Error") + .Select(diagnostic => diagnostic.Code), + ]; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCollocationTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCollocationTests.cs new file mode 100644 index 00000000..51faf35a --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCollocationTests.cs @@ -0,0 +1,301 @@ +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; + +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable RS1035 // Real-file reads are intentional in this end-to-end suite +#pragma warning disable IDE0042 // Positions read better as (Line, Character) pairs + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// Collocated files of a Razor file, against the real fixture solution: the class selectors of +/// Card.razor.css complete inside a class value and define to every selector that +/// declares them, and a InvokeAsync<IJSObjectReference>("import", "…") call defines +/// to the start of the Card.razor.js it names, from Razor, a code-behind and plain C#. +/// Implements [RAZOR-TESTING] for [RAZOR-COLLOCATION]. +/// +public sealed class RazorCollocationTests : IClassFixture +{ + private readonly RazorFixture _fixture; + + public RazorCollocationTests(RazorFixture fixture) + { + _fixture = fixture; + } + + private string Card => _fixture.Path("BlazorApp", "Components", "Shared", "Card.razor"); + + private string Stylesheet => + _fixture.Path("BlazorApp", "Components", "Shared", "Card.razor.css"); + + private string Module => _fixture.Path("BlazorApp", "Components", "Shared", "Card.razor.js"); + + private string CodeBehind => + _fixture.Path("BlazorApp", "Components", "Shared", "Card.razor.cs"); + + private string Interop => _fixture.Path("BlazorApp", "Components", "Shared", "CardInterop.cs"); + + private string Board => _fixture.Path("BlazorApp", "Components", "Pages", "Board.razor"); + + /// Text of the stylesheet that is not a class selector. + private static readonly string[] NotClasses = + [ + "commented", + "alsoCommented", + "zone", + "rem", + "pdf", + "png", + "a", + "b", + ]; + + [Fact] + public async Task Class_completion_offers_each_stylesheet_class_once() + { + using var manager = await _fixture.OpenManagerAsync(); + var (items, at) = await CompleteAfterAsync(manager, Card, "\n
item.Label == "title"); + Assert.Equal("Value", title.Kind); + Assert.Equal("Card.razor.css", title.Detail); + Assert.NotNull(title.TextEdit); + Assert.Equal(at.Line, title.TextEdit.StartLine); + Assert.Equal(at.Character - 2, title.TextEdit.StartCharacter); + Assert.Equal(at.Character, title.TextEdit.EndCharacter); + Assert.Equal("title", title.TextEdit.NewText); + + // Declared inside at-rules, `:is()` and `::deep`, they all complete. + Assert.Contains(items, item => item.Label == "card__title"); + Assert.Contains(items, item => item.Label == "accent"); + Assert.Contains(items, item => item.Label == "muted"); + Assert.Contains(items, item => item.Label == "card--grid"); + Assert.Contains(items, item => item.Label == "meta"); + + // Never text that is not a class selector: a comment, a declaration value, a number, + // a string or a url. + Assert.DoesNotContain( + items, + item => item.Kind == "Value" && NotClasses.Contains(item.Label, StringComparer.Ordinal) + ); + + // A class after a bare attribute, single-quoted, still completes the class at the caret. + var (after, end) = await CompleteAfterAsync(manager, Card, "\n
-internal static class HtmlMarkupContext +internal static partial class HtmlMarkupContext { /// The markup context at ; out-of-range offsets clamp. public static MarkupContext At(string html, int offset) @@ -67,7 +67,19 @@ private enum Scanner /// The tokenizer state. /// The current start tag's name; the raw-text target while in . /// Whether the current tag is an end tag. - private readonly record struct Scan(Scanner State, string Tag, bool EndTag); + /// Where the current attribute's name begins; -1 when no start tag has one. + /// Where the current attribute's name ends; -1 while the tokenizer reads it. + /// Where the current attribute's value begins, its quote excluded; -1 before it. + /// Where the current attribute's value ends, its quote excluded; -1 while it is read. + private readonly record struct Scan( + Scanner State, + string Tag, + bool EndTag, + int AttributeStart = -1, + int NameEnd = -1, + int ValueStart = -1, + int ValueEnd = -1 + ); private static Scan ScanTo(string html, int end) { @@ -83,7 +95,7 @@ private static Scan ScanTo(string html, int end) private static (Scan Next, int Index) Advance(Scan s, string html, int i, int end) { - return (s.State, html[i]) switch + var (next, index) = (s.State, html[i]) switch { (Scanner.TagOpen, '!') => Declaration(html, i), (Scanner.TagOpen, '?') => (Data(), Bogus(html, i + 1)), @@ -96,6 +108,7 @@ private static (Scan Next, int Index) Advance(Scan s, string html, int i, int en (Scanner.RawText, _) => RawText(s, html, i, end), _ => (Step(s, html[i]), i + 1), }; + return (Track(s, next, i), index); } private static Scan Step(Scan s, char c) @@ -174,7 +187,7 @@ private static Scan Attribute(Scan s, char c) '>' => Close(s), '/' => Kept(Scanner.SelfClosing, s), '=' => Kept(Scanner.BeforeValue, s), - _ => IsSpace(c) ? Kept(Scanner.AfterAttribute, s) : s, + _ => IsSpace(c) ? Kept(Scanner.AfterAttribute, s) : Kept(Scanner.Attribute, s), }; } @@ -366,7 +379,7 @@ private static Scan Data(Scanner state = Scanner.Data) private static Scan Kept(Scanner state, Scan s) { - return new(state, s.Tag, s.EndTag); + return s with { State = state }; } private static Scan Named(Scan s, char c, bool endTag) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCollocation.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCollocation.cs new file mode 100644 index 00000000..4260de14 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCollocation.cs @@ -0,0 +1,240 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Workspace; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// A Razor file's collocated files: the stylesheet that scopes a component's CSS +/// (Card.razor.css) and the JavaScript module it imports (Card.razor.js). The +/// stylesheet's classes complete and define inside a class attribute value, and an +/// InvokeAsync<IJSObjectReference>("import", "…") call defines to its module. +/// Implements [RAZOR-COLLOCATION]. +/// +internal static class RazorCollocation +{ + /// The types whose InvokeAsync imports a JavaScript module. + private static readonly string[] JruntimeTypes = + [ + "Microsoft.JSInterop.IJSRuntime", + "Microsoft.JSInterop.IJSObjectReference", + ]; + + /// + /// The class attribute value holding , with the class name + /// the caret is in: the whole token between whitespace, not the whole value. Null wherever the + /// attribute is not a plain HTML class, a directive attribute and a component's own + /// parameter never counting. [RAZOR-COLLOCATION-CSS] + /// + public static (TextSpan Value, TextSpan Class)? ClassValueAt(RazorMarkup markup, int offset) + { + return + HtmlMarkupContext.AttributeAt(markup.HtmlText, offset) is { } attribute + && IsHtmlClass(markup, attribute) + && attribute.Value is { } value + && value.Start <= offset + && offset <= value.End + ? (value, TokenAt(markup.HtmlText, value, offset)) + : null; + } + + /// + /// The items for the classes declares, each once, replacing the + /// class name at the caret and naming the stylesheet. [RAZOR-COLLOCATION-CSS] + /// + public static List ClassCompletions( + RazorMarkup markup, + RazorStylesheet stylesheet, + (TextSpan Value, TextSpan Class) at + ) + { + return + [ + .. stylesheet.Names.Select(name => new CompletionItem + { + Label = name, + Kind = "Value", + Detail = NativePaths.NameOf(stylesheet.FilePath), + SortText = name, + Index = -1, + TextEdit = DocumentText.ToTextEdit(markup.Text, new TextChange(at.Class, name)), + }), + ]; + } + + /// + /// The definitions of the class name at the caret: every selector occurrence of that class in + /// the stylesheet, each ranged on the name after its .. [RAZOR-COLLOCATION-CSS] + /// + public static LocationListResult ClassDefinitions( + RazorMarkup markup, + RazorStylesheet stylesheet, + (TextSpan Value, TextSpan Class) at + ) + { + var name = markup.Text.ToString(at.Class); + return new LocationListResult + { + Locations = + [ + .. stylesheet + .Classes.Where(cssClass => cssClass.Name == name) + .Select(cssClass => ToLocation(stylesheet, cssClass)), + ], + }; + } + + /// The name a module import at passes to InvokeAsync; null elsewhere. + public static LiteralExpressionSyntax? ModuleLiteral(SyntaxNode root, int offset) + { + var token = root.FindToken(Math.Clamp(offset, 0, root.FullSpan.End)); + return + token.Parent is LiteralExpressionSyntax literal + && literal.IsKind(SyntaxKind.StringLiteralExpression) + && literal.Span.Contains(offset) + ? literal + : null; + } + + /// + /// The module path names, when the call it is part of passes the + /// constant "import" to an IJSRuntime or IJSObjectReference + /// InvokeAsync. Resolved with the semantic model, never by reading text. + /// [RAZOR-COLLOCATION-JS] + /// + public static string? ImportedModule( + SemanticModel model, + LiteralExpressionSyntax literal, + CancellationToken ct + ) + { + return + ImportCall(literal) is { } call + && ImportsModule(model, call, ct) + && model.GetConstantValue(literal, ct) is { HasValue: true, Value: string path } + ? path + : null; + } + + /// + /// The file a module path names: the leading ./ and the app's base path both resolve + /// against the project directory, and only a .razor.js or .cshtml.js is a module. + /// [RAZOR-COLLOCATION-JS] + /// + public static string? ModuleFile(string projectDirectory, string path) + { + return + NativePaths.FromModulePath(projectDirectory, path) is { } resolved + && ( + NativePaths.HasExtension(resolved, ".razor.js") + || NativePaths.HasExtension(resolved, ".cshtml.js") + ) + ? resolved + : null; + } + + /// Whether the attribute names the plain HTML class, unbound by any tag helper. + private static bool IsHtmlClass( + RazorMarkup markup, + (string Name, TextSpan NameSpan, TextSpan? Value) attribute + ) + { + return string.Equals(attribute.Name, "class", StringComparison.OrdinalIgnoreCase) + && !markup.Attributes.Any(fact => + fact.Kind != MarkupAttributeKind.Html + && fact.NameSpan is { } name + && name.IntersectsWith(attribute.NameSpan) + ); + } + + /// The whole class name holding , kept inside the value. + private static TextSpan TokenAt(string html, TextSpan value, int offset) + { + var start = Math.Clamp(offset, value.Start, value.End); + var end = start; + while (start > value.Start && !HtmlMarkupContext.IsSpace(html[start - 1])) + { + start--; + } + + while (end < value.End && !HtmlMarkupContext.IsSpace(html[end])) + { + end++; + } + + return TextSpan.FromBounds(start, end); + } + + private static LocationResult ToLocation(RazorStylesheet stylesheet, CssClass cssClass) + { + var start = stylesheet.Text.Lines.GetLinePosition(cssClass.Span.Start); + var end = stylesheet.Text.Lines.GetLinePosition(cssClass.Span.End); + return new LocationResult + { + FilePath = stylesheet.FilePath, + Line = start.Line, + Character = start.Character, + EndLine = end.Line, + EndCharacter = end.Character, + }; + } + + /// The call the literal is the second argument of; null when it is not one. + private static InvocationExpressionSyntax? ImportCall(LiteralExpressionSyntax literal) + { + return + literal.Ancestors().OfType().FirstOrDefault() + is { Parent: ArgumentListSyntax list } argument + && list.Arguments.IndexOf(argument) == 1 + && list.Parent is InvocationExpressionSyntax call + ? call + : null; + } + + /// + /// Whether the call is an InvokeAsync of a JavaScript runtime whose first argument is the + /// constant "import". + /// + private static bool ImportsModule( + SemanticModel model, + InvocationExpressionSyntax call, + CancellationToken ct + ) + { + return model.GetConstantValue(call.ArgumentList.Arguments[0].Expression, ct) + is { HasValue: true, Value: "import" } + && model.GetSymbolInfo(call, ct).Symbol is IMethodSymbol method + && method.Name == "InvokeAsync" + && IsRuntime(model.Compilation, method); + } + + /// + /// Whether the call runs on a JavaScript runtime: its declaring type, or its receiver, is + /// IJSRuntime or IJSObjectReference. The params object?[] overload is an + /// extension method called on the runtime, so its receiver is the runtime; called as a static + /// method it passes the path third, which no import names. + /// + private static bool IsRuntime(Compilation compilation, IMethodSymbol method) + { + var runtimes = JruntimeTypes + .Select(compilation.GetTypeByMetadataName) + .OfType(); + return new ITypeSymbol?[] { method.ContainingType, method.ReceiverType }.Any(receiver => + runtimes.Any(runtime => IsOrImplements(receiver, runtime)) + ); + } + + private static bool IsOrImplements(ITypeSymbol? type, INamedTypeSymbol runtime) + { + return type is not null + && ( + SymbolEqualityComparer.Default.Equals(type, runtime) + || type.AllInterfaces.Any(iface => + SymbolEqualityComparer.Default.Equals(iface, runtime) + ) + ); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionEdits.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionEdits.cs index eccd1d7f..1e835447 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionEdits.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionEdits.cs @@ -9,7 +9,8 @@ namespace SharpLsp.Sidecar.CSharp.Razor; /// /// A completion's additional edits, moved from a projection into its Razor file: an edit /// inside one C# fragment maps across, an added C# using becomes an @using line -/// after the file's own, and anything else cannot be placed and is dropped. +/// after the file's own (else after its @page, which a Razor Page must open with), and +/// anything else cannot be placed and is dropped. /// Implements [RAZOR-ARCHITECTURE-PROJECTION] and [RAZOR-FEATURES-COMPLETION]. /// internal static class RazorCompletionEdits @@ -17,7 +18,8 @@ internal static class RazorCompletionEdits /// The Razor edits for computed on 's projection. public static IEnumerable Map( RazorProjection origin, - IEnumerable changes + IEnumerable changes, + RazorMarkup? markup ) { var existing = OwnUsings(origin).Select(Spelling).ToHashSet(StringComparer.Ordinal); @@ -34,7 +36,7 @@ IEnumerable changes foreach (var added in AddedUsings(change).Where(existing.Add)) { - yield return UsingLine(origin, added); + yield return UsingLine(origin, added, markup); } } } @@ -72,25 +74,32 @@ private static string Spelling(UsingDirectiveSyntax directive) return isStatic + alias + directive.NamespaceOrType.ToString(); } - /// An @using line inserted after the file's last own using, else at its start. - private static TextEditResult UsingLine(RazorProjection origin, string spelling) + /// + /// An @using line inserted after the file's last own using, else after its + /// @page directive, else at its start. [RAZOR-CODE-ACTIONS-USING] + /// + /// The line holding the last character of , which may end in its line break. + private static int LastLineOf(SourceText text, TextSpan span) { - var after = OwnUsings(origin) + return text.Lines.GetLineFromPosition(Math.Max(span.Start, span.End - 1)).LineNumber; + } + + private static TextEditResult UsingLine( + RazorProjection origin, + string spelling, + RazorMarkup? markup + ) + { + var usings = OwnUsings(origin) .Select(directive => directive.NamespaceOrType.GetLocation().GetMappedLineSpan()) - .Select(span => span.EndLinePosition.Line + 1) - .DefaultIfEmpty(0) - .Max(); + .Select(span => span.EndLinePosition.Line + 1); + var page = (markup?.Directives ?? []) + .Where(directive => directive.Name == "page") + .Select(directive => LastLineOf(origin.RazorText, directive.Span) + 1); + var after = usings.Concat(page).DefaultIfEmpty(0).Max(); var line = Math.Min(after, origin.RazorText.Lines.Count - 1); var at = origin.RazorText.Lines[line].Start; - var text = "@using " + spelling + LineBreak(origin.RazorText); + var text = "@using " + spelling + RazorHandlerText.LineBreak(origin.RazorText); return DocumentText.ToTextEdit(origin.RazorText, new TextChange(new TextSpan(at, 0), text)); } - - private static string LineBreak(SourceText text) - { - var first = text.Lines[0]; - return first.SpanIncludingLineBreak.Length > first.Span.Length - ? text.ToString(TextSpan.FromBounds(first.End, first.EndIncludingLineBreak)) - : "\n"; - } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorExtractActions.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorExtractActions.cs new file mode 100644 index 00000000..7561dbda --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorExtractActions.cs @@ -0,0 +1,330 @@ +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Workspace; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// Razor refactorings that move markup or code into a new file: a component's @code +/// block into a code-behind partial class, and a run of sibling elements into a new +/// component. Implements [RAZOR-CODE-ACTIONS-CODE-BEHIND] and [RAZOR-CODE-ACTIONS-COMPONENT]. +/// +internal static class RazorExtractActions +{ + /// + /// Move the @code block whose name holds the caret into Name.razor.cs, when the + /// component has no code-behind yet. The partial class carries the file's imported + /// namespaces that the project does not import globally. + /// + public static RazorCodeAction? CodeBehind( + RazorProjection projection, + RazorMarkup markup, + Compilation compilation, + int caret + ) + { + // The caret may sit on the directive's `@` as well as on its name. + var block = markup.Directives.FirstOrDefault(directive => + directive.Span.Start <= caret && caret <= directive.NameSpan.End + ); + var path = NativePaths.CodeBehindOf(projection.RazorPath); + var body = block is { Name: "code" } ? BodyOf(projection, block.Span) : null; + if (markup.FileKind != RazorFileKind.Component || body is null || File.Exists(path)) + { + return null; + } + + var name = NativePaths.StemOf(projection.RazorPath); + var newline = RazorHandlerText.LineBreak(markup.Text); + var text = PartialClass( + markup, + compilation, + name, + markup.Text.ToString(body.Value), + newline + ); + var removed = WithLineBreak(markup.Text, block!.Span); + return new RazorCodeAction( + "Extract block to code behind", + "refactor.extract", + false, + Edits(markup.Text, projection.RazorPath, new TextChange(removed, ""), path, text) + ); + } + + /// + /// Move the sibling elements the selection covers into a new component beside the file, + /// and put its tag in their place. Offered only when their C# names nothing the + /// component or an enclosing block declares, which the new component could not see. + /// + public static RazorCodeAction? Component( + RazorProjection projection, + RazorMarkup markup, + SemanticModel model, + TextSpan range + ) + { + var selected = Trimmed(markup.Text, range); + if ( + markup.FileKind != RazorFileKind.Component + || !CoversSiblings(markup, selected) + || !StandsAlone(projection, model, selected) + ) + { + return null; + } + + var folder = NativePaths.DirectoryOf(projection.RazorPath); + var name = FreeName(folder); + var newline = RazorHandlerText.LineBreak(markup.Text); + var text = ComponentText(markup, selected, newline); + return new RazorCodeAction( + $"Extract element to new component '{name}'", + "refactor.extract", + false, + Edits( + markup.Text, + projection.RazorPath, + new TextChange(selected, $"<{name} />"), + NativePaths.ComponentIn(folder, name), + text + ) + ); + } + + /// The C# of a block: from its first fragment's start to its last fragment's end. + private static TextSpan? BodyOf(RazorProjection projection, TextSpan block) + { + var inside = projection + .Map.Mappings.Where(mapping => + block.Start <= mapping.RazorStart && mapping.RazorEnd <= block.End + ) + .ToList(); + return inside.Count == 0 + ? null + : TextSpan.FromBounds(inside.Min(m => m.RazorStart), inside.Max(m => m.RazorEnd)); + } + + private static string PartialClass( + RazorMarkup markup, + Compilation compilation, + string name, + string body, + string newline + ) + { + var global = GlobalUsings(compilation); + var usings = markup + .ImportedNamespaces.Where(space => !global.Contains(space)) + .Distinct() + .Order(StringComparer.Ordinal) + .Select(space => $"using {space};{newline}"); + var space = markup.Namespace is { Length: > 0 } declared + ? $"namespace {declared};{newline}{newline}" + : ""; + var members = body.Trim('\r', '\n').TrimEnd(); + return string.Concat(usings) + + (global.Count < markup.ImportedNamespaces.Length ? newline : "") + + space + + $"public partial class {name}{newline}{{{newline}{members}{newline}}}{newline}"; + } + + /// The namespaces the compilation imports with global using, as their symbols name them. + private static HashSet GlobalUsings(Compilation compilation) + { + return compilation + .SyntaxTrees.SelectMany(tree => + tree.GetCompilationUnitRoot() + .Usings.Where(directive => + directive.GlobalKeyword.Span.Length > 0 && directive.Alias is null + ) + .Select(directive => + compilation + .GetSemanticModel(tree) + .GetSymbolInfo(directive.NamespaceOrType) + .Symbol + ) + ) + .OfType() + .Select(space => space.ToDisplayString()) + .ToHashSet(StringComparer.Ordinal); + } + + /// with the line break that follows it, so no blank line stays. + private static TextSpan WithLineBreak(SourceText text, TextSpan span) + { + var line = text.Lines.GetLineFromPosition(span.End); + return line.End == span.End + ? TextSpan.FromBounds(span.Start, line.EndIncludingLineBreak) + : span; + } + + /// without the whitespace at either end. + private static TextSpan Trimmed(SourceText text, TextSpan range) + { + var start = range.Start; + var end = range.End; + while (start < end && char.IsWhiteSpace(text[start])) + { + start++; + } + + while (end > start && char.IsWhiteSpace(text[end - 1])) + { + end--; + } + + return TextSpan.FromBounds(start, end); + } + + /// Whether starts at one element and ends at a sibling's end. + private static bool CoversSiblings(RazorMarkup markup, TextSpan selected) + { + var first = markup + .Tags.Where(tag => tag.Element.Start == selected.Start) + .MaxBy(tag => tag.Element.Length); + var last = markup + .Tags.Where(tag => tag.Element.End == selected.End) + .MaxBy(tag => tag.Element.Length); + return selected.Length > 0 + && first is not null + && last is not null + && (first == last || first.Element.End <= last.Element.Start) + && Parent(markup, first) == Parent(markup, last); + } + + private static MarkupTag? Parent(RazorMarkup markup, MarkupTag tag) + { + return markup + .Tags.Where(other => other != tag && other.Element.Contains(tag.Element)) + .MinBy(other => other.Element.Length); + } + + /// + /// Whether the selection's C# binds only to what it declares itself or what any component + /// can name: never a member of this component, nor a local or parameter declared outside it. + /// + private static bool StandsAlone( + RazorProjection projection, + SemanticModel model, + TextSpan selected + ) + { + var generated = projection + .Map.Mappings.Where(mapping => + selected.Start <= mapping.RazorStart && mapping.RazorEnd <= selected.End + ) + .Select(mapping => new TextSpan(mapping.GeneratedStart, mapping.Length)) + .ToList(); + var component = model.GetDeclaredSymbol( + projection.GeneratedRoot.DescendantNodes().OfType().First() + ); + return generated + .SelectMany(span => + projection.GeneratedRoot.DescendantNodes(span).OfType() + ) + .Where(name => generated.Any(span => span.Contains(name.Span))) + .Select(name => model.GetSymbolInfo(name).Symbol) + .OfType() + .All(symbol => !Captures(symbol, component, generated)); + } + + private static bool Captures( + ISymbol symbol, + INamedTypeSymbol? component, + List selected + ) + { + var declaredInside = symbol.Locations.Any(location => + location.IsInSource && selected.Any(span => span.Contains(location.SourceSpan)) + ); + return symbol switch + { + ILocalSymbol or IParameterSymbol or IRangeVariableSymbol => !declaredInside, + _ => SymbolEqualityComparer.Default.Equals(symbol.ContainingType, component), + }; + } + + /// Component, else the first ComponentN no file in the folder takes. + private static string FreeName(string folder) + { + return Enumerable + .Range(1, int.MaxValue - 1) + .Select(n => n == 1 ? "Component" : $"Component{n}") + .First(name => !File.Exists(NativePaths.ComponentIn(folder, name))); + } + + /// The new component: the file's own @using lines, then the elements, dedented. + private static string ComponentText(RazorMarkup markup, TextSpan selected, string newline) + { + var usings = markup + .Directives.Where(directive => directive.Name == "using") + .Select(directive => markup.Text.ToString(directive.Span).TrimEnd() + newline); + var text = markup.Text; + var first = text.Lines.GetLineFromPosition(selected.Start); + var indent = selected.Start - first.Start; + var lines = Enumerable + .Range( + first.LineNumber, + text.Lines.GetLineFromPosition(selected.End).LineNumber - first.LineNumber + 1 + ) + .Select(number => text.Lines[number]) + .Select(line => + text.ToString( + TextSpan.FromBounds( + Math.Max( + line.Start, + line == first ? selected.Start : Dedented(text, line, indent) + ), + Math.Min(line.End, selected.End) + ) + ) + ); + var head = string.Concat(usings); + return head + (head.Length > 0 ? newline : "") + string.Join(newline, lines) + newline; + } + + /// Where 's text starts once up to of its leading whitespace is dropped. + private static int Dedented(SourceText text, TextLine line, int indent) + { + var start = line.Start; + while (start < line.End && start - line.Start < indent && char.IsWhiteSpace(text[start])) + { + start++; + } + + return start; + } + + /// The edit to the Razor file and the new file's whole text, the new file first. + private static WorkspaceEditResult Edits( + SourceText razorText, + string razorPath, + TextChange change, + string newPath, + string newText + ) + { + return new WorkspaceEditResult + { + DocumentChanges = + [ + new DocumentEditResult + { + FilePath = newPath, + Edits = [new TextEditResult { NewText = newText }], + Create = true, + }, + new DocumentEditResult + { + FilePath = razorPath, + Edits = [DocumentText.ToTextEdit(razorText, change)], + }, + ], + }; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHandlerText.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHandlerText.cs new file mode 100644 index 00000000..efaad5f9 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHandlerText.cs @@ -0,0 +1,64 @@ +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.CSharp.Workspace; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// The text of a generated event handler and where it goes: before the closing brace of the +/// file's last @code or @functions block, else in a new @code block at the +/// end of the file. Implements [RAZOR-CODE-ACTIONS-HANDLER]. +/// +internal static class RazorHandlerText +{ + private const string Indent = " "; + + /// A private method named , taking the event's argument when there is one. + public static IReadOnlyList Method(string name, string? argument) + { + var parameters = argument is null ? "" : $"{argument} args"; + return [$"private void {name}({parameters})", "{", "}"]; + } + + /// The edit that inserts into 's file. + public static TextEditResult Insert(RazorMarkup markup, IReadOnlyList method) + { + var text = markup.Text; + var newline = LineBreak(text); + var block = markup + .Directives.Where(directive => directive.Name is "code" or "functions") + .Select(directive => directive.Span) + .Where(span => span.Length > 0 && text[span.End - 1] == '}') + .OrderBy(span => span.End) + .LastOrDefault(); + var body = string.Concat(method.Select(line => Indent + line + newline)); + var change = + block.Length > 0 + ? new TextChange(new TextSpan(block.End - 1, 0), newline + body) + : new TextChange( + new TextSpan(text.Length, 0), + (EndsWithLineBreak(text) ? "" : newline) + + newline + + "@code {" + + newline + + body + + "}" + + newline + ); + return DocumentText.ToTextEdit(text, change); + } + + /// The file's own line break, from its first line; \n for a one-line file. + internal static string LineBreak(SourceText text) + { + var first = text.Lines[0]; + return first.EndIncludingLineBreak > first.End + ? text.ToString(TextSpan.FromBounds(first.End, first.EndIncludingLineBreak)) + : "\n"; + } + + private static bool EndsWithLineBreak(SourceText text) + { + var last = text.Lines[^1]; + return text.Length == 0 || last.Span.Length == 0; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHints.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHints.cs new file mode 100644 index 00000000..9748fa5a --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHints.cs @@ -0,0 +1,94 @@ +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// Inlay hints of a projection read in the Razor file it belongs to: hints computed on the C# +/// the Razor lines map to, each placed at its Razor position. A hint outside every C# fragment +/// cannot be shown in the file, so it is dropped. Implements [RAZOR-ARCHITECTURE-PROJECTION] +/// and [RAZOR-FEATURES-INLAY-HINTS]. +/// +internal static class RazorHints +{ + /// + /// The generated lines the Razor .. + /// map to, the last one included so that a caller reading to the end of it sees its content; + /// null when they cover no fragment, where the file has no C# to hint. + /// + public static (int Start, int End)? Lines( + RazorProjection projection, + int startLine, + int endLine + ) + { + var lines = projection.RazorText.Lines; + var window = TextSpan.FromBounds( + lines[Clamped(lines, startLine)].Start, + lines[Clamped(lines, endLine)].End + ); + var covering = projection + .Map.Mappings.Where(entry => + entry.RazorStart < window.End && window.Start < entry.RazorEnd + ) + .ToList(); + return covering.Count == 0 + ? null + : ( + LineOf(projection, covering.Min(entry => entry.GeneratedStart)), + LineOf(projection, covering.Max(entry => entry.GeneratedEnd)) + ); + } + + /// + /// , whose positions are in the projection, placed in + /// 's Razor file when they lie within its lines + /// ..; the rest are dropped. + /// + public static List Map( + List hints, + RazorProjection projection, + int startLine, + int endLine + ) + { + return + [ + .. hints + .Select(hint => Map(hint, projection, startLine, endLine)) + .OfType(), + ]; + } + + private static InlayHintResult? Map( + InlayHintResult hint, + RazorProjection projection, + int startLine, + int endLine + ) + { + var at = new LinePosition(hint.Line, hint.Character); + return + projection.ToRazor(new LinePositionSpan(at, at)) is { } razor + && razor.Start.Line >= startLine + && razor.Start.Line <= endLine + ? new InlayHintResult + { + Line = razor.Start.Line, + Character = razor.Start.Character, + Label = hint.Label, + Kind = hint.Kind, + } + : null; + } + + /// The generated line is on. + private static int LineOf(RazorProjection projection, int generatedOffset) + { + return projection.GeneratedText.Lines.GetLinePosition(generatedOffset).Line; + } + + private static int Clamped(TextLineCollection lines, int line) + { + return Math.Clamp(line, 0, lines.Count - 1); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs index 7a7131a7..a195a0c8 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs @@ -27,6 +27,7 @@ SourceText text bound.Kind, bound.Name, named.Name, + named.End, tag, bound.TagHelper, bound.Attribute, diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs index 29f0964e..87b86868 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs @@ -64,11 +64,16 @@ private static bool Touches(TextSpan span, int offset) } } -/// An attribute of a tag, with what binds it. +/// +/// An attribute of a tag, with what binds it. is where the attribute +/// ends — its value and closing quote, or a minimized attribute's name — as the compiler's +/// spans say; null when they do not. +/// internal sealed record MarkupAttribute( MarkupAttributeKind Kind, string Name, TextSpan? NameSpan, + int? End, MarkupTag Owner, TagHelperDescriptor? TagHelper, BoundAttributeDescriptor? Bound, diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs index 8156be5a..9eac8b7a 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs @@ -94,14 +94,42 @@ tag.Component is not null ) .MinBy(tag => tag.Element.Length); var children = parent is null ? [] : ChildContents(markup, parent); - return children.Concat(Components(markup)); + return children.Concat(Components(markup)).Concat(TagHelperElements(markup.Scope)); } - /// Each component once: its short name where its namespace is in scope, else its full one. + /// + /// Each element an MVC tag helper in scope targets by name (partial, environment, + /// a), spelled with the scope's prefix, once, with the tag helpers that bind it. A rule + /// for every element (*) names none. [RAZOR-MVC-TAGHELPERS] + /// + private static IEnumerable TagHelperElements(RazorTagHelperScope scope) + { + return scope + .TagHelpers.SelectMany(helper => + helper + .TagMatchingRules.Where(rule => rule.TagName is not (null or "*")) + .Select(rule => (Label: scope.Prefix + rule.TagName, Helper: helper)) + ) + .GroupBy(element => element.Label, StringComparer.OrdinalIgnoreCase) + .Select(element => new Entry( + element.Key, + "Class", + string.Join(", ", element.Select(bound => bound.Helper.Name).Distinct()), + element.First().Helper.Documentation, + 1 + )); + } + + /// + /// Each component once: its short name where its namespace is in scope, else its full one. + /// A legacy file binds no component by its tag, so it completes none. [RAZOR-MVC-SCOPE] + /// private static IEnumerable Components(RazorMarkup markup) { return markup - .TagHelpers.Where(helper => helper.Kind == TagHelperKind.Component) + .TagHelpers.Where(helper => + helper.Kind == TagHelperKind.Component && markup.FileKind != RazorFileKind.Legacy + ) .GroupBy(helper => helper.TypeName, StringComparer.Ordinal) .Select(group => group.FirstOrDefault(helper => diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorQuickFixes.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorQuickFixes.cs new file mode 100644 index 00000000..10a87331 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorQuickFixes.cs @@ -0,0 +1,244 @@ +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.FindSymbols; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// A Razor code action, its edit computed when it is offered. +internal sealed record RazorCodeAction( + string Title, + string Kind, + bool IsPreferred, + WorkspaceEditResult Edit +); + +/// +/// Razor quick fixes for C# names the projection cannot bind: @using for a type +/// another namespace declares, and a handler method for an event attribute or +/// EventCallback parameter that names one not yet written. Implements +/// [RAZOR-CODE-ACTIONS-USING] and [RAZOR-CODE-ACTIONS-HANDLER]. +/// +internal static class RazorQuickFixes +{ + /// The C# diagnostics that name something the projection cannot find. + private static readonly string[] Unbound = ["CS0103", "CS0246"]; + + /// The fixes for the projection's unbound names that lie in . + public static async Task> ForAsync( + RazorProjection projection, + RazorMarkup markup, + TextSpan range, + CancellationToken ct + ) + { + var model = + await projection.Generated.GetSemanticModelAsync(ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("The projection has no semantic model"); + var names = model + .GetDiagnostics(cancellationToken: ct) + .Where(diagnostic => Unbound.Contains(diagnostic.Id)) + .Select(diagnostic => UnboundName(projection, diagnostic, range)) + .OfType() + .DistinctBy(name => name.SpanStart); + var fixes = new List(); + foreach (var name in names) + { + fixes.AddRange( + await UsingsAsync(projection, markup, model, name, ct).ConfigureAwait(false) + ); + fixes.AddRange(Handlers(projection, markup, model, name)); + } + + return fixes + .Concat(ComponentUsings(projection, markup, range)) + .DistinctBy(fix => fix.Title); + } + + /// The name a diagnostic reports, when its Razor span overlaps . + private static SimpleNameSyntax? UnboundName( + RazorProjection projection, + Diagnostic diagnostic, + TextSpan range + ) + { + var span = diagnostic.Location.SourceSpan; + var razor = projection.Map.ToRazor(span); + return razor is { } mapped && (mapped.OverlapsWith(range) || mapped.Contains(range.Start)) + ? projection + .GeneratedRoot.FindNode(span, getInnermostNodeForTie: true) + .DescendantNodesAndSelf() + .OfType() + .FirstOrDefault() + : null; + } + + /// + /// @using for each namespace that declares an accessible type of the unbound name + /// and arity, which the file does not import yet; one choice is preferred. + /// + private static async Task> UsingsAsync( + RazorProjection projection, + RazorMarkup markup, + SemanticModel model, + SimpleNameSyntax name, + CancellationToken ct + ) + { + var arity = name is GenericNameSyntax generic + ? generic.TypeArgumentList.Arguments.Count + : 0; + var declared = await SymbolFinder + .FindDeclarationsAsync( + projection.Generated.Project, + name.Identifier.ValueText, + ignoreCase: false, + SymbolFilter.Type, + ct + ) + .ConfigureAwait(false); + var namespaces = declared + .OfType() + .Where(type => + type.Arity == arity + && type.ContainingType is null + && model.IsAccessible(name.SpanStart, type) + && !type.ContainingNamespace.IsGlobalNamespace + ) + .Select(type => type.ContainingNamespace.ToDisplayString()) + .Where(space => !markup.ImportedNamespaces.Contains(space)) + .Distinct() + .Order(StringComparer.Ordinal) + .ToList(); + return namespaces.Select(space => + UsingFix(projection, markup, space, namespaces.Count == 1) + ); + } + + /// + /// @using for each namespace holding a component that an unknown element names + /// (RZ10012): the compiler binds a component tag only where its namespace is in scope. + /// + private static IEnumerable ComponentUsings( + RazorProjection projection, + RazorMarkup markup, + TextSpan range + ) + { + var spans = markup + .Diagnostics.Where(diagnostic => + diagnostic.Id == "RZ10012" + && (diagnostic.Span.OverlapsWith(range) || diagnostic.Span.Contains(range.Start)) + ) + .Select(diagnostic => diagnostic.Span) + .ToList(); + var unknown = markup + .Tags.Where(tag => spans.Any(span => span.Contains(tag.StartName.Start))) + .Select(tag => tag.Name) + .ToHashSet(StringComparer.Ordinal); + return markup + .TagHelpers.Where(helper => + helper.Kind == TagHelperKind.Component + && unknown.Contains(helper.TypeNameIdentifier ?? "") + && helper.TypeNamespace is { Length: > 0 } space + && !markup.ImportedNamespaces.Contains(space) + ) + .Select(helper => helper.TypeNamespace!) + .Distinct() + .Select(space => UsingFix(projection, markup, space, true)); + } + + private static RazorCodeAction UsingFix( + RazorProjection projection, + RazorMarkup markup, + string space, + bool preferred + ) + { + var line = RazorCompletionEdits.Map( + projection, + [new TextChange(new TextSpan(0, 0), $"using {space};")], + markup + ); + return new RazorCodeAction( + $"@using {space}", + "quickfix", + preferred, + Edit(projection.RazorPath, line) + ); + } + + /// + /// A handler for an unbound name that the projection passes to + /// EventCallback.Factory.Create: an event attribute (@onclick="Missing") or an + /// EventCallback parameter. It takes the event's argument when the callback has one. + /// + private static IEnumerable Handlers( + RazorProjection projection, + RazorMarkup markup, + SemanticModel model, + SimpleNameSyntax name + ) + { + var create = name.Ancestors() + .OfType() + .FirstOrDefault(invocation => CreatesCallback(model, invocation)); + if (create is null || name is GenericNameSyntax) + { + return []; + } + + var argument = CallbackArgument(model, create, name.SpanStart); + var method = RazorHandlerText.Method(name.Identifier.ValueText, argument); + return + [ + new RazorCodeAction( + $"Generate event handler '{name.Identifier.ValueText}'", + "quickfix", + true, + Edit(projection.RazorPath, [RazorHandlerText.Insert(markup, method)]) + ), + ]; + } + + /// Whether calls EventCallbackFactory.Create. + private static bool CreatesCallback(SemanticModel model, InvocationExpressionSyntax invocation) + { + var info = model.GetSymbolInfo(invocation); + return info + .CandidateSymbols.Append(info.Symbol) + .OfType() + .Any(method => + method.Name == "Create" + && method.ContainingType.ToDisplayString() + == "Microsoft.AspNetCore.Components.EventCallbackFactory" + ); + } + + /// The callback's argument type, as the file spells it, from the call's type argument; none for a plain EventCallback. + private static string? CallbackArgument( + SemanticModel model, + InvocationExpressionSyntax create, + int position + ) + { + var argument = create.Expression switch + { + MemberAccessExpressionSyntax { Name: GenericNameSyntax generic } => + generic.TypeArgumentList.Arguments.FirstOrDefault(), + _ => null, + }; + return argument is null + ? null + : model.GetTypeInfo(argument).Type?.ToMinimalDisplayString(model, position); + } + + private static WorkspaceEditResult Edit(string path, IEnumerable edits) + { + return new WorkspaceEditResult + { + DocumentChanges = [new DocumentEditResult { FilePath = path, Edits = [.. edits] }], + }; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSelectionRanges.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSelectionRanges.cs new file mode 100644 index 00000000..71b74354 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSelectionRanges.cs @@ -0,0 +1,134 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// The selection ranges of a Razor file: each position answers the chain from its innermost +/// span — the C# fragment's token and enclosing syntax nodes, then the markup facts' attribute, +/// tag name, elements, directives and code blocks — out to the whole document. A candidate +/// that cannot map back to the Razor file as a whole, that does not hold the chain so far, or +/// that duplicates it, is dropped, never approximated. Implements [RAZOR-FEATURES-SELECTION]. +/// +internal static class RazorSelectionRanges +{ + /// The spans holding , innermost first, in Razor offsets. + public static IEnumerable At( + RazorProjection? projection, + RazorMarkup? markup, + int offset + ) + { + return Nested( + Fragment(projection, offset) + .Concat(Markup(markup, offset)) + .Append(Document(projection, markup)), + offset + ); + } + + /// + /// The C# fragment at : the Roslyn token there, then each + /// enclosing node of the projection's syntax tree, keeping only the spans that map back + /// to the Razor file whole. [RAZOR-ARCHITECTURE-PROJECTION] + /// + private static IEnumerable Fragment(RazorProjection? projection, int offset) + { + return projection is not { } found || found.Map.ToGenerated(offset) is not { } generated + ? [] + : NodeSpans(found.GeneratedRoot.FindToken(generated), generated) + .Select(found.Map.ToRazor) + .OfType(); + } + + /// The token's span when it holds the position, then every enclosing node's span. + private static IEnumerable NodeSpans(SyntaxToken token, int generated) + { + var nodes = token.Parent?.AncestorsAndSelf().Select(node => node.Span) ?? []; + return token.Span.Contains(generated) ? new[] { token.Span }.Concat(nodes) : nodes; + } + + /// + /// The markup facts' spans holding : an attribute, a tag name, + /// every enclosing element, directives and C# code blocks. [RAZOR-ARCHITECTURE-ENGINE-FACTS] + /// + private static IEnumerable Markup(RazorMarkup? markup, int offset) + { + return markup is not { } facts + ? [] + : Attribute(facts, offset) + .Concat(TagName(facts, offset)) + .Concat( + facts + .Tags.Where(tag => tag.Element.Contains(offset)) + .OrderBy(tag => tag.Element.Length) + .Select(tag => tag.Element) + ) + .Concat( + facts + .Directives.Where(directive => directive.Span.Contains(offset)) + .Select(directive => directive.Span) + ) + .Concat(facts.CodeBlocks.Where(block => block.Contains(offset))); + } + + /// + /// The attribute whose name or value holds : its name's span, + /// then the whole attribute when it reaches past the name. + /// + private static IEnumerable Attribute(RazorMarkup facts, int offset) + { + var found = facts + .Attributes.Select(attribute => (attribute.NameSpan, Whole: Whole(attribute))) + .FirstOrDefault(pair => pair.Whole is { } whole && whole.Contains(offset)); + return found.NameSpan is not { } name ? [] + : found.Whole is { } whole && whole != name ? [name, whole] + : [name]; + } + + /// + /// The attribute's whole span, from its name's start to the end the compiler's spans give + /// it; an attribute they give no end is its name. + /// + private static TextSpan? Whole(MarkupAttribute attribute) + { + return attribute.NameSpan is not { } name ? null + : attribute.End is { } end && end > name.End ? TextSpan.FromBounds(name.Start, end) + : name; + } + + /// The start or end tag name holding , when one does. + private static IEnumerable TagName(RazorMarkup facts, int offset) + { + return facts.TagNamedAt(offset) is { } tag ? [tag.NameAt(offset)] : []; + } + + /// The whole file's span, from the facts' or the projection's text. + private static TextSpan Document(RazorProjection? projection, RazorMarkup? markup) + { + return new TextSpan(0, (markup?.Text ?? projection?.RazorText)?.Length ?? 0); + } + + /// + /// The candidates that hold and hold every span kept before + /// them, innermost first: one that does not contain the chain so far, or that duplicates + /// it, drops out. + /// + private static ImmutableList Nested(IEnumerable candidates, int offset) + { + return candidates.Aggregate( + ImmutableList.Empty, + (chain, candidate) => + chain.Count == 0 && !candidate.Contains(offset) ? chain + : chain.Count > 0 && !StrictlyHolds(chain[^1], candidate) ? chain + : chain.Add(candidate) + ); + } + + /// Whether contains with room to spare. + private static bool StrictlyHolds(TextSpan inner, TextSpan outer) + { + return outer.Contains(inner) && outer != inner; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorStylesheet.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorStylesheet.cs new file mode 100644 index 00000000..59e43804 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorStylesheet.cs @@ -0,0 +1,111 @@ +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// A class selector a stylesheet declares: its name, with its escapes decoded, and the span of +/// the class name after its .. [RAZOR-COLLOCATION-CSS] +/// +internal readonly record struct CssClass(string Name, TextSpan Span); + +/// +/// A collocated stylesheet: the classes its rule preludes declare, in the order they appear. +/// Only a rule's prelude names selectors, so a class written in a declaration value, a string, +/// a comment or a url is never one. Implements [RAZOR-COLLOCATION-CSS]. +/// +internal sealed record RazorStylesheet( + string FilePath, + SourceText Text, + IReadOnlyList Classes +) +{ + /// The distinct class names the stylesheet declares, in the order they first appear. + public IReadOnlyList Names { get; } = + [.. Classes.Select(cssClass => cssClass.Name).Distinct(StringComparer.Ordinal)]; + + /// The stylesheet holds, read from its text. + public static RazorStylesheet Parse(string filePath, SourceText text) + { + return new RazorStylesheet(filePath, text, RazorStylesheetReader.ClassesOf(text)); + } +} + +/// +/// Reads the class selectors of a stylesheet from the tokens of the CSS Syntax Module Level 3 +/// tokenizer. Every { ends a prelude: an at-rule's preludes are skipped, and a rule's +/// own yields each . immediately followed by an ident. Blocks nest, so at-rules inside +/// at-rules and nested rules read the same way. Implements [RAZOR-COLLOCATION-CSS]. +/// +internal static class RazorStylesheetReader +{ + /// Every class selector occurrence of , in source order. + public static IReadOnlyList ClassesOf(SourceText css) + { + var tokens = CssTokenizer.Tokenize(css.ToString()); + var classes = new List(); + var prelude = 0; + for (var at = 0; at < tokens.Count; at++) + { + if (tokens[at].Kind == CssTokenKind.BraceOpen && IsSelectorPrelude(tokens, prelude, at)) + { + classes.AddRange(ClassesIn(css, tokens, prelude, at)); + } + + prelude = StartsPrelude(tokens[at]) ? at + 1 : prelude; + } + + return classes; + } + + /// Whether the block a { opens starts a prelude of its own after it. + private static bool StartsPrelude(CssToken token) + { + return token.Kind + is CssTokenKind.BraceOpen + or CssTokenKind.BraceClose + or CssTokenKind.Semicolon; + } + + /// Whether the tokens from name a rule's selectors, not an at-rule. + private static bool IsSelectorPrelude(List tokens, int start, int end) + { + // A default token's empty span stands for a prelude holding nothing but whitespace. + var first = tokens.Skip(start).Take(end - start).FirstOrDefault(NotSpace); + return first.Span.Length > 0 + && first.Kind is not (CssTokenKind.AtKeyword or CssTokenKind.Cdo or CssTokenKind.Cdc); + } + + private static bool NotSpace(CssToken token) + { + return token.Kind != CssTokenKind.Whitespace; + } + + /// The classes of a prelude: each . and the ident right after it. + private static IEnumerable ClassesIn( + SourceText css, + List tokens, + int start, + int end + ) + { + return Enumerable + .Range(start, end - start) + .Where(at => IsClassDot(css, tokens, at)) + .Select(at => tokens[at + 1]) + .Select(ident => new CssClass(CssTokenizer.Value(css, ident), ident.Span)); + } + + /// Whether the token is a . directly followed by the ident naming a class. + private static bool IsClassDot(SourceText css, List tokens, int at) + { + return tokens[at].Kind == CssTokenKind.Delim + && IsDot(css, tokens[at]) + && tokens[at + 1].Kind == CssTokenKind.Ident + && tokens[at + 1].Span.Start == tokens[at].Span.End; + } + + private static bool IsDot(SourceText css, CssToken token) + { + return token.Span.Length == 1 && css[token.Span.Start] == '.'; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs index 4241e430..d0b16935 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs @@ -67,27 +67,40 @@ CancellationToken ct { foreach (var location in symbol.Locations.Where(l => l.IsInSource)) { - var tree = location.SourceTree; - if (tree is null) - { - continue; - } - - var model = await document - .Project.Solution.GetDocument(document.Id)! - .GetSemanticModelAsync(ct) + var (model, root) = await DeclarationAsync(document, location, ct) .ConfigureAwait(false); - if (model is null) + if (model is not null && root is not null) { - continue; + CollectOutgoingCalls(root.FindNode(location.SourceSpan), model, results, ct); } - - var root = await tree.GetRootAsync(ct).ConfigureAwait(false); - var node = root.FindNode(location.SourceSpan); - CollectOutgoingCalls(node, model, results, ct); } } + /// + /// The semantic model and syntax root of the tree is declared in: + /// the queried document's own — a Razor projection's included, which has no document of its + /// own in the solution — or, for a declaration in another regular document, that document's. + /// + private static async Task<(SemanticModel? Model, SyntaxNode? Root)> DeclarationAsync( + Document document, + Location location, + CancellationToken ct + ) + { + var tree = location.SourceTree; + var own = await document.GetSyntaxTreeAsync(ct).ConfigureAwait(false); + var owner = + tree is null ? null + : tree == own ? document + : document.Project.Solution.GetDocument(tree); + return owner is null + ? (null, null) + : ( + await owner.GetSemanticModelAsync(ct).ConfigureAwait(false), + await owner.GetSyntaxRootAsync(ct).ConfigureAwait(false) + ); + } + private static void CollectOutgoingCalls( SyntaxNode node, SemanticModel model, @@ -203,19 +216,23 @@ private static CallSiteResult ToCallSite(Location location) }; } - private static string MapSymbolKind(ISymbol symbol) + /// + /// The PascalCase kind the host maps to an LSP SymbolKind; a symbol LSP has no kind for + /// is a Function. [RAZOR-FEATURES-HIERARCHY] + /// + internal static string MapSymbolKind(ISymbol symbol) { return symbol.Kind switch { - SymbolKind.Method => "method", - SymbolKind.Property => "property", - SymbolKind.Field => "field", - SymbolKind.NamedType => "class", - SymbolKind.Event => "event", - SymbolKind.Namespace => "namespace", - SymbolKind.Local => "variable", - SymbolKind.Parameter => "parameter", - SymbolKind.TypeParameter => "typeParameter", + SymbolKind.Method => "Method", + SymbolKind.Property => "Property", + SymbolKind.Field => "Field", + SymbolKind.NamedType => "Class", + SymbolKind.Event => "Event", + SymbolKind.Namespace => "Namespace", + SymbolKind.Local => "Variable", + SymbolKind.Parameter => "Parameter", + SymbolKind.TypeParameter => "TypeParameter", SymbolKind.ArrayType or SymbolKind.PointerType or SymbolKind.FunctionPointerType @@ -227,8 +244,8 @@ or SymbolKind.Alias or SymbolKind.RangeVariable or SymbolKind.Assembly or SymbolKind.NetModule - or SymbolKind.Discard => "function", - _ => "function", + or SymbolKind.Discard => "Function", + _ => "Function", }; } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs index 46b12a36..4bf170d0 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs @@ -564,6 +564,7 @@ private static DocumentEditResult CreateAddedDocumentEdit(string filePath, strin { FilePath = filePath, Edits = [CreateWholeDocumentEdit(text)], + Create = true, }; } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeLensResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeLensResolver.cs index ab04ff74..5cc46bf0 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeLensResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeLensResolver.cs @@ -1,6 +1,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; namespace SharpLsp.Sidecar.CSharp.Workspace; @@ -10,11 +11,16 @@ namespace SharpLsp.Sidecar.CSharp.Workspace; /// internal static class CodeLensResolver { - /// Get code lenses for a document. + /// + /// Get code lenses for a document, each on its declaration. A declaration + /// moves is lensed where it puts it, and one it maps nowhere has no lens: that is how a Razor + /// file's lenses land on the declarations in its C# fragments ([RAZOR-FEATURES-CODE-LENS]). + /// public static async Task> GetLensesAsync( Document document, SearchScope scope, - CancellationToken ct + CancellationToken ct, + Func? place = null ) { var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); @@ -25,7 +31,7 @@ CancellationToken ct } var lenses = new List(); - await CollectLensesAsync(root, model, scope, lenses, ct).ConfigureAwait(false); + await CollectLensesAsync(root, model, scope, lenses, place, ct).ConfigureAwait(false); return lenses; } @@ -34,6 +40,7 @@ private static async Task CollectLensesAsync( SemanticModel model, SearchScope scope, List lenses, + Func? place, CancellationToken ct ) { @@ -54,13 +61,7 @@ or ConstructorDeclarationSyntax } var symbol = model.GetDeclaredSymbol(node, ct); - if (symbol is null) - { - continue; - } - - var span = node.GetLocation().GetMappedLineSpan(); - if (!span.IsValid) + if (symbol is null || Declared(node, place) is not { } span) { continue; } @@ -81,6 +82,28 @@ symbol is INamedTypeSymbol implSymbol } } + /// + /// Where the lens for sits: its declaration's own mapped range, or, + /// when maps it, that range in the file it belongs to; null when the + /// declaration maps nowhere and its lens is dropped. + /// + private static FileLinePositionSpan? Declared( + SyntaxNode node, + Func? place + ) + { + var location = node.GetLocation(); + if (place is null) + { + var mapped = location.GetMappedLineSpan(); + return mapped.IsValid ? mapped : null; + } + + return place(location.GetLineSpan().Span) is { } placed + ? new FileLinePositionSpan(location.SourceTree?.FilePath ?? "", placed) + : null; + } + /// A lens titled at the start of . private static CodeLensResult LensAt(FileLinePositionSpan span, string title) { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs index e8465f6a..988c5017 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs @@ -78,13 +78,31 @@ CancellationToken ct return new LocationListResult(); } - var metadataLocation = MetadataNavigator.ResolveMetadataSymbol(symbol, compilation); - + var metadataLocation = NamedMetadataLocation(project, symbol, compilation); return metadataLocation is null ? new LocationListResult() : new LocationListResult { Locations = [metadataLocation] }; } + /// + /// The decompiled source of , carrying its identity when an F# + /// project declares it, so the host can land in its F# source instead. [DEFINITION-CROSSLANG-IDENTITY] + /// + private static LocationResult? NamedMetadataLocation( + Project project, + ISymbol symbol, + Compilation compilation + ) + { + var decompiled = MetadataNavigator.ResolveMetadataSymbol(symbol, compilation); + return decompiled is null + ? null + : FSharpProjectSymbols.Naming( + decompiled, + FSharpProjectSymbols.IdentityOf(project, symbol) + ); + } + /// /// Single-location metadata fallback for type-definition and declaration. /// @@ -95,10 +113,9 @@ CancellationToken ct ) { var compilation = await document.Project.GetCompilationAsync(ct).ConfigureAwait(false); - return compilation is null ? null - : MetadataNavigator.ResolveMetadataSymbol(symbol, compilation); + : NamedMetadataLocation(document.Project, symbol, compilation); } /// Find the type definition of the symbol at a position. diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FSharpProjectSymbols.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FSharpProjectSymbols.cs new file mode 100644 index 00000000..b10df527 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FSharpProjectSymbols.cs @@ -0,0 +1,133 @@ +using Microsoft.CodeAnalysis; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.Common.Solutions; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Symbols a C# project reads from an F# project of the solution. C# sees an F# project only +/// as its built DLL ([DEFINITION-CROSSLANG]), so such a symbol is a metadata symbol whose +/// assembly is that DLL. Its identity, its assembly and XML doc signature, is the name the +/// F# sidecar finds it by, to navigate to its F# declaration, to find its F# uses and to +/// rename it there. Implements [DEFINITION-CROSSLANG-IDENTITY]. +/// +internal static class FSharpProjectSymbols +{ + /// + /// Whether , used in , is declared by an + /// F# project that references: it has no source, and its + /// assembly is that F# project's. Assembly names, not paths: a design-time build and + /// can spell one DLL's path apart. + /// + public static bool Declares(Project project, ISymbol symbol) + { + return !symbol.Locations.Any(location => location.IsInSource) + && symbol.ContainingAssembly?.Name is { } assembly + && FSharpAssembliesOf(project).Contains(assembly); + } + + /// + /// A metadata reference to , with the XML documentation the build + /// writes beside it, so hover shows an F# symbol's /// comments. [RAZOR-FSHARP-METADATA] + /// + public static MetadataReference Reference(string dll) + { + var xml = NativePaths.WithExtension(dll, ".xml"); + return MetadataReference.CreateFromFile( + dll, + documentation: File.Exists(xml) ? XmlDocumentationProvider.CreateFromFile(xml) : null + ); + } + + /// + /// The identity the F# sidecar finds by, when an F# project + /// declares it; null otherwise. A constructor is named by its type: F# declares a record's + /// or union's constructor nowhere but in the type itself. + /// + public static RenameIdentityResultWire? IdentityOf(Project project, ISymbol symbol) + { + var named = symbol is IMethodSymbol { MethodKind: MethodKind.Constructor } constructor + ? constructor.ContainingType + : symbol; + return Declares(project, named) ? Identity(named) : null; + } + + /// + /// reading the F# project's build output , + /// documented, in place of any reference of the same assembly: a design-time build hands C# + /// the F# project's reference assembly (obj/…/refint), which has no XML documentation + /// beside it. [RAZOR-FSHARP-METADATA] + /// + public static Solution WithDocumentedOutput(Solution solution, ProjectId projectId, string dll) + { + var stem = NativePaths.StemOf(dll); + var stale = solution + .GetProject(projectId)! + .MetadataReferences.OfType() + .Where(reference => + reference.FilePath is { } path + && NativePaths.SameName(NativePaths.StemOf(path), stem) + ) + .ToList(); + return stale + .Aggregate( + solution, + (current, reference) => current.RemoveMetadataReference(projectId, reference) + ) + .AddMetadataReference(projectId, Reference(dll)); + } + + /// + /// The assembly and XML doc signature both compilers give ; none + /// without either. FCS writes a method's signature without the ~ReturnType Roslyn + /// appends, which only a conversion operator needs to be told apart. [DEFINITION-CROSSLANG-IDENTITY] + /// + public static RenameIdentityResultWire Identity(ISymbol? symbol) + { + var assemblyName = symbol?.ContainingAssembly?.Name; + var declared = symbol is null ? null : DocumentationCommentId.CreateDeclarationId(symbol); + var xmlDocSig = + symbol is IMethodSymbol { MethodKind: not MethodKind.Conversion } + && declared?.IndexOf('~', StringComparison.Ordinal) is int at and >= 0 + ? declared[..at] + : declared; + return string.IsNullOrEmpty(assemblyName) || string.IsNullOrEmpty(xmlDocSig) + ? new RenameIdentityResultWire() + : new RenameIdentityResultWire + { + Found = true, + AssemblyName = assemblyName, + XmlDocSig = xmlDocSig, + }; + } + + /// , carrying the identity of the F# symbol it shows, if any. + public static LocationResult Naming(LocationResult location, RenameIdentityResultWire? symbol) + { + return symbol is null + ? location + : new LocationResult + { + FilePath = location.FilePath, + Line = location.Line, + Character = location.Character, + EndLine = location.EndLine, + EndCharacter = location.EndCharacter, + Symbol = symbol, + }; + } + + /// The assembly name of each F# project references. + private static HashSet FSharpAssembliesOf(Project project) + { + return project.FilePath is null + ? [] + : + [ + .. ProjectReferences + .ReadReferencedProjects(project.FilePath) + .Where(referenced => NativePaths.HasExtension(referenced, ".fsproj")) + .Select(ProjectReferences.AssemblyNameOf), + ]; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/InlayHintResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/InlayHintResolver.cs index 6d1179ad..9ad46873 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/InlayHintResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/InlayHintResolver.cs @@ -33,7 +33,7 @@ CancellationToken ct var span = TextSpan.FromBounds(start, Math.Max(start, endPos)); var hints = new List(); - CollectParameterHints(root, model, span, hints, ct); + CollectParameterHints(root, model, text, span, hints, ct); CollectTypeHints(root, model, text, span, hints, ct); CollectLambdaTypeHints(root, model, text, span, hints, ct); return hints; @@ -42,6 +42,7 @@ CancellationToken ct private static void CollectParameterHints( SyntaxNode root, SemanticModel model, + SourceText text, TextSpan span, List hints, CancellationToken ct @@ -50,7 +51,7 @@ CancellationToken ct foreach (var invocation in root.DescendantNodes(span).OfType()) { ct.ThrowIfCancellationRequested(); - AddParameterHintsForArgs(invocation.ArgumentList, model, hints, ct); + AddParameterHintsForArgs(invocation.ArgumentList, model, text, hints, ct); } foreach ( @@ -60,7 +61,7 @@ var creation in root.DescendantNodes(span).OfType hints, CancellationToken ct ) @@ -85,17 +87,15 @@ CancellationToken ct continue; } - var pos = arg.Expression.GetLocation().GetMappedLineSpan(); - if (!pos.IsValid) - { - continue; - } - + // The position is in this document's own text, like every other hint's, so a caller + // reading it in another file — a Razor projection read in its Razor file — can place + // it, and a generated argument it cannot place is dropped rather than misplaced. + var pos = text.Lines.GetLinePosition(arg.Expression.SpanStart); hints.Add( new InlayHintResult { - Line = pos.StartLinePosition.Line, - Character = pos.StartLinePosition.Character, + Line = pos.Line, + Character = pos.Character, Label = $"{paramSymbol.Name}:", Kind = 2, // Parameter } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TypeHierarchyResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TypeHierarchyResolver.cs index b838ac0a..5fcb8961 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TypeHierarchyResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TypeHierarchyResolver.cs @@ -133,10 +133,10 @@ CancellationToken ct private static string MapKind(INamedTypeSymbol symbol) { - return symbol.TypeKind == TypeKind.Interface ? "interface" - : symbol.TypeKind == TypeKind.Struct ? "struct" - : symbol.TypeKind == TypeKind.Enum ? "enum" - : symbol.TypeKind == TypeKind.Delegate ? "delegate" - : "class"; + return symbol.TypeKind == TypeKind.Interface ? "Interface" + : symbol.TypeKind == TypeKind.Struct ? "Struct" + : symbol.TypeKind == TypeKind.Enum ? "Enum" + : symbol.TypeKind == TypeKind.Delegate ? "Delegate" + : "Class"; } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs index 1c3feb9e..4f4f4e9a 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs @@ -24,18 +24,23 @@ namespace SharpLsp.Sidecar.CSharp.Workspace; /// internal sealed partial class WorkspaceManager { - /// Get code lenses for a document. + /// + /// Get code lenses for a document, or, for a Razor file, those of the members its C# + /// fragments declare, each on its Razor declaration. [RAZOR-FEATURES-CODE-LENS] + /// public Task GetCodeLensesAsync( string filePath, CancellationToken ct = default ) { - return RunScopedQueryAsync>( - filePath, - [], - (document, scope) => CodeLensResolver.GetLensesAsync(document, scope, ct), - ct - ); + return NativePaths.IsRazor(filePath) + ? GetRazorCodeLensesAsync(filePath, ct) + : RunScopedQueryAsync>( + filePath, + [], + (document, scope) => CodeLensResolver.GetLensesAsync(document, scope, ct), + ct + ); } /// Format an entire document. SEQUESTERED — not called by the LSP server. @@ -150,7 +155,10 @@ public Task GetSemanticTokensRangeAsync( ); } - /// Get inlay hints for a range. + /// + /// Get inlay hints for a range, or, for a Razor file, those of the C# its lines hold, each + /// placed back in the file. [RAZOR-FEATURES-INLAY-HINTS] + /// public Task GetInlayHintsAsync( string filePath, int startLine, @@ -158,11 +166,13 @@ public Task GetInlayHintsAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync>( - filePath, - [], - document => InlayHintResolver.GetHintsAsync(document, startLine, endLine, ct), - ct - ); + return NativePaths.IsRazor(filePath) + ? GetRazorInlayHintsAsync(filePath, startLine, endLine, ct) + : RunDocumentQueryAsync>( + filePath, + [], + document => InlayHintResolver.GetHintsAsync(document, startLine, endLine, ct), + ct + ); } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignReferences.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignReferences.cs new file mode 100644 index 00000000..dc6780e3 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignReferences.cs @@ -0,0 +1,67 @@ +using ReferencesResult = Outcome.Result; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// A symbol the other sidecar names by identity, and whether its declaration counts. +internal readonly record struct ForeignSymbolQuery( + string AssemblyName, + string XmlDocSig, + bool IncludeDeclaration +); + +internal sealed partial class WorkspaceManager +{ + /// + /// Every C# and Razor use of the symbol the other sidecar names by identity, and its C# + /// declaration when C# declares it. Razor uses land in their Razor files, and hidden + /// scaffolding is dropped. Implements [REFERENCES-CROSS-LANGUAGE] and [RAZOR-FSHARP-REFERENCES]. + /// + public async Task GetForeignReferencesAsync( + ForeignSymbolQuery query, + CancellationToken ct = default + ) + { + try + { + return new ReferencesResult.Ok( + await ForeignReferencesAsync(query, ct).ConfigureAwait(false) + ); + } + catch (Exception ex) + { + return ReferencesResult.Failure(ex.Message); + } + } + + private async Task ForeignReferencesAsync( + ForeignSymbolQuery query, + CancellationToken ct + ) + { + var solution = _solution; + if ( + solution is null + || string.IsNullOrWhiteSpace(query.AssemblyName) + || string.IsNullOrWhiteSpace(query.XmlDocSig) + ) + { + return new LocationListResult(); + } + + var scope = new SearchScope(solution, null, null); + var identity = new ForeignRenameKey(query.AssemblyName, query.XmlDocSig); + var symbols = await ResolveForeignSymbolsAsync(solution, identity, ct) + .ConfigureAwait(false); + var found = await Task.WhenAll( + symbols.Select(symbol => + ReferenceResolver.ReferencesOfAsync(symbol, scope, query.IncludeDeclaration, ct) + ) + ) + .ConfigureAwait(false); + return await MapLocationsAsync( + ReferenceResolver.Distinct(found.SelectMany(result => result.Locations)), + ct + ) + .ConfigureAwait(false); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs index 3b2c4ca2..32315e00 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs @@ -48,6 +48,11 @@ public async Task GetRenameIdentityAsync( } } + /// + /// The identity of the symbol a rename at the position targets: a C# source symbol, or an + /// F# project's symbol C# uses. A Razor position resolves through its projection, so a + /// rename asked from a Razor file reaches the other language too. [RAZOR-FSHARP-RENAME] + /// private async Task ResolveRenameIdentityAsync( string filePath, int line, @@ -55,29 +60,13 @@ private async Task ResolveRenameIdentityAsync( CancellationToken ct ) { - var document = await FindDocumentAsync(filePath, ct).ConfigureAwait(false); - if (document is null) - { - return new RenameIdentityResultWire(); - } - - var target = await FindRenameTargetAsync(document, line, character, ct) + var at = await ResolveQueryTargetAsync(filePath, (line, character), ct) .ConfigureAwait(false); - return CreateRenameIdentity(target?.Symbol); - } - - private static RenameIdentityResultWire CreateRenameIdentity(ISymbol? symbol) - { - var assemblyName = symbol?.ContainingAssembly?.Name; - var xmlDocSig = symbol is null ? null : DocumentationCommentId.CreateDeclarationId(symbol); - return string.IsNullOrEmpty(assemblyName) || string.IsNullOrEmpty(xmlDocSig) - ? new RenameIdentityResultWire() - : new RenameIdentityResultWire - { - Found = true, - AssemblyName = assemblyName, - XmlDocSig = xmlDocSig, - }; + var target = at is null + ? null + : await FindRenameTargetAsync(at.Document, at.Line, at.Character, ct) + .ConfigureAwait(false); + return FSharpProjectSymbols.Identity(target?.Symbol); } public async Task RenameForeignAsync( diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs index d79a0c95..002dec09 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs @@ -211,7 +211,11 @@ _lastCompletionList is null ); result.AdditionalEdits.AddRange( _lastCompletionOrigin is { } origin - ? RazorCompletionEdits.Map(origin, additional) + ? RazorCompletionEdits.Map( + origin, + additional, + await RazorMarkupEngine.ProcessAsync(origin.Razor, ct).ConfigureAwait(false) + ) : additional.Select(textChange => DocumentText.ToTextEdit(text, textChange)) ); return result; diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Hierarchy.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Hierarchy.cs index 8a8e571f..f5858380 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Hierarchy.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Hierarchy.cs @@ -12,10 +12,13 @@ namespace SharpLsp.Sidecar.CSharp.Workspace; /// -/// Call hierarchy and type hierarchy workspace methods. +/// Call hierarchy and type hierarchy workspace methods: each runs where the position lands — a +/// C# document, or a Razor file's projection — and places what it finds in the file it belongs +/// to, dropping hidden scaffolding ([RAZOR-FEATURES-HIERARCHY]). /// internal sealed partial class WorkspaceManager { + /// Prepare a call hierarchy item where the position lands. public Task PrepareCallHierarchyAsync( string filePath, int line, @@ -23,14 +26,10 @@ public Task PrepareCallHierarchyAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( - filePath, - null, - document => CallHierarchyResolver.PrepareAsync(document, line, character, ct), - ct - ); + return RunItemAtAsync(filePath, (line, character), CallHierarchyResolver.PrepareAsync, ct); } + /// The calls of the symbol the position lands on, from each project's active framework. public Task GetIncomingCallsAsync( string filePath, int line, @@ -38,7 +37,7 @@ public Task GetIncomingCallsAsync( CancellationToken ct = default ) { - return RunScopedAtAsync( + return RunScopedRowsAtAsync( filePath, (line, character), CallHierarchyResolver.GetIncomingAsync, @@ -46,6 +45,7 @@ public Task GetIncomingCallsAsync( ); } + /// The calls the symbol the position lands on makes. public Task GetOutgoingCallsAsync( string filePath, int line, @@ -53,14 +53,15 @@ public Task GetOutgoingCallsAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( + return RunRowsAtAsync( filePath, - new List(), - document => CallHierarchyResolver.GetOutgoingAsync(document, line, character, ct), + (line, character), + CallHierarchyResolver.GetOutgoingAsync, ct ); } + /// Prepare a type hierarchy item where the position lands. public Task PrepareTypeHierarchyAsync( string filePath, int line, @@ -68,14 +69,10 @@ public Task PrepareTypeHierarchyAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( - filePath, - null, - document => TypeHierarchyResolver.PrepareAsync(document, line, character, ct), - ct - ); + return RunItemAtAsync(filePath, (line, character), TypeHierarchyResolver.PrepareAsync, ct); } + /// The supertypes of the type the position lands on. public Task GetSupertypesAsync( string filePath, int line, @@ -83,14 +80,18 @@ public Task GetSupertypesAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( + return RunRowsAtAsync( filePath, - new List(), - document => TypeHierarchyResolver.GetSupertypesAsync(document, line, character, ct), + (line, character), + TypeHierarchyResolver.GetSupertypesAsync, ct ); } + /// + /// The subtypes of the type the position lands on, from each project's active framework + /// ([NETFX-PROJECTS-CSHARP]). + /// public Task GetSubtypesAsync( string filePath, int line, @@ -98,7 +99,7 @@ public Task GetSubtypesAsync( CancellationToken ct = default ) { - return RunScopedAtAsync( + return RunScopedRowsAtAsync( filePath, (line, character), TypeHierarchyResolver.GetSubtypesAsync, @@ -106,21 +107,93 @@ public Task GetSubtypesAsync( ); } + /// The hierarchy item finds, placed in the file it belongs to. + private Task RunItemAtAsync( + string filePath, + (int Line, int Character) at, + Func> prepare, + CancellationToken ct + ) + { + return RunPlacedAtAsync( + filePath, + at, + null, + prepare, + (item, token) => PlaceAsync(item, token), + ct + ); + } + + /// The hierarchy rows finds, each placed in its file. + private Task, string>> RunRowsAtAsync( + string filePath, + (int Line, int Character) at, + Func>> query, + CancellationToken ct + ) + where T : HierarchyItem + { + return RunPlacedAtAsync( + filePath, + at, + [], + query, + (rows, token) => PlaceAsync(rows, token), + ct + ); + } + + /// + /// What finds where the position lands — a C# document, or a Razor + /// file's projection — placed by in the file it belongs to. + /// + private Task> RunPlacedAtAsync( + string filePath, + (int Line, int Character) at, + TValue empty, + Func> query, + Func> place, + CancellationToken ct + ) + { + return RunQueryAsync( + () => ResolveHierarchyTargetAsync(filePath, at, ct), + empty, + async target => + await place( + await query(target.Document, target.Line, target.Character, ct) + .ConfigureAwait(false), + ct + ) + .ConfigureAwait(false) + ); + } + /// - /// A hierarchy list at a position, searched in each project's active framework - /// ([NETFX-PROJECTS-CSHARP]); empty when the document is not in the solution. + /// for a query that searches beyond the document: + /// also gets each project's active framework to search. /// - private Task, string>> RunScopedAtAsync( + private Task, string>> RunScopedRowsAtAsync( string filePath, (int Line, int Character) at, Func>> query, CancellationToken ct ) + where T : HierarchyItem { - return RunScopedQueryAsync( + return RunRowsAtAsync( filePath, - new List(), - (document, scope) => query(document, scope, at.Line, at.Character, ct), + at, + async (document, line, character, token) => + await query( + document, + await ScopeAsync(document, token).ConfigureAwait(false), + line, + character, + token + ) + .ConfigureAwait(false), ct ); } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Navigation.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Navigation.cs index 7f60fe70..53861ecc 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Navigation.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Navigation.cs @@ -74,32 +74,71 @@ public async Task GetDefinitionAsync( filePath, (line, character), new LocationListResult(), - async target => - await MapLocationsAsync( - await DefinitionResolver - .ResolveDefinitionLocationsAsync( - target.Document, - target.Line, - target.Character, - ct - ) - .ConfigureAwait(false), - ct - ) - .ConfigureAwait(false), + target => DefinitionAtAsync(target, ct), ct ) .ConfigureAwait(false); - // Where no C# fragment answers in a Razor file, the markup name there does. - // [RAZOR-FEATURES-DEFINITION] - return - csharp - is ImplementationsResult.Ok { Value.Locations.Count: 0 } - && NativePaths.IsRazor(filePath) - ? await MarkupDefinitionAsync(filePath, line, character, ct).ConfigureAwait(false) + return IsEmpty(csharp) && NativePaths.IsRazor(filePath) + ? await RazorMarkupDefinitionAsync(filePath, line, character, ct).ConfigureAwait(false) : csharp; } + /// + /// The definition at a target: the collocated module an import call names, ahead of any + /// enclosing declaration the position would fall back to ([RAZOR-COLLOCATION-JS]); else the + /// declarations of the symbol there. + /// + private async Task DefinitionAtAsync( + QueryTarget target, + CancellationToken ct + ) + { + var module = await ModuleStartAsync(target, ct).ConfigureAwait(false); + return module.Locations.Count > 0 + ? module + : await MapLocationsAsync( + await DefinitionResolver + .ResolveDefinitionLocationsAsync( + target.Document, + target.Line, + target.Character, + ct + ) + .ConfigureAwait(false), + ct + ) + .ConfigureAwait(false); + } + + /// + /// Where no C# fragment answers in a Razor file: the markup name there + /// ([RAZOR-FEATURES-DEFINITION]), else the collocated stylesheet's selectors of the class at + /// the caret ([RAZOR-COLLOCATION-CSS]). + /// + private async Task RazorMarkupDefinitionAsync( + string filePath, + int line, + int character, + CancellationToken ct + ) + { + var markup = await MarkupDefinitionAsync(filePath, line, character, ct) + .ConfigureAwait(false); + return IsEmpty(markup) + ? new ImplementationsResult.Ok( + await CollocatedClassDefinitionAsync(filePath, line, character, ct) + .ConfigureAwait(false) + ) + : markup; + } + + /// Whether a definition answered, and found nothing. + private static bool IsEmpty(ImplementationsResult result) + { + return result + is ImplementationsResult.Ok { Value.Locations.Count: 0 }; + } + /// Go to type definition at a position. public Task GetTypeDefinitionAsync( string filePath, diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCodeActions.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCodeActions.cs new file mode 100644 index 00000000..45d7fcf0 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCodeActions.cs @@ -0,0 +1,113 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; +using CodeActionsResult = Outcome.Result< + System.Collections.Generic.List, + string +>; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Razor code actions: quick fixes for C# names the projection cannot bind, and refactorings +/// that move a block or elements into a new file. Each is offered with its edit computed, under +/// a negative id so it never meets a Roslyn action's; a new request replaces the last offers. +/// Implements [RAZOR-CODE-ACTIONS]. +/// +internal sealed partial class WorkspaceManager +{ + private ImmutableDictionary _razorActions = []; + + private int _lastRazorAction; + + /// The Razor file's code actions at ; none for a file no project lists. + private Task GetRazorCodeActionsAsync( + string filePath, + LinePositionSpan range, + CancellationToken ct + ) + { + return RunQueryAsync( + async () => + { + var projection = await FindRazorProjectionAsync(filePath, ct).ConfigureAwait(false); + var markup = await FindRazorMarkupAsync(filePath, ct).ConfigureAwait(false); + return projection is null || markup is null + ? null + : new RazorFile(projection, markup); + }, + new List(), + async found => + Offer( + await RazorActionsAsync(found.Projection, found.Markup, range, ct) + .ConfigureAwait(false) + ) + ); + } + + /// A Razor file's projection and markup facts, which its code actions read. + private sealed record RazorFile(RazorProjection Projection, RazorMarkup Markup); + + private static async Task> RazorActionsAsync( + RazorProjection projection, + RazorMarkup markup, + LinePositionSpan range, + CancellationToken ct + ) + { + var span = markup.Text.Lines.GetTextSpan(range); + var model = + await projection.Generated.GetSemanticModelAsync(ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("The projection has no semantic model"); + var fixes = await RazorQuickFixes + .ForAsync(projection, markup, span, ct) + .ConfigureAwait(false); + RazorCodeAction?[] refactorings = + [ + RazorExtractActions.CodeBehind(projection, markup, model.Compilation, span.Start), + RazorExtractActions.Component(projection, markup, model, span), + ]; + return fixes.Concat(refactorings.OfType()); + } + + /// Offer in place of the last ones offered. + private List Offer(IEnumerable actions) + { + var offered = actions + .Select(action => (Id: Interlocked.Decrement(ref _lastRazorAction), Action: action)) + .ToList(); + _razorActions = offered.ToImmutableDictionary( + entry => entry.Id, + entry => entry.Action.Edit + ); + return + [ + .. offered.Select(entry => new CodeActionItem + { + Id = entry.Id, + Title = entry.Action.Title, + Kind = entry.Action.Kind, + IsPreferred = entry.Action.IsPreferred, + }), + ]; + } + + /// The edit of a Razor action offered last; null for any other id. + private WorkspaceEditResult? ResolveRazorAction(int actionId) + { + return _razorActions.GetValueOrDefault(actionId); + } + + /// Whether names a Razor action: Roslyn's ids count up from one. + private static bool IsRazorAction(int actionId) + { + return actionId < 0; + } + + /// Whether a code action request is for a Razor file. + private static bool IsRazorRequest(string filePath) + { + return NativePaths.IsRazor(filePath); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCollocation.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCollocation.cs new file mode 100644 index 00000000..a2ceddaf --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCollocation.cs @@ -0,0 +1,157 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Serilog; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// The collocated files of a Razor file: the stylesheet its CSS isolation declares +/// (Card.razor.css) and the JavaScript module it imports (Card.razor.js). +/// Implements [RAZOR-COLLOCATION]. +/// +internal sealed partial class WorkspaceManager +{ + /// + /// The items the collocated stylesheet adds at a caret inside a class value; none + /// elsewhere or when the file has no stylesheet. [RAZOR-COLLOCATION-CSS] + /// + private static async Task> CollocatedClassCompletionsAsync( + TextDocument? razor, + RazorMarkup markup, + int offset, + CancellationToken ct + ) + { + return + razor is not null + && RazorCollocation.ClassValueAt(markup, offset) is { } at + && await CollocatedStylesheetAsync(razor, ct).ConfigureAwait(false) is { } stylesheet + ? RazorCollocation.ClassCompletions(markup, stylesheet, at) + : []; + } + + /// The start of the collocated module an import call at the target's position names. + private static async Task ModuleStartAsync( + QueryTarget target, + CancellationToken ct + ) + { + var module = await ImportedModuleAsync(target, ct).ConfigureAwait(false); + return module is null + ? new LocationListResult() + : new LocationListResult { Locations = [StartOf(module)] }; + } + + /// The selectors of the class named in a class value's collocated stylesheet. [RAZOR-COLLOCATION-CSS] + private async Task CollocatedClassDefinitionAsync( + string filePath, + int line, + int character, + CancellationToken ct + ) + { + var razor = FindRazorDocument(filePath); + if (razor is null) + { + return new LocationListResult(); + } + + var markup = await RazorMarkupEngine.ProcessAsync(razor, ct).ConfigureAwait(false); + return + markup is not null + && DocumentText.TryOffset(markup.Text, new LinePosition(line, character)) is { } offset + && RazorCollocation.ClassValueAt(markup, offset) is { } at + && await CollocatedStylesheetAsync(razor, ct).ConfigureAwait(false) is { } stylesheet + ? RazorCollocation.ClassDefinitions(markup, stylesheet, at) + : new LocationListResult(); + } + + /// + /// The stylesheet beside , from its project's own copy of the file + /// when it lists one, else read from disk: an editor never sends a stylesheet as a document + /// ([RAZOR-COLLOCATION-CSS]). + /// + private static async Task CollocatedStylesheetAsync( + TextDocument razor, + CancellationToken ct + ) + { + var path = NativePaths.StylesheetOf(razor.FilePath ?? ""); + var buffered = razor.Project.AdditionalDocuments.FirstOrDefault(document => + NativePaths.AreEqual(document.FilePath, path) + ); + var text = buffered is null + ? await ReadCollocatedTextAsync(path, ct).ConfigureAwait(false) + : await buffered.GetTextAsync(ct).ConfigureAwait(false); + return text is null ? null : RazorStylesheet.Parse(path, text); + } + + private static async Task ReadCollocatedTextAsync( + string path, + CancellationToken ct + ) + { + if (!File.Exists(path)) + { + return null; + } + + try + { + return SourceText.From(await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Log.Warning("[Razor] collocated file unreadable {File} {Failure}", path, ex.GetType()); + return null; + } + } + + /// The collocated module file an import call at a position names in the project. + private static async Task ImportedModuleAsync(QueryTarget target, CancellationToken ct) + { + return + await ModuleLiteralAsync(target, ct).ConfigureAwait(false) is { } literal + && await target.Document.GetSemanticModelAsync(ct).ConfigureAwait(false) is { } model + && RazorCollocation.ImportedModule(model, literal, ct) is { } path + ? ModuleTarget(target.Document.Project, path) + : null; + } + + /// The string literal at the target's position, when the caret sits in one. + private static async Task ModuleLiteralAsync( + QueryTarget target, + CancellationToken ct + ) + { + var root = await target.Document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + var text = await target.Document.GetTextAsync(ct).ConfigureAwait(false); + return + root is not null + && DocumentText.TryOffset(text, new LinePosition(target.Line, target.Character)) + is { } offset + ? RazorCollocation.ModuleLiteral(root, offset) + : null; + } + + /// The module file names in the project, when it exists. + private static string? ModuleTarget(Project project, string path) + { + return + project.FilePath is not null + && RazorCollocation.ModuleFile(NativePaths.DirectoryOf(project.FilePath), path) + is { } module + && File.Exists(module) + ? module + : null; + } + + /// A definition location at the very start of . + private static LocationResult StartOf(string filePath) + { + return new LocationResult { FilePath = filePath }; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHierarchy.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHierarchy.cs new file mode 100644 index 00000000..d5287cc3 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHierarchy.cs @@ -0,0 +1,240 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; +using CodeLensesResult = Outcome.Result< + System.Collections.Generic.List, + string +>; +using InlayHintsResult = Outcome.Result< + System.Collections.Generic.List, + string +>; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Inlay hints, code lenses and hierarchies answered from Razor positions: each runs on the +/// file's C# projection and puts what it finds back in the Razor text, dropping whatever lies +/// in the projection's hidden scaffolding. Implements [RAZOR-ARCHITECTURE-PROJECTION], +/// [RAZOR-FEATURES-INLAY-HINTS], [RAZOR-FEATURES-CODE-LENS] and [RAZOR-FEATURES-HIERARCHY]. +/// +internal sealed partial class WorkspaceManager +{ + /// + /// A Razor file's inlay hints for its lines ..: + /// those of the generated lines they map to, each placed back in the file. + /// [RAZOR-FEATURES-INLAY-HINTS] + /// + private Task GetRazorInlayHintsAsync( + string filePath, + int startLine, + int endLine, + CancellationToken ct + ) + { + return RunQueryAsync>( + () => FindRazorProjectionAsync(filePath, ct), + [], + async projection => + await HintsAsync(projection, startLine, endLine, ct).ConfigureAwait(false) + ); + } + + private static async Task> HintsAsync( + RazorProjection projection, + int startLine, + int endLine, + CancellationToken ct + ) + { + // A reader asking to the end of a line means to see it, but the resolver's range stops + // at the start of the line it ends on, so the window reaches one line further. + var hints = RazorHints.Lines(projection, startLine, endLine) is not { } lines + ? [] + : await InlayHintResolver + .GetHintsAsync(projection.Generated, lines.Start, lines.End + 1, ct) + .ConfigureAwait(false); + return RazorHints.Map(hints, projection, startLine, endLine); + } + + /// + /// A Razor file's code lenses: those of the members its projection declares in C# fragments, + /// each on its Razor declaration, so its class and generated members are not lensed. + /// [RAZOR-FEATURES-CODE-LENS] + /// + private Task GetRazorCodeLensesAsync(string filePath, CancellationToken ct) + { + return RunQueryAsync>( + () => FindRazorProjectionAsync(filePath, ct), + [], + async projection => + await CodeLensResolver + .GetLensesAsync( + projection.Generated, + await ScopeAsync(projection.Generated, ct).ConfigureAwait(false), + ct, + projection.ToRazor + ) + .ConfigureAwait(false) + ); + } + + /// The locations hierarchy answers search, in each project's active framework. + private Task ScopeAsync(Document document, CancellationToken ct) + { + return SearchScope.OfAsync(document, _activeFrameworks, ct); + } + + /// + /// Where a hierarchy request at a Razor position runs: the mapped C# position, or, at the + /// very start of the file, the component class its projection generates. The second is the + /// inverse of the rule that lands that class at the file's start, so the follow-up a client + /// makes from such an item — subtypes of a component, and its calls — answers from the + /// component's own file. [RAZOR-ARCHITECTURE-PROJECTION] + /// + private async Task ResolveHierarchyTargetAsync( + string filePath, + (int Line, int Character) at, + CancellationToken ct + ) + { + return await ResolveQueryTargetAsync(filePath, at, ct).ConfigureAwait(false) + ?? ( + NativePaths.IsRazor(filePath) && at is (0, 0) + ? await ComponentTargetAsync(filePath, ct).ConfigureAwait(false) + : null + ); + } + + /// The component class generates, as a query target. + private async Task ComponentTargetAsync(string razorPath, CancellationToken ct) + { + var projection = await FindRazorProjectionAsync(razorPath, ct).ConfigureAwait(false); + var declared = projection is null ? null : FileClass(projection); + if (projection is null || declared is null) + { + return null; + } + + var at = projection.GeneratedText.Lines.GetLinePosition(declared.Identifier.SpanStart); + return new QueryTarget(projection.Generated, at.Line, at.Character, projection); + } + + /// The class a projection generates for its Razor file: the file's own type. + private static ClassDeclarationSyntax? FileClass(RazorProjection projection) + { + return projection + .GeneratedRoot.DescendantNodes() + .OfType() + .FirstOrDefault(); + } + + /// + /// placed in the file it belongs to, or null when its declaration + /// cannot be placed. A call row's sites are inside its declaration, so they are placed with + /// it: within a C# fragment they already carry their Razor range, and a row that cannot be + /// placed takes its sites with it. [RAZOR-FEATURES-HIERARCHY] + /// + private async Task PlaceAsync(T? item, CancellationToken ct) + where T : HierarchyItem + { + if (item is null) + { + return null; + } + + var declaration = DeclarationOf(item); + if (await MapLocationAsync(declaration, ct).ConfigureAwait(false) is { } mapped) + { + return Place(item, mapped); + } + + var file = await FileMemberAsync(declaration, ct).ConfigureAwait(false); + return file is null + ? null + : Place(AsFile(item, file), new LocationResult { FilePath = file }); + } + + /// + /// The Razor file whose projection declares in its own + /// generated class, its own members included; null for a location in no projection, or in + /// the scaffolding of one outside that class, which has nothing in the file to point at. A + /// component is its file, so the class and its members are the file's C#, and a call made from + /// them names the file as its caller ([RAZOR-ARCHITECTURE-PROJECTION]). + /// + private async Task FileMemberAsync(LocationResult declaration, CancellationToken ct) + { + var projection = _solution is null + ? null + : await RazorLocations + .FindProjectionAsync(_solution, declaration.FilePath, ct) + .ConfigureAwait(false); + return projection is not null && Owns(projection, declaration) + ? projection.RazorPath + : null; + } + + /// + /// A generated member's item as the component it belongs to: named by its Razor file and a + /// class, because a follow-up at the file's start runs on the component class + /// ([RAZOR-FEATURES-HIERARCHY]), never on a member the file does not show. + /// + private static T AsFile(T item, string razorPath) + where T : HierarchyItem + { + item.Name = NativePaths.NameOf(razorPath); + item.Kind = "Class"; + return item; + } + + /// Whether the file's own generated class holds . + private static bool Owns(RazorProjection projection, LocationResult declaration) + { + var at = new LinePosition(declaration.Line, declaration.Character); + return DocumentText.TryOffset(projection.GeneratedText, at) is { } offset + && FileClass(projection) is { } own + && projection.GeneratedRoot.FindToken(offset).Parent?.AncestorsAndSelf().Contains(own) + == true; + } + + /// placed in their files, hidden scaffolding dropped. + private async Task> PlaceAsync(List items, CancellationToken ct) + where T : HierarchyItem + { + var placed = new List(); + foreach (var item in items) + { + if (await PlaceAsync(item, ct).ConfigureAwait(false) is { } found) + { + placed.Add(found); + } + } + + return placed; + } + + private static LocationResult DeclarationOf(HierarchyItem item) + { + return new LocationResult + { + FilePath = item.FilePath, + Line = item.Line, + Character = item.Character, + EndLine = item.EndLine, + EndCharacter = item.EndCharacter, + }; + } + + private static T Place(T item, LocationResult place) + where T : HierarchyItem + { + item.FilePath = place.FilePath; + item.Line = place.Line; + item.Character = place.Character; + item.EndLine = place.EndLine; + item.EndCharacter = place.EndCharacter; + return item; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorMarkup.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorMarkup.cs index ddcfc578..2591b38c 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorMarkup.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorMarkup.cs @@ -85,7 +85,16 @@ CancellationToken ct var markup = csharpAnswered ? null : RazorMarkupCompletion.At(found.Markup, found.Compilation, found.Offset); - return [.. RazorMarkupCompletion.Directives(found.Markup, found.Offset), .. markup ?? []]; + // The collocated stylesheet's classes complete inside a `class` value. + // [RAZOR-COLLOCATION-CSS] + var classes = await CollocatedClassCompletionsAsync(razor, found.Markup, found.Offset, ct) + .ConfigureAwait(false); + return + [ + .. RazorMarkupCompletion.Directives(found.Markup, found.Offset), + .. markup ?? [], + .. classes, + ]; } /// What a markup query at a position works on: the facts, the compilation, the offset. diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorSelection.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorSelection.cs new file mode 100644 index 00000000..bf7d2d65 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorSelection.cs @@ -0,0 +1,76 @@ +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.CSharp.Razor; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Selection ranges of Razor files: each position chains from its innermost C# fragment or +/// markup span out to the whole document, so an editor can grow the selection outward. A file +/// no project lists, or a non-Razor path, answers empty. Implements +/// [RAZOR-FEATURES-SELECTION]. +/// +internal sealed partial class WorkspaceManager +{ + /// One answer per position, in order; empty when the file has no workspace presence. + public async Task, string>> GetSelectionRangesAsync( + string filePath, + List<(int Line, int Character)> positions, + CancellationToken ct + ) + { + try + { + var projection = await FindRazorProjectionAsync(filePath, ct).ConfigureAwait(false); + var markup = await FindRazorMarkupAsync(filePath, ct).ConfigureAwait(false); + var text = markup?.Text ?? projection?.RazorText; + return new Outcome.Result, string>.Ok< + List, + string + >( + text is null + ? [.. positions.Select(_ => new SelectionRangeResult())] + : [.. positions.Select(position => At(projection, markup, text, position))] + ); + } + catch (Exception ex) + { + return Outcome.Result, string>.Failure(ex.Message); + } + } + + /// One position's chain, mapped onto the file's lines. + private static SelectionRangeResult At( + RazorProjection? projection, + RazorMarkup? markup, + SourceText text, + (int Line, int Character) position + ) + { + var offset = DocumentText.TryOffset( + text, + new LinePosition(position.Line, position.Character) + ); + return new SelectionRangeResult + { + Ranges = + [ + .. ( + offset is { } found ? RazorSelectionRanges.At(projection, markup, found) : [] + ).Select(span => SpanOf(text, span)), + ], + }; + } + + /// A Razor offset span as its (line, character) bounds. + private static SelectionSpanResult SpanOf(SourceText text, TextSpan span) + { + var at = text.Lines.GetLinePositionSpan(span); + return new SelectionSpanResult + { + StartLine = at.Start.Line, + StartCharacter = at.Start.Character, + EndLine = at.End.Line, + EndCharacter = at.End.Character, + }; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs index e2c31307..09e0c428 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs @@ -13,11 +13,16 @@ namespace SharpLsp.Sidecar.CSharp.Workspace; internal sealed partial class WorkspaceManager { + /// + /// What a rename at a position renames. A foreign target is a symbol an F# project + /// declares, which C# reads from its DLL: F# renames its declaration. [RAZOR-FSHARP-RENAME] + /// private readonly record struct RenameTarget( Document Document, SourceText Text, ISymbol Symbol, - SyntaxToken Token + SyntaxToken Token, + bool Foreign ); private readonly record struct RenameSolutions( @@ -80,21 +85,48 @@ CancellationToken ct { var (target, _) = await FindRenameTargetAsync(filePath, line, character, ct) .ConfigureAwait(false); - if (target is null || _solution is null || !CanUseNewName(target.Value, newName)) - { - return EmptyRenameResult(); - } + return target is null || _solution is null || !CanUseNewName(target.Value, newName) + ? EmptyRenameResult() + : target.Value.Foreign + ? await RenameFSharpSymbolAsync(target.Value.Symbol, newName, ct) + .ConfigureAwait(false) + : await RenameSourceSymbolAsync(_solution, target.Value.Symbol, newName, ct) + .ConfigureAwait(false); + } - var renamed = await RenameSolutionAsync(_solution, target.Value.Symbol, newName, ct) + /// A C# source symbol's rename: Roslyn's edits, and its Razor uses'. + private static async Task RenameSourceSymbolAsync( + Solution solution, + ISymbol symbol, + string newName, + CancellationToken ct + ) + { + var renamed = await RenameSolutionAsync(solution, symbol, newName, ct) .ConfigureAwait(false); - var edits = await BuildRenameEditsAsync(_solution, renamed, newName, ct) + var edits = await BuildRenameEditsAsync(solution, renamed, newName, ct) .ConfigureAwait(false); var razor = await RazorRenameEdits - .FindAsync(_solution, target.Value.Symbol, newName, ct) + .FindAsync(solution, symbol, newName, ct) .ConfigureAwait(false); return RenameSuccess(new WorkspaceEditResult { DocumentChanges = [.. edits, .. razor] }); } + /// + /// An F# project's symbol, renamed from C# or Razor: its C# and Razor uses here. The host + /// adds F#'s edits, its declaration included, through the symbol's identity. + /// [RAZOR-FSHARP-RENAME] [RENAME-CROSSLANGUAGE] + /// + private Task RenameFSharpSymbolAsync( + ISymbol symbol, + string newName, + CancellationToken ct + ) + { + var identity = FSharpProjectSymbols.Identity(symbol); + return RenameForeignCoreAsync(identity.AssemblyName, identity.XmlDocSig, newName, ct); + } + /// /// The rename target at a position, and the Razor file it was asked from. A Razor position /// outside every C# fragment has no target; any other file must be in the workspace. @@ -139,8 +171,11 @@ CancellationToken ct var symbol = await FindSymbolAsync(document, position, ct).ConfigureAwait(false); var targetSymbol = symbol is null ? null : RenameConflictTarget(symbol); - return targetSymbol is not null && IsSourceSymbol(targetSymbol) - ? new RenameTarget(document, text, targetSymbol, token!.Value) + var foreign = + targetSymbol is not null + && FSharpProjectSymbols.Declares(document.Project, targetSymbol); + return targetSymbol is not null && (foreign || IsSourceSymbol(targetSymbol)) + ? new RenameTarget(document, text, targetSymbol, token!.Value, foreign) : null; } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs index f8f1a0fb..13f523ff 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs @@ -317,6 +317,15 @@ public Task GetCodeActionsAsync( CancellationToken ct = default ) { + if (IsRazorRequest(filePath)) + { + var range = new LinePositionSpan( + new LinePosition(startLine, startCharacter), + new LinePosition(endLine, endCharacter) + ); + return GetRazorCodeActionsAsync(filePath, range, ct); + } + return RunDocumentQueryAsync>( filePath, [], @@ -347,9 +356,11 @@ public async Task ResolveCodeActionAsync( return ResolveResult.Failure("No solution loaded"); } - var result = await _codeActionResolver - .ResolveAsync(actionId, _solution, ct) - .ConfigureAwait(false); + var result = IsRazorAction(actionId) + ? ResolveRazorAction(actionId) + : await _codeActionResolver + .ResolveAsync(actionId, _solution, ct) + .ConfigureAwait(false); return result is null ? ResolveResult.Failure($"Code action {actionId} not found") : new ResolveResult.Ok(result); @@ -596,6 +607,7 @@ var stubRef in project solution = solution.RemoveProjectReference(projectId, stubRef); } + solution = FSharpProjectSymbols.WithDocumentedOutput(solution, projectId, dll); project = solution.GetProject(projectId)!; // Add the referenced project's output DLL *and its sibling assemblies*. @@ -616,7 +628,7 @@ var stubRef in project { solution = solution.AddMetadataReference( projectId, - MetadataReference.CreateFromFile(sibling) + FSharpProjectSymbols.Reference(sibling) ); } } diff --git a/src/sidecars/SharpLsp.Sidecar.Common/NativePaths.Razor.cs b/src/sidecars/SharpLsp.Sidecar.Common/NativePaths.Razor.cs index 0c5fac11..a747a1a3 100644 --- a/src/sidecars/SharpLsp.Sidecar.Common/NativePaths.Razor.cs +++ b/src/sidecars/SharpLsp.Sidecar.Common/NativePaths.Razor.cs @@ -40,4 +40,47 @@ public static string FromRazorProjectPath(string projectDirectory, string razorP { return Resolve(projectDirectory, razorPath.TrimStart('/')); } + + /// + /// The code-behind partial class file of a component: Counter.razor.cs beside + /// Counter.razor. [RAZOR-CODE-ACTIONS-CODE-BEHIND] + /// + public static string CodeBehindOf(string componentPath) + { + return componentPath + ".cs"; + } + + /// + /// The stylesheet that scopes a Razor file's CSS: Counter.razor.css beside + /// Counter.razor. [RAZOR-COLLOCATION-CSS] + /// + public static string StylesheetOf(string razorPath) + { + return razorPath + ".css"; + } + + /// + /// The file a JavaScript module path names, the app's base path being the project directory: + /// ./Components/Card.razor.js and /Components/Card.razor.js both name + /// Components/Card.razor.js in it. Null for a path that names no file. [RAZOR-COLLOCATION-JS] + /// + public static string? FromModulePath(string projectDirectory, string modulePath) + { + var relative = AppRelative(Slashed(modulePath)); + return relative.Length > 0 ? Resolve(projectDirectory, relative) : null; + } + + /// A module path with the app's base path removed: ./a/b.js is a/b.js. + private static string AppRelative(string path) + { + return path.StartsWith("./", StringComparison.Ordinal) ? AppRelative(path[2..]) + : path.StartsWith('/') ? AppRelative(path[1..]) + : path; + } + + /// The component file named in : Name.razor. + public static string ComponentIn(string folder, string name) + { + return Join(folder, name + ".razor"); + } } diff --git a/src/sidecars/SharpLsp.Sidecar.Common/Solutions/ProjectReferences.cs b/src/sidecars/SharpLsp.Sidecar.Common/Solutions/ProjectReferences.cs index 193261f4..7986af22 100644 --- a/src/sidecars/SharpLsp.Sidecar.Common/Solutions/ProjectReferences.cs +++ b/src/sidecars/SharpLsp.Sidecar.Common/Solutions/ProjectReferences.cs @@ -56,7 +56,7 @@ .. doc.Descendants() return null; } - var dllName = AssemblyName(projectFilePath) + ".dll"; + var dllName = AssemblyNameOf(projectFilePath) + ".dll"; return Directory .EnumerateFiles(binDir, dllName, SearchOption.AllDirectories) .OrderByDescending(File.GetLastWriteTimeUtc) @@ -69,7 +69,7 @@ .. doc.Descendants() } /// Simple assembly name: <AssemblyName> or the file stem. - private static string AssemblyName(string projectFilePath) + public static string AssemblyNameOf(string projectFilePath) { try { diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsTests.fs b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsTests.fs new file mode 100644 index 00000000..b71495ed --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsTests.fs @@ -0,0 +1,157 @@ +/// The F# side of a symbol C# or Razor names by identity, its assembly and XML doc +/// signature: where F# declares it and every F# use, searched only in the project that +/// builds its assembly and the projects reading that one. Implements [RAZOR-TESTING] for +/// [DEFINITION-CROSSLANG-IDENTITY] and [REFERENCES-CROSS-LANGUAGE]. +module SharpLsp.Sidecar.FSharp.Tests.FSharpForeignSymbolsTests + +open Xunit +open FSharp.Compiler.Symbols +open SharpLsp.Sidecar.FSharp +open SharpLsp.Sidecar.FSharp.Tests.FSharpCoverageTests +open SharpLsp.Sidecar.FSharp.Tests.FSharpRenameSemanticTests +open SharpLsp.Sidecar.Common + +let private DOMAIN = + source + [ "namespace Domain" + "" + "type Player = { Name: string; Score: int }" + "" + "module Scoring =" + " let total (players: seq) = players |> Seq.sumBy (fun player -> player.Score)" + " let best (players: seq) = players |> Seq.maxBy (fun player -> player.Score)" ] + +let private BOARD = + source + [ "module Board" + "open Domain" + "let ada = { Name = \"Ada\"; Score = 3 }" + "let points = ada.Score + Scoring.total [ ada ]" ] + +/// A symbol of the test project's assembly, as C# names it. +let private named xmlDocSig : FSharpForeignSymbols.SymbolIdentity = + { AssemblyName = "TestProject"; XmlDocSig = xmlDocSig } + +let private placed (locations: FSharpWorkspace.DefinitionLocation list) = + locations + |> List.map (fun location -> string (NativePaths.NameOf location.FilePath), location.Line, location.Character) + |> List.sort + +let private domain () = loadWorkspace [ "Domain.fs", DOMAIN; "Board.fs", BOARD ] + +[] +let ``declarations place a type, a record field and a function where F# declares them`` () = task { + let! (state, dir, _, _) = domain () + try + let! player = FSharpForeignSymbols.declarations state (named "T:Domain.Player") true + Assert.Equal<(string * int * int) list>([ "Domain.fs", 2, 5 ], placed player) + let! score = FSharpForeignSymbols.declarations state (named "P:Domain.Player.Score") true + Assert.Equal<(string * int * int) list>([ "Domain.fs", 2, 30 ], placed score) + let total = "M:Domain.Scoring.total(System.Collections.Generic.IEnumerable{Domain.Player})" + let! declared = FSharpForeignSymbols.declarations state (named total) false + Assert.Equal<(string * int * int) list>([ "Domain.fs", 5, 8 ], placed declared) + let range = List.exactlyOne declared + Assert.Equal((5, 13), (range.EndLine, range.EndCharacter)) + + // Another assembly's symbol, an unknown one and an empty identity have no F# declaration. + let elsewhere = { named "T:Domain.Player" with AssemblyName = "Elsewhere" } + let! foreign = FSharpForeignSymbols.declarations state elsewhere true + Assert.Empty(foreign) + let! missing = FSharpForeignSymbols.declarations state (named "T:Domain.Missing") true + Assert.Empty(missing) + let! empty = FSharpForeignSymbols.declarations state (named "") true + Assert.Empty(empty) + finally + cleanup dir +} + +[] +let ``uses list every F# use, with the declaration only when asked`` () = task { + let! (state, dir, _, _) = domain () + try + let uses = [ "Domain.fs", 5, 82; "Domain.fs", 6, 81; "Board.fs", 2, 26; "Board.fs", 3, 17 ] + let! withDeclaration = FSharpForeignSymbols.uses state (named "P:Domain.Player.Score") true + Assert.Equal<(string * int * int) list>(List.sort (("Domain.fs", 2, 30) :: uses), placed withDeclaration) + let! withoutDeclaration = FSharpForeignSymbols.uses state (named "P:Domain.Player.Score") false + Assert.Equal<(string * int * int) list>(List.sort uses, placed withoutDeclaration) + + // The type's uses: each annotation; the function's: its one call. + let! player = FSharpForeignSymbols.uses state (named "T:Domain.Player") false + Assert.Equal<(string * int * int) list>([ "Domain.fs", 5, 28; "Domain.fs", 6, 27 ], placed player) + let total = "M:Domain.Scoring.total(System.Collections.Generic.IEnumerable{Domain.Player})" + let! called = FSharpForeignSymbols.uses state (named total) false + Assert.Equal<(string * int * int) list>([ "Board.fs", 3, 33 ], placed called) + finally + cleanup dir +} + +[] +let ``an unloaded workspace has no declarations or uses`` () = task { + let state = FSharpWorkspace.create () + let! declared = FSharpForeignSymbols.declarations state (named "T:Domain.Player") true + Assert.Empty(declared) + let! used = FSharpForeignSymbols.uses state (named "T:Domain.Player") true + Assert.Empty(used) + Assert.Empty(FSharpForeignSymbols.scopeOf state "TestProject") +} + +/// F#'s own references and a foreign request agree: each use is placed on its name, never +/// on the whole dotted path FCS ranges it over (`ada.Score`). [REFERENCES-FSHARP-FIND] +[] +let ``F# references and foreign uses place each use on its name alike`` () = task { + let! (state, dir, _, paths) = domain () + try + let! own = FSharpReferences.getReferences state paths[0] 2 30 true + let! foreign = FSharpForeignSymbols.uses state (named "P:Domain.Player.Score") true + Assert.Equal<(string * int * int) list>(placed foreign, placed own) + let ends = own |> List.map (fun location -> location.EndCharacter - location.Character) |> List.distinct + Assert.Equal([ "Score".Length ], ends) + + // From a use, too: the dotted `ada.Score` in Board.fs. + let! fromUse = FSharpReferences.getReferences state paths[1] 3 18 false + Assert.Equal<(string * int * int) list>( + [ "Board.fs", 2, 26; "Board.fs", 3, 17; "Domain.fs", 5, 82; "Domain.fs", 6, 81 ], + placed fromUse + ) + finally + cleanup dir +} + +let private MARKS = + source + [ "module Marks" + "" + "type MarkAttribute() =" + " inherit System.Attribute()" + "" + "[]" + "let marked (value: 'T) = value" ] + +/// An attribute used by its short name (`[]` for `MarkAttribute`) has no token that +/// spells its symbol, so its use keeps FCS's range; a type parameter has no XML doc signature, +/// so C# can never name it. [REFERENCES-FSHARP-FIND] [DEFINITION-CROSSLANG-IDENTITY] +[] +let ``a use no token spells keeps its range, and a symbol without a signature has no identity`` () = task { + let! (state, dir, _, paths) = loadWorkspace [ "Marks.fs", MARKS ] + try + let! fileCheck = FSharpWorkspace.checkFile state paths[0] + let checkResults, _ = FSharpForeignSymbols.requireCheckedFile paths[0] fileCheck + let uses = checkResults.GetAllUsesOfAllSymbolsInFile() |> Seq.toArray + let tokenized = FSharpRenameToken.tokenizeSource state.Checker (FSharpWorkspace.readSource state paths[0]) + + let mark = + uses + |> Array.find (fun symbolUse -> + let range = symbolUse.Range + range.StartLine = 6 && range.StartColumn = 2) + Assert.Equal((5, 2, 5, 6), FSharpReferences.nameSpan state.Checker tokenized mark) + + let typeParameter = uses |> Array.find (fun symbolUse -> symbolUse.Symbol :? FSharpGenericParameter) + Assert.True((FSharpForeignSymbols.identityOf typeParameter.Symbol).IsNone, "a type parameter") + let declared = + uses |> Array.find (fun symbolUse -> symbolUse.Symbol :? FSharpEntity && symbolUse.IsFromDefinition) + let identity = FSharpForeignSymbols.identityOf declared.Symbol + Assert.Equal(Some "T:Marks.MarkAttribute", identity |> Option.map (fun named -> named.XmlDocSig)) + finally + cleanup dir +} diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsWireTests.fs b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsWireTests.fs new file mode 100644 index 00000000..ecc50dd3 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsWireTests.fs @@ -0,0 +1,76 @@ +/// The foreign-symbol requests over the sidecar's real IPC socket: the C# sidecar names an F# +/// symbol by its assembly and XML doc signature, and `workspace/declarationForeign` and +/// `workspace/referencesForeign` answer where F# declares and uses it. Implements +/// [RAZOR-TESTING] for [DEFINITION-CROSSLANG-IDENTITY] and [REFERENCES-CROSS-LANGUAGE]. +module SharpLsp.Sidecar.FSharp.Tests.FSharpForeignSymbolsWireTests + +open Xunit +open MessagePack +open SharpLsp.Sidecar.FSharp +open SharpLsp.Sidecar.Common +open SharpLsp.Sidecar.FSharp.Tests.SidecarEndToEndTests + +/// The `workspace/*Foreign` request for the fixture project's symbol `xmlDocSig`. +let private request assemblyName xmlDocSig includeDeclaration = + MessagePackSerializer.Serialize( + { ForeignSymbolRequest.AssemblyName = assemblyName + XmlDocSig = xmlDocSig + IncludeDeclaration = includeDeclaration } + ) + +/// Each location as (file name, line, character, end character). +let private placed (result: LocationListResult) = + result.Locations + |> Array.map (fun location -> + string (NativePaths.NameOf location.FilePath), location.Line, location.Character, location.EndCharacter) + |> Array.sort + |> List.ofArray + +let private ADD = "M:TestProject.Library.add(System.Int32,System.Int32)" + +type FSharpForeignSymbolsWireTests(fixture: SidecarFixture) = + interface IClassFixture + + [] + member _.``a foreign declaration request lands on the F# declaration``() = task { + let! person = fixture.Send("workspace/declarationForeign", request "TestProject" "T:TestProject.Library.Person" true) + Assert.Null(person.Error) + Assert.Equal<(string * int * int * int) list>( + [ "Library.fs", 9, 5, 11 ], + placed (deserialize person.Payload) + ) + + // A function is named with its parameter types, and lands on its name. + let! add = fixture.Send("workspace/declarationForeign", request "TestProject" ADD false) + Assert.Null(add.Error) + Assert.Equal<(string * int * int * int) list>( + [ "Library.fs", 6, 4, 7 ], + placed (deserialize add.Payload) + ) + + // Another assembly's symbol of the same name has no F# declaration here. + let! elsewhere = fixture.Send("workspace/declarationForeign", request "Elsewhere" "T:TestProject.Library.Person" true) + Assert.Null(elsewhere.Error) + Assert.Empty((deserialize elsewhere.Payload).Locations) + } + + [] + member _.``a foreign references request lists F# uses, the declaration only when asked``() = task { + let! withDeclaration = fixture.Send("workspace/referencesForeign", request "TestProject" ADD true) + Assert.Null(withDeclaration.Error) + let all = placed (deserialize withDeclaration.Payload) + Assert.Contains(("Library.fs", 6, 4, 7), all) + Assert.Contains(("Library.fs", 15, 13, 16), all) + + let! withoutDeclaration = fixture.Send("workspace/referencesForeign", request "TestProject" ADD false) + Assert.Null(withoutDeclaration.Error) + let uses = placed (deserialize withoutDeclaration.Payload) + Assert.DoesNotContain(("Library.fs", 6, 4, 7), uses) + Assert.Equal<(string * int * int * int) list>(List.except [ "Library.fs", 6, 4, 7 ] all, uses) + Assert.All(uses, fun (_, _, character, endCharacter) -> Assert.Equal("add".Length, endCharacter - character)) + + // A symbol F# does not declare has no uses. + let! missing = fixture.Send("workspace/referencesForeign", request "TestProject" "T:TestProject.Library.Missing" true) + Assert.Null(missing.Error) + Assert.Empty((deserialize missing.Payload).Locations) + } diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SharpLsp.Sidecar.FSharp.Tests.fsproj b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SharpLsp.Sidecar.FSharp.Tests.fsproj index ed6abf86..b73ff18d 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SharpLsp.Sidecar.FSharp.Tests.fsproj +++ b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SharpLsp.Sidecar.FSharp.Tests.fsproj @@ -35,6 +35,8 @@ + + diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpForeignSymbols.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpForeignSymbols.fs new file mode 100644 index 00000000..38b220f4 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpForeignSymbols.fs @@ -0,0 +1,124 @@ +/// Symbols the other language's sidecar names by identity: the assembly that declares +/// them and their XML doc signature, the only names both compilers give a symbol. The +/// C# sidecar names an F# symbol this way when C# or Razor uses it, and these are its +/// F# declarations and uses. Implements [DEFINITION-CROSSLANG-IDENTITY], +/// [REFERENCES-CROSS-LANGUAGE] and [RENAME-CROSSLANGUAGE]. +module SharpLsp.Sidecar.FSharp.FSharpForeignSymbols + +open System +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Symbols +open SharpLsp.Sidecar.Common + +/// A symbol as both compilers name it. +[] +type SymbolIdentity = + { AssemblyName: string + XmlDocSig: string } + +/// The XML doc signature FCS gives `symbol`; empty for a symbol that has none. +let internal xmlDocSignature (symbol: FSharpSymbol) = + match symbol with + | :? FSharpEntity as entity -> entity.XmlDocSig + | :? FSharpMemberOrFunctionOrValue as memberValue -> memberValue.XmlDocSig + | :? FSharpField as field -> field.XmlDocSig + | :? FSharpUnionCase as unionCase -> unionCase.XmlDocSig + | _ -> "" + +/// The simple name of the assembly that declares `symbol`; empty when FCS cannot say. +let internal assemblyNameOf (symbol: FSharpSymbol) = + try symbol.Assembly.SimpleName |> string + with _ -> "" + +/// `symbol`'s identity, when it has both an assembly and a signature. +let internal identityOf (symbol: FSharpSymbol) = + let assemblyName = assemblyNameOf symbol + let xmlDocSig = xmlDocSignature symbol + if String.IsNullOrWhiteSpace(assemblyName) || String.IsNullOrWhiteSpace(xmlDocSig) then None + else Some { AssemblyName = assemblyName; XmlDocSig = xmlDocSig } + +/// Whether `symbolUse` is a use of the symbol `assemblyName` and `xmlDocSig` name. +let internal matches assemblyName xmlDocSig (symbolUse: FSharpSymbolUse) = + String.Equals(assemblyNameOf symbolUse.Symbol, assemblyName, StringComparison.Ordinal) + && String.Equals(xmlDocSignature symbolUse.Symbol, xmlDocSig, StringComparison.Ordinal) + +/// The check results of `filePath`; FCS failing to check a project file is an error. +let internal requireCheckedFile filePath checkedFile = + checkedFile + |> Option.defaultWith (fun () -> raise (InvalidOperationException($"FCS could not check {filePath}"))) + +/// Every use in `filePath` of the symbol `assemblyName` and `xmlDocSig` name. +let internal usesInFile state assemblyName xmlDocSig filePath = + task { + let! fileCheck = FSharpWorkspace.checkFile state filePath + let checkResults, _ = requireCheckedFile filePath fileCheck + return + checkResults.GetAllUsesOfAllSymbolsInFile() + |> Seq.filter (matches assemblyName xmlDocSig) + |> Seq.toArray + } + +/// Every use in `files` of the symbol `assemblyName` and `xmlDocSig` name, file by file. +let internal usesIn state assemblyName xmlDocSig (files: string array) = + task { + let collected = ResizeArray() + for filePath in files do + let! uses = usesInFile state assemblyName xmlDocSig filePath + collected.AddRange(uses) + return collected.ToArray() + } + +/// Whether `entry` builds the assembly named `assemblyName`. +let private builds assemblyName (entry: FSharpDesignTime.FSharpProjectEntry) = + String.Equals(FSharpWorkspace.parseFsprojAssemblyName entry.Path, assemblyName, StringComparison.Ordinal) + +/// Whether `entry` references an assembly named `assemblyName` in any framework. +let private references assemblyName (entry: FSharpDesignTime.FSharpProjectEntry) = + entry.Resolved.Values + |> Seq.concat + |> Seq.exists (fun reference -> + String.Equals(NativePaths.StemOf reference.Assembly, assemblyName, StringComparison.Ordinal)) + +/// The files of `entries`' query scopes ([SHARPLSP-ARCHITECTURE-PROJECTS-FSHARP-REFERENCES]). +let private scopesOf state (entries: FSharpDesignTime.FSharpProjectEntry seq) = + entries + |> Seq.choose (fun entry -> entry.Options.SourceFiles |> Array.tryHead) + |> Seq.collect (FSharpWorkspace.scopeSourceFiles state) + |> Seq.distinctBy NativePaths.NormalizeFullPath + |> Array.ofSeq + +/// The files that can hold a use of a symbol of `assemblyName`: the F# project that builds +/// that assembly and every project reading it in memory; for an assembly no F# project +/// builds, the F# projects that reference it. Never every file of the workspace. +let internal scopeOf (state: FSharpWorkspace.FSharpWorkspaceState) assemblyName = + let owners = state.Projects.Values |> Seq.filter (builds assemblyName) |> List.ofSeq + if List.isEmpty owners then + state.Projects.Values |> Seq.filter (references assemblyName) |> scopesOf state + else + scopesOf state owners + +/// The names of the uses `query` keeps, of those in scope of the symbol `identity` names; an +/// unloaded workspace or an empty identity has none. +let private inScope (state: FSharpWorkspace.FSharpWorkspaceState) (identity: SymbolIdentity) query = + task { + if not state.IsLoaded + || String.IsNullOrWhiteSpace identity.AssemblyName + || String.IsNullOrWhiteSpace identity.XmlDocSig then + return [] + else + let files = scopeOf state identity.AssemblyName + let! uses = usesIn state identity.AssemblyName identity.XmlDocSig files + return FSharpReferences.nameLocations state (query uses) + } + +let private isDefinition (symbolUse: FSharpSymbolUse) = symbolUse.IsFromDefinition + +/// Where F# declares the symbol `identity` names: the name of each defining use. A symbol +/// no loaded F# project declares has none. [DEFINITION-CROSSLANG-IDENTITY] +let declarations state identity (_: bool) = + inScope state identity (Seq.filter isDefinition) + +/// Every F# use of the symbol `identity` names, and its F# declaration when +/// `includeDeclaration` asks for it. [REFERENCES-CROSS-LANGUAGE] +let uses state identity includeDeclaration = + inScope state identity (Seq.filter (fun symbolUse -> includeDeclaration || not (isDefinition symbolUse))) diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs index 349f1439..e2ecabf3 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs @@ -16,6 +16,39 @@ type HighlightLocation = EndCharacter: int Kind: int } +/// Where `symbolUse` names its symbol, as zero-based (startLine, start, endLine, end). FCS +/// ranges a dotted use (`player.Score`) over the whole path, so the name is the token at the +/// range's end that spells the symbol's name; a use no token spells keeps FCS's range. +let internal nameSpan checker tokenized (symbolUse: FSharpSymbolUse) = + let range = symbolUse.Range + match FSharpRenameToken.tokenForUse checker tokenized symbolUse with + | Some token -> range.EndLine - 1, token.StartCharacter, range.EndLine - 1, token.EndCharacter + | None -> range.StartLine - 1, range.StartColumn, range.EndLine - 1, range.EndColumn + +/// Each use's name as a location, each file read and tokenized once, each place once. +let internal nameLocations (state: FSharpWorkspace.FSharpWorkspaceState) (uses: FSharpSymbolUse seq) = + uses + |> Seq.filter (fun symbolUse -> + let range = symbolUse.Range + range.FileName <> "") + |> Seq.groupBy (fun symbolUse -> + let range = symbolUse.Range + range.FileName) + |> Seq.collect (fun (filePath, inFile) -> + let tokenized = FSharpRenameToken.tokenizeSource state.Checker (FSharpWorkspace.readSource state filePath) + inFile + |> Seq.map (fun symbolUse -> + let line, start, endLine, finish = nameSpan state.Checker tokenized symbolUse + let location: FSharpWorkspace.DefinitionLocation = + { FilePath = filePath + Line = line + Character = start + EndLine = endLine + EndCharacter = finish } + location)) + |> Seq.distinctBy (fun location -> NativePaths.NormalizeFullPath location.FilePath, location.Line, location.Character) + |> List.ofSeq + /// Check whether an FSharpSymbolUse represents a write (definition or pattern). let private isWriteUse (su: FSharpSymbolUse) = su.IsFromDefinition || su.IsFromPattern @@ -144,10 +177,8 @@ let getReferences let! uses = getProjectUsages state filePath line character return uses - |> Array.choose (fun (su: FSharpSymbolUse) -> - if not includeDeclaration && su.IsFromDefinition then None - else FSharpWorkspace.rangeToLocation su.Range) - |> Array.toList + |> Array.filter (fun (su: FSharpSymbolUse) -> includeDeclaration || not su.IsFromDefinition) + |> nameLocations state } /// Find document highlights for the symbol at a position (current file only). @@ -168,20 +199,21 @@ let getDocumentHighlights | Some symbolUse -> let usesInFile = checkResults.GetUsesOfSymbolInFile(symbolUse.Symbol) + let tokenized = FSharpRenameToken.tokenizeSource state.Checker source return usesInFile |> Array.choose (fun (su: FSharpSymbolUse) -> let r = su.Range if r.FileName = "" then None else - let kind = if isWriteUse su then 3 else 2 + let line, start, endLine, finish = nameSpan state.Checker tokenized su Some { FilePath = r.FileName - StartLine = r.StartLine - 1 - StartCharacter = r.StartColumn - EndLine = r.EndLine - 1 - EndCharacter = r.EndColumn - Kind = kind }) + StartLine = line + StartCharacter = start + EndLine = endLine + EndCharacter = finish + Kind = if isWriteUse su then 3 else 2 }) |> Array.toList with ex -> Log.Debug(ex, "[F# DocumentHighlight] failed") diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs index e6a9571a..cc3d262d 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs @@ -21,10 +21,7 @@ type PrepareRename = Placeholder: string } /// Stable compiler identity shared with the other language sidecar. -[] -type RenameIdentity = - { AssemblyName: string - XmlDocSig: string } +type RenameIdentity = FSharpForeignSymbols.SymbolIdentity [] type private ResolvedRename = @@ -53,27 +50,6 @@ type private ProjectedCandidate = StartCharacter: int EndCharacter: int } -let private xmlDocSignature (symbol: FSharpSymbol) = - match symbol with - | :? FSharpEntity as entity -> entity.XmlDocSig - | :? FSharpMemberOrFunctionOrValue as memberValue -> memberValue.XmlDocSig - | :? FSharpField as field -> field.XmlDocSig - | :? FSharpUnionCase as unionCase -> unionCase.XmlDocSig - | _ -> "" - -let private symbolAssemblyName (symbol: FSharpSymbol) = - try symbol.Assembly.SimpleName |> string - with _ -> "" - -let private identityForSymbol symbol = - let assemblyName = symbolAssemblyName symbol - let xmlDocSig = xmlDocSignature symbol - if String.IsNullOrWhiteSpace(assemblyName) || String.IsNullOrWhiteSpace(xmlDocSig) then None - else Some { AssemblyName = assemblyName; XmlDocSig = xmlDocSig } - -let private requireCheckedFile filePath checkedFile = - checkedFile |> Option.defaultWith (fun () -> raise (InvalidOperationException($"FCS could not check {filePath}"))) - /// Pure prepare-rename computation over an already-checked file. Kept separate /// from the `task` so the async wrapper has a single bind + single return — the /// shape FCS can compile to a static state machine (avoids FS3511). @@ -217,7 +193,7 @@ let prepareRename let private computeRenameIdentity state parseResults checkResults source line character = resolveRenameUse state checkResults parseResults source line character - |> Option.bind (fun resolved -> identityForSymbol (normalizeSymbol resolved.SymbolUse.Symbol)) + |> Option.bind (fun resolved -> FSharpForeignSymbols.identityOf (normalizeSymbol resolved.SymbolUse.Symbol)) /// Return the compiler-stable identity for the renameable symbol at a position. let getRenameIdentity (state: FSharpWorkspace.FSharpWorkspaceState) filePath line character = @@ -227,7 +203,7 @@ let getRenameIdentity (state: FSharpWorkspace.FSharpWorkspaceState) filePath lin return raise (InvalidOperationException("F# workspace is not loaded")) else let! fileCheck = FSharpWorkspace.checkFileWithParse state filePath - let parseResults, checkResults, source = requireCheckedFile filePath fileCheck + let parseResults, checkResults, source = FSharpForeignSymbols.requireCheckedFile filePath fileCheck return computeRenameIdentity state parseResults checkResults source line character with ex -> Log.Debug(ex, "[F# RenameIdentity] failed") @@ -306,32 +282,10 @@ let private renameChecked state parseResults checkResults source line character | Some resolved -> return! standardRename state newName resolved } -let private matchesIdentity assemblyName xmlDocSig (symbolUse: FSharpSymbolUse) = - String.Equals(symbolAssemblyName symbolUse.Symbol, assemblyName, StringComparison.Ordinal) - && String.Equals(xmlDocSignature symbolUse.Symbol, xmlDocSig, StringComparison.Ordinal) - -let private matchingUsesInFile state assemblyName xmlDocSig filePath = - task { - let! fileCheck = FSharpWorkspace.checkFile state filePath - let checkResults, _ = requireCheckedFile filePath fileCheck - return - checkResults.GetAllUsesOfAllSymbolsInFile() - |> Seq.filter (matchesIdentity assemblyName xmlDocSig) - |> Seq.toArray - } - -let private collectMatchingUses - (state: FSharpWorkspace.FSharpWorkspaceState) - assemblyName - xmlDocSig = - task { - let collected = ResizeArray() - let files = FSharpWorkspace.allSourceFiles state - for filePath in files do - let! uses = matchingUsesInFile state assemblyName xmlDocSig filePath - collected.AddRange(uses) - return collected.ToArray() - } +/// Every use of the symbol `assemblyName` and `xmlDocSig` name. A rename by identity, from +/// the other language with no F# file to anchor on, walks every loaded project. +let private collectMatchingUses (state: FSharpWorkspace.FSharpWorkspaceState) assemblyName xmlDocSig = + FSharpForeignSymbols.usesIn state assemblyName xmlDocSig (FSharpWorkspace.allSourceFiles state) let private signatureHeaderEnd (xmlDocSig: string) = [| xmlDocSig.IndexOf('('); xmlDocSig.IndexOf('~') |] @@ -432,7 +386,7 @@ let private matchingProjectedEdits FSharpWorkspace.getSymbolUse checkResults source candidate.Original.Line candidate.StartCharacter |> Option.filter (matchesProjectedRange candidate) - |> Option.filter (matchesIdentity assemblyName staleXml) + |> Option.filter (FSharpForeignSymbols.matches assemblyName staleXml) |> Option.map (fun _ -> candidateEdit newName candidate.Original)) let private transientFileEdits @@ -446,7 +400,7 @@ let private transientFileEdits let projectedCandidates = projectCandidatePositions newName candidates let projectedSource = projectSource source newName candidates let! checkedFile = FSharpWorkspace.checkFileWithSource state filePath projectedSource - let _, checkResults, checkedSource = requireCheckedFile filePath checkedFile + let _, checkResults, checkedSource = FSharpForeignSymbols.requireCheckedFile filePath checkedFile return matchingProjectedEdits assemblyName staleXml newName checkResults checkedSource projectedCandidates } diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs index c80b3e80..0406fe6c 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs @@ -293,6 +293,11 @@ type FSharpSidecar() = base.Register("textDocument/renameIdentity", Helpers.renameIdentityHandler workspace) base.Register("workspace/renameForeign", Helpers.renameForeignHandler workspace) + // A symbol C# or Razor uses, named by identity: where F# declares it and every F# + // use. [DEFINITION-CROSSLANG-IDENTITY] [REFERENCES-CROSS-LANGUAGE] + base.Register("workspace/declarationForeign", Helpers.foreignLocationsHandler (FSharpForeignSymbols.declarations workspace)) + base.Register("workspace/referencesForeign", Helpers.foreignLocationsHandler (FSharpForeignSymbols.uses workspace)) + // Call hierarchy prepare [SHARPLSP-FEATURES-NAVIGATION] base.Register("textDocument/prepareCallHierarchy", Helpers.atPosition (FSharpHierarchy.prepareCall workspace) (Helpers.optionOf Helpers.toHierItem)) diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs index 37b02a1d..d0981807 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs @@ -294,6 +294,16 @@ type RenameIdentityResultWire = [] AssemblyName: string [] XmlDocSig: string } +/// A symbol another sidecar names by identity, for its F# declarations or uses. +/// [DEFINITION-CROSSLANG-IDENTITY] [REFERENCES-CROSS-LANGUAGE] +[] +[] +type ForeignSymbolRequest = + { [] AssemblyName: string + [] XmlDocSig: string + /// Whether references include the declaration; a declaration request ignores it. + [] IncludeDeclaration: bool } + [] [] type RenameForeignRequest = @@ -383,6 +393,18 @@ module internal Helpers = atPosition (getLocations workspace) (fun locations ct -> serializeOk { Locations = locations |> List.map toLocationResult |> Array.ofList } ct) + /// Answer a request naming a symbol by identity with the locations `query` finds for it. + let foreignLocationsHandler + (query: FSharpForeignSymbols.SymbolIdentity -> bool -> Task) + : Func> = + handle + (fun (request: ForeignSymbolRequest) -> + query + { AssemblyName = request.AssemblyName; XmlDocSig = request.XmlDocSig } + request.IncludeDeclaration) + (fun locations ct -> + serializeOk { Locations = locations |> List.map toLocationResult |> Array.ofList } ct) + /// Map a hierarchy domain item to its wire shape (call + type hierarchy). let toHierItem (item: FSharpHierarchy.HierItem) : HierarchyItemResult = { Name = item.Name diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj b/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj index 9a1e005e..a794faa9 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj @@ -58,8 +58,9 @@ - + + From 79838e2cfde8da919d4a9537d4e947053db1c374 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:17:23 +1100 Subject: [PATCH 3/4] Upgrade rustls-webpki, webpki-roots and the website's markdown-it past new advisories RUSTSEC-2026-0382 (rustls-webpki 0.103.15 -> 0.103.16), RUSTSEC-2026-0381 (webpki-roots 1.0.9 -> 1.0.10), and GHSA-vfgh-jcmw-g37q / GHSA-gfr2-rv44-r23c (markdown-it 14.3.2 -> 14.3.3 under Eleventy) failed the dependency audit. --- Cargo.lock | 8 ++++---- src/website/package-lock.json | 24 ++++++++++++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 700864d0..599c7ad9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1148,9 +1148,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -1946,9 +1946,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/src/website/package-lock.json b/src/website/package-lock.json index 6d2a4bd5..54e4dfe4 100644 --- a/src/website/package-lock.json +++ b/src/website/package-lock.json @@ -230,9 +230,9 @@ } }, "node_modules/@11ty/eleventy/node_modules/markdown-it": { - "version": "14.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", - "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", + "version": "14.3.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz", + "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==", "funding": [ { "type": "github", @@ -1018,9 +1018,9 @@ } }, "node_modules/eleventy-plugin-techdoc/node_modules/markdown-it": { - "version": "14.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", - "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", + "version": "14.3.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz", + "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==", "funding": [ { "type": "github", @@ -1492,9 +1492,9 @@ } }, "node_modules/markdown-it": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.2.tgz", - "integrity": "sha512-q4IGxMv56jCqT4OCRCADBoDP3LO4MhmTXjFbphHPXs4g3j9Xg5RDnxqN8IF/3vIWEU+VCnUq+7JUg/cfy2E6Qw==", + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.3.tgz", + "integrity": "sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==", "funding": [ { "type": "github", @@ -1507,9 +1507,9 @@ ], "license": "MIT", "dependencies": { - "argparse": "^3.0.0", - "entities": "^8.0.0", - "linkify-it": "^6.0.0", + "argparse": "^3.0.3", + "entities": "^8.1.0", + "linkify-it": "^6.1.0", "mdurl": "^2.1.0", "punycode.js": "^2.3.1", "uc.micro": "^3.0.0" From 68dd7983850b747cf9504b2dfc8543cce1d26a47 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:37:50 +1100 Subject: [PATCH 4/4] Compare the F# Razor rename's files as the file system does On Windows the rename's edit for Scores.fs and the document the test opened spell the drive letter differently, so a lookup by Uri.path found no edits. Key the edits by comparablePath(fsPath), merging spellings of one file, and assert exactly two files are edited. --- .../vscode/src/test/suite/razor-lsp.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/editors/vscode/src/test/suite/razor-lsp.test.ts b/src/editors/vscode/src/test/suite/razor-lsp.test.ts index 5d86d6c2..0215ac9a 100644 --- a/src/editors/vscode/src/test/suite/razor-lsp.test.ts +++ b/src/editors/vscode/src/test/suite/razor-lsp.test.ts @@ -23,6 +23,7 @@ import { import { packageJson } from './run-debug-kit'; import { closeAllEditors, + comparablePath, pollProvider, pollUntilResult, removeDirRecursive, @@ -348,9 +349,20 @@ suite('Razor in VS Code', () => { score, 'Points', ); - const byFile = new Map(edit.entries().map(([target, edits]) => [target.path, edits])); - assert.equal(byFile.get(uri.path)?.length, 1, 'the Razor use'); - assert.equal(byFile.get(scores.uri.path)?.length, 5, 'the F# declaration and four uses'); + // Keyed as the file system compares paths: on Windows a drive letter may be + // spelled in either case, and one file must never count as two. + const byFile = new Map(); + for (const [target, edits] of edit.entries()) { + const key = comparablePath(target.fsPath); + byFile.set(key, [...(byFile.get(key) ?? []), ...edits]); + } + assert.equal(byFile.get(comparablePath(uri.fsPath))?.length, 1, 'the Razor use'); + assert.equal( + byFile.get(comparablePath(scores.uri.fsPath))?.length, + 5, + 'the F# declaration and four uses', + ); + assert.equal(byFile.size, 2, 'the Razor file and the F# file, and nothing else'); assert.ok( [...byFile.values()].flat().every((change) => change.newText === 'Points'), 'every edit renames to Points',