Skip to content

Commit 19d5c5b

Browse files
authored
Add support for a "Show Documentation" quick fix menu entry (#789)
* Add support for a "Show Documentation" quick fix menu entry This requires a corresponding PR to VSCode to handle the new command: PowerShell.ShowCodeActionDocumentation Changed the use of the Diagnostic.Code field to hold the PSSA rule name. According to the LSP docs, this field is meant to be displayed and tslint uses it to display the vioated rule. Update the logic to use a combination of diagnostic fields to get a unique id in order to retrieve the stored "correction edits" when the textDocument/codeAction message is processed with the diagnostic array returned earlier. Also, observed a few times during startup that RunScriptDiagnostics was getting called with an empty filesToAnalyze array. Modified to return early in that case. Remove a private overload of DelayThenInvokeDiagnostics that wasn't being used. * Address PR comments by switching to StringBuilder Also update codeAction processing to ensure we don't have multiple show documentation commands for the same rule. * Remove extra filesToAnalyze empty array check per PR feedback * Address PR feedback on StringBuilder * Fix indentation issue as per PR feedback
1 parent abfffca commit 19d5c5b

File tree

2 files changed

+89
-37
lines changed

2 files changed

+89
-37
lines changed

src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs

+82-35
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
using System.Linq;
1818
using System.Management.Automation;
1919
using System.Management.Automation.Language;
20+
using System.Text;
2021
using System.Text.RegularExpressions;
2122
using System.Threading;
2223
using System.Threading.Tasks;
@@ -27,7 +28,7 @@ namespace Microsoft.PowerShell.EditorServices.Protocol.Server
2728
{
2829
public class LanguageServer
2930
{
30-
private static CancellationTokenSource existingRequestCancellation;
31+
private static CancellationTokenSource s_existingRequestCancellation;
3132

3233
private static readonly Location[] s_emptyLocationResult = new Location[0];
3334

@@ -48,6 +49,7 @@ public class LanguageServer
4849
private LanguageServerEditorOperations editorOperations;
4950
private LanguageServerSettings currentSettings = new LanguageServerSettings();
5051

52+
// The outer key is the file's uri, the inner key is a unique id for the diagnostic
5153
private Dictionary<string, Dictionary<string, MarkerCorrection>> codeActionsPerFile =
5254
new Dictionary<string, Dictionary<string, MarkerCorrection>>();
5355

@@ -1182,6 +1184,7 @@ private bool IsQueryMatch(string query, string symbolName)
11821184
return symbolName.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0;
11831185
}
11841186

1187+
// https://microsoft.github.io/language-server-protocol/specification#textDocument_codeAction
11851188
protected async Task HandleCodeActionRequest(
11861189
CodeActionParams codeActionParams,
11871190
RequestContext<CodeActionCommand[]> requestContext)
@@ -1190,12 +1193,22 @@ protected async Task HandleCodeActionRequest(
11901193
Dictionary<string, MarkerCorrection> markerIndex = null;
11911194
List<CodeActionCommand> codeActionCommands = new List<CodeActionCommand>();
11921195

1196+
// If there are any code fixes, send these commands first so they appear at top of "Code Fix" menu in the client UI.
11931197
if (this.codeActionsPerFile.TryGetValue(codeActionParams.TextDocument.Uri, out markerIndex))
11941198
{
11951199
foreach (var diagnostic in codeActionParams.Context.Diagnostics)
11961200
{
1197-
if (!string.IsNullOrEmpty(diagnostic.Code) &&
1198-
markerIndex.TryGetValue(diagnostic.Code, out correction))
1201+
if (string.IsNullOrEmpty(diagnostic.Code))
1202+
{
1203+
this.Logger.Write(
1204+
LogLevel.Warning,
1205+
$"textDocument/codeAction skipping diagnostic with empty Code field: {diagnostic.Source} {diagnostic.Message}");
1206+
1207+
continue;
1208+
}
1209+
1210+
string diagnosticId = GetUniqueIdFromDiagnostic(diagnostic);
1211+
if (markerIndex.TryGetValue(diagnosticId, out correction))
11991212
{
12001213
codeActionCommands.Add(
12011214
new CodeActionCommand
@@ -1208,8 +1221,30 @@ protected async Task HandleCodeActionRequest(
12081221
}
12091222
}
12101223

1211-
await requestContext.SendResult(
1212-
codeActionCommands.ToArray());
1224+
// Add "show documentation" commands last so they appear at the bottom of the client UI.
1225+
// These commands do not require code fixes. Sometimes we get a batch of diagnostics
1226+
// to create commands for. No need to create multiple show doc commands for the same rule.
1227+
var ruleNamesProcessed = new HashSet<string>();
1228+
foreach (var diagnostic in codeActionParams.Context.Diagnostics)
1229+
{
1230+
if (string.IsNullOrEmpty(diagnostic.Code)) { continue; }
1231+
1232+
if (string.Equals(diagnostic.Source, "PSScriptAnalyzer", StringComparison.OrdinalIgnoreCase) &&
1233+
!ruleNamesProcessed.Contains(diagnostic.Code))
1234+
{
1235+
ruleNamesProcessed.Add(diagnostic.Code);
1236+
1237+
codeActionCommands.Add(
1238+
new CodeActionCommand
1239+
{
1240+
Title = $"Show documentation for \"{diagnostic.Code}\"",
1241+
Command = "PowerShell.ShowCodeActionDocumentation",
1242+
Arguments = JArray.FromObject(new[] { diagnostic.Code })
1243+
});
1244+
}
1245+
}
1246+
1247+
await requestContext.SendResult(codeActionCommands.ToArray());
12131248
}
12141249

12151250
protected async Task HandleDocumentFormattingRequest(
@@ -1454,15 +1489,15 @@ private Task RunScriptDiagnostics(
14541489
// If there's an existing task, attempt to cancel it
14551490
try
14561491
{
1457-
if (existingRequestCancellation != null)
1492+
if (s_existingRequestCancellation != null)
14581493
{
14591494
// Try to cancel the request
1460-
existingRequestCancellation.Cancel();
1495+
s_existingRequestCancellation.Cancel();
14611496

14621497
// If cancellation didn't throw an exception,
14631498
// clean up the existing token
1464-
existingRequestCancellation.Dispose();
1465-
existingRequestCancellation = null;
1499+
s_existingRequestCancellation.Dispose();
1500+
s_existingRequestCancellation = null;
14661501
}
14671502
}
14681503
catch (Exception e)
@@ -1479,11 +1514,17 @@ private Task RunScriptDiagnostics(
14791514
return cancelTask.Task;
14801515
}
14811516

1517+
// If filesToAnalzye is empty, nothing to do so return early.
1518+
if (filesToAnalyze.Length == 0)
1519+
{
1520+
return Task.FromResult(true);
1521+
}
1522+
14821523
// Create a fresh cancellation token and then start the task.
14831524
// We create this on a different TaskScheduler so that we
14841525
// don't block the main message loop thread.
14851526
// TODO: Is there a better way to do this?
1486-
existingRequestCancellation = new CancellationTokenSource();
1527+
s_existingRequestCancellation = new CancellationTokenSource();
14871528
Task.Factory.StartNew(
14881529
() =>
14891530
DelayThenInvokeDiagnostics(
@@ -1494,36 +1535,14 @@ private Task RunScriptDiagnostics(
14941535
editorSession,
14951536
eventSender,
14961537
this.Logger,
1497-
existingRequestCancellation.Token),
1538+
s_existingRequestCancellation.Token),
14981539
CancellationToken.None,
14991540
TaskCreationOptions.None,
15001541
TaskScheduler.Default);
15011542

15021543
return Task.FromResult(true);
15031544
}
15041545

1505-
private static async Task DelayThenInvokeDiagnostics(
1506-
int delayMilliseconds,
1507-
ScriptFile[] filesToAnalyze,
1508-
bool isScriptAnalysisEnabled,
1509-
Dictionary<string, Dictionary<string, MarkerCorrection>> correctionIndex,
1510-
EditorSession editorSession,
1511-
EventContext eventContext,
1512-
ILogger Logger,
1513-
CancellationToken cancellationToken)
1514-
{
1515-
await DelayThenInvokeDiagnostics(
1516-
delayMilliseconds,
1517-
filesToAnalyze,
1518-
isScriptAnalysisEnabled,
1519-
correctionIndex,
1520-
editorSession,
1521-
eventContext.SendEvent,
1522-
Logger,
1523-
cancellationToken);
1524-
}
1525-
1526-
15271546
private static async Task DelayThenInvokeDiagnostics(
15281547
int delayMilliseconds,
15291548
ScriptFile[] filesToAnalyze,
@@ -1573,6 +1592,7 @@ private static async Task DelayThenInvokeDiagnostics(
15731592

15741593
await PublishScriptDiagnostics(
15751594
scriptFile,
1595+
// Concat script analysis errors to any existing parse errors
15761596
scriptFile.SyntaxMarkers.Concat(semanticMarkers).ToArray(),
15771597
correctionIndex,
15781598
eventSender);
@@ -1620,7 +1640,8 @@ private static async Task PublishScriptDiagnostics(
16201640
Diagnostic markerDiagnostic = GetDiagnosticFromMarker(marker);
16211641
if (marker.Correction != null)
16221642
{
1623-
fileCorrections.Add(markerDiagnostic.Code, marker.Correction);
1643+
string diagnosticId = GetUniqueIdFromDiagnostic(markerDiagnostic);
1644+
fileCorrections.Add(diagnosticId, marker.Correction);
16241645
}
16251646

16261647
diagnostics.Add(markerDiagnostic);
@@ -1639,13 +1660,39 @@ await eventSender(
16391660
});
16401661
}
16411662

1663+
// Generate a unique id that is used as a key to look up the associated code action (code fix) when
1664+
// we receive and process the textDocument/codeAction message.
1665+
private static string GetUniqueIdFromDiagnostic(Diagnostic diagnostic)
1666+
{
1667+
Position start = diagnostic.Range.Start;
1668+
Position end = diagnostic.Range.End;
1669+
1670+
var sb = new StringBuilder(256)
1671+
.Append(diagnostic.Source ?? "?")
1672+
.Append("_")
1673+
.Append(diagnostic.Code ?? "?")
1674+
.Append("_")
1675+
.Append(diagnostic.Severity?.ToString() ?? "?")
1676+
.Append("_")
1677+
.Append(start.Line)
1678+
.Append(":")
1679+
.Append(start.Character)
1680+
.Append("-")
1681+
.Append(end.Line)
1682+
.Append(":")
1683+
.Append(end.Character);
1684+
1685+
var id = sb.ToString();
1686+
return id;
1687+
}
1688+
16421689
private static Diagnostic GetDiagnosticFromMarker(ScriptFileMarker scriptFileMarker)
16431690
{
16441691
return new Diagnostic
16451692
{
16461693
Severity = MapDiagnosticSeverity(scriptFileMarker.Level),
16471694
Message = scriptFileMarker.Message,
1648-
Code = scriptFileMarker.Source + Guid.NewGuid().ToString(),
1695+
Code = scriptFileMarker.RuleName,
16491696
Source = scriptFileMarker.Source,
16501697
Range = new Range
16511698
{

src/PowerShellEditorServices/Workspace/ScriptFileMarker.cs

+7-2
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ public class ScriptFileMarker
6262
/// Gets or sets the marker's message string.
6363
/// </summary>
6464
public string Message { get; set; }
65+
66+
/// <summary>
67+
/// Gets or sets the ruleName associated with this marker.
68+
/// </summary>
69+
public string RuleName { get; set; }
6570

6671
/// <summary>
6772
/// Gets or sets the marker's message level.
@@ -130,7 +135,6 @@ internal static ScriptFileMarker FromDiagnosticRecord(PSObject psObject)
130135
// the diagnostic record's properties directly i.e. <instance>.<propertyName>
131136
// without having to go through PSObject's Members property.
132137
var diagnosticRecord = psObject as dynamic;
133-
string ruleName = diagnosticRecord.RuleName as string;
134138

135139
if (diagnosticRecord.SuggestedCorrections != null)
136140
{
@@ -160,7 +164,8 @@ internal static ScriptFileMarker FromDiagnosticRecord(PSObject psObject)
160164

161165
return new ScriptFileMarker
162166
{
163-
Message = $"{diagnosticRecord.Message as string} ({ruleName})",
167+
Message = $"{diagnosticRecord.Message as string}",
168+
RuleName = $"{diagnosticRecord.RuleName as string}",
164169
Level = GetMarkerLevelFromDiagnosticSeverity((diagnosticRecord.Severity as Enum).ToString()),
165170
ScriptRegion = ScriptRegion.Create(diagnosticRecord.Extent as IScriptExtent),
166171
Correction = correction,

0 commit comments

Comments
 (0)