diff --git a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs index a13f8ee11..9f00ac7fa 100644 --- a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs +++ b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs @@ -535,7 +535,7 @@ private async Task HandleGetCommandRequestAsync( { PSCommand psCommand = new PSCommand(); if (!string.IsNullOrEmpty(param)) - { + { psCommand.AddCommand("Microsoft.PowerShell.Core\\Get-Command").AddArgument(param); } else @@ -1267,7 +1267,7 @@ protected async Task HandleCodeActionRequest( } } - // Add "show documentation" commands last so they appear at the bottom of the client UI. + // Add "show documentation" commands last so they appear at the bottom of the client UI. // These commands do not require code fixes. Sometimes we get a batch of diagnostics // to create commands for. No need to create multiple show doc commands for the same rule. var ruleNamesProcessed = new HashSet(); @@ -1390,14 +1390,15 @@ private FoldingRange[] Fold(string documentUri) if (!editorSession.Workspace.TryGetFile(documentUri, out scriptFile)) { return null; } var result = new List(); - FoldingReference[] foldableRegions = - TokenOperations.FoldableRegions(scriptFile.ScriptTokens, this.currentSettings.CodeFolding.ShowLastLine); - foreach (FoldingReference fold in foldableRegions) + // If we're showing the last line, decrement the Endline of all regions by one. + int endLineOffset = this.currentSettings.CodeFolding.ShowLastLine ? -1 : 0; + + foreach (FoldingReference fold in TokenOperations.FoldableReferences(scriptFile.ScriptTokens).References) { result.Add(new FoldingRange { EndCharacter = fold.EndCharacter, - EndLine = fold.EndLine, + EndLine = fold.EndLine + endLineOffset, Kind = fold.Kind, StartCharacter = fold.StartCharacter, StartLine = fold.StartLine @@ -1744,7 +1745,7 @@ await eventSender( }); } - // Generate a unique id that is used as a key to look up the associated code action (code fix) when + // Generate a unique id that is used as a key to look up the associated code action (code fix) when // we receive and process the textDocument/codeAction message. private static string GetUniqueIdFromDiagnostic(Diagnostic diagnostic) { diff --git a/src/PowerShellEditorServices/Language/FoldingReference.cs b/src/PowerShellEditorServices/Language/FoldingReference.cs index 54e3401df..2b8a14502 100644 --- a/src/PowerShellEditorServices/Language/FoldingReference.cs +++ b/src/PowerShellEditorServices/Language/FoldingReference.cs @@ -4,6 +4,7 @@ // using System; +using System.Collections.Generic; namespace Microsoft.PowerShell.EditorServices { @@ -60,4 +61,52 @@ public int CompareTo(FoldingReference that) { return string.Compare(this.Kind, that.Kind); } } + + /// + /// A class that holds a list of FoldingReferences and ensures that when adding a reference that the + /// folding rules are obeyed, e.g. Only one fold per start line + /// + public class FoldingReferenceList + { + private readonly Dictionary references = new Dictionary(); + + /// + /// Return all references in the list + /// + public IEnumerable References + { + get + { + return references.Values; + } + } + + /// + /// Adds a FoldingReference to the list and enforces ordering rules e.g. Only one fold per start line + /// + public void SafeAdd(FoldingReference item) + { + if (item == null) { return; } + + // Only add the item if it hasn't been seen before or it's the largest range + if (references.TryGetValue(item.StartLine, out FoldingReference currentItem)) + { + if (currentItem.CompareTo(item) == 1) { references[item.StartLine] = item; } + } + else + { + references[item.StartLine] = item; + } + } + + /// + /// Helper method to easily convert the Dictionary Values into an array + /// + public FoldingReference[] ToArray() + { + var result = new FoldingReference[references.Count]; + references.Values.CopyTo(result, 0); + return result; + } + } } diff --git a/src/PowerShellEditorServices/Language/TokenOperations.cs b/src/PowerShellEditorServices/Language/TokenOperations.cs index 3003cd6e8..e62dca68c 100644 --- a/src/PowerShellEditorServices/Language/TokenOperations.cs +++ b/src/PowerShellEditorServices/Language/TokenOperations.cs @@ -21,103 +21,140 @@ internal static class TokenOperations private const string RegionKindRegion = "region"; private const string RegionKindNone = null; - // Opening tokens for { } and @{ } - private static readonly TokenKind[] s_openingBraces = new [] - { - TokenKind.LCurly, - TokenKind.AtCurly - }; - - // Opening tokens for ( ), @( ), $( ) - private static readonly TokenKind[] s_openingParens = new [] - { - TokenKind.LParen, - TokenKind.AtParen, - TokenKind.DollarParen - }; + // These regular expressions are used to match lines which mark the start and end of region comment in a PowerShell + // script. They are based on the defaults in the VS Code Language Configuration at; + // https://github.com/Microsoft/vscode/blob/64186b0a26/extensions/powershell/language-configuration.json#L26-L31 + // https://github.com/Microsoft/vscode/issues/49070 + static private readonly Regex s_startRegionTextRegex = new Regex( + @"^\s*#[rR]egion\b", RegexOptions.Compiled); + static private readonly Regex s_endRegionTextRegex = new Regex( + @"^\s*#[eE]nd[rR]egion\b", RegexOptions.Compiled); /// /// Extracts all of the unique foldable regions in a script given the list tokens /// - internal static FoldingReference[] FoldableRegions( - Token[] tokens, - bool ShowLastLine) + internal static FoldingReferenceList FoldableReferences( + Token[] tokens) { - List foldableRegions = new List(); - - // Find matching braces { -> } - // Find matching hashes @{ -> } - foldableRegions.AddRange( - MatchTokenElements(tokens, s_openingBraces, TokenKind.RCurly, RegionKindNone) - ); - - // Find matching parentheses ( -> ) - // Find matching array literals @( -> ) - // Find matching subexpressions $( -> ) - foldableRegions.AddRange( - MatchTokenElements(tokens, s_openingParens, TokenKind.RParen, RegionKindNone) - ); + var refList = new FoldingReferenceList(); - // Find contiguous here strings @' -> '@ - foldableRegions.AddRange( - MatchTokenElement(tokens, TokenKind.HereStringLiteral, RegionKindNone) - ); - - // Find unopinionated variable names ${ \n \n } - foldableRegions.AddRange( - MatchTokenElement(tokens, TokenKind.Variable, RegionKindNone) - ); - - // Find contiguous here strings @" -> "@ - foldableRegions.AddRange( - MatchTokenElement(tokens, TokenKind.HereStringExpandable, RegionKindNone) - ); + Stack tokenCurlyStack = new Stack(); + Stack tokenParenStack = new Stack(); + foreach (Token token in tokens) + { + switch (token.Kind) + { + // Find matching braces { -> } + // Find matching hashes @{ -> } + case TokenKind.LCurly: + case TokenKind.AtCurly: + tokenCurlyStack.Push(token); + break; + + case TokenKind.RCurly: + if (tokenCurlyStack.Count > 0) + { + refList.SafeAdd(CreateFoldingReference(tokenCurlyStack.Pop(), token, RegionKindNone)); + } + break; + + // Find matching parentheses ( -> ) + // Find matching array literals @( -> ) + // Find matching subexpressions $( -> ) + case TokenKind.LParen: + case TokenKind.AtParen: + case TokenKind.DollarParen: + tokenParenStack.Push(token); + break; + + case TokenKind.RParen: + if (tokenParenStack.Count > 0) + { + refList.SafeAdd(CreateFoldingReference(tokenParenStack.Pop(), token, RegionKindNone)); + } + break; + + // Find contiguous here strings @' -> '@ + // Find unopinionated variable names ${ \n \n } + // Find contiguous expandable here strings @" -> "@ + case TokenKind.HereStringLiteral: + case TokenKind.Variable: + case TokenKind.HereStringExpandable: + if (token.Extent.StartLineNumber != token.Extent.EndLineNumber) + { + refList.SafeAdd(CreateFoldingReference(token, token, RegionKindNone)); + } + break; + } + } // Find matching comment regions #region -> #endregion - foldableRegions.AddRange( - MatchCustomCommentRegionTokenElements(tokens, RegionKindRegion) - ); - + // Given a list of tokens, find the tokens that are comments and + // the comment text is either `#region` or `#endregion`, and then use a stack to determine + // the ranges they span + // // Find blocks of line comments # comment1\n# comment2\n... - foldableRegions.AddRange( - MatchBlockCommentTokenElement(tokens, RegionKindComment) - ); - + // Finding blocks of comment tokens is more complicated as the newline characters are not + // classed as comments. To workaround this we search for valid block comments (See IsBlockCmment) + // and then determine contiguous line numbers from there + // // Find comments regions <# -> #> - foldableRegions.AddRange( - MatchTokenElement(tokens, TokenKind.Comment, RegionKindComment) - ); + // Match the token start and end of kind TokenKind.Comment + var tokenCommentRegionStack = new Stack(); + Token blockStartToken = null; + int blockNextLine = -1; - // Remove any null entries. Nulls appear if the folding reference is invalid - // or missing - foldableRegions.RemoveAll(item => item == null); + for (int index = 0; index < tokens.Length; index++) + { + Token token = tokens[index]; + if (token.Kind != TokenKind.Comment) { continue; } + + // Processing for comment regions <# -> #> + if (token.Extent.StartLineNumber != token.Extent.EndLineNumber) + { + refList.SafeAdd(CreateFoldingReference(token, token, RegionKindComment)); + continue; + } - // Sort the FoldingReferences, starting at the top of the document, - // and ensure that, in the case of multiple ranges starting the same line, - // that the largest range (i.e. most number of lines spanned) is sorted - // first. This is needed to detect duplicate regions. The first in the list - // will be used and subsequent duplicates ignored. - foldableRegions.Sort(); + if (!IsBlockComment(index, tokens)) { continue; } - // It's possible to have duplicate or overlapping ranges, that is, regions which have the same starting - // line number as the previous region. Therefore only emit ranges which have a different starting line - // than the previous range. - foldableRegions.RemoveAll( (FoldingReference item) => { - // Note - I'm not happy with searching here, but as the RemoveAll - // doesn't expose the index in the List, we need to calculate it. Fortunately the - // list is sorted at this point, so we can use BinarySearch. - int index = foldableRegions.BinarySearch(item); - if (index == 0) { return false; } - return (item.StartLine == foldableRegions[index - 1].StartLine); - }); + // Regex's are very expensive. Use them sparingly! + // Processing for #region -> #endregion + if (s_startRegionTextRegex.IsMatch(token.Text)) + { + tokenCommentRegionStack.Push(token); + continue; + } + if (s_endRegionTextRegex.IsMatch(token.Text)) + { + // Mismatched regions in the script can cause bad stacks. + if (tokenCommentRegionStack.Count > 0) + { + refList.SafeAdd(CreateFoldingReference(tokenCommentRegionStack.Pop(), token, RegionKindRegion)); + } + continue; + } - // Some editors have different folding UI, sometimes the lastline should be displayed - // If we do want to show the last line, just change the region to be one line less - if (ShowLastLine) { - foldableRegions.ForEach( item => { item.EndLine--; }); + // If it's neither a start or end region then it could be block line comment + // Processing for blocks of line comments # comment1\n# comment2\n... + int thisLine = token.Extent.StartLineNumber - 1; + if ((blockStartToken != null) && (thisLine != blockNextLine)) + { + refList.SafeAdd(CreateFoldingReference(blockStartToken, blockNextLine - 1, RegionKindComment)); + blockStartToken = token; + } + if (blockStartToken == null) { blockStartToken = token; } + blockNextLine = thisLine + 1; } - return foldableRegions.ToArray(); + // If we exit the token array and we're still processing comment lines, then the + // comment block simply ends at the end of document + if (blockStartToken != null) + { + refList.SafeAdd(CreateFoldingReference(blockStartToken, blockNextLine - 1, RegionKindComment)); + } + + return refList; } /// @@ -160,47 +197,6 @@ static private FoldingReference CreateFoldingReference( }; } - /// - /// Given an array of tokens, find matching regions which start (array of tokens) and end with a different TokenKind - /// - static private List MatchTokenElements( - Token[] tokens, - TokenKind[] startTokenKind, - TokenKind endTokenKind, - string matchKind) - { - List result = new List(); - Stack tokenStack = new Stack(); - foreach (Token token in tokens) - { - if (Array.IndexOf(startTokenKind, token.Kind) != -1) { - tokenStack.Push(token); - } - if ((tokenStack.Count > 0) && (token.Kind == endTokenKind)) { - result.Add(CreateFoldingReference(tokenStack.Pop(), token, matchKind)); - } - } - return result; - } - - /// - /// Given an array of token, finds a specific token - /// - static private List MatchTokenElement( - Token[] tokens, - TokenKind tokenKind, - string matchKind) - { - List result = new List(); - foreach (Token token in tokens) - { - if ((token.Kind == tokenKind) && (token.Extent.StartLineNumber != token.Extent.EndLineNumber)) { - result.Add(CreateFoldingReference(token, token, matchKind)); - } - } - return result; - } - /// /// Returns true if a Token is a block comment; /// - Must be a TokenKind.comment @@ -215,79 +211,5 @@ static private bool IsBlockComment(int index, Token[] tokens) { if (tokens[index - 1].Kind != TokenKind.NewLine) { return false; } return thisToken.Text.StartsWith("#"); } - - // This regular expressions is used to detect a line comment (as opposed to an inline comment), that is not a region - // block directive i.e. - // - No text between the beginning of the line and `#` - // - Comment does start with region - // - Comment does start with endregion - static private readonly Regex s_nonRegionLineCommentRegex = new Regex( - @"\s*#(?!region\b|endregion\b)", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - - /// - /// Finding blocks of comment tokens is more complicated as the newline characters are not - /// classed as comments. To workaround this we search for valid block comments (See IsBlockCmment) - /// and then determine contiguous line numbers from there - /// - static private List MatchBlockCommentTokenElement( - Token[] tokens, - string matchKind) - { - List result = new List(); - Token startToken = null; - int nextLine = -1; - for (int index = 0; index < tokens.Length; index++) - { - Token thisToken = tokens[index]; - if (IsBlockComment(index, tokens) && s_nonRegionLineCommentRegex.IsMatch(thisToken.Text)) { - int thisLine = thisToken.Extent.StartLineNumber - 1; - if ((startToken != null) && (thisLine != nextLine)) { - result.Add(CreateFoldingReference(startToken, nextLine - 1, matchKind)); - startToken = thisToken; - } - if (startToken == null) { startToken = thisToken; } - nextLine = thisLine + 1; - } - } - // If we exit the token array and we're still processing comment lines, then the - // comment block simply ends at the end of document - if (startToken != null) { - result.Add(CreateFoldingReference(startToken, nextLine - 1, matchKind)); - } - return result; - } - - /// - /// Given a list of tokens, find the tokens that are comments and - /// the comment text is either `# region` or `# endregion`, and then use a stack to determine - /// the ranges they span - /// - static private List MatchCustomCommentRegionTokenElements( - Token[] tokens, - string matchKind) - { - // These regular expressions are used to match lines which mark the start and end of region comment in a PowerShell - // script. They are based on the defaults in the VS Code Language Configuration at; - // https://github.com/Microsoft/vscode/blob/64186b0a26/extensions/powershell/language-configuration.json#L26-L31 - string startRegionText = @"^\s*#region\b"; - string endRegionText = @"^\s*#endregion\b"; - - List result = new List(); - Stack tokenStack = new Stack(); - for (int index = 0; index < tokens.Length; index++) - { - if (IsBlockComment(index, tokens)) { - Token token = tokens[index]; - if (Regex.IsMatch(token.Text, startRegionText, RegexOptions.IgnoreCase)) { - tokenStack.Push(token); - } - if ((tokenStack.Count > 0) && (Regex.IsMatch(token.Text, endRegionText, RegexOptions.IgnoreCase))) { - result.Add(CreateFoldingReference(tokenStack.Pop(), token, matchKind)); - } - } - } - return result; - } } } diff --git a/test/PowerShellEditorServices.Test/Language/TokenOperationsTests.cs b/test/PowerShellEditorServices.Test/Language/TokenOperationsTests.cs index 2b45cf531..38a62993b 100644 --- a/test/PowerShellEditorServices.Test/Language/TokenOperationsTests.cs +++ b/test/PowerShellEditorServices.Test/Language/TokenOperationsTests.cs @@ -13,13 +13,17 @@ public class TokenOperationsTests /// /// Helper method to create a stub script file and then call FoldableRegions /// - private FoldingReference[] GetRegions(string text, bool showLastLine = true) { + private FoldingReference[] GetRegions(string text) { ScriptFile scriptFile = new ScriptFile( "testfile", "clienttestfile", text, Version.Parse("5.0")); - return Microsoft.PowerShell.EditorServices.TokenOperations.FoldableRegions(scriptFile.ScriptTokens, showLastLine); + + var result = Microsoft.PowerShell.EditorServices.TokenOperations.FoldableReferences(scriptFile.ScriptTokens).ToArray(); + // The foldable regions need to be deterministic for testing so sort the array. + Array.Sort(result); + return result; } /// @@ -39,11 +43,11 @@ private static FoldingReference CreateFoldingReference(int startLine, int startC // folding regions and regions which should not be // detected. Due to file encoding this could be CLRF or LF line endings private const string allInOneScript = -@"#RegIon This should fold +@"#Region This should fold <# Nested different comment types. This should fold #> -#EnDReGion +#EndRegion # region This should not fold due to whitespace $shouldFold = $false @@ -120,24 +124,28 @@ double quoted herestrings should also fold ${this is valid} = 5 + +#RegIon This should fold due to casing +$foo = 'bar' +#EnDReGion "; private FoldingReference[] expectedAllInOneScriptFolds = { - CreateFoldingReference(0, 0, 3, 10, "region"), - CreateFoldingReference(1, 0, 2, 2, "comment"), - CreateFoldingReference(10, 0, 14, 2, "comment"), - CreateFoldingReference(16, 30, 62, 1, null), - CreateFoldingReference(17, 0, 21, 2, "comment"), - CreateFoldingReference(23, 7, 25, 2, null), - CreateFoldingReference(31, 5, 33, 2, null), - CreateFoldingReference(38, 2, 39, 0, "comment"), - CreateFoldingReference(42, 2, 51, 14, "region"), - CreateFoldingReference(44, 4, 47, 14, "region"), - CreateFoldingReference(54, 7, 55, 3, null), - CreateFoldingReference(59, 7, 61, 3, null), - CreateFoldingReference(67, 0, 68, 0, "comment"), - CreateFoldingReference(70, 0, 74, 26, "region"), - CreateFoldingReference(71, 0, 72, 0, "comment"), - CreateFoldingReference(78, 0, 79, 6, null), + CreateFoldingReference(0, 0, 4, 10, "region"), + CreateFoldingReference(1, 0, 3, 2, "comment"), + CreateFoldingReference(10, 0, 15, 2, "comment"), + CreateFoldingReference(16, 30, 63, 1, null), + CreateFoldingReference(17, 0, 22, 2, "comment"), + CreateFoldingReference(23, 7, 26, 2, null), + CreateFoldingReference(31, 5, 34, 2, null), + CreateFoldingReference(38, 2, 40, 0, "comment"), + CreateFoldingReference(42, 2, 52, 14, "region"), + CreateFoldingReference(44, 4, 48, 14, "region"), + CreateFoldingReference(54, 7, 56, 3, null), + CreateFoldingReference(59, 7, 62, 3, null), + CreateFoldingReference(67, 0, 69, 0, "comment"), + CreateFoldingReference(70, 0, 75, 26, "region"), + CreateFoldingReference(71, 0, 73, 0, "comment"), + CreateFoldingReference(78, 0, 80, 6, null), }; /// @@ -180,20 +188,6 @@ public void LaguageServiceFindsFoldablRegionsWithCRLF() { AssertFoldingReferenceArrays(expectedAllInOneScriptFolds, result); } - [Trait("Category", "Folding")] - [Fact] - public void LaguageServiceFindsFoldablRegionsWithoutLastLine() { - FoldingReference[] result = GetRegions(allInOneScript, false); - // Incrememnt the end line of the expected regions by one as we will - // be hiding the last line - FoldingReference[] expectedFolds = expectedAllInOneScriptFolds.Clone() as FoldingReference[]; - for (int index = 0; index < expectedFolds.Length; index++) - { - expectedFolds[index].EndLine++; - } - AssertFoldingReferenceArrays(expectedFolds, result); - } - [Trait("Category", "Folding")] [Fact] public void LaguageServiceFindsFoldablRegionsWithMismatchedRegions() { @@ -207,7 +201,7 @@ public void LaguageServiceFindsFoldablRegionsWithMismatchedRegions() { #region should not fold - mismatched "; FoldingReference[] expectedFolds = { - CreateFoldingReference(2, 0, 3, 10, "region") + CreateFoldingReference(2, 0, 4, 10, "region") }; FoldingReference[] result = GetRegions(testString); @@ -225,8 +219,8 @@ public void LaguageServiceFindsFoldablRegionsWithDuplicateRegions() { }) "; FoldingReference[] expectedFolds = { - CreateFoldingReference(1, 64, 1, 27, null), - CreateFoldingReference(2, 35, 3, 2, null) + CreateFoldingReference(1, 64, 2, 27, null), + CreateFoldingReference(2, 35, 4, 2, null) }; FoldingReference[] result = GetRegions(testString); @@ -251,9 +245,87 @@ public void LaguageServiceFindsFoldablRegionsWithSameEndToken() { ) "; FoldingReference[] expectedFolds = { - CreateFoldingReference(0, 19, 4, 1, null), - CreateFoldingReference(2, 9, 3, 5, null), - CreateFoldingReference(7, 5, 8, 1, null) + CreateFoldingReference(0, 19, 5, 1, null), + CreateFoldingReference(2, 9, 4, 5, null), + CreateFoldingReference(7, 5, 9, 1, null) + }; + + FoldingReference[] result = GetRegions(testString); + + AssertFoldingReferenceArrays(expectedFolds, result); + } + + // A simple PowerShell Classes test + [Fact] + public void LaguageServiceFindsFoldablRegionsWithClasses() { + string testString = +@"class TestClass { + [string[]] $TestProperty = @( + 'first', + 'second', + 'third') + + [string] TestMethod() { + return $this.TestProperty[0] + } +} +"; + FoldingReference[] expectedFolds = { + CreateFoldingReference(0, 16, 9, 1, null), + CreateFoldingReference(1, 31, 4, 16, null), + CreateFoldingReference(6, 26, 8, 5, null) + }; + + FoldingReference[] result = GetRegions(testString); + + AssertFoldingReferenceArrays(expectedFolds, result); + } + + // This tests DSC style keywords and param blocks + [Fact] + public void LaguageServiceFindsFoldablRegionsWithDSC() { + string testString = +@"Configuration Example +{ + param + ( + [Parameter()] + [System.String[]] + $NodeName = 'localhost', + + [Parameter(Mandatory = $true)] + [ValidateNotNullorEmpty()] + [System.Management.Automation.PSCredential] + $Credential + ) + + Import-DscResource -Module ActiveDirectoryCSDsc + + Node $AllNodes.NodeName + { + WindowsFeature ADCS-Cert-Authority + { + Ensure = 'Present' + Name = 'ADCS-Cert-Authority' + } + + AdcsCertificationAuthority CertificateAuthority + { + IsSingleInstance = 'Yes' + Ensure = 'Present' + Credential = $Credential + CAType = 'EnterpriseRootCA' + DependsOn = '[WindowsFeature]ADCS-Cert-Authority' + } + } +} +"; + FoldingReference[] expectedFolds = { + CreateFoldingReference(1, 0, 33, 1, null), + CreateFoldingReference(3, 4, 12, 5, null), + CreateFoldingReference(17, 4, 32, 5, null), + CreateFoldingReference(19, 8, 22, 9, null), + CreateFoldingReference(25, 8, 31, 9, null) }; FoldingReference[] result = GetRegions(testString);