Skip to content

Commit c942560

Browse files
dkattanCopilot
andcommitted
feat: revive provider-backed workspace
Rebase feature/get-content onto current upstream/main. Keep WorkspaceService synchronous while routing file reads and workspace enumeration through PowerShell when a host is available, add pspath URI support, and cover provider-backed launching in tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d2112c2 commit c942560

5 files changed

Lines changed: 216 additions & 35 deletions

File tree

src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,11 @@ public Task<Unit> Handle(DidChangeWatchedFilesParams request, CancellationToken
6868
matcher.AddExcludePatterns(_workspaceService.ExcludeFilesGlob);
6969
foreach (FileEvent change in request.Changes)
7070
{
71-
if (matcher.Match(change.Uri.GetFileSystemPath()).HasMatches)
71+
string changePath = change.Uri.ToUri().IsFile
72+
? change.Uri.GetFileSystemPath()
73+
: change.Uri.ToUri().AbsolutePath;
74+
75+
if (matcher.Match(changePath).HasMatches)
7276
{
7377
continue;
7478
}
@@ -102,7 +106,7 @@ public Task<Unit> Handle(DidChangeWatchedFilesParams request, CancellationToken
102106
string fileContents;
103107
try
104108
{
105-
fileContents = WorkspaceService.ReadFileContents(change.Uri);
109+
fileContents = _workspaceService.ReadFileContents(change.Uri);
106110
}
107111
catch
108112
{

src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,12 +196,31 @@ internal static List<string> GetLines(string text)
196196
/// <returns>True if the path is an untitled file, false otherwise.</returns>
197197
internal static bool IsUntitledPath(string path)
198198
{
199-
Validate.IsNotNull(nameof(path), path);
200-
// This may not have been given a URI, so return false instead of throwing.
201-
return Uri.IsWellFormedUriString(path, UriKind.RelativeOrAbsolute) &&
202-
!string.Equals(DocumentUri.From(path).Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase);
199+
if (!Uri.IsWellFormedUriString(path, UriKind.RelativeOrAbsolute))
200+
{
201+
return false;
202+
}
203+
204+
DocumentUri documentUri = DocumentUri.From(path);
205+
string scheme = documentUri.Scheme?.ToLowerInvariant();
206+
if (!IsSupportedScheme(scheme))
207+
{
208+
return false;
209+
}
210+
211+
return scheme switch
212+
{
213+
"inmemory" or "untitled" or "vscode-notebook-cell" => true,
214+
_ => false,
215+
};
203216
}
204217

218+
internal static bool IsSupportedScheme(string scheme) => scheme?.ToLowerInvariant() switch
219+
{
220+
"file" or "inmemory" or "untitled" or "vscode-notebook-cell" or "pspath" => true,
221+
_ => false,
222+
};
223+
205224
/// <summary>
206225
/// Gets a line from the file's contents.
207226
/// </summary>

src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs

Lines changed: 151 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@
66
using System.Collections.Generic;
77
using System.IO;
88
using System.Linq;
9+
using System.Management.Automation;
910
using System.Security;
1011
using System.Text;
12+
using System.Threading;
1113
using Microsoft.Extensions.FileSystemGlobbing;
1214
using Microsoft.Extensions.Logging;
15+
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution;
16+
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host;
1317
using Microsoft.PowerShell.EditorServices.Services.TextDocument;
1418
using Microsoft.PowerShell.EditorServices.Services.Workspace;
1519
using Microsoft.PowerShell.EditorServices.Utility;
@@ -51,9 +55,12 @@ internal class WorkspaceService
5155
"**/*"
5256
};
5357

58+
private const string s_psPathScheme = "pspath";
59+
5460
private readonly ILogger logger;
5561
private readonly Version powerShellVersion;
5662
private readonly ConcurrentDictionary<string, ScriptFile> workspaceFiles = new();
63+
private readonly PsesInternalHost psesInternalHost;
5764

5865
#endregion
5966

@@ -100,6 +107,12 @@ public WorkspaceService(ILoggerFactory factory)
100107
FollowSymlinks = true;
101108
}
102109

110+
/// <summary>
111+
/// Creates a new instance of the Workspace class backed by a PowerShell host.
112+
/// </summary>
113+
public WorkspaceService(ILoggerFactory factory, PsesInternalHost psesInternalHost)
114+
: this(factory) => this.psesInternalHost = psesInternalHost;
115+
103116
#endregion
104117

105118
#region Public Methods
@@ -139,18 +152,8 @@ public ScriptFile GetFile(DocumentUri documentUri)
139152
// Make sure the file isn't already loaded into the workspace
140153
if (!workspaceFiles.TryGetValue(keyName, out ScriptFile scriptFile))
141154
{
142-
// This method allows FileNotFoundException to bubble up
143-
// if the file isn't found.
144-
using (StreamReader streamReader = OpenStreamReader(documentUri))
145-
{
146-
scriptFile =
147-
new ScriptFile(
148-
documentUri,
149-
streamReader,
150-
powerShellVersion);
151-
152-
workspaceFiles[keyName] = scriptFile;
153-
}
155+
scriptFile = ScriptFile.Create(documentUri, ReadFileContents(documentUri), powerShellVersion);
156+
workspaceFiles[keyName] = scriptFile;
154157

155158
logger.LogDebug("Opened file on disk: " + documentUri.ToString());
156159
}
@@ -192,18 +195,10 @@ public bool TryGetFile(Uri fileUri, out ScriptFile scriptFile) =>
192195
/// <param name="scriptFile">The out parameter that will contain the ScriptFile object.</param>
193196
public bool TryGetFile(DocumentUri documentUri, out ScriptFile scriptFile)
194197
{
195-
switch (documentUri.Scheme)
198+
if (ScriptFile.IsUntitledPath(documentUri.ToString()) || !ScriptFile.IsSupportedScheme(documentUri.Scheme))
196199
{
197-
// List supported schemes here
198-
case "file":
199-
case "inmemory":
200-
case "untitled":
201-
case "vscode-notebook-cell":
202-
break;
203-
204-
default:
205-
scriptFile = null;
206-
return false;
200+
scriptFile = null;
201+
return false;
207202
}
208203

209204
try
@@ -396,11 +391,53 @@ public IEnumerable<string> EnumeratePSFiles(
396391
int maxDepth,
397392
bool ignoreReparsePoints)
398393
{
394+
string[] workspacePaths = GetPowerShellWorkspacePaths()
395+
.Where(path => !string.IsNullOrEmpty(path))
396+
.Distinct(StringComparer.OrdinalIgnoreCase)
397+
.ToArray();
398+
399+
if (workspacePaths.Length == 0)
400+
{
401+
yield break;
402+
}
403+
404+
if (psesInternalHost is not null)
405+
{
406+
PSCommand psCommand = new PSCommand()
407+
.AddCommand(@"Microsoft.PowerShell.Management\Get-ChildItem")
408+
.AddParameter("LiteralPath", workspacePaths)
409+
.AddParameter("Recurse")
410+
.AddParameter("ErrorAction", ActionPreference.SilentlyContinue)
411+
.AddParameter("Force")
412+
.AddParameter("Include", includeGlobs.Concat(VersionUtils.IsNetCore ? s_psFileExtensionsCoreFramework : s_psFileExtensionsFullFramework).ToArray())
413+
.AddParameter("Exclude", excludeGlobs)
414+
.AddParameter("Depth", maxDepth);
415+
416+
if (VersionUtils.IsNetCore)
417+
{
418+
psCommand.AddParameter("FollowSymlink", !ignoreReparsePoints);
419+
}
420+
421+
psCommand
422+
.AddCommand("Where-Object")
423+
.AddParameter("Property", "PSIsContainer")
424+
.AddParameter("EQ")
425+
.AddParameter("Value", false);
426+
427+
IReadOnlyList<PSObject> results = psesInternalHost.InvokePSCommand<PSObject>(psCommand, null, CancellationToken.None);
428+
foreach (string path in results.Select(ConvertWorkspaceItemPath).Where(path => !string.IsNullOrEmpty(path)))
429+
{
430+
yield return path;
431+
}
432+
433+
yield break;
434+
}
435+
399436
Matcher matcher = new();
400437
foreach (string pattern in includeGlobs) { matcher.AddInclude(pattern); }
401438
foreach (string pattern in excludeGlobs) { matcher.AddExclude(pattern); }
402439

403-
foreach (string rootPath in WorkspacePaths)
440+
foreach (string rootPath in workspacePaths)
404441
{
405442
if (!Directory.Exists(rootPath))
406443
{
@@ -439,10 +476,97 @@ internal static StreamReader OpenStreamReader(DocumentUri uri)
439476
return new StreamReader(fileStream, new UTF8Encoding(), detectEncodingFromByteOrderMarks: true);
440477
}
441478

442-
internal static string ReadFileContents(DocumentUri uri)
479+
internal string ReadFileContents(DocumentUri uri)
480+
{
481+
if (psesInternalHost is null)
482+
{
483+
using StreamReader reader = OpenStreamReader(uri);
484+
return reader.ReadToEnd();
485+
}
486+
487+
string psPath = GetPowerShellPath(uri);
488+
try
489+
{
490+
IReadOnlyList<string> result = psesInternalHost.InvokePSCommand<string>(
491+
new PSCommand()
492+
.AddCommand(@"Microsoft.PowerShell.Management\Get-Content")
493+
.AddParameter("LiteralPath", psPath)
494+
.AddParameter("ErrorAction", ActionPreference.Stop),
495+
new PowerShellExecutionOptions { ThrowOnError = true },
496+
CancellationToken.None);
497+
498+
return string.Join(Environment.NewLine, result);
499+
}
500+
catch (ActionPreferenceStopException ex)
501+
when (ex.ErrorRecord.CategoryInfo.Category == ErrorCategory.ObjectNotFound
502+
&& ex.ErrorRecord.TargetObject is string[] missingFiles
503+
&& missingFiles.Length == 1)
504+
{
505+
throw new FileNotFoundException(ex.ErrorRecord.ToString(), missingFiles[0], ex.ErrorRecord.Exception);
506+
}
507+
}
508+
509+
private IEnumerable<string> GetPowerShellWorkspacePaths()
510+
{
511+
if (WorkspaceFolders.Count > 0)
512+
{
513+
return WorkspaceFolders.Select(folder => GetPowerShellPath(folder.Uri));
514+
}
515+
516+
return string.IsNullOrEmpty(InitialWorkingDirectory)
517+
? Array.Empty<string>()
518+
: new[] { InitialWorkingDirectory };
519+
}
520+
521+
private static string ConvertWorkspaceItemPath(PSObject item)
522+
{
523+
if (item.Properties["FullName"]?.Value is string fullName && !string.IsNullOrEmpty(fullName))
524+
{
525+
return fullName;
526+
}
527+
528+
return item.Properties["PSPath"]?.Value is string psPath && !string.IsNullOrEmpty(psPath)
529+
? CreatePowerShellPathUri(psPath)
530+
: null;
531+
}
532+
533+
private static string GetPowerShellPath(DocumentUri uri)
443534
{
444-
using StreamReader reader = OpenStreamReader(uri);
445-
return reader.ReadToEnd();
535+
Uri parsedUri = uri.ToUri();
536+
if (parsedUri.IsFile)
537+
{
538+
return parsedUri.LocalPath;
539+
}
540+
541+
if (string.Equals(uri.Scheme, s_psPathScheme, StringComparison.OrdinalIgnoreCase))
542+
{
543+
string provider = parsedUri.GetComponents(UriComponents.Host, UriFormat.Unescaped);
544+
string path = Uri.UnescapeDataString(parsedUri.AbsolutePath);
545+
if (path.Length >= 3 && path[0] == '/' && char.IsLetter(path[1]) && path[2] == ':')
546+
{
547+
path = path.TrimStart('/');
548+
}
549+
550+
return string.IsNullOrEmpty(provider)
551+
? path.TrimStart('/')
552+
: $"{provider}::{path}";
553+
}
554+
555+
throw new NotSupportedException($"Unsupported URI scheme '{uri.Scheme}'.");
556+
}
557+
558+
private static string CreatePowerShellPathUri(string psPath)
559+
{
560+
string[] parts = psPath.Split(new[] { "::" }, 2, StringSplitOptions.None);
561+
if (parts.Length != 2)
562+
{
563+
return $"{s_psPathScheme}:///{Uri.EscapeDataString(psPath)}";
564+
}
565+
566+
string provider = parts[0].Split('\\').Last();
567+
string normalizedPath = parts[1].Replace('\\', '/');
568+
string encodedPath = string.Join("/", normalizedPath.Split('/').Select(Uri.EscapeDataString));
569+
return $"{s_psPathScheme}://{Uri.EscapeDataString(provider)}/{encodedPath}";
446570
}
447571

448572
/// <summary>

test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public class DebugServiceTests : IAsyncLifetime
4242
private WorkspaceService workspace;
4343
private ScriptFile debugScriptFile;
4444
private ScriptFile oddPathScriptFile;
45+
private ScriptFile psProviderPathScriptFile;
4546
private ScriptFile variableScriptFile;
4647
private readonly TestReadLine testReadLine = new();
4748

@@ -70,10 +71,19 @@ public async Task InitializeAsync()
7071
debugService.DebuggerStopped += OnDebuggerStopped;
7172

7273
// Load the test debug files.
73-
workspace = new WorkspaceService(NullLoggerFactory.Instance);
74+
workspace = new WorkspaceService(NullLoggerFactory.Instance, psesHost);
7475
debugScriptFile = GetDebugScript("DebugTest.ps1");
7576
oddPathScriptFile = GetDebugScript("Debug' W&ith $Params [Test].ps1");
7677
variableScriptFile = GetDebugScript("VariableTest.ps1");
78+
79+
string variableScriptFilePath = TestUtilities.GetSharedPath(Path.Combine("Debugging", "VariableTest.ps1"));
80+
dynamic psItem = (await psesHost.ExecutePSCommandAsync<dynamic>(
81+
new PSCommand()
82+
.AddCommand("Get-Item")
83+
.AddParameter("LiteralPath", variableScriptFilePath),
84+
CancellationToken.None)).First();
85+
86+
psProviderPathScriptFile = workspace.GetFile(ConvertPSPathToUri((string)psItem.PSPath.ToString()));
7787
}
7888

7989
public async Task DisposeAsync()
@@ -94,6 +104,15 @@ public async Task DisposeAsync()
94104
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD110:Observe result of async calls", Justification = "This intentionally fires and forgets on another thread.")]
95105
private void OnDebuggerStopped(object sender, DebuggerStoppedEventArgs e) => Task.Run(() => debuggerStoppedQueue.Add(e));
96106

107+
private static string ConvertPSPathToUri(string psPath)
108+
{
109+
string[] parts = psPath.Split(new[] { "::" }, 2, StringSplitOptions.None);
110+
string provider = parts[0].Split('\\').Last();
111+
string normalizedPath = parts[1].Replace('\\', '/');
112+
string encodedPath = string.Join("/", normalizedPath.Split('/').Select(Uri.EscapeDataString));
113+
return $"pspath://{Uri.EscapeDataString(provider)}/{encodedPath}";
114+
}
115+
97116
private ScriptFile GetDebugScript(string fileName) => workspace.GetFile(TestUtilities.GetSharedPath(Path.Combine("Debugging", fileName)));
98117

99118
private Task<VariableDetailsBase[]> GetVariables(string scopeName)
@@ -626,6 +645,20 @@ public async Task OddFilePathsLaunchCorrectly()
626645
Assert.Equal(". " + PSCommandHelpers.EscapeScriptFilePath(oddPathScriptFile.FilePath), Assert.Single(historyResult));
627646
}
628647

648+
[Fact]
649+
public async Task PSProviderPathsLaunchCorrectly()
650+
{
651+
ConfigurationDoneHandler configurationDoneHandler = new(
652+
NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null);
653+
await configurationDoneHandler.LaunchScriptAsync(psProviderPathScriptFile.FilePath);
654+
655+
IReadOnlyList<string> historyResult = await psesHost.ExecutePSCommandAsync<string>(
656+
new PSCommand().AddScript("(Get-History).CommandLine"),
657+
CancellationToken.None);
658+
659+
Assert.Equal(". $args[0]", Assert.Single(historyResult));
660+
}
661+
629662
[Fact]
630663
public async Task DebuggerVariableStringDisplaysCorrectly()
631664
{

test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -665,8 +665,9 @@ public void DocumentUriReturnsCorrectStringForAbsolutePath()
665665
[InlineData(@"C:\Users\me\Documents\test.ps1", false)]
666666
[InlineData("/Users/me/Documents/test.ps1", false)]
667667
[InlineData("vscode-notebook-cell:/Users/me/Documents/test.ps1#0001", true)]
668-
[InlineData("https://microsoft.com", true)]
668+
[InlineData("https://microsoft.com", false)]
669669
[InlineData("Untitled:Untitled-1", true)]
670+
[InlineData("pspath://filesystem/C%3A/Users/me/Documents/test.ps1", false)]
670671
[InlineData(@"'a log statement' > 'c:\Users\me\Documents\test.txt'
671672
", false)]
672673
public void IsUntitledFileIsCorrect(string path, bool expected) => Assert.Equal(expected, ScriptFile.IsUntitledPath(path));

0 commit comments

Comments
 (0)