From 1fe6d8a0f440a967bb02043539eb5f708b0697b6 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 03:22:53 +0200 Subject: [PATCH 1/7] Report XML doc parameter names as related symbol uses `XmlDoc` keeps the range of every `///` line and lists the `name` and `cref` attribute values it contains with their source ranges. The checker reports each ``, ``, `` and `` name that matches a parameter or type parameter of the documented declaration as a `RelatedSymbolUseKind.XmlDocParameter` use, for let-bound functions, members, primary constructors, union case fields and signature-file vals. The kind is opt-in: `GetUsesOfSymbolInFile` returns it only when asked for, the `ItemKeyStore` behind Find All References never receives it, and semantic classification ignores it. Co-Authored-By: Claude Fable 5.1 --- src/Compiler/Checking/CheckDeclarations.fs | 11 ++ .../Checking/CheckIncrementalClasses.fs | 5 + .../Checking/Expressions/CheckExpressions.fs | 18 ++- src/Compiler/Checking/NameResolution.fs | 21 +++ src/Compiler/Checking/NameResolution.fsi | 6 + src/Compiler/Checking/RelatedSymbolUse.fs | 3 + src/Compiler/Service/IncrementalBuild.fs | 5 +- .../Service/SemanticClassification.fs | 3 +- src/Compiler/Service/TransparentCompiler.fs | 5 +- src/Compiler/SyntaxTree/XmlDoc.fs | 139 +++++++++++++++- src/Compiler/SyntaxTree/XmlDoc.fsi | 30 ++++ .../FSharpChecker/FindReferences.fs | 150 ++++++++++++++++++ .../SemanticClassificationRegressions.fs | 24 +++ ...iler.Service.SurfaceArea.netstandard20.bsl | 57 +++++++ .../XmlDocTests.fs | 75 +++++++++ 15 files changed, 541 insertions(+), 11 deletions(-) diff --git a/src/Compiler/Checking/CheckDeclarations.fs b/src/Compiler/Checking/CheckDeclarations.fs index 05ce83c5f6b..21a7e97c3b2 100644 --- a/src/Compiler/Checking/CheckDeclarations.fs +++ b/src/Compiler/Checking/CheckDeclarations.fs @@ -597,6 +597,16 @@ module TcRecdUnionAndEnumDeclarations = let checkXmlDocs = cenv.diagnosticOptions.CheckXmlDocs let xmlDoc = xmldoc.ToXmlDoc(checkXmlDocs, Some names) + + match parent with + | Parent tcref -> + let fields = + [ for i, f in List.indexed rfields do + if not f.rfield_name_generated then + f.LogicalName, Item.UnionCaseField (UnionCaseInfo (thisTyInst, UnionCaseRef (tcref, id.idText)), i) ] + ReportXmlDocRefUses cenv.tcSink xmlDoc fields [] + | ParentNone -> () + let attrs, getFinalAttrs, _ = TcAttributesCanFail cenv env AttributeTargets.UnionCaseDecl synAttrs let unionCase = Construct.NewUnionCase id rfields recordTy attrs xmlDoc vis @@ -2935,6 +2945,7 @@ module EstablishTypeDefinitionCores = let checkXmlDocs = cenv.diagnosticOptions.CheckXmlDocs let xmlDoc = xmlDoc.ToXmlDoc(checkXmlDocs, Some paramNames ) + ReportXmlDocRefUses cenv.tcSink xmlDoc [] [ for tp in checkedTypars -> tp.Name, Item.TypeVar(tp.Name, tp) ] Construct.NewTycon (cpath, id.idText, id.idRange, vis, visOfRepr, TyparKind.Type, LazyWithContext.NotLazy checkedTypars, xmlDoc, preferPostfix, preEstablishedHasDefaultCtor, hasSelfReferentialCtor, lmodTy) diff --git a/src/Compiler/Checking/CheckIncrementalClasses.fs b/src/Compiler/Checking/CheckIncrementalClasses.fs index 8e1c4636c6f..9f8d3900c88 100644 --- a/src/Compiler/Checking/CheckIncrementalClasses.fs +++ b/src/Compiler/Checking/CheckIncrementalClasses.fs @@ -204,6 +204,11 @@ let TcImplicitCtorInfo_Phase2A(cenv: cenv, env, tpenv, tcref: TyconRef, vis, att let checkXmlDocs = cenv.diagnosticOptions.CheckXmlDocs let xmlDoc = xmlDoc.ToXmlDoc(checkXmlDocs, Some paramNames) let ctorVal = MakeAndPublishVal cenv env (Parent tcref, false, ModuleOrMemberBinding, ValInRecScope isComplete, ctorValScheme, attribs, xmlDoc, None, false) + + // The `` tags of a primary constructor live on the type's doc + let ctorParameters = [ for v in ctorArgs -> v.LogicalName, Item.Value(mkLocalValRef v) ] + ReportXmlDocRefUses cenv.tcSink xmlDoc ctorParameters [] + ReportXmlDocRefUses cenv.tcSink tcref.Deref.XmlDoc ctorParameters [] ctorValScheme, ctorVal let thisVal = diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index aaca860a630..2fa469f9ca7 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -11759,6 +11759,18 @@ and TcNormalizedBinding declKind (cenv: cenv) env tpenv overallTy safeThisValOpt if isFixed then TcAndBuildFixedExpr cenv env (overallPatTy, rhsExprChecked, overallExprTy, mBinding) else rhsExprChecked + // The parameters of a function binding are the binders of its outer lambda chain + let rec parameterVals expr = + match stripDebugPoints expr with + | Expr.Lambda (_, _, _, vs, body, _, _) -> + [ for v in vs do + if not (v.IsMemberThisVal || v.IsCtorThisVal || v.IsCompilerGenerated) then + v.LogicalName, Item.Value(mkLocalValRef v) ] + @ parameterVals body + | _ -> [] + + ReportXmlDocRefUses cenv.tcSink xmlDoc (parameterVals rhsExprChecked) [ for tp in declaredTypars -> tp.Name, Item.TypeVar(tp.Name, tp) ] + match apinfoOpt with | Some (apinfo, apOverallTy, m) -> let activePatResTys = NewInferenceTypes g apinfo.ActiveTags @@ -13665,9 +13677,10 @@ let private PublishArguments (cenv: cenv) (env: TcEnv) vspec (synValSig: SynValS |> Seq.collect (fun x -> x ||> Seq.zip) |> Seq.choose (fun (synArgInfo, argInfo) -> synArgInfo.Ident |> Option.map (pair argInfo)) - for (argTy, argReprInfo), ident in argData do + [ for (argTy, argReprInfo), ident in argData do let item = Item.OtherName (Some ident, argTy, Some argReprInfo, None, ident.idRange) CallNameResolutionSink cenv.tcSink (ident.idRange, env.NameEnv, item, emptyTyparInst, ItemOccurrence.Binding, env.AccessRights) + ident.idText, item ] let TcAndPublishValSpec (cenv: cenv, env, containerInfo: ContainerInfo, declKind : DeclKind, memFlagsOpt, tpenv, synValSig) = @@ -13756,7 +13769,8 @@ let TcAndPublishValSpec (cenv: cenv, env, containerInfo: ContainerInfo, declKind let vspec = MakeAndPublishVal cenv env (altActualParent, true, declKind, ValNotInRecScope, valscheme, attrs, xmlDoc, literalValue, isGeneratedEventVal) - PublishArguments cenv env vspec synValSig allDeclaredTypars.Length + let parameters = PublishArguments cenv env vspec synValSig allDeclaredTypars.Length + ReportXmlDocRefUses cenv.tcSink xmlDoc parameters [ for tp in allDeclaredTypars -> tp.Name, Item.TypeVar(tp.Name, tp) ] assert(vspec.InlineInfo = inlineFlag) diff --git a/src/Compiler/Checking/NameResolution.fs b/src/Compiler/Checking/NameResolution.fs index a80bbe04c65..a917d1fc370 100644 --- a/src/Compiler/Checking/NameResolution.fs +++ b/src/Compiler/Checking/NameResolution.fs @@ -4,6 +4,7 @@ /// Name environment and name resolution module internal FSharp.Compiler.NameResolution +open System open System.Collections.Generic open Internal.Utilities.Collections @@ -33,6 +34,7 @@ open FSharp.Compiler.TypedTree open FSharp.Compiler.TypedTreeBasics open FSharp.Compiler.TypedTreeOps open FSharp.Compiler.TypeHierarchy +open FSharp.Compiler.Xml #if !NO_TYPEPROVIDERS open FSharp.Compiler.TypeProviders @@ -2653,6 +2655,25 @@ let CallRelatedSymbolSink (sink: TcResultsSink) (m: range, item: Item, kind: Rel | None -> () | Some currentSink -> currentSink.NotifyRelatedSymbolUse(m, item, kind) +/// Report each ``/``/``/`` of a declaration's XML doc +/// as a related use of the parameter or type parameter it names, at the range of the attribute value. +let ReportXmlDocRefUses (sink: TcResultsSink) (doc: XmlDoc) (parameters: (string * Item) list) (typars: (string * Item) list) = + match sink.CurrentSink with + | Some currentSink when doc.NonEmpty && (not parameters.IsEmpty || not typars.IsEmpty) -> + for docRef in doc.GetRefs() do + let candidates = + match docRef.Kind with + | XmlDocRefKind.Param + | XmlDocRefKind.ParamRef -> parameters + | XmlDocRefKind.TypeParam + | XmlDocRefKind.TypeParamRef -> typars + | XmlDocRefKind.Cref -> [] + + for name, item in candidates do + if String.Equals(name, docRef.Text, StringComparison.Ordinal) then + currentSink.NotifyRelatedSymbolUse(docRef.Range, item, RelatedSymbolUseKind.XmlDocParameter) + | _ -> () + /// Report a specific expression typing at a source range let CallExprHasTypeSink (sink: TcResultsSink) (m: range, nenv, ty, ad) = match sink.CurrentSink with diff --git a/src/Compiler/Checking/NameResolution.fsi b/src/Compiler/Checking/NameResolution.fsi index fac10862948..6e3716f3b72 100755 --- a/src/Compiler/Checking/NameResolution.fsi +++ b/src/Compiler/Checking/NameResolution.fsi @@ -14,6 +14,7 @@ open FSharp.Compiler.Text open FSharp.Compiler.TypedTree open FSharp.Compiler.TypedTreeOps open FSharp.Compiler.TcGlobals +open FSharp.Compiler.Xml exception NoConstructorsAvailableForType of TType * DisplayEnv * range @@ -654,6 +655,11 @@ val internal RegisterUnionCaseTesterForProperty: TcResultsSink -> identRange: ra /// Report a related symbol use at a source range (does not affect colorization or symbol info) val internal CallRelatedSymbolSink: TcResultsSink -> range * Item * RelatedSymbolUseKind -> unit +/// Report each ``/``/``/`` of a declaration's XML doc +/// as a related use of the parameter or type parameter it names, at the range of the attribute value. +val internal ReportXmlDocRefUses: + TcResultsSink -> doc: XmlDoc -> parameters: (string * Item) list -> typars: (string * Item) list -> unit + /// Report a specific name resolution at a source range val internal CallExprHasTypeSink: TcResultsSink -> range * NameResolutionEnv * TType * AccessorDomain -> unit diff --git a/src/Compiler/Checking/RelatedSymbolUse.fs b/src/Compiler/Checking/RelatedSymbolUse.fs index 2c120294dba..c12e8c12580 100644 --- a/src/Compiler/Checking/RelatedSymbolUse.fs +++ b/src/Compiler/Checking/RelatedSymbolUse.fs @@ -13,5 +13,8 @@ type RelatedSymbolUseKind = | UnionCaseTester = 1 /// Record type via copy-and-update expression (e.g., { r with ... } → RecordType) | CopyAndUpdateRecord = 2 + /// Parameter or type parameter via the `name` of a `param`, `paramref`, `typeparam` or `typeparamref` + /// tag in the declaration's XML doc + | XmlDocParameter = 4 /// All related symbol kinds | All = 0x7FFFFFFF diff --git a/src/Compiler/Service/IncrementalBuild.fs b/src/Compiler/Service/IncrementalBuild.fs index f68f3495547..a878bb42408 100644 --- a/src/Compiler/Service/IncrementalBuild.fs +++ b/src/Compiler/Service/IncrementalBuild.fs @@ -343,9 +343,10 @@ type BoundModel private ( if not r.IsSynthetic && preventDuplicates.Add struct(r.Start, r.End) then builder.Write(cnr.Range, cnr.Item)) + // A name inside a `///` comment is for rename and highlighting, not for symbol search sResolutions.CapturedRelatedSymbolUses - |> Seq.iter (fun (m, item, _kind) -> - if not m.IsSynthetic then + |> Seq.iter (fun (m, item, kind) -> + if not m.IsSynthetic && kind <> RelatedSymbolUseKind.XmlDocParameter then builder.Write(m, item)) let semanticClassification = sResolutions.GetSemanticClassification(tcGlobals, tcImports.GetImportMap(), sink.GetFormatSpecifierLocations(), None, RelatedSymbolUseKind.All) diff --git a/src/Compiler/Service/SemanticClassification.fs b/src/Compiler/Service/SemanticClassification.fs index 1bf5a752e18..9d91adc4972 100644 --- a/src/Compiler/Service/SemanticClassification.fs +++ b/src/Compiler/Service/SemanticClassification.fs @@ -455,7 +455,8 @@ module TcResolutionsExtensions = match relatedSymbolKinds with | Some kinds -> for (m, item, kind) in sResolutions.CapturedRelatedSymbolUses do - if kinds.HasFlag kind then + // A name inside a `///` comment is never classified, whatever the caller asked for + if kinds.HasFlag kind && kind <> RelatedSymbolUseKind.XmlDocParameter then match range, item with | Some r, _ when not (rangeContainsPos r m.Start || rangeContainsPos r m.End) -> () | _, Item.UnionCase _ -> results.Add(SemanticClassificationItem((m, SemanticClassificationType.UnionCase))) diff --git a/src/Compiler/Service/TransparentCompiler.fs b/src/Compiler/Service/TransparentCompiler.fs index eef56443bf6..ba8aba2cb6e 100644 --- a/src/Compiler/Service/TransparentCompiler.fs +++ b/src/Compiler/Service/TransparentCompiler.fs @@ -2094,9 +2094,10 @@ type internal TransparentCompiler if not r.IsSynthetic && preventDuplicates.Add struct (r.Start, r.End) then builder.Write(cnr.Range, cnr.Item)) + // A name inside a `///` comment is for rename and highlighting, not for symbol search sResolutions.CapturedRelatedSymbolUses - |> Seq.iter (fun (m, item, _kind) -> - if not m.IsSynthetic then + |> Seq.iter (fun (m, item, kind) -> + if not m.IsSynthetic && kind <> RelatedSymbolUseKind.XmlDocParameter then builder.Write(m, item)) builder.TryBuildAndReset()) diff --git a/src/Compiler/SyntaxTree/XmlDoc.fs b/src/Compiler/SyntaxTree/XmlDoc.fs index 3a987257a17..e37cd0851a3 100644 --- a/src/Compiler/SyntaxTree/XmlDoc.fs +++ b/src/Compiler/SyntaxTree/XmlDoc.fs @@ -12,10 +12,119 @@ open Internal.Utilities.Collections open FSharp.Compiler.DiagnosticsLogger open FSharp.Compiler.IO open FSharp.Compiler.Text +open FSharp.Compiler.Text.Position open FSharp.Compiler.Text.Range +/// The tag an XML doc attribute value belongs to +[] +type XmlDocRefKind = + | Param + | ParamRef + | TypeParam + | TypeParamRef + | Cref + +/// An attribute value in an XML doc that names something else: a `name` or a `cref` +[] +type XmlDocRef = + { + Kind: XmlDocRefKind + Text: string + Range: range + } + +module private XmlDocRefScanner = + + let private tags = + [| + "typeparamref", XmlDocRefKind.TypeParamRef, "name" + "typeparam", XmlDocRefKind.TypeParam, "name" + "paramref", XmlDocRefKind.ParamRef, "name" + "param", XmlDocRefKind.Param, "name" + "permission", XmlDocRefKind.Cref, "cref" + "exception", XmlDocRefKind.Cref, "cref" + "seealso", XmlDocRefKind.Cref, "cref" + "see", XmlDocRefKind.Cref, "cref" + |] + + /// The three slashes of `///` are not part of the stored line + let private lineTextOffset = 3 + + let private isNameEnd (text: string) i = + i >= text.Length || Char.IsWhiteSpace text[i] || text[i] = '/' || text[i] = '>' + + let private tagAt (text: string) i = + tags + |> Array.tryFind (fun (tag, _, _) -> + String.CompareOrdinal(text, i, tag, 0, tag.Length) = 0 + && isNameEnd text (i + tag.Length)) + + /// The value of `attribute` between `start` and the closing `>` of the tag, as an offset and a length in `text` + let private attributeValue (text: string) (attribute: string) (start: int) = + let close = + match text.IndexOf('>', start) with + | -1 -> text.Length + | i -> i + + let rec find i = + match text.IndexOf(attribute, i, StringComparison.Ordinal) with + | -1 -> ValueNone + | at when at >= close -> ValueNone + | at -> + let mutable j = at + attribute.Length + + while j < close && Char.IsWhiteSpace text[j] do + j <- j + 1 + + if j < close && text[j] = '=' then + j <- j + 1 + + while j < close && Char.IsWhiteSpace text[j] do + j <- j + 1 + + if j < close && (text[j] = '"' || text[j] = '\'') then + match text.IndexOf(text[j], j + 1) with + | -1 -> ValueNone + | quoteEnd when quoteEnd > close -> ValueNone + | quoteEnd -> ValueSome struct (j + 1, quoteEnd - j - 1) + else + find (at + 1) + else + find (at + 1) + + find start + + let scan (lines: string[]) (lineRanges: range[]) = + [| + for i in 0 .. lines.Length - 1 do + let text = lines[i] + let m = lineRanges[i] + let mutable lt = text.IndexOf '<' + + while lt >= 0 do + match tagAt text (lt + 1) with + | Some(tag, kind, attribute) -> + match attributeValue text attribute (lt + 1 + tag.Length) with + | ValueSome struct (offset, length) -> + let column = m.StartColumn + lineTextOffset + offset + + { + Kind = kind + Text = text.Substring(offset, length) + Range = mkFileIndexRange m.FileIndex (mkPos m.StartLine column) (mkPos m.StartLine (column + length)) + } + | ValueNone -> () + | None -> () + + lt <- text.IndexOf('<', lt + 1) + |] + /// Represents collected XmlDoc lines -type XmlDoc(unprocessedLines: string[], range: range) = +type XmlDoc(unprocessedLines: string[], lineRanges: range[], range: range) = + do + if lineRanges.Length <> 0 && lineRanges.Length <> unprocessedLines.Length then + invalidArg (nameof lineRanges) "one range per line, or none" + let rec processLines (lines: string list) = match lines with | [] -> [] @@ -31,6 +140,8 @@ type XmlDoc(unprocessedLines: string[], range: range) = @ (lines |> List.map Internal.Utilities.XmlAdapters.escape) @ [ "" ] + new(unprocessedLines: string[], range: range) = XmlDoc(unprocessedLines, [||], range) + /// Get the lines before insertion of implicit summary tags and encoding member _.UnprocessedLines = unprocessedLines @@ -44,6 +155,16 @@ type XmlDoc(unprocessedLines: string[], range: range) = member _.Range = range + /// The source range of each unprocessed line; empty when the doc did not come from source + member _.LineRanges = lineRanges + + /// The `name` and `cref` attribute values in the doc, with their source ranges; empty without line ranges + member _.GetRefs() = + if lineRanges.Length = 0 then + [||] + else + XmlDocRefScanner.scan unprocessedLines lineRanges + static member Empty = XmlDocStatics.Empty member _.IsEmpty = unprocessedLines |> Array.forall String.IsNullOrWhiteSpace @@ -56,7 +177,16 @@ type XmlDoc(unprocessedLines: string[], range: range) = elif doc2.IsEmpty then doc1.Range else unionRanges doc1.Range doc2.Range - XmlDoc(Array.append doc1.UnprocessedLines doc2.UnprocessedLines, range) + let lineRanges = + if + doc1.LineRanges.Length = doc1.UnprocessedLines.Length + && doc2.LineRanges.Length = doc2.UnprocessedLines.Length + then + Array.append doc1.LineRanges doc2.LineRanges + else + [||] + + XmlDoc(Array.append doc1.UnprocessedLines doc2.UnprocessedLines, lineRanges, range) member doc.GetXmlText() = if doc.IsEmpty then @@ -241,8 +371,9 @@ type PreXmlDoc = XmlDoc.Empty else let lines = Array.map fst preLines - let m = Array.reduce unionRanges (Array.map snd preLines) - let doc = XmlDoc(lines, m) + let lineRanges = Array.map snd preLines + let m = Array.reduce unionRanges lineRanges + let doc = XmlDoc(lines, lineRanges, m) if check then doc.Check(paramNamesOpt) diff --git a/src/Compiler/SyntaxTree/XmlDoc.fsi b/src/Compiler/SyntaxTree/XmlDoc.fsi index 619d6be53cd..1942bd11819 100644 --- a/src/Compiler/SyntaxTree/XmlDoc.fsi +++ b/src/Compiler/SyntaxTree/XmlDoc.fsi @@ -4,12 +4,35 @@ namespace FSharp.Compiler.Xml open FSharp.Compiler.Text +/// The tag an XML doc attribute value belongs to +[] +type XmlDocRefKind = + | Param + | ParamRef + | TypeParam + | TypeParamRef + | Cref + +/// An attribute value in an XML doc that names something else: a `name` or a `cref` +[] +type XmlDocRef = + { + Kind: XmlDocRefKind + /// The attribute value as written + Text: string + /// The source range of the value, without its quotes + Range: range + } + /// Represents collected XmlDoc lines [] type public XmlDoc = new: unprocessedLines: string[] * range: range -> XmlDoc + /// Lines with their source ranges; one range per line, or none when the doc did not come from source + new: unprocessedLines: string[] * lineRanges: range[] * range: range -> XmlDoc + /// Merge two XML documentation static member Merge: doc1: XmlDoc -> doc2: XmlDoc -> XmlDoc @@ -37,6 +60,13 @@ type public XmlDoc = /// Indicates the overall original source range of the XmlDoc member Range: range + /// The source range of each unprocessed line; empty when the doc did not come from source + member LineRanges: range[] + + /// The `name` and `cref` attribute values of `param`, `paramref`, `typeparam`, `typeparamref`, + /// `see`, `seealso`, `exception` and `permission` tags, with their source ranges. Empty without line ranges. + member GetRefs: unit -> XmlDocRef[] + /// Get the lines before insertion of implicit summary tags and encoding member UnprocessedLines: string[] diff --git a/tests/FSharp.Compiler.ComponentTests/FSharpChecker/FindReferences.fs b/tests/FSharp.Compiler.ComponentTests/FSharpChecker/FindReferences.fs index d4a070d51f3..441e2c91afa 100644 --- a/tests/FSharp.Compiler.ComponentTests/FSharpChecker/FindReferences.fs +++ b/tests/FSharp.Compiler.ComponentTests/FSharpChecker/FindReferences.fs @@ -1066,6 +1066,156 @@ let count = list.Count $"Assembly should be a system assembly, got: {assembly.SimpleName}")) } +/// https://github.com/dotnet/fsharp/issues/20630 https://github.com/dotnet/fsharp/issues/15134 +module XmlDocParameters = + + let private rangesOf (uses: FSharpSymbolUse seq) = + uses + |> Seq.map (fun su -> su.Range.StartLine, su.Range.StartColumn, su.Range.EndColumn) + |> Seq.sort + |> Seq.toList + + /// The doc `name` ranges show up only when asked for as XmlDocParameter uses, never by default + let private expectDocUses source (symbolAt: FSharpSymbolUse -> bool) codeRanges docRanges = + checkAllSymbols source (fun result allUses -> + let symbol = (allUses |> Seq.find symbolAt).Symbol + + result.GetUsesOfSymbolInFile symbol |> rangesOf |> fun actual -> Assert.Equal<(int * int * int) list>(codeRanges, actual) + + result.GetUsesOfSymbolInFile(symbol, relatedSymbolKinds = RelatedSymbolUseKind.XmlDocParameter) + |> rangesOf + |> fun actual -> Assert.Equal<(int * int * int) list>(List.sort (codeRanges @ docRanges), actual) + + // A caller that asks for everything else still gets nothing from the docs + let otherKinds = RelatedSymbolUseKind.UnionCaseTester ||| RelatedSymbolUseKind.CopyAndUpdateRecord + + result.GetUsesOfSymbolInFile(symbol, relatedSymbolKinds = otherKinds) + |> rangesOf + |> fun actual -> Assert.Equal<(int * int * int) list>(codeRanges, actual)) + + // SyntheticProject puts its own `module` header on line 1; a source starting with a newline begins on line 3 + let private definitionNamed name (su: FSharpSymbolUse) = + su.IsFromDefinition && su.Symbol.DisplayName = name + + let private definitionAt line column (su: FSharpSymbolUse) = + su.IsFromDefinition && su.Range.StartLine = line && su.Range.StartColumn = column + + // A type parameter's declaration is reported as a use in a type, not as a definition + let private typarNamed name line (su: FSharpSymbolUse) = + match su.Symbol with + | :? FSharp.Compiler.Symbols.FSharpGenericParameter as p -> p.Name = name && su.Range.StartLine = line + | _ -> false + + [] + let ``param and paramref of a let-bound function`` () = + let source = """ +/// Adds. +/// The first number. +/// Added to . +let add x y = x + y +""" + expectDocUses source (definitionNamed "x") [ (6, 8, 9); (6, 14, 15) ] [ (4, 17, 18); (5, 45, 46) ] + + [] + let ``typeparam and typeparamref of an explicitly generic function`` () = + let source = """ +/// The element type, see . +/// The value. +let id<'T> (x: 'T) = x +""" + expectDocUses source (typarNamed "T" 5) [ (5, 7, 9); (5, 15, 17) ] [ (3, 21, 22); (3, 66, 67) ] + + [] + let ``param of a member, not confused with this`` () = + let source = """ +type C() = + /// What to keep. + member this.Keep(value: int) = value +""" + expectDocUses source (definitionNamed "value") [ (5, 21, 26); (5, 35, 40) ] [ (4, 21, 26) ] + + [] + let ``param of a primary constructor documented on the type`` () = + let source = """ +/// A holder. +/// The initial value. +type Holder(seed: int) = + member _.Seed = seed +""" + expectDocUses source (definitionNamed "seed") [ (5, 12, 16); (6, 20, 24) ] [ (4, 17, 21) ] + + [] + let ``typeparam of a generic type`` () = + let source = """ +/// The payload. +type Box<'T>(value: 'T) = + member _.Value = value +""" + expectDocUses source (typarNamed "T" 4) [ (4, 9, 11); (4, 20, 22) ] [ (3, 21, 22) ] + + [] + let ``param of a union case field`` () = + let source = """ +type Shape = + /// Distance from the centre. + | Circle of radius: float +""" + expectDocUses source (definitionNamed "radius") [ (5, 16, 22) ] [ (4, 21, 27) ] + + [] + let ``param of a signature file val is found in code but its doc stays out of find all references`` () = + let signature = """ +/// The first number. +val add: x: int -> y: int -> int +""" + let implementation = "let add x y = x + y" + SyntheticProject.Create( + { sourceFile "Source" [] with Source = implementation; SignatureFile = Custom signature }) + .Workflow { + placeCursor "Source" "x" + findAllReferences (expectToFind [ + "FileSource.fsi", 4, 9, 10 + "FileSource.fs", 2, 8, 9 + "FileSource.fs", 2, 14, 15 + ]) + } + + [] + let ``an unknown name in the doc reports nothing and does not break the others`` () = + let source = """ +/// Not a parameter. +/// The parameter. +let f x = x +""" + expectDocUses source (definitionNamed "x") [ (5, 6, 7); (5, 10, 11) ] [ (4, 17, 18) ] + + [] + let ``a duplicated param name reports every occurrence`` () = + let source = """ +/// Once. +/// Twice. +let f x = x +""" + expectDocUses source (definitionNamed "x") [ (5, 6, 7); (5, 10, 11) ] [ (3, 17, 18); (4, 17, 18) ] + + [] + let ``a backticked parameter is matched by its bare name`` () = + let source = """ +/// Spaced. +let f ``a b`` = ``a b`` +""" + expectDocUses source (definitionAt 4 6) [ (4, 6, 13); (4, 16, 23) ] [ (3, 17, 20) ] + + [] + let ``inheritdoc and include tags do not disturb the scan`` () = + let source = """ +/// +/// +/// Still found. +let f x = x +""" + expectDocUses source (definitionNamed "x") [ (6, 6, 7); (6, 10, 11) ] [ (5, 17, 18) ] + /// https://github.com/dotnet/fsharp/issues/16993 module CSharpExtensionMethods = diff --git a/tests/FSharp.Compiler.ComponentTests/FSharpChecker/SemanticClassificationRegressions.fs b/tests/FSharp.Compiler.ComponentTests/FSharpChecker/SemanticClassificationRegressions.fs index d77b221db87..b25370565ec 100644 --- a/tests/FSharp.Compiler.ComponentTests/FSharpChecker/SemanticClassificationRegressions.fs +++ b/tests/FSharp.Compiler.ComponentTests/FSharpChecker/SemanticClassificationRegressions.fs @@ -74,6 +74,30 @@ let updated = { x with ValidationErrors = [] } ) } +/// (#20630) A parameter named in a `///` doc is reported as a related symbol use so that rename can +/// rewrite it, but nothing inside a comment may be coloured, even when the caller asks for every related kind. +[] +let ``XML doc param names are not classified`` () : Task = + task { + let source = + """ +module Test + +/// The first number. +/// Added to . +let add x y = x + y +""" + + let! items = getClassifications source + + let insideDocs = + items + |> Array.filter (fun item -> item.Range.StartLine = 4 || item.Range.StartLine = 5) + |> Array.map (fun i -> i.Range, i.Type) + + Assert.True(insideDocs.Length = 0, $"Nothing inside the doc comment should be classified, but found: %A{insideDocs}") + } + /// (#16621) Helper: assert UnionCase classifications on expected lines. /// Each entry is (line, expectedCount, maxRangeWidth). /// maxRangeWidth guards against dot-coloring regressions (range including "x." prefix). diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl index bce0bc86e9b..b22a11c77f4 100644 --- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl +++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl @@ -2623,6 +2623,7 @@ FSharp.Compiler.CodeAnalysis.RelatedSymbolUseKind: FSharp.Compiler.CodeAnalysis. FSharp.Compiler.CodeAnalysis.RelatedSymbolUseKind: FSharp.Compiler.CodeAnalysis.RelatedSymbolUseKind CopyAndUpdateRecord FSharp.Compiler.CodeAnalysis.RelatedSymbolUseKind: FSharp.Compiler.CodeAnalysis.RelatedSymbolUseKind None FSharp.Compiler.CodeAnalysis.RelatedSymbolUseKind: FSharp.Compiler.CodeAnalysis.RelatedSymbolUseKind UnionCaseTester +FSharp.Compiler.CodeAnalysis.RelatedSymbolUseKind: FSharp.Compiler.CodeAnalysis.RelatedSymbolUseKind XmlDocParameter FSharp.Compiler.CodeAnalysis.RelatedSymbolUseKind: Int32 value__ FSharp.Compiler.CodeAnalysis.TransparentCompiler.CacheSizes: Boolean Equals(FSharp.Compiler.CodeAnalysis.TransparentCompiler.CacheSizes) FSharp.Compiler.CodeAnalysis.TransparentCompiler.CacheSizes: Boolean Equals(FSharp.Compiler.CodeAnalysis.TransparentCompiler.CacheSizes, System.Collections.IEqualityComparer) @@ -12731,14 +12732,70 @@ FSharp.Compiler.Xml.XmlDoc: Boolean get_IsEmpty() FSharp.Compiler.Xml.XmlDoc: Boolean get_NonEmpty() FSharp.Compiler.Xml.XmlDoc: FSharp.Compiler.Text.Range Range FSharp.Compiler.Xml.XmlDoc: FSharp.Compiler.Text.Range get_Range() +FSharp.Compiler.Xml.XmlDoc: FSharp.Compiler.Text.Range[] LineRanges +FSharp.Compiler.Xml.XmlDoc: FSharp.Compiler.Text.Range[] get_LineRanges() FSharp.Compiler.Xml.XmlDoc: FSharp.Compiler.Xml.XmlDoc Empty FSharp.Compiler.Xml.XmlDoc: FSharp.Compiler.Xml.XmlDoc Merge(FSharp.Compiler.Xml.XmlDoc, FSharp.Compiler.Xml.XmlDoc) FSharp.Compiler.Xml.XmlDoc: FSharp.Compiler.Xml.XmlDoc get_Empty() +FSharp.Compiler.Xml.XmlDoc: FSharp.Compiler.Xml.XmlDocRef[] GetRefs() FSharp.Compiler.Xml.XmlDoc: System.String GetXmlText() FSharp.Compiler.Xml.XmlDoc: System.String[] GetElaboratedXmlLines() FSharp.Compiler.Xml.XmlDoc: System.String[] UnprocessedLines FSharp.Compiler.Xml.XmlDoc: System.String[] get_UnprocessedLines() FSharp.Compiler.Xml.XmlDoc: Void .ctor(System.String[], FSharp.Compiler.Text.Range) +FSharp.Compiler.Xml.XmlDoc: Void .ctor(System.String[], FSharp.Compiler.Text.Range[], FSharp.Compiler.Text.Range) +FSharp.Compiler.Xml.XmlDocRef: Boolean Equals(FSharp.Compiler.Xml.XmlDocRef) +FSharp.Compiler.Xml.XmlDocRef: Boolean Equals(FSharp.Compiler.Xml.XmlDocRef, System.Collections.IEqualityComparer) +FSharp.Compiler.Xml.XmlDocRef: Boolean Equals(System.Object) +FSharp.Compiler.Xml.XmlDocRef: Boolean Equals(System.Object, System.Collections.IEqualityComparer) +FSharp.Compiler.Xml.XmlDocRef: FSharp.Compiler.Text.Range Range +FSharp.Compiler.Xml.XmlDocRef: FSharp.Compiler.Text.Range get_Range() +FSharp.Compiler.Xml.XmlDocRef: FSharp.Compiler.Xml.XmlDocRefKind Kind +FSharp.Compiler.Xml.XmlDocRef: FSharp.Compiler.Xml.XmlDocRefKind get_Kind() +FSharp.Compiler.Xml.XmlDocRef: Int32 GetHashCode() +FSharp.Compiler.Xml.XmlDocRef: Int32 GetHashCode(System.Collections.IEqualityComparer) +FSharp.Compiler.Xml.XmlDocRef: System.String Text +FSharp.Compiler.Xml.XmlDocRef: System.String ToString() +FSharp.Compiler.Xml.XmlDocRef: System.String get_Text() +FSharp.Compiler.Xml.XmlDocRef: Void .ctor(FSharp.Compiler.Xml.XmlDocRefKind, System.String, FSharp.Compiler.Text.Range) +FSharp.Compiler.Xml.XmlDocRefKind+Tags: Int32 Cref +FSharp.Compiler.Xml.XmlDocRefKind+Tags: Int32 Param +FSharp.Compiler.Xml.XmlDocRefKind+Tags: Int32 ParamRef +FSharp.Compiler.Xml.XmlDocRefKind+Tags: Int32 TypeParam +FSharp.Compiler.Xml.XmlDocRefKind+Tags: Int32 TypeParamRef +FSharp.Compiler.Xml.XmlDocRefKind: Boolean Equals(FSharp.Compiler.Xml.XmlDocRefKind) +FSharp.Compiler.Xml.XmlDocRefKind: Boolean Equals(FSharp.Compiler.Xml.XmlDocRefKind, System.Collections.IEqualityComparer) +FSharp.Compiler.Xml.XmlDocRefKind: Boolean Equals(System.Object) +FSharp.Compiler.Xml.XmlDocRefKind: Boolean Equals(System.Object, System.Collections.IEqualityComparer) +FSharp.Compiler.Xml.XmlDocRefKind: Boolean IsCref +FSharp.Compiler.Xml.XmlDocRefKind: Boolean IsParam +FSharp.Compiler.Xml.XmlDocRefKind: Boolean IsParamRef +FSharp.Compiler.Xml.XmlDocRefKind: Boolean IsTypeParam +FSharp.Compiler.Xml.XmlDocRefKind: Boolean IsTypeParamRef +FSharp.Compiler.Xml.XmlDocRefKind: Boolean get_IsCref() +FSharp.Compiler.Xml.XmlDocRefKind: Boolean get_IsParam() +FSharp.Compiler.Xml.XmlDocRefKind: Boolean get_IsParamRef() +FSharp.Compiler.Xml.XmlDocRefKind: Boolean get_IsTypeParam() +FSharp.Compiler.Xml.XmlDocRefKind: Boolean get_IsTypeParamRef() +FSharp.Compiler.Xml.XmlDocRefKind: FSharp.Compiler.Xml.XmlDocRefKind Cref +FSharp.Compiler.Xml.XmlDocRefKind: FSharp.Compiler.Xml.XmlDocRefKind Param +FSharp.Compiler.Xml.XmlDocRefKind: FSharp.Compiler.Xml.XmlDocRefKind ParamRef +FSharp.Compiler.Xml.XmlDocRefKind: FSharp.Compiler.Xml.XmlDocRefKind TypeParam +FSharp.Compiler.Xml.XmlDocRefKind: FSharp.Compiler.Xml.XmlDocRefKind TypeParamRef +FSharp.Compiler.Xml.XmlDocRefKind: FSharp.Compiler.Xml.XmlDocRefKind get_Cref() +FSharp.Compiler.Xml.XmlDocRefKind: FSharp.Compiler.Xml.XmlDocRefKind get_Param() +FSharp.Compiler.Xml.XmlDocRefKind: FSharp.Compiler.Xml.XmlDocRefKind get_ParamRef() +FSharp.Compiler.Xml.XmlDocRefKind: FSharp.Compiler.Xml.XmlDocRefKind get_TypeParam() +FSharp.Compiler.Xml.XmlDocRefKind: FSharp.Compiler.Xml.XmlDocRefKind get_TypeParamRef() +FSharp.Compiler.Xml.XmlDocRefKind: FSharp.Compiler.Xml.XmlDocRefKind+Tags +FSharp.Compiler.Xml.XmlDocRefKind: Int32 CompareTo(FSharp.Compiler.Xml.XmlDocRefKind) +FSharp.Compiler.Xml.XmlDocRefKind: Int32 CompareTo(System.Object) +FSharp.Compiler.Xml.XmlDocRefKind: Int32 CompareTo(System.Object, System.Collections.IComparer) +FSharp.Compiler.Xml.XmlDocRefKind: Int32 GetHashCode() +FSharp.Compiler.Xml.XmlDocRefKind: Int32 GetHashCode(System.Collections.IEqualityComparer) +FSharp.Compiler.Xml.XmlDocRefKind: Int32 Tag +FSharp.Compiler.Xml.XmlDocRefKind: Int32 get_Tag() +FSharp.Compiler.Xml.XmlDocRefKind: System.String ToString() Internal.Utilities.Library.DelayInitArrayMap`3[T,TDictKey,TDictValue]: System.Collections.Generic.IDictionary`2[TDictKey,TDictValue] CreateDictionary(T[]) Internal.Utilities.Library.DelayInitArrayMap`3[T,TDictKey,TDictValue]: System.Collections.Generic.IDictionary`2[TDictKey,TDictValue] GetDictionary() Internal.Utilities.Library.DelayInitArrayMap`3[T,TDictKey,TDictValue]: T[] GetArray() diff --git a/tests/FSharp.Compiler.Service.Tests/XmlDocTests.fs b/tests/FSharp.Compiler.Service.Tests/XmlDocTests.fs index 82992cc3c96..1205e8c1f6e 100644 --- a/tests/FSharp.Compiler.Service.Tests/XmlDocTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/XmlDocTests.fs @@ -205,6 +205,81 @@ let checkParsingErrors expected (parseResults: FSharpParseFileResults) = error, Line range.StartLine, Col range.StartColumn, Line range.EndLine, Col range.EndColumn, x.Message) |> shouldEqual expected +module XmlDocRefs = + + open FSharp.Compiler.Text + open FSharp.Compiler.Xml + + /// A doc whose `///` lines start at column 4 of consecutive lines from 10, as the lexer records them + let private docOf (lines: string[]) = + let lineRanges = + lines + |> Array.mapi (fun i line -> + let lineNumber = 10 + i + Range.mkRange "test.fs" (Position.mkPos lineNumber 4) (Position.mkPos lineNumber (4 + 3 + line.Length))) + + XmlDoc(lines, lineRanges, Array.reduce Range.unionRanges lineRanges) + + let private refsOf lines = + (docOf lines).GetRefs() + |> Array.map (fun r -> r.Kind, r.Text, (r.Range.StartLine, r.Range.StartColumn, r.Range.EndColumn)) + |> Array.toList + + [] + let ``every tag kind, with the value column offset by the three slashes`` () = + // `///` at column 4, so the stored text starts at column 7; ` first""" + """ """ + """ t""" + """ """ + """ """ + """ """ + """ when""" + """ """ |] + |> shouldEqual + [ XmlDocRefKind.Param, "x", (10, 21, 22) + XmlDocRefKind.ParamRef, "x", (11, 24, 25) + XmlDocRefKind.TypeParam, "T", (12, 25, 26) + XmlDocRefKind.TypeParamRef, "T", (13, 28, 29) + XmlDocRefKind.Cref, "MyType", (14, 19, 25) + XmlDocRefKind.Cref, "M:A.B.C", (15, 23, 30) + XmlDocRefKind.Cref, "System.Exception", (16, 25, 41) + XmlDocRefKind.Cref, "P", (17, 26, 27) ] + + [] + let ``attribute syntax variations`` () = + refsOf [| """spaces and single quotes""" + """other attributes first""" + """two tags on one line""" |] + |> shouldEqual + [ XmlDocRefKind.Param, "x", (10, 24, 25) + XmlDocRefKind.Param, "y", (11, 28, 29) + XmlDocRefKind.ParamRef, "z", (12, 23, 24) + XmlDocRefKind.ParamRef, "w", (12, 44, 45) ] + + [] + let ``blank lines, malformed xml and unrelated tags yield nothing`` () = + refsOf [| "" + "not a param" + "not one of the tags" + "unquoted" + "no name" + "text with a < that is not a tag" |] + |> shouldEqual [] + + [] + let ``a doc without line ranges has no refs`` () = + XmlDoc([| """""" |], Range.range0).GetRefs() |> shouldEqual [||] + + [] + let ``a merged doc keeps its refs when both halves have line ranges`` () = + let merged = XmlDoc.Merge (docOf [| """""" |]) (docOf [| """""" |]) + merged.GetRefs() |> Array.map (fun r -> r.Text) |> shouldEqual [| "x"; "y" |] + + let withoutRanges = XmlDoc.Merge (docOf [| """""" |]) (XmlDoc([| """""" |], Range.range0)) + withoutRanges.GetRefs() |> shouldEqual [||] + [] let ``xml-doc eof``(): unit = checkSignatureAndImplementation """ From 22d091a55ba9b8c8418705aaa458b9728c8d898e Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 03:38:39 +0200 Subject: [PATCH 2/7] Add release note for XML doc parameter symbol uses Co-Authored-By: Claude Fable 5.1 --- docs/release-notes/.FSharp.Compiler.Service/11.0.200.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 6212188b65f..8d35f19996f 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -1,6 +1,7 @@ ### Added * F# Interactive gains a JSON-RPC server mode, `--fsi-server-jsonrpc:`, in which a host submits interactions over a named pipe and receives structured results — diagnostics with positions, escaping exceptions, the values each interaction bound, and the session's own process id — instead of recovering them by looking for a `SERVER-PROMPT>` marker in the output text. Program output continues to flow through the redirected console streams. The pipe admits only the user running the session; `--fsi-server-client-pid:` names the host process whose exit ends the session. `FsiEvaluationSession` exposes both options as `JsonRpcServerPipeName` and `JsonRpcClientProcessId`. The mode is part of the .NET fsi only. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) +* `XmlDoc` keeps the range of every `///` line (`LineRanges`) and lists the `name` and `cref` attribute values it contains with their source ranges (`GetRefs`). The checker reports each ``, ``, `` and `` name that matches a parameter or type parameter of the documented declaration as a `RelatedSymbolUseKind.XmlDocParameter` use, which `GetUsesOfSymbolInFile` returns only when asked for and Find All References and semantic classification never see. ([Issue #20630](https://github.com/dotnet/fsharp/issues/20630), [Issue #15134](https://github.com/dotnet/fsharp/issues/15134), [PR #20637](https://github.com/dotnet/fsharp/pull/20637)) ### Fixed From 3e157e06e262a73ac1de36657f059921aeb98bab Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 03:45:41 +0200 Subject: [PATCH 3/7] Rename a parameter together with its XML doc param tags Rename asks for every related use, so the ``, ``, `` and `` names the checker now reports are rewritten with the parameter; a name inside a `///` line gets the bare text, without the type-parameter tick or backticks. Find All References passes `SymbolHelpers.codeSymbolUseKinds`, which leaves the doc names out, and document highlights keep showing them. Co-Authored-By: Claude Fable 5.1 --- .../InlineRename/InlineRenameService.fs | 36 ++++- .../LanguageService/SymbolHelpers.fs | 25 +++- .../Navigation/FindUsagesService.fs | 2 +- .../DocumentHighlightsServiceTests.fs | 21 +++ .../FSharp.Editor.Tests.fsproj | 2 + .../FindReferencesTests.fs | 33 +++++ .../InlineRenameXmlDocTests.fs | 125 ++++++++++++++++++ 7 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 vsintegration/tests/FSharp.Editor.Tests/InlineRenameXmlDocTests.fs diff --git a/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs b/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs index 8a5debfdc84..013dbc726c0 100644 --- a/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs +++ b/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs @@ -33,8 +33,26 @@ type internal InlineRenameLocationSet inherit FSharpInlineRenameLocationSet() + // `` and its kin hold the bare name: no type-parameter tick, no backticks + static let docAttributeText (replacementText: string) = + let text = replacementText.TrimStart('\'', '^') + + if Tokenizer.isDoubleBacktickIdent text then + text.Substring(2, text.Length - 4) + else + text + + // A name inside a `///` comment is the only use the checker reports on such a line + static let isXmlDocLine (sourceText: SourceText) (span: TextSpan) = + sourceText.Lines + .GetLineFromPosition(span.Start) + .ToString() + .AsSpan() + .TrimStart() + .StartsWith("///".AsSpan(), StringComparison.Ordinal) + static let rec applyChanges - replacementText + (replacementText: string) (solution: Solution) (locationsByDocument: (Document * FSharpInlineRenameLocation list) list) = @@ -45,9 +63,20 @@ type internal InlineRenameLocationSet | [] -> return solution | (document, locations) :: rest -> let! oldSource = document.GetTextAsync(cancellationToken) + let docText = docAttributeText replacementText let newSource = - oldSource.WithChanges(locations |> List.map (fun l -> TextChange(l.TextSpan, replacementText))) + oldSource.WithChanges( + locations + |> List.map (fun l -> + let text = + if isXmlDocLine oldSource l.TextSpan then + docText + else + replacementText + + TextChange(l.TextSpan, text)) + ) return! applyChanges replacementText (solution.WithDocumentText(document.Id, newSource)) rest } @@ -97,8 +126,9 @@ type internal InlineRenameInfo return! document.GetTextAsync(cancellationToken) } + // Rename follows a parameter into its ``/`` tags, which Find All References leaves out let symbolUses = - SymbolHelpers.getSymbolUsesInSolution (symbolUse, checkFileResults, document) ct + SymbolHelpers.getSymbolUsesInSolution (symbolUse, checkFileResults, document, RelatedSymbolUseKind.All) ct let symbolDisplayName = symbolUse.Symbol.DisplayName diff --git a/vsintegration/src/FSharp.Editor/LanguageService/SymbolHelpers.fs b/vsintegration/src/FSharp.Editor/LanguageService/SymbolHelpers.fs index 36319820f80..670cdb01a8a 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/SymbolHelpers.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/SymbolHelpers.fs @@ -102,10 +102,16 @@ module internal SymbolHelpers = TelemetryReporter.ReportSingleEvent(TelemetryEvents.GetSymbolUsesInProjectsFinished, props) } + /// The related uses that belong in a list of code references: everything but a name inside a `///` comment, + /// which only Rename and highlighting act on. + let codeSymbolUseKinds = + RelatedSymbolUseKind.All &&& ~~~RelatedSymbolUseKind.XmlDocParameter + let findSymbolUses (symbolUse: FSharpSymbolUse) (currentDocument: Document) (checkFileResults: FSharpCheckFileResults) + (relatedSymbolKinds: RelatedSymbolUseKind) (onFound: Document -> range -> CancellableTask) = cancellableTask { @@ -113,7 +119,7 @@ module internal SymbolHelpers = | Some SymbolScope.CurrentDocument -> let symbolUses = - checkFileResults.GetUsesOfSymbolInFile(symbolUse.Symbol, relatedSymbolKinds = RelatedSymbolUseKind.All) + checkFileResults.GetUsesOfSymbolInFile(symbolUse.Symbol, relatedSymbolKinds = relatedSymbolKinds) do! symbolUses @@ -135,7 +141,7 @@ module internal SymbolHelpers = let symbolUses = (checkFileResults, currentDocument) :: otherFileCheckResults |> Seq.collect (fun (checkFileResults, doc) -> - checkFileResults.GetUsesOfSymbolInFile(symbolUse.Symbol, relatedSymbolKinds = RelatedSymbolUseKind.All) + checkFileResults.GetUsesOfSymbolInFile(symbolUse.Symbol, relatedSymbolKinds = relatedSymbolKinds) |> Seq.map (fun symbolUse -> (doc, symbolUse.Range))) do! symbolUses |> Seq.map ((<||) onFound) |> CancellableTask.whenAll @@ -172,21 +178,28 @@ module internal SymbolHelpers = do! getSymbolUsesInProjects (symbolUse.Symbol, projectsToCheck, onFound) } - let getSymbolUses (symbolUse: FSharpSymbolUse) (currentDocument: Document) (checkFileResults: FSharpCheckFileResults) = + let getSymbolUses + (symbolUse: FSharpSymbolUse) + (currentDocument: Document) + (checkFileResults: FSharpCheckFileResults) + (relatedSymbolKinds: RelatedSymbolUseKind) + = cancellableTask { let symbolUses = ConcurrentBag() let onFound = fun document range -> cancellableTask { symbolUses.Add(document, range) } - do! findSymbolUses symbolUse currentDocument checkFileResults onFound + do! findSymbolUses symbolUse currentDocument checkFileResults relatedSymbolKinds onFound return symbolUses |> seq } - let getSymbolUsesInSolution (symbolUse: FSharpSymbolUse, checkFileResults: FSharpCheckFileResults, document: Document) = + let getSymbolUsesInSolution + (symbolUse: FSharpSymbolUse, checkFileResults: FSharpCheckFileResults, document: Document, relatedSymbolKinds: RelatedSymbolUseKind) + = cancellableTask { - let! symbolUses = getSymbolUses symbolUse document checkFileResults + let! symbolUses = getSymbolUses symbolUse document checkFileResults relatedSymbolKinds let symbolUsesWithDocumentId = symbolUses |> Seq.map (fun (doc, range) -> doc.Id, range) diff --git a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs index a81d2ec3fed..8e62b8db3b1 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs @@ -159,7 +159,7 @@ module FSharpFindUsagesService = symbol.Ident.idText context.OnReferenceFoundAsync - do! SymbolHelpers.findSymbolUses symbolUse document checkFileResults onFound + do! SymbolHelpers.findSymbolUses symbolUse document checkFileResults SymbolHelpers.codeSymbolUseKinds onFound } open FSharpFindUsagesService diff --git a/vsintegration/tests/FSharp.Editor.Tests/DocumentHighlightsServiceTests.fs b/vsintegration/tests/FSharp.Editor.Tests/DocumentHighlightsServiceTests.fs index 600e52c4024..f3be0f4dc20 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/DocumentHighlightsServiceTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/DocumentHighlightsServiceTests.fs @@ -82,3 +82,24 @@ module DocumentHighlightsServiceTests = let expected = [| span sourceText false (2, 28) (2, 31) |] Assert.Equal(expected, spans) + + [] + let ``Highlights the parameter name inside its param tag`` () = + let fileContents = + """ +/// The addend. +let addOne x = x + 1 +""" + + let sourceText = SourceText.From(fileContents) + let caretPosition = fileContents.IndexOf("x = x") + 1 + let spans = getSpans fileContents caretPosition |> Array.sortBy _.TextSpan.Start + + let expected = + [| + span sourceText false (2, 17) (2, 18) + span sourceText true (3, 11) (3, 12) + span sourceText false (3, 15) (3, 16) + |] + + Assert.Equal(expected, spans) diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index ecce1205b8c..5eeb5936317 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -27,6 +27,7 @@ + @@ -94,6 +95,7 @@ + diff --git a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesTests.fs index 5519fdd337b..218c3079c84 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesTests.fs @@ -244,3 +244,36 @@ module FindReferences = // Should find 1 reference (the call site) - the identifier "get" must NOT be filtered if foundReferences.Count <> 1 then failwith $"Expected 1 reference but found {foundReferences.Count}" + + /// The `` tag of a parameter is for Rename and highlighting, not a reference to list. + [] + let ``Find references does not list the param tag of a parameter`` () = + + let project = + SyntheticProject.Create( + { sourceFile "First" [] with + SignatureFile = No + ExtraSource = + "/// The input.\n" + + "let documented funcParam = funcParam * 1\n" + } + ) + + let solution, _ = RoslynTestHelpers.CreateSolution project + + let context, foundDefinitions, foundReferences = getContext () + + let documentPath = project.GetFilePath "First" + + let document = + solution.TryGetDocumentFromPath documentPath + |> ValueOption.defaultWith (fun _ -> failwith "Document not found") + + findUsagesService.FindReferencesAsync(document, getPositionOf "funcParam =" documentPath, context).Wait() + + if foundDefinitions.Count <> 1 then + failwith $"Expected 1 definition but found {foundDefinitions.Count}" + + // The use in the body only; the `` tag stays out + if foundReferences.Count <> 1 then + failwith $"Expected 1 reference but found {foundReferences.Count}" diff --git a/vsintegration/tests/FSharp.Editor.Tests/InlineRenameXmlDocTests.fs b/vsintegration/tests/FSharp.Editor.Tests/InlineRenameXmlDocTests.fs new file mode 100644 index 00000000000..d498846f53b --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/InlineRenameXmlDocTests.fs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace FSharp.Editor.Tests + +module InlineRenameXmlDocTests = + + open System + open System.Threading + open Xunit + open Microsoft.VisualStudio.FSharp.Editor + open FSharp.Editor.Tests.Helpers + + /// Renames the symbol under the caret the way Visual Studio does and returns the rewritten source. + let private rename (fileContents: string) (caretAt: string) (newName: string) = + let document = + RoslynTestHelpers.CreateSolution(fileContents) + |> RoslynTestHelpers.GetSingleDocument + + let position = fileContents.IndexOf(caretAt, StringComparison.Ordinal) + Assert.True(position >= 0, $"'{caretAt}' is not in the test source") + + let info = + InlineRenameService().GetRenameInfoAsync(document, position, CancellationToken.None).Result + + Assert.NotNull info + + let locationSet = + info.FindRenameLocationsAsync(Unchecked.defaultof<_>, Unchecked.defaultof<_>, CancellationToken.None).Result + + let replacements = + locationSet.GetReplacementsAsync(newName, CancellationToken.None).Result + + Assert.True replacements.ReplacementTextValid + + replacements.NewSolution.GetDocument(document.Id).GetTextAsync(CancellationToken.None).Result.ToString() + + [] + let ``param and paramref of a function follow the parameter`` () = + let source = + """ +/// Adds to one. +/// The addend. +let addOne x = x + 1 +""" + + let expected = + """ +/// Adds to one. +/// The addend. +let addOne count = count + 1 +""" + + Assert.Equal(expected, rename source "x = x + 1" "count") + + [] + let ``typeparam and typeparamref get the name without its tick`` () = + let source = + """ +/// Wraps a . +/// The element type. +let wrap<'T> (x: 'T) = [ x ] +""" + + let expected = + """ +/// Wraps a . +/// The element type. +let wrap<'U> (x: 'U) = [ x ] +""" + + Assert.Equal(expected, rename source "T> (x" "'U") + + [] + let ``a backticked name goes into the tag without its backticks`` () = + let source = + """ +/// The addend. +let addOne x = x + 1 +""" + + let expected = + """ +/// The addend. +let addOne ``the addend`` = ``the addend`` + 1 +""" + + Assert.Equal(expected, rename source "x = x + 1" "``the addend``") + + [] + let ``param of a member follows the parameter and leaves this alone`` () = + let source = + """ +type C() = + /// The input. + member this.M x = this.GetHashCode() + x +""" + + let expected = + """ +type C() = + /// The input. + member this.M input = this.GetHashCode() + input +""" + + Assert.Equal(expected, rename source "x = this" "input") + + [] + let ``param of a primary constructor documented on the type follows the parameter`` () = + let source = + """ +/// Holds a value. +/// The held value. +type Holder(value: int) = + member _.Value = value +""" + + let expected = + """ +/// Holds a value. +/// The held value. +type Holder(item: int) = + member _.Value = item +""" + + Assert.Equal(expected, rename source "value: int" "item") From e9a9b864eb7149775308a2dd5d9f24c4ecceb444 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 04:09:17 +0200 Subject: [PATCH 4/7] Add release note for renaming XML doc param tags Co-Authored-By: Claude Fable 5.1 --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index e6034dca8df..42c1ad64b7a 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -9,6 +9,7 @@ * Peek Definition on an F# symbol whose definition lives in metadata no longer deadlocks Visual Studio. Peek holds the main thread in `JoinableTaskFactory.Run` without pumping messages while it asks the language service for the definition, and generating the metadata document needs that same thread; Peek now stops at definitions that already have a document, and Go To Definition, which owns the wait it makes, still opens the generated one. ([PR #20503](https://github.com/dotnet/fsharp/pull/20503)) * Improve Find All References performance by throttling parallel typechecks. ([PR #20128](https://github.com/dotnet/fsharp/pull/20128)) * Fixed Rename incorrectly renaming `get` and `set` keywords for properties with explicit accessors. ([Issue #18270](https://github.com/dotnet/fsharp/issues/18270), [PR #19252](https://github.com/dotnet/fsharp/pull/19252)) +* Rename rewrites the ``, ``, `` and `` tags of the renamed parameter or type parameter in the declaration's `///` comment, and document highlights include them; Find All References leaves them out. ([Issue #20630](https://github.com/dotnet/fsharp/issues/20630), [PR #20638](https://github.com/dotnet/fsharp/pull/20638)) * Fixed Find All References crash when F# project contains non-F# files like `.cshtml`. ([Issue #16394](https://github.com/dotnet/fsharp/issues/16394), [PR #19252](https://github.com/dotnet/fsharp/pull/19252)) * Avoid using `cancellableTask` in `DocumentCache`; the editor cache now uses direct `CancellationToken`-aware `task` wrappers, avoiding the background `Task.Run` offload and a larger wrapper closure from the `cancellableTask` builder. ([Issue #20268](https://github.com/dotnet/fsharp/issues/20268)) * Cache document diagnostics by version stamp, so an unchanged document is not reanalyzed on every crawler pass. ([Issue #20120](https://github.com/dotnet/fsharp/issues/20120), [PR #20121](https://github.com/dotnet/fsharp/pull/20121)) From 2139ae025262ee710b73d44b893e7360f1bc5fc9 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 12:33:56 +0200 Subject: [PATCH 5/7] Scan XML doc attributes as tokens across lines The scanner looked for the attribute name as a substring of each line, so `` reported `wrong`, and a tag whose `name` sat on the next `///` line reported nothing. It now walks the attributes of each tag one at a time over the whole comment, keeps a `>` inside a quoted value from ending the tag, maps each value back to its line, and skips a value that runs over a line break. Co-Authored-By: Claude Opus 5.5 --- src/Compiler/SyntaxTree/XmlDoc.fs | 129 +++++++++++------- .../XmlDocTests.fs | 24 ++++ 2 files changed, 104 insertions(+), 49 deletions(-) diff --git a/src/Compiler/SyntaxTree/XmlDoc.fs b/src/Compiler/SyntaxTree/XmlDoc.fs index e37cd0851a3..8d7c2d2a5d2 100644 --- a/src/Compiler/SyntaxTree/XmlDoc.fs +++ b/src/Compiler/SyntaxTree/XmlDoc.fs @@ -59,64 +59,95 @@ module private XmlDocRefScanner = String.CompareOrdinal(text, i, tag, 0, tag.Length) = 0 && isNameEnd text (i + tag.Length)) - /// The value of `attribute` between `start` and the closing `>` of the tag, as an offset and a length in `text` - let private attributeValue (text: string) (attribute: string) (start: int) = - let close = - match text.IndexOf('>', start) with - | -1 -> text.Length - | i -> i + let private skipWhiteSpace (text: string) i = + let mutable i = i - let rec find i = - match text.IndexOf(attribute, i, StringComparison.Ordinal) with - | -1 -> ValueNone - | at when at >= close -> ValueNone - | at -> - let mutable j = at + attribute.Length + while i < text.Length && Char.IsWhiteSpace text[i] do + i <- i + 1 - while j < close && Char.IsWhiteSpace text[j] do - j <- j + 1 + i - if j < close && text[j] = '=' then - j <- j + 1 + let private attributeNameEnd (text: string) i = + let mutable i = i - while j < close && Char.IsWhiteSpace text[j] do - j <- j + 1 + while not (isNameEnd text i) && text[i] <> '=' do + i <- i + 1 - if j < close && (text[j] = '"' || text[j] = '\'') then - match text.IndexOf(text[j], j + 1) with - | -1 -> ValueNone - | quoteEnd when quoteEnd > close -> ValueNone - | quoteEnd -> ValueSome struct (j + 1, quoteEnd - j - 1) - else - find (at + 1) - else - find (at + 1) + i - find start + /// The quoted value of `attribute` in the tag whose attributes start at `start`, as an offset and a length in `text`. + /// Walks the attributes one at a time, so an attribute whose name ends in `attribute` and a `>` inside a quoted value + /// are not mistaken for it. A value that runs over a line break has no single-line range and is skipped. + let private attributeValue (text: string) (attribute: string) (start: int) = + let rec next i = + let nameStart = skipWhiteSpace text i + + if nameStart >= text.Length || text[nameStart] = '>' || text[nameStart] = '/' then + ValueNone + else + let nameEnd = attributeNameEnd text nameStart + let equals = skipWhiteSpace text nameEnd + if equals >= text.Length || text[equals] <> '=' then + ValueNone + else + let quote = skipWhiteSpace text (equals + 1) + + if quote >= text.Length || (text[quote] <> '"' && text[quote] <> '\'') then + ValueNone + else + match text.IndexOf(text[quote], quote + 1) with + | -1 -> ValueNone + | quoteEnd -> + let valueStart = quote + 1 + let valueLength = quoteEnd - valueStart + + if + nameEnd - nameStart = attribute.Length + && String.CompareOrdinal(text, nameStart, attribute, 0, attribute.Length) = 0 + then + if text.IndexOf('\n', valueStart, valueLength) >= 0 then + ValueNone + else + ValueSome struct (valueStart, valueLength) + else + next (quoteEnd + 1) + + next start + + /// Scans the lines as one text, since a tag and its attributes may run over several `///` lines let scan (lines: string[]) (lineRanges: range[]) = + let text = String.Join("\n", lines) + let lineStarts = Array.zeroCreate lines.Length + + for i in 1 .. lines.Length - 1 do + lineStarts[i] <- lineStarts[i - 1] + lines[i - 1].Length + 1 + [| - for i in 0 .. lines.Length - 1 do - let text = lines[i] - let m = lineRanges[i] - let mutable lt = text.IndexOf '<' - - while lt >= 0 do - match tagAt text (lt + 1) with - | Some(tag, kind, attribute) -> - match attributeValue text attribute (lt + 1 + tag.Length) with - | ValueSome struct (offset, length) -> - let column = m.StartColumn + lineTextOffset + offset - - { - Kind = kind - Text = text.Substring(offset, length) - Range = mkFileIndexRange m.FileIndex (mkPos m.StartLine column) (mkPos m.StartLine (column + length)) - } - | ValueNone -> () - | None -> () - - lt <- text.IndexOf('<', lt + 1) + let mutable lt = text.IndexOf '<' + + while lt >= 0 do + match tagAt text (lt + 1) with + | Some(tag, kind, attribute) -> + match attributeValue text attribute (lt + 1 + tag.Length) with + | ValueSome struct (offset, length) -> + let line = + match Array.BinarySearch(lineStarts, offset) with + | i when i >= 0 -> i + | i -> ~~~i - 1 + + let m = lineRanges[line] + let column = m.StartColumn + lineTextOffset + offset - lineStarts[line] + + { + Kind = kind + Text = text.Substring(offset, length) + Range = mkFileIndexRange m.FileIndex (mkPos m.StartLine column) (mkPos m.StartLine (column + length)) + } + | ValueNone -> () + | None -> () + + lt <- text.IndexOf('<', lt + 1) |] /// Represents collected XmlDoc lines diff --git a/tests/FSharp.Compiler.Service.Tests/XmlDocTests.fs b/tests/FSharp.Compiler.Service.Tests/XmlDocTests.fs index 1205e8c1f6e..5955beceddc 100644 --- a/tests/FSharp.Compiler.Service.Tests/XmlDocTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/XmlDocTests.fs @@ -257,6 +257,30 @@ module XmlDocRefs = XmlDocRefKind.ParamRef, "z", (12, 23, 24) XmlDocRefKind.ParamRef, "w", (12, 44, 45) ] + [] + let ``only a whole attribute name matches, and a quoted > does not end the tag`` () = + refsOf [| """suffix of another attribute""" + """angle bracket in a value""" |] + |> shouldEqual + [ XmlDocRefKind.Param, "x", (10, 36, 37) + XmlDocRefKind.Param, "y", (11, 30, 31) ] + + [] + let ``a tag whose attributes run over several lines`` () = + refsOf [| """on the next line""" + """after another attribute""" |] + |> shouldEqual + [ XmlDocRefKind.Param, "x", (11, 16, 17) + XmlDocRefKind.TypeParam, "T", (13, 24, 25) ] + + [] + let ``a value that runs over a line break yields nothing`` () = + refsOf [| """split value""" |] + |> shouldEqual [] + [] let ``blank lines, malformed xml and unrelated tags yield nothing`` () = refsOf [| "" From a605e28a8ce405c5fb290278f604b0d1561ad008 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 12:34:07 +0200 Subject: [PATCH 6/7] Rename the doc tag of a symbol found project-wide A public union case field is found through the project-wide index, which leaves out names inside `///` comments, so renaming it left its `` tag behind. When the caller asks for XML doc uses, they are now read from the check results of the files that declare the symbol, next to the index results. Co-Authored-By: Claude Opus 5.5 --- .../LanguageService/SymbolHelpers.fs | 33 +++++++++++++++++++ .../InlineRenameXmlDocTests.fs | 28 ++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/SymbolHelpers.fs b/vsintegration/src/FSharp.Editor/LanguageService/SymbolHelpers.fs index 670cdb01a8a..9a58d73f4f1 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/SymbolHelpers.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/SymbolHelpers.fs @@ -107,6 +107,36 @@ module internal SymbolHelpers = let codeSymbolUseKinds = RelatedSymbolUseKind.All &&& ~~~RelatedSymbolUseKind.XmlDocParameter + /// The project-wide index behind `getSymbolUsesInProjects` leaves out names inside `///` comments, and only + /// a declaration carries them, so they are read from the check results of the files that declare the symbol. + let private findXmlDocUsesInDeclaringFiles + (symbol: FSharpSymbol) + (currentDocument: Document) + (onFound: Document -> range -> CancellableTask) + = + cancellableTask { + let declaringFiles = + [ symbol.DeclarationLocation; symbol.SignatureLocation ] + |> List.choose id + |> List.map _.FileName + |> List.distinct + + for file in declaringFiles do + match currentDocument.Project.Solution.TryGetDocumentFromPath file with + | ValueSome doc -> + let! _, checkFileResults = doc.GetFSharpParseAndCheckResultsAsync("findXmlDocUsesInDeclaringFiles") + let codeUses = checkFileResults.GetUsesOfSymbolInFile(symbol) + + for docUse in checkFileResults.GetUsesOfSymbolInFile(symbol, relatedSymbolKinds = RelatedSymbolUseKind.XmlDocParameter) do + let isCodeUse = + codeUses + |> Array.exists (fun codeUse -> Range.equals codeUse.Range docUse.Range) + + if not isCodeUse then + do! onFound doc docUse.Range + | ValueNone -> () + } + let findSymbolUses (symbolUse: FSharpSymbolUse) (currentDocument: Document) @@ -176,6 +206,9 @@ module internal SymbolHelpers = | None -> Seq.toList currentDocument.Project.Solution.Projects do! getSymbolUsesInProjects (symbolUse.Symbol, projectsToCheck, onFound) + + if relatedSymbolKinds.HasFlag RelatedSymbolUseKind.XmlDocParameter then + do! findXmlDocUsesInDeclaringFiles symbolUse.Symbol currentDocument onFound } let getSymbolUses diff --git a/vsintegration/tests/FSharp.Editor.Tests/InlineRenameXmlDocTests.fs b/vsintegration/tests/FSharp.Editor.Tests/InlineRenameXmlDocTests.fs index d498846f53b..2d71b0e4f8c 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/InlineRenameXmlDocTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/InlineRenameXmlDocTests.fs @@ -104,6 +104,34 @@ type C() = Assert.Equal(expected, rename source "x = this" "input") + [] + let ``param of a public union case field follows the field`` () = + let source = + """ +module M + +type Shape = + /// A circle. + /// The radius. + | Circle of radius: float + +let unitCircle = Circle(radius = 1.0) +""" + + let expected = + """ +module M + +type Shape = + /// A circle. + /// The radius. + | Circle of size: float + +let unitCircle = Circle(size = 1.0) +""" + + Assert.Equal(expected, rename source "radius: float" "size") + [] let ``param of a primary constructor documented on the type follows the parameter`` () = let source = From dd78714ed256c56220275ae357c82ec3a9bf94b3 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 15:50:52 +0200 Subject: [PATCH 7/7] Check XML doc lines and tag text without copying Recognizing a `///` line copied the whole line into a string for every rename location; it now reads the characters from the source text. The tag text no longer goes through an intermediate `TrimStart` copy. Co-Authored-By: Claude Opus 5.5 --- .../InlineRename/InlineRenameService.fs | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs b/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs index 013dbc726c0..90476875a75 100644 --- a/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs +++ b/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs @@ -35,21 +35,28 @@ type internal InlineRenameLocationSet // `` and its kin hold the bare name: no type-parameter tick, no backticks static let docAttributeText (replacementText: string) = - let text = replacementText.TrimStart('\'', '^') - - if Tokenizer.isDoubleBacktickIdent text then - text.Substring(2, text.Length - 4) + if + replacementText.StartsWith("'", StringComparison.Ordinal) + || replacementText.StartsWith("^", StringComparison.Ordinal) + then + replacementText.Substring(1) + elif Tokenizer.isDoubleBacktickIdent replacementText then + replacementText.Substring(2, replacementText.Length - 4) else - text + replacementText // A name inside a `///` comment is the only use the checker reports on such a line static let isXmlDocLine (sourceText: SourceText) (span: TextSpan) = - sourceText.Lines - .GetLineFromPosition(span.Start) - .ToString() - .AsSpan() - .TrimStart() - .StartsWith("///".AsSpan(), StringComparison.Ordinal) + let line = sourceText.Lines.GetLineFromPosition(span.Start) + let mutable i = line.Start + + while i < line.End && Char.IsWhiteSpace sourceText[i] do + i <- i + 1 + + i + 3 <= line.End + && sourceText[i] = '/' + && sourceText[i + 1] = '/' + && sourceText[i + 2] = '/' static let rec applyChanges (replacementText: string)