diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/GeneratePartCodeRefactoringProvider.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/GeneratePartCodeRefactoringProvider.cs new file mode 100644 index 000000000..ea06ab0ab --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/GeneratePartCodeRefactoringProvider.cs @@ -0,0 +1,76 @@ +// Copyright (c) Tunnel Vision Laboratories, LLC. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace StyleCop.Analyzers.PrivateCodeFixes; + +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Analyzer.Utilities; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeRefactorings; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +[ExportCodeRefactoringProvider(LanguageNames.CSharp, Name = nameof(GeneratePartCodeRefactoringProvider))] +internal sealed class GeneratePartCodeRefactoringProvider + : CodeRefactoringProvider +{ + public override async Task ComputeRefactoringsAsync(CodeRefactoringContext context) + { + var partialType = await context.TryGetRelevantNodeAsync(CSharpRefactoringHelpers.Instance).ConfigureAwait(false); + if (partialType is not { Modifiers: var modifiers } + || !modifiers.Any(SyntaxKind.PartialKeyword)) + { + return; + } + + context.RegisterRefactoring(CodeAction.Create( + "Generate additional part", + async cancellationToken => + { + var namespaceDeclaration = partialType.FirstAncestorOrSelf(); + if (namespaceDeclaration is null) + { + return context.Document.Project.Solution; + } + + var firstUsing = namespaceDeclaration.Usings.FirstOrDefault()?.Name.ToString(); + + var namespaceName = namespaceDeclaration.Name.ToString(); + var subNamespace = namespaceName; + var rootNamespace = context.Document.Project.DefaultNamespace; + if (!string.IsNullOrEmpty(rootNamespace) && namespaceName.StartsWith(rootNamespace + ".")) + { + subNamespace = namespaceName[(rootNamespace.Length + 1)..]; + } + + var content = $@"// Copyright (c) Tunnel Vision Laboratories, LLC. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace {namespaceName}; + +using {firstUsing}; + +public partial class {partialType.Identifier.ValueText} {partialType.BaseList} +{{ +}} +"; + + var fileName = partialType.Identifier.ValueText + ".cs"; + var directory = Path.GetDirectoryName(context.Document.Project.FilePath)!; + var existingText = await context.Document.GetTextAsync(cancellationToken).ConfigureAwait(false); + + var addedDocument = context.Document.Project.AddDocument( + fileName, + SourceText.From(content, new UTF8Encoding(true), existingText.ChecksumAlgorithm), + folders: subNamespace.Split('.'), + filePath: Path.Combine(directory, Path.Combine(subNamespace.Split('.')), fileName)); + + return addedDocument.Project.Solution; + }, + nameof(GeneratePartCodeRefactoringProvider))); + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/AbstractRefactoringHelpers`3.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/AbstractRefactoringHelpers`3.cs new file mode 100644 index 000000000..ec4c5d028 --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/AbstractRefactoringHelpers`3.cs @@ -0,0 +1,526 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Refactoring/AbstractRefactoringHelpers%603.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +#nullable disable warnings + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Analyzer.Utilities.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Analyzer.Utilities +{ + internal abstract class AbstractRefactoringHelpers + : IRefactoringHelpers + where TExpressionSyntax : SyntaxNode + where TArgumentSyntax : SyntaxNode + where TExpressionStatementSyntax : SyntaxNode + { + protected abstract ISyntaxFacts SyntaxFacts { get; } + + public async Task> GetRelevantNodesAsync( + Document document, + TextSpan selection, + CancellationToken cancellationToken) + where TSyntaxNode : SyntaxNode + { + // Given selection is trimmed first to enable over-selection that spans multiple lines. Since trailing whitespace ends + // at newline boundary over-selection to e.g. a line after LocalFunctionStatement would cause FindNode to find enclosing + // block's Node. That is because in addition to LocalFunctionStatement the selection would also contain trailing trivia + // (whitespace) of following statement. + + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root == null) + { + return ImmutableArray.Empty; + } + + var syntaxFacts = SyntaxFacts; + var selectionTrimmed = await CodeRefactoringHelpers.GetTrimmedTextSpanAsync(document, selection, cancellationToken).ConfigureAwait(false); + + // If user selected only whitespace we don't want to return anything. We could do following: + // 1) Consider token that owns (as its trivia) the whitespace. + // 2) Consider start/beginning of whitespace as location (empty selection) + // Option 1) can't be used all the time and 2) can be confusing for users. Therefore bailing out is the + // most consistent option. + if (selectionTrimmed.IsEmpty && !selection.IsEmpty) + { + return ImmutableArray.Empty; + } + + var relevantNodesBuilder = ImmutableArray.CreateBuilder(); + + // Every time a Node is considered an extractNodes method is called to add all nodes around the original one + // that should also be considered. + // + // That enables us to e.g. return node `b` when Node `var a = b;` is being considered without a complex (and potentially + // lang. & situation dependent) into Children descending code here. We can't just try extracted Node because we might + // want the whole node `var a = b;` + + // Handle selections: + // - Most/the whole wanted Node is selected (e.g. `C [|Fun() {}|]` + // - The smallest node whose FullSpan includes the whole (trimmed) selection + // - Using FullSpan is important because it handles over-selection with comments + // - Travels upwards through same-sized (FullSpan) nodes, extracting + // - Token with wanted Node as direct parent is selected (e.g. IdentifierToken for LocalFunctionStatement: `C [|Fun|]() {}`) + // Note: Whether we have selection or location has to be checked against original selection because selecting just + // whitespace could collapse selectionTrimmed into and empty Location. But we don't want `[| |]token` + // registering as ` [||]token`. + if (!selectionTrimmed.IsEmpty) + { + AddRelevantNodesForSelection(syntaxFacts, root, selectionTrimmed, relevantNodesBuilder, cancellationToken); + } + else + { + // No more selection -> Handle what current selection is touching: + // + // Consider touching only for empty selections. Otherwise `[|C|] methodName(){}` would be considered as + // touching the Method's Node (through the left edge, see below) which is something the user probably + // didn't want since they specifically selected only the return type. + // + // What the selection is touching is used in two ways. + // - Firstly, it is used to handle situation where it touches a Token whose direct ancestor is wanted Node. + // While having the (even empty) selection inside such token or to left of such Token is already handle + // by code above touching it from right `C methodName[||](){}` isn't (the FindNode for that returns Args node). + // - Secondly, it is used for left/right edge climbing. E.g. `[||]C methodName(){}` the touching token's direct + // ancestor is TypeNode for the return type but it is still reasonable to expect that the user might want to + // be given refactorings for the whole method (as he has caret on the edge of it). Therefore we travel the + // Node tree upwards and as long as we're on the left edge of a Node's span we consider such node & potentially + // continue traveling upwards. The situation for right edge (`C methodName(){}[||]`) is analogical. + // E.g. for right edge `C methodName(){}[||]`: CloseBraceToken -> BlockSyntax -> LocalFunctionStatement -> null (higher + // node doesn't end on position anymore) + // Note: left-edge climbing needs to handle AttributeLists explicitly, see below for more information. + // - Thirdly, if location isn't touching anything, we move the location to the token in whose trivia location is in. + // more about that below. + // - Fourthly, if we're in an expression / argument we consider touching a parent expression whenever we're within it + // as long as it is on the first line of such expression (arbitrary heuristic). + + // First we need to get tokens we might potentially be touching, tokenToRightOrIn and tokenToLeft. + var (tokenToRightOrIn, tokenToLeft, location) = await GetTokensToRightOrInToLeftAndUpdatedLocationAsync( + document, root, selectionTrimmed, cancellationToken).ConfigureAwait(false); + + // In addition to per-node extr also check if current location (if selection is empty) is in a header of higher level + // desired node once. We do that only for locations because otherwise `[|int|] A { get; set; }) would trigger all refactorings for + // Property Decl. + // We cannot check this any sooner because the above code could've changed current location. + AddNonHiddenCorrectTypeNodes(ExtractNodesInHeader(root, location, syntaxFacts), relevantNodesBuilder, cancellationToken); + + // Add Nodes for touching tokens as described above. + AddNodesForTokenToRightOrIn(syntaxFacts, root, relevantNodesBuilder, location, tokenToRightOrIn, cancellationToken); + AddNodesForTokenToLeft(syntaxFacts, relevantNodesBuilder, location, tokenToLeft, cancellationToken); + + // If the wanted node is an expression syntax -> traverse upwards even if location is deep within a SyntaxNode. + // We want to treat more types like expressions, e.g.: ArgumentSyntax should still trigger even if deep-in. + if (IsWantedTypeExpressionLike()) + { + // Reason to treat Arguments (and potentially others) as Expression-like: + // https://github.com/dotnet/roslyn/pull/37295#issuecomment-516145904 + await AddNodesDeepInAsync(document, location, relevantNodesBuilder, cancellationToken).ConfigureAwait(false); + } + } + + return relevantNodesBuilder.ToImmutable(); + } + + private static bool IsWantedTypeExpressionLike() where TSyntaxNode : SyntaxNode + { + var wantedType = typeof(TSyntaxNode); + + var expressionType = typeof(TExpressionSyntax); + var argumentType = typeof(TArgumentSyntax); + var expressionStatementType = typeof(TExpressionStatementSyntax); + + return IsAEqualOrSubclassOfB(wantedType, expressionType) || + IsAEqualOrSubclassOfB(wantedType, argumentType) || + IsAEqualOrSubclassOfB(wantedType, expressionStatementType); + + static bool IsAEqualOrSubclassOfB(Type a, Type b) + { + return a.GetTypeInfo().IsSubclassOf(b) || a == b; + } + } + + private static async Task<(SyntaxToken tokenToRightOrIn, SyntaxToken tokenToLeft, int location)> GetTokensToRightOrInToLeftAndUpdatedLocationAsync( + Document document, + SyntaxNode root, + TextSpan selectionTrimmed, + CancellationToken cancellationToken) + { + // get Token for current location + var location = selectionTrimmed.Start; + var tokenOnLocation = root.FindToken(location); + + // Gets a token that is directly to the right of current location or that encompasses current location (`[||]tokenToRightOrIn` or `tok[||]enToRightOrIn`) + var tokenToRightOrIn = tokenOnLocation.Span.Contains(location) + ? tokenOnLocation + : default; + + // A token can be to the left only when there's either no tokenDirectlyToRightOrIn or there's one directly starting at current location. + // Otherwise (otherwise tokenToRightOrIn is also left from location, e.g: `tok[||]enToRightOrIn`) + var tokenToLeft = default(SyntaxToken); + if (tokenToRightOrIn == default || tokenToRightOrIn.FullSpan.Start == location) + { + var tokenPreLocation = (tokenOnLocation.Span.End == location) + ? tokenOnLocation + : tokenOnLocation.GetPreviousToken(includeZeroWidth: true); + + tokenToLeft = (tokenPreLocation.Span.End == location) + ? tokenPreLocation + : default; + } + + // If both tokens directly to left & right are empty -> we're somewhere in the middle of whitespace. + // Since there wouldn't be (m)any other refactorings we can try to offer at least the ones for (semantically) + // closest token/Node. Thus, we move the location to the token in whose `.FullSpan` the original location was. + if (tokenToLeft == default && tokenToRightOrIn == default) + { + var sourceText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false); + + if (IsAcceptableLineDistanceAway(sourceText, tokenOnLocation, location)) + { + // tokenOnLocation: token in whose trivia location is at + if (tokenOnLocation.Span.Start >= location) + { + tokenToRightOrIn = tokenOnLocation; + location = tokenToRightOrIn.Span.Start; + } + else + { + tokenToLeft = tokenOnLocation; + location = tokenToLeft.Span.End; + } + } + } + + return (tokenToRightOrIn, tokenToLeft, location); + + static bool IsAcceptableLineDistanceAway( + SourceText sourceText, SyntaxToken tokenOnLocation, int location) + { + // assume non-trivia token can't span multiple lines + var tokenLine = sourceText.Lines.GetLineFromPosition(tokenOnLocation.Span.Start); + var locationLine = sourceText.Lines.GetLineFromPosition(location); + + // Change location to nearest token only if the token is off by one line or less + var lineDistance = tokenLine.LineNumber - locationLine.LineNumber; + if (lineDistance is not 0 and not 1) + return false; + + // Note: being a line below a tokenOnLocation is impossible in current model as whitespace + // trailing trivia ends on new line. Which is fine because if you're a line _after_ some node + // you usually don't want refactorings for what's above you. + + if (lineDistance == 1) + { + // position is one line above the node of interest. This is fine if that + // line is blank. Otherwise, if it isn't (i.e. it contains comments, + // directives, or other trivia), then it's not likely the user is selecting + // this entry. + return locationLine.IsEmptyOrWhitespace(); + } + + // On hte same line. This position is acceptable. + return true; + } + } + + private void AddNodesForTokenToLeft(ISyntaxFacts syntaxFacts, ImmutableArray.Builder relevantNodesBuilder, int location, SyntaxToken tokenToLeft, CancellationToken cancellationToken) where TSyntaxNode : SyntaxNode + { + // there could be multiple (n) tokens to the left if first n-1 are Empty -> iterate over all of them + while (tokenToLeft != default) + { + var leftNode = tokenToLeft.Parent!; + do + { + // Consider either a Node that is: + // - Ancestor Node of such Token as long as their span ends on location (it's still on the edge) + AddNonHiddenCorrectTypeNodes(ExtractNodesSimple(leftNode, syntaxFacts), relevantNodesBuilder, cancellationToken); + + leftNode = leftNode.Parent; + if (leftNode == null || !(leftNode.GetLastToken().Span.End == location || leftNode.Span.End == location)) + { + break; + } + } + while (true); + + // as long as current tokenToLeft is empty -> its previous token is also tokenToLeft + tokenToLeft = tokenToLeft.Span.IsEmpty + ? tokenToLeft.GetPreviousToken(includeZeroWidth: true) + : default; + } + } + + private void AddNodesForTokenToRightOrIn(ISyntaxFacts syntaxFacts, SyntaxNode root, ImmutableArray.Builder relevantNodesBuilder, int location, SyntaxToken tokenToRightOrIn, CancellationToken cancellationToken) where TSyntaxNode : SyntaxNode + { + if (tokenToRightOrIn != default) + { + var rightNode = tokenToRightOrIn.Parent!; + do + { + // Consider either a Node that is: + // - Parent of touched Token (location can be within) + // - Ancestor Node of such Token as long as their span starts on location (it's still on the edge) + AddNonHiddenCorrectTypeNodes(ExtractNodesSimple(rightNode, syntaxFacts), relevantNodesBuilder, cancellationToken); + + rightNode = rightNode.Parent; + if (rightNode == null) + { + break; + } + + // The edge climbing for node to the right needs to handle Attributes e.g.: + // [Test1] + // //Comment1 + // [||]object Property1 { get; set; } + // In essence: + // - On the left edge of the node (-> left edge of first AttributeLists) + // - On the left edge of the node sans AttributeLists (& as everywhere comments) + if (rightNode.Span.Start != location) + { + var rightNodeSpanWithoutAttributes = syntaxFacts.GetSpanWithoutAttributes(root, rightNode); + if (rightNodeSpanWithoutAttributes.Start != location) + { + break; + } + } + } + while (true); + } + } + + private void AddRelevantNodesForSelection(ISyntaxFacts syntaxFacts, SyntaxNode root, TextSpan selectionTrimmed, ImmutableArray.Builder relevantNodesBuilder, CancellationToken cancellationToken) + where TSyntaxNode : SyntaxNode + { + var selectionNode = root.FindNode(selectionTrimmed, getInnermostNodeForTie: true); + var prevNode = selectionNode; + do + { + var nonHiddenExtractedSelectedNodes = ExtractNodesSimple(selectionNode, syntaxFacts).OfType().Where(n => !n.OverlapsHiddenPosition(cancellationToken)); + foreach (var nonHiddenExtractedNode in nonHiddenExtractedSelectedNodes) + { + // For selections we need to handle an edge case where only AttributeLists are within selection (e.g. `Func([|[in][out]|] arg1);`). + // In that case the smallest encompassing node is still the whole argument node but it's hard to justify showing refactorings for it + // if user selected only its attributes. + + // Selection contains only AttributeLists -> don't consider current Node + var spanWithoutAttributes = syntaxFacts.GetSpanWithoutAttributes(root, nonHiddenExtractedNode); + if (!selectionTrimmed.IntersectsWith(spanWithoutAttributes)) + { + break; + } + + relevantNodesBuilder.Add(nonHiddenExtractedNode); + } + + prevNode = selectionNode; + selectionNode = selectionNode.Parent; + } + while (selectionNode != null && prevNode.FullWidth() == selectionNode.FullWidth()); + } + + /// + /// Extractor function that retrieves all nodes that should be considered for extraction of given current node. + /// + /// The rationale is that when user selects e.g. entire local declaration statement [|var a = b;|] it is reasonable + /// to provide refactoring for `b` node. Similarly for other types of refactorings. + /// + /// + /// + /// Should also return given node. + /// + protected virtual IEnumerable ExtractNodesSimple(SyntaxNode? node, ISyntaxFacts syntaxFacts) + { + if (node == null) + { + yield break; + } + + // First return the node itself so that it is considered + yield return node; + + // REMARKS: + // The set of currently attempted extractions is in no way exhaustive and covers only cases + // that were found to be relevant for refactorings that were moved to `TryGetSelectedNodeAsync`. + // Feel free to extend it / refine current heuristics. + + // `var a = b;` | `var a = b`; + if (syntaxFacts.IsLocalDeclarationStatement(node) || syntaxFacts.IsLocalDeclarationStatement(node.Parent)) + { + var localDeclarationStatement = syntaxFacts.IsLocalDeclarationStatement(node) ? node : node.Parent; + + // Check if there's only one variable being declared, otherwise following transformation + // would go through which isn't reasonable since we can't say the first one specifically + // is wanted. + // `var a = 1, `c = 2, d = 3`; + // -> `var a = 1`, c = 2, d = 3; + var variables = syntaxFacts.GetVariablesOfLocalDeclarationStatement(localDeclarationStatement); + if (variables.Count == 1) + { + var declaredVariable = variables.First(); + + // -> `a = b` + yield return declaredVariable; + + // -> `b` + var initializer = syntaxFacts.GetInitializerOfVariableDeclarator(declaredVariable); + if (initializer != null) + { + var value = syntaxFacts.GetValueOfEqualsValueClause(initializer); + if (value != null) + { + yield return value; + } + } + } + } + + // var `a = b`; + if (syntaxFacts.IsVariableDeclarator(node)) + { + // -> `b` + var initializer = syntaxFacts.GetInitializerOfVariableDeclarator(node); + if (initializer != null) + { + var value = syntaxFacts.GetValueOfEqualsValueClause(initializer); + if (value != null) + { + yield return value; + } + } + } + + // `a = b;` + // -> `b` + if (syntaxFacts.IsSimpleAssignmentStatement(node)) + { + syntaxFacts.GetPartsOfAssignmentExpressionOrStatement(node, out _, out _, out var rightSide); + yield return rightSide; + } + + // `a();` + // -> a() + if (syntaxFacts.IsExpressionStatement(node)) + { + yield return syntaxFacts.GetExpressionOfExpressionStatement(node); + } + + // `a()`; + // -> `a();` + if (syntaxFacts.IsExpressionStatement(node.Parent)) + { + yield return node.Parent; + } + } + + /// + /// Extractor function that checks and retrieves all nodes current location is in a header. + /// + protected virtual IEnumerable ExtractNodesInHeader(SyntaxNode root, int location, ISyntaxFacts syntaxFacts) + { + // Header: [Test] `public int a` { get; set; } + if (syntaxFacts.IsOnPropertyDeclarationHeader(root, location, out var propertyDeclaration)) + { + yield return propertyDeclaration; + } + + // Header: public C([Test]`int a = 42`) {} + if (syntaxFacts.IsOnParameterHeader(root, location, out var parameter)) + { + yield return parameter; + } + + // Header: `public I.C([Test]int a = 42)` {} + if (syntaxFacts.IsOnMethodHeader(root, location, out var method)) + { + yield return method; + } + + // Header: `static C([Test]int a = 42)` {} + if (syntaxFacts.IsOnLocalFunctionHeader(root, location, out var localFunction)) + { + yield return localFunction; + } + + // Header: `var a = `3,` b = `5,` c = `7 + 3``; + if (syntaxFacts.IsOnLocalDeclarationHeader(root, location, out var localDeclaration)) + { + yield return localDeclaration; + } + + // Header: `if(...)`{ }; + if (syntaxFacts.IsOnIfStatementHeader(root, location, out var ifStatement)) + { + yield return ifStatement; + } + + // Header: `foreach (var a in b)` { } + if (syntaxFacts.IsOnForeachHeader(root, location, out var foreachStatement)) + { + yield return foreachStatement; + } + + if (syntaxFacts.IsOnTypeHeader(root, location, out var typeDeclaration)) + { + yield return typeDeclaration; + } + } + + protected virtual async Task AddNodesDeepInAsync( + Document document, int position, + ImmutableArray.Builder relevantNodesBuilder, + CancellationToken cancellationToken) where TSyntaxNode : SyntaxNode + { + // If we're deep inside we don't have to deal with being on edges (that gets dealt by TryGetSelectedNodeAsync) + // -> can simply FindToken -> proceed testing its ancestors + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false) + ?? throw new NotSupportedException("Document does not support syntax trees"); + var token = root.FindTokenOnRightOfPosition(position, true); + + // traverse upwards and add all parents if of correct type + var ancestor = token.Parent; + while (ancestor != null) + { + if (ancestor is TSyntaxNode correctTypeNode) + { + var sourceText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false); + + var argumentStartLine = sourceText.Lines.GetLineFromPosition(correctTypeNode.Span.Start).LineNumber; + var caretLine = sourceText.Lines.GetLineFromPosition(position).LineNumber; + + if (argumentStartLine == caretLine && !correctTypeNode.OverlapsHiddenPosition(cancellationToken)) + { + relevantNodesBuilder.Add(correctTypeNode); + } + else if (argumentStartLine < caretLine) + { + // higher level nodes will have Span starting at least on the same line -> can bail out + return; + } + } + + ancestor = ancestor.Parent; + } + } + + private static void AddNonHiddenCorrectTypeNodes(IEnumerable nodes, ImmutableArray.Builder resultBuilder, CancellationToken cancellationToken) + where TSyntaxNode : SyntaxNode + { + var correctTypeNonHiddenNodes = nodes.OfType().Where(n => !n.OverlapsHiddenPosition(cancellationToken)); + foreach (var nodeToBeAdded in correctTypeNonHiddenNodes) + { + resultBuilder.Add(nodeToBeAdded); + } + } + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/AbstractSyntaxFacts.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/AbstractSyntaxFacts.cs new file mode 100644 index 000000000..f423806c5 --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/AbstractSyntaxFacts.cs @@ -0,0 +1,95 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Refactoring/AbstractSyntaxFacts.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using System; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using Analyzer.Utilities.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Analyzer.Utilities +{ + internal abstract class AbstractSyntaxFacts + { + public abstract ISyntaxKinds SyntaxKinds { get; } + + public bool IsOnHeader(SyntaxNode root, int position, SyntaxNode ownerOfHeader, SyntaxNodeOrToken lastTokenOrNodeOfHeader) + => IsOnHeader(root, position, ownerOfHeader, lastTokenOrNodeOfHeader, ImmutableArray.Empty); + + public bool IsOnHeader( + SyntaxNode root, + int position, + SyntaxNode ownerOfHeader, + SyntaxNodeOrToken lastTokenOrNodeOfHeader, + ImmutableArray holes) + where THoleSyntax : SyntaxNode + { + Debug.Assert(ownerOfHeader.FullSpan.Contains(lastTokenOrNodeOfHeader.Span)); + + var headerSpan = TextSpan.FromBounds( + start: GetStartOfNodeExcludingAttributes(root, ownerOfHeader), + end: lastTokenOrNodeOfHeader.FullSpan.End); + + // Is in header check is inclusive, being on the end edge of an header still counts + if (!headerSpan.IntersectsWith(position)) + { + return false; + } + + // Holes are exclusive: + // To be consistent with other 'being on the edge' of Tokens/Nodes a position is + // in a hole (not in a header) only if it's inside _inside_ a hole, not only on the edge. + if (holes.Any(h => h.Span.Contains(position) && position > h.Span.Start)) + { + return false; + } + + return true; + } + + /// + /// Tries to get an ancestor of a Token on current position or of Token directly to left: + /// e.g.: tokenWithWantedAncestor[||]tokenWithoutWantedAncestor + /// + protected TNode? TryGetAncestorForLocation(SyntaxNode root, int position) + where TNode : SyntaxNode + { + var tokenToRightOrIn = root.FindToken(position); + var nodeToRightOrIn = tokenToRightOrIn.GetAncestor(); + if (nodeToRightOrIn != null) + { + return nodeToRightOrIn; + } + + // not at the beginning of a Token -> no (different) token to the left + if (tokenToRightOrIn.FullSpan.Start != position && tokenToRightOrIn.RawKind != SyntaxKinds.EndOfFileToken) + { + return null; + } + + return tokenToRightOrIn.GetPreviousToken().GetAncestor(); + } + + protected int GetStartOfNodeExcludingAttributes(SyntaxNode root, SyntaxNode node) + { + var attributeList = GetAttributeLists(node); + if (attributeList.Any()) + { + var endOfAttributeLists = attributeList.Last().Span.End; + var afterAttributesToken = root.FindTokenOnRightOfPosition(endOfAttributeLists); + + return Math.Min(afterAttributesToken.Span.Start, node.Span.End); + } + + return node.SpanStart; + } + + public abstract SyntaxList GetAttributeLists(SyntaxNode node); + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/CSharpRefactoringHelpers.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/CSharpRefactoringHelpers.cs new file mode 100644 index 000000000..93fcdff3d --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/CSharpRefactoringHelpers.cs @@ -0,0 +1,62 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Refactoring.CSharp/CSharpRefactoringHelpers.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Analyzer.Utilities +{ + internal sealed class CSharpRefactoringHelpers : AbstractRefactoringHelpers + { + public static CSharpRefactoringHelpers Instance { get; } = new CSharpRefactoringHelpers(); + + private CSharpRefactoringHelpers() + { + } + + protected override ISyntaxFacts SyntaxFacts => CSharpSyntaxFacts.Instance; + + protected override IEnumerable ExtractNodesSimple(SyntaxNode? node, ISyntaxFacts syntaxFacts) + { + if (node == null) + { + yield break; + } + + foreach (var extractedNode in base.ExtractNodesSimple(node, syntaxFacts)) + { + yield return extractedNode; + } + + // `var a = b;` + // -> `var a = b`; + if (node is LocalDeclarationStatementSyntax localDeclaration) + { + yield return localDeclaration.Declaration; + } + + // var `a = b`; + if (node is VariableDeclaratorSyntax declarator) + { + var declaration = declarator.Parent; + if (declaration?.Parent is LocalDeclarationStatementSyntax localDeclarationStatement) + { + var variables = syntaxFacts.GetVariablesOfLocalDeclarationStatement(localDeclarationStatement); + if (variables.Count == 1) + { + // -> `var a = b`; + yield return declaration; + + // -> `var a = b;` + yield return localDeclarationStatement; + } + } + } + } + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/CSharpSyntaxFacts.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/CSharpSyntaxFacts.cs new file mode 100644 index 000000000..e5fb55a23 --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/CSharpSyntaxFacts.cs @@ -0,0 +1,177 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Refactoring.CSharp/CSharpSyntaxFacts.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +#nullable disable warnings + +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Analyzer.Utilities.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Analyzer.Utilities +{ + internal sealed class CSharpSyntaxFacts : AbstractSyntaxFacts, ISyntaxFacts + { + public static CSharpSyntaxFacts Instance { get; } = new CSharpSyntaxFacts(); + + private CSharpSyntaxFacts() + { + } + + public override ISyntaxKinds SyntaxKinds => CSharpSyntaxKinds.Instance; + + public SyntaxNode GetExpressionOfExpressionStatement(SyntaxNode node) + => ((ExpressionStatementSyntax)node).Expression; + + public bool IsSimpleAssignmentStatement(SyntaxNode statement) + { + return statement is ExpressionStatementSyntax exprStatement + && exprStatement.Expression.IsKind(SyntaxKind.SimpleAssignmentExpression); + } + + public void GetPartsOfAssignmentExpressionOrStatement(SyntaxNode statement, out SyntaxNode left, out SyntaxToken operatorToken, out SyntaxNode right) + { + var expression = statement; + if (statement is ExpressionStatementSyntax expressionStatement) + { + expression = expressionStatement.Expression; + } + + var assignment = (AssignmentExpressionSyntax)expression; + left = assignment.Left; + operatorToken = assignment.OperatorToken; + right = assignment.Right; + } + + public override SyntaxList GetAttributeLists(SyntaxNode node) + => node.GetAttributeLists(); + + public SeparatedSyntaxList GetVariablesOfLocalDeclarationStatement(SyntaxNode node) + => ((LocalDeclarationStatementSyntax)node).Declaration.Variables; + + public SyntaxNode GetInitializerOfVariableDeclarator(SyntaxNode node) + => ((VariableDeclaratorSyntax)node).Initializer; + + public SyntaxNode? GetValueOfEqualsValueClause(SyntaxNode? node) + => ((EqualsValueClauseSyntax?)node)?.Value; + + public bool IsOnTypeHeader(SyntaxNode root, int position, bool fullHeader, [NotNullWhen(true)] out SyntaxNode? typeDeclaration) + { + var node = TryGetAncestorForLocation(root, position); + if (node is null) + { + typeDeclaration = null; + return false; + } + + typeDeclaration = node; + var lastToken = (node as TypeDeclarationSyntax)?.TypeParameterList?.GetLastToken() ?? node.Identifier; + if (fullHeader) + lastToken = node.BaseList?.GetLastToken() ?? lastToken; + + return IsOnHeader(root, position, node, lastToken); + } + + public bool IsOnPropertyDeclarationHeader(SyntaxNode root, int position, [NotNullWhen(true)] out SyntaxNode? propertyDeclaration) + { + var node = TryGetAncestorForLocation(root, position); + if (node is null) + { + propertyDeclaration = null; + return false; + } + + propertyDeclaration = node; + return IsOnHeader(root, position, node, node.Identifier); + } + + public bool IsOnParameterHeader(SyntaxNode root, int position, [NotNullWhen(true)] out SyntaxNode? parameter) + { + var node = TryGetAncestorForLocation(root, position); + if (node is null) + { + parameter = null; + return false; + } + + parameter = node; + return IsOnHeader(root, position, node, node); + } + + public bool IsOnMethodHeader(SyntaxNode root, int position, [NotNullWhen(true)] out SyntaxNode? method) + { + var node = TryGetAncestorForLocation(root, position); + if (node is null) + { + method = null; + return false; + } + + method = node; + return IsOnHeader(root, position, node, node.ParameterList); + } + + public bool IsOnLocalFunctionHeader(SyntaxNode root, int position, [NotNullWhen(true)] out SyntaxNode? localFunction) + { + var node = TryGetAncestorForLocation(root, position); + if (node is null) + { + localFunction = null; + return false; + } + + localFunction = node; + return IsOnHeader(root, position, node, node.ParameterList); + } + + public bool IsOnLocalDeclarationHeader(SyntaxNode root, int position, [NotNullWhen(true)] out SyntaxNode? localDeclaration) + { + var node = TryGetAncestorForLocation(root, position); + if (node is null) + { + localDeclaration = null; + return false; + } + + localDeclaration = node; + var initializersExpressions = node.Declaration.Variables + .Where(v => v.Initializer != null) + .Select(initializedV => initializedV.Initializer.Value) + .ToImmutableArray(); + return IsOnHeader(root, position, node, node, holes: initializersExpressions); + } + + public bool IsOnIfStatementHeader(SyntaxNode root, int position, [NotNullWhen(true)] out SyntaxNode? ifStatement) + { + var node = TryGetAncestorForLocation(root, position); + if (node is null) + { + ifStatement = null; + return false; + } + + ifStatement = node; + return IsOnHeader(root, position, node, node.CloseParenToken); + } + + public bool IsOnForeachHeader(SyntaxNode root, int position, [NotNullWhen(true)] out SyntaxNode? foreachStatement) + { + var node = TryGetAncestorForLocation(root, position); + if (node is null) + { + foreachStatement = null; + return false; + } + + foreachStatement = node; + return IsOnHeader(root, position, node, node.CloseParenToken); + } + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/CSharpSyntaxKinds.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/CSharpSyntaxKinds.cs new file mode 100644 index 000000000..700a9649c --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/CSharpSyntaxKinds.cs @@ -0,0 +1,27 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Refactoring.CSharp/CSharpSyntaxKinds.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using Microsoft.CodeAnalysis.CSharp; + +namespace Analyzer.Utilities +{ + internal sealed class CSharpSyntaxKinds : ISyntaxKinds + { + public static CSharpSyntaxKinds Instance { get; } = new CSharpSyntaxKinds(); + + private CSharpSyntaxKinds() + { + } + + public int EndOfFileToken => (int)SyntaxKind.EndOfFileToken; + + public int ExpressionStatement => (int)SyntaxKind.ExpressionStatement; + public int LocalDeclarationStatement => (int)SyntaxKind.LocalDeclarationStatement; + + public int VariableDeclarator => (int)SyntaxKind.VariableDeclarator; + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/CodeRefactoringContextExtensions.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/CodeRefactoringContextExtensions.cs new file mode 100644 index 000000000..c3794807d --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/CodeRefactoringContextExtensions.cs @@ -0,0 +1,48 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Refactoring/CodeRefactoringContextExtensions.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeRefactorings; +using Microsoft.CodeAnalysis.Text; + +namespace Analyzer.Utilities +{ + internal static class CodeRefactoringContextExtensions + { + internal static Task TryGetRelevantNodeAsync(this CodeRefactoringContext context, IRefactoringHelpers helpers) + where TSyntaxNode : SyntaxNode + => TryGetRelevantNodeAsync(context.Document, helpers, context.Span, context.CancellationToken); + + internal static Task> GetRelevantNodesAsync(this CodeRefactoringContext context, IRefactoringHelpers helpers) + where TSyntaxNode : SyntaxNode + => GetRelevantNodesAsync(context.Document, helpers, context.Span, context.CancellationToken); + + internal static async Task TryGetRelevantNodeAsync( + this Document document, + IRefactoringHelpers helpers, + TextSpan span, + CancellationToken cancellationToken) + where TSyntaxNode : SyntaxNode + { + var potentialNodes = await GetRelevantNodesAsync(document, helpers, span, cancellationToken).ConfigureAwait(false); + return potentialNodes.FirstOrDefault(); + } + + internal static Task> GetRelevantNodesAsync( + this Document document, + IRefactoringHelpers helpers, + TextSpan span, + CancellationToken cancellationToken) where TSyntaxNode : SyntaxNode + { + return helpers.GetRelevantNodesAsync(document, span, cancellationToken); + } + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/CodeRefactoringHelpers.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/CodeRefactoringHelpers.cs new file mode 100644 index 000000000..765ba681d --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/CodeRefactoringHelpers.cs @@ -0,0 +1,48 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Refactoring/CodeRefactoringHelpers.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Analyzer.Utilities +{ + internal static class CodeRefactoringHelpers + { + /// + /// Trims leading and trailing whitespace from . + /// + /// + /// Returns unchanged in case . + /// Returns empty Span with original in case it contains only whitespace. + /// + public static async Task GetTrimmedTextSpanAsync(Document document, TextSpan span, CancellationToken cancellationToken) + { + if (span.IsEmpty) + { + return span; + } + + var sourceText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false); + var start = span.Start; + var end = span.End; + + while (start < end && char.IsWhiteSpace(sourceText[end - 1])) + { + end--; + } + + while (start < end && char.IsWhiteSpace(sourceText[start])) + { + start++; + } + + return TextSpan.FromBounds(start, end); + } + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/IRefactoringHelpers.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/IRefactoringHelpers.cs new file mode 100644 index 000000000..533b7958a --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/IRefactoringHelpers.cs @@ -0,0 +1,47 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Refactoring/IRefactoringHelpers.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Analyzer.Utilities +{ + internal interface IRefactoringHelpers + { + /// + /// + /// Returns an array of instances for refactoring given specified selection in document. + /// + /// + /// A instance is returned if: + /// + /// + /// Selection is zero-width and inside/touching a Token with direct parent of type . + /// Selection is zero-width and touching a Token whose ancestor of type ends/starts precisely on current selection. + /// Selection is zero-width and in whitespace that corresponds to a Token whose direct ancestor is of type of type . + /// Selection is zero-width and in a header (defined by ISyntaxFacts helpers) of an node of type of type . + /// Token whose direct parent of type is selected. + /// Selection is zero-width and wanted node is an expression / argument with selection within such syntax node (arbitrarily deep) on its first line. + /// Whole node of a type is selected. + /// + /// + /// Attempts extracting a Node of type for each Node it considers (see above). + /// E.g. extracts initializer expressions from declarations and assignments, Property declaration from any header node, etc. + /// + /// + /// Note: this function trims all whitespace from both the beginning and the end of given . + /// The trimmed version is then used to determine relevant . It also handles incomplete selections + /// of tokens gracefully. Over-selection containing leading comments is also handled correctly. + /// + /// + Task> GetRelevantNodesAsync(Document document, TextSpan selection, CancellationToken cancellationToken) + where TSyntaxNode : SyntaxNode; + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/ISyntaxFacts.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/ISyntaxFacts.cs new file mode 100644 index 000000000..3ea99a5f8 --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/ISyntaxFacts.cs @@ -0,0 +1,43 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Refactoring/ISyntaxFacts.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis; + +namespace Analyzer.Utilities +{ + internal interface ISyntaxFacts + { + ISyntaxKinds SyntaxKinds { get; } + + SyntaxNode GetExpressionOfExpressionStatement(SyntaxNode node); + + bool IsSimpleAssignmentStatement(SyntaxNode statement); + void GetPartsOfAssignmentExpressionOrStatement(SyntaxNode statement, out SyntaxNode left, out SyntaxToken operatorToken, out SyntaxNode right); + + SyntaxList GetAttributeLists(SyntaxNode node); + + SeparatedSyntaxList GetVariablesOfLocalDeclarationStatement(SyntaxNode node); + SyntaxNode GetInitializerOfVariableDeclarator(SyntaxNode node); + SyntaxNode? GetValueOfEqualsValueClause(SyntaxNode? node); + + /// + /// controls how much of the type header should be considered. If only the span up through the type name will be considered. If + /// then the span through the base-list will be considered. + /// + bool IsOnTypeHeader(SyntaxNode root, int position, bool fullHeader, [NotNullWhen(true)] out SyntaxNode? typeDeclaration); + + bool IsOnPropertyDeclarationHeader(SyntaxNode root, int position, [NotNullWhen(true)] out SyntaxNode? propertyDeclaration); + bool IsOnParameterHeader(SyntaxNode root, int position, [NotNullWhen(true)] out SyntaxNode? parameter); + bool IsOnMethodHeader(SyntaxNode root, int position, [NotNullWhen(true)] out SyntaxNode? method); + bool IsOnLocalFunctionHeader(SyntaxNode root, int position, [NotNullWhen(true)] out SyntaxNode? localFunction); + bool IsOnLocalDeclarationHeader(SyntaxNode root, int position, [NotNullWhen(true)] out SyntaxNode? localDeclaration); + bool IsOnIfStatementHeader(SyntaxNode root, int position, [NotNullWhen(true)] out SyntaxNode? ifStatement); + bool IsOnForeachHeader(SyntaxNode root, int position, [NotNullWhen(true)] out SyntaxNode? foreachStatement); + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/ISyntaxFactsExtensions.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/ISyntaxFactsExtensions.cs new file mode 100644 index 000000000..1643c73b1 --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/ISyntaxFactsExtensions.cs @@ -0,0 +1,58 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Refactoring/Extensions/ISyntaxFactsExtensions.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Analyzer.Utilities.Extensions +{ + internal static class ISyntaxFactsExtensions + { + public static TextSpan GetSpanWithoutAttributes(this ISyntaxFacts syntaxFacts, SyntaxNode root, SyntaxNode node) + { + // Span without AttributeLists + // - No AttributeLists -> original .Span + // - Some AttributeLists -> (first non-trivia/comment Token.Span.Begin, original.Span.End) + // - We need to be mindful about comments due to: + // // [Test1] + // //Comment1 + // [||]object Property1 { get; set; } + // the comment node being part of the next token's (`object`) leading trivia and not the AttributeList's node. + // - In case only attribute is written we need to be careful to not to use next (unrelated) token as beginning current the node. + var attributeList = syntaxFacts.GetAttributeLists(node); + if (attributeList.Any()) + { + var endOfAttributeLists = attributeList.Last().Span.End; + var afterAttributesToken = root.FindTokenOnRightOfPosition(endOfAttributeLists); + + var endOfNode = node.Span.End; + var startOfNodeWithoutAttributes = Math.Min(afterAttributesToken.Span.Start, endOfNode); + + return TextSpan.FromBounds(startOfNodeWithoutAttributes, endOfNode); + } + + return node.Span; + } + + /// + /// Checks if the position is on the header of a type (from the start of the type up through it's name). + /// + public static bool IsOnTypeHeader(this ISyntaxFacts syntaxFacts, SyntaxNode root, int position, [NotNullWhen(true)] out SyntaxNode? typeDeclaration) + => syntaxFacts.IsOnTypeHeader(root, position, fullHeader: false, out typeDeclaration); + + public static bool IsExpressionStatement(this ISyntaxFacts syntaxFacts, [NotNullWhen(true)] SyntaxNode? node) + => node?.RawKind == syntaxFacts.SyntaxKinds.ExpressionStatement; + + public static bool IsLocalDeclarationStatement(this ISyntaxFacts syntaxFacts, [NotNullWhen(true)] SyntaxNode? node) + => node?.RawKind == syntaxFacts.SyntaxKinds.LocalDeclarationStatement; + + public static bool IsVariableDeclarator(this ISyntaxFacts syntaxFacts, [NotNullWhen(true)] SyntaxNode? node) + => node?.RawKind == syntaxFacts.SyntaxKinds.VariableDeclarator; + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/ISyntaxKinds.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/ISyntaxKinds.cs new file mode 100644 index 000000000..9aec63b38 --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/ISyntaxKinds.cs @@ -0,0 +1,19 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Refactoring/ISyntaxKinds.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +namespace Analyzer.Utilities +{ + internal interface ISyntaxKinds + { + int EndOfFileToken { get; } + + int ExpressionStatement { get; } + int LocalDeclarationStatement { get; } + + int VariableDeclarator { get; } + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/RoslynDebug.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/RoslynDebug.cs new file mode 100644 index 000000000..648b025d9 --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/RoslynDebug.cs @@ -0,0 +1,24 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Compiler/Debug.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace Analyzer.Utilities +{ + internal static class RoslynDebug + { + /// + [Conditional("DEBUG")] + public static void Assert([DoesNotReturnIf(false)] bool b) => Debug.Assert(b); + + /// + [Conditional("DEBUG")] + public static void Assert([DoesNotReturnIf(false)] bool b, string message) + => Debug.Assert(b, message); + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/SourceTextExtensions.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/SourceTextExtensions.cs new file mode 100644 index 000000000..c1c32f01f --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/SourceTextExtensions.cs @@ -0,0 +1,72 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Refactoring/Extensions/SourceTextExtensions.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using System; +using System.Threading; +using Microsoft.CodeAnalysis.Text; + +namespace Analyzer.Utilities.Extensions +{ + internal static partial class SourceTextExtensions + { + public static bool OverlapsHiddenPosition( + this SourceText text, + TextSpan span, + Func isPositionHidden, + CancellationToken cancellationToken) + { + var result = TryOverlapsHiddenPosition(text, span, isPositionHidden, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return result; + } + + /// + /// Same as OverlapsHiddenPosition but doesn't throw on cancellation. Instead, returns false + /// in that case. + /// + public static bool TryOverlapsHiddenPosition( + this SourceText text, + TextSpan span, + Func isPositionHidden, + CancellationToken cancellationToken) + { + var startLineNumber = text.Lines.IndexOf(span.Start); + var endLineNumber = text.Lines.IndexOf(span.End); + + // NOTE(cyrusn): It's safe to examine the start of a line because you can't have a line + // with both a pp directive and code on it. so, for example, if a node crosses a region + // then it must be the case that the start of some line from the start of the node to + // the end is hidden. i.e.: +#if false +' class C +' { +'#line hidden +' } +'#line default +#endif + // The start of the line with the } on it is hidden, and thus the node overlaps a hidden + // region. + + for (var lineNumber = startLineNumber; lineNumber <= endLineNumber; lineNumber++) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + var linePosition = text.Lines[lineNumber].Start; + var isHidden = isPositionHidden(linePosition, cancellationToken); + if (isHidden) + { + return true; + } + } + + return false; + } + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/SyntaxNodeExtensions.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/SyntaxNodeExtensions.cs new file mode 100644 index 000000000..885c38873 --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/SyntaxNodeExtensions.cs @@ -0,0 +1,176 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Refactoring/Extensions/SyntaxNodeExtensions.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using System; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Analyzer.Utilities.Extensions +{ + internal static partial class SyntaxNodeExtensions + { + /// + /// Look inside a trivia list for a skipped token that contains the given position. + /// + private static readonly Func s_findSkippedTokenForward = FindSkippedTokenForward; + + /// + /// Look inside a trivia list for a skipped token that contains the given position. + /// + private static readonly Func s_findSkippedTokenBackward = FindSkippedTokenBackward; + + public static int Width(this SyntaxNode node) + => node.Span.Length; + + public static int FullWidth(this SyntaxNode node) + => node.FullSpan.Length; + + public static bool OverlapsHiddenPosition(this SyntaxNode node, CancellationToken cancellationToken) + => node.OverlapsHiddenPosition(node.Span, cancellationToken); + + public static bool OverlapsHiddenPosition(this SyntaxNode node, TextSpan span, CancellationToken cancellationToken) + => node.SyntaxTree.OverlapsHiddenPosition(span, cancellationToken); + + /// + /// If the position is inside of token, return that token; otherwise, return the token to the right. + /// + public static SyntaxToken FindTokenOnRightOfPosition( + this SyntaxNode root, + int position, + bool includeSkipped = false, + bool includeDirectives = false, + bool includeDocumentationComments = false) + { + var findSkippedToken = includeSkipped ? s_findSkippedTokenForward : ((l, p) => default); + + var token = GetInitialToken(root, position, includeSkipped, includeDirectives, includeDocumentationComments); + + if (position < token.SpanStart) + { + var skippedToken = findSkippedToken(token.LeadingTrivia, position); + token = skippedToken.RawKind != 0 ? skippedToken : token; + } + else if (token.Span.End <= position) + { + do + { + var skippedToken = findSkippedToken(token.TrailingTrivia, position); + token = skippedToken.RawKind != 0 + ? skippedToken + : token.GetNextToken(includeZeroWidth: false, includeSkipped: includeSkipped, includeDirectives: includeDirectives, includeDocumentationComments: includeDocumentationComments); + } + while (token.RawKind != 0 && token.Span.End <= position && token.Span.End <= root.FullSpan.End); + } + + if (token.Span.IsEmpty) + { + token = token.GetNextToken(); + } + + return token; + } + + /// + /// If the position is inside of token, return that token; otherwise, return the token to the left. + /// + public static SyntaxToken FindTokenOnLeftOfPosition( + this SyntaxNode root, + int position, + bool includeSkipped = false, + bool includeDirectives = false, + bool includeDocumentationComments = false) + { + var findSkippedToken = includeSkipped ? s_findSkippedTokenBackward : ((l, p) => default); + + var token = GetInitialToken(root, position, includeSkipped, includeDirectives, includeDocumentationComments); + + if (position <= token.SpanStart) + { + do + { + var skippedToken = findSkippedToken(token.LeadingTrivia, position); + token = skippedToken.RawKind != 0 + ? skippedToken + : token.GetPreviousToken(includeZeroWidth: false, includeSkipped: includeSkipped, includeDirectives: includeDirectives, includeDocumentationComments: includeDocumentationComments); + } + while (position <= token.SpanStart && root.FullSpan.Start < token.SpanStart); + } + else if (token.Span.End < position) + { + var skippedToken = findSkippedToken(token.TrailingTrivia, position); + token = skippedToken.RawKind != 0 ? skippedToken : token; + } + + if (token.Span.IsEmpty) + { + token = token.GetPreviousToken(); + } + + return token; + } + + private static SyntaxToken GetInitialToken( + SyntaxNode root, + int position, + bool includeSkipped = false, + bool includeDirectives = false, + bool includeDocumentationComments = false) + { + return (position < root.FullSpan.End || !(root is ICompilationUnitSyntax)) + ? root.FindToken(position, includeSkipped || includeDirectives || includeDocumentationComments) + : root.GetLastToken(includeZeroWidth: true, includeSkipped: true, includeDirectives: true, includeDocumentationComments: true) + .GetPreviousToken(includeZeroWidth: false, includeSkipped: includeSkipped, includeDirectives: includeDirectives, includeDocumentationComments: includeDocumentationComments); + } + + /// + /// Look inside a trivia list for a skipped token that contains the given position. + /// + private static SyntaxToken FindSkippedTokenForward(SyntaxTriviaList triviaList, int position) + { + foreach (var trivia in triviaList) + { + if (trivia.HasStructure && + trivia.GetStructure() is ISkippedTokensTriviaSyntax skippedTokensTrivia) + { + foreach (var token in skippedTokensTrivia.Tokens) + { + if (!token.Span.IsEmpty && position <= token.Span.End) + { + return token; + } + } + } + } + + return default; + } + + /// + /// Look inside a trivia list for a skipped token that contains the given position. + /// + private static SyntaxToken FindSkippedTokenBackward(SyntaxTriviaList triviaList, int position) + { + foreach (var trivia in triviaList.Reverse()) + { + if (trivia.HasStructure && + trivia.GetStructure() is ISkippedTokensTriviaSyntax skippedTokensTrivia) + { + foreach (var token in skippedTokensTrivia.Tokens) + { + if (!token.Span.IsEmpty && token.SpanStart <= position) + { + return token; + } + } + } + } + + return default; + } + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/SyntaxNodeExtensions_GetAttributeLists.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/SyntaxNodeExtensions_GetAttributeLists.cs new file mode 100644 index 000000000..d2faa9bf4 --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/SyntaxNodeExtensions_GetAttributeLists.cs @@ -0,0 +1,25 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Refactoring.CSharp/Extensions/SyntaxNodeExtensions.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Analyzer.Utilities.Extensions +{ + internal static partial class SyntaxNodeExtensions + { + public static SyntaxList GetAttributeLists(this SyntaxNode declaration) + => declaration switch + { + MemberDeclarationSyntax memberDecl => memberDecl.AttributeLists, + AccessorDeclarationSyntax accessor => accessor.AttributeLists, + ParameterSyntax parameter => parameter.AttributeLists, + CompilationUnitSyntax compilationUnit => compilationUnit.AttributeLists, + _ => default, + }; + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/SyntaxTokenExtensions.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/SyntaxTokenExtensions.cs new file mode 100644 index 000000000..a65b0a753 --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/SyntaxTokenExtensions.cs @@ -0,0 +1,19 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Refactoring/Extensions/SyntaxTokenExtensions.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using System; +using Microsoft.CodeAnalysis; + +namespace Analyzer.Utilities.Extensions +{ + internal static class SyntaxTokenExtensions + { + public static T? GetAncestor(this SyntaxToken token, Func? predicate = null) + where T : SyntaxNode + => token.Parent?.FirstAncestorOrSelf(predicate); + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/SyntaxTreeExtensions.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/SyntaxTreeExtensions.cs new file mode 100644 index 000000000..1725a07aa --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/SyntaxTreeExtensions.cs @@ -0,0 +1,37 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Refactoring/Extensions/SyntaxTreeExtensions.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Analyzer.Utilities.Extensions +{ + internal static class SyntaxTreeExtensions + { + public static bool OverlapsHiddenPosition([NotNullWhen(returnValue: true)] this SyntaxTree? tree, TextSpan span, CancellationToken cancellationToken) + { + if (tree == null) + { + return false; + } + + var text = tree.GetText(cancellationToken); + + return text.OverlapsHiddenPosition( + span, + (position, cancellationToken2) => + { + // implements the ASP.NET IsHidden rule + var lineVisibility = tree.GetLineVisibility(position, cancellationToken2); + return lineVisibility is LineVisibility.Hidden or LineVisibility.BeforeFirstLineDirective; + }, + cancellationToken); + } + } +} diff --git a/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/TextLineExtensions.cs b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/TextLineExtensions.cs new file mode 100644 index 000000000..5c64d9b9e --- /dev/null +++ b/StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/Helpers/TextLineExtensions.cs @@ -0,0 +1,32 @@ +// +// https://raw.githubusercontent.com/dotnet/roslyn-analyzers/84fb81c27e0554eadf6b12f97eb52c7cd2803c7e/src/Utilities/Refactoring/Extensions/TextLineExtensions.cs + +#nullable enable + +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information. + +using Microsoft.CodeAnalysis.Text; + +namespace Analyzer.Utilities.Extensions +{ + internal static class TextLineExtensions + { + /// + /// Determines whether the specified line is empty or contains whitespace only. + /// + public static bool IsEmptyOrWhitespace(this TextLine line) + { + var text = line.Text; + RoslynDebug.Assert(text is object); + for (var i = line.Span.Start; i < line.Span.End; i++) + { + if (!char.IsWhiteSpace(text[i])) + { + return false; + } + } + + return true; + } + } +}