Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ public Task<Unit> Handle(DidChangeWatchedFilesParams request, CancellationToken
matcher.AddExcludePatterns(_workspaceService.ExcludeFilesGlob);
foreach (FileEvent change in request.Changes)
{
if (matcher.Match(change.Uri.GetFileSystemPath()).HasMatches)
string changePath = change.Uri.ToUri().IsFile
? change.Uri.GetFileSystemPath()
: change.Uri.ToUri().AbsolutePath;

if (matcher.Match(changePath).HasMatches)
{
continue;
}
Expand Down Expand Up @@ -102,7 +106,7 @@ public Task<Unit> Handle(DidChangeWatchedFilesParams request, CancellationToken
string fileContents;
try
{
fileContents = WorkspaceService.ReadFileContents(change.Uri);
fileContents = _workspaceService.ReadFileContents(change.Uri);
}
catch
{
Expand Down
27 changes: 23 additions & 4 deletions src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,12 +196,31 @@ internal static List<string> GetLines(string text)
/// <returns>True if the path is an untitled file, false otherwise.</returns>
internal static bool IsUntitledPath(string path)
{
Validate.IsNotNull(nameof(path), path);
// This may not have been given a URI, so return false instead of throwing.
return Uri.IsWellFormedUriString(path, UriKind.RelativeOrAbsolute) &&
!string.Equals(DocumentUri.From(path).Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase);
if (!Uri.IsWellFormedUriString(path, UriKind.RelativeOrAbsolute))
{
return false;
}

DocumentUri documentUri = DocumentUri.From(path);
string scheme = documentUri.Scheme?.ToLowerInvariant();
if (!IsSupportedScheme(scheme))
{
return false;
}

return scheme switch
{
"inmemory" or "untitled" or "vscode-notebook-cell" => true,
_ => false,
};
}

internal static bool IsSupportedScheme(string scheme) => scheme?.ToLowerInvariant() switch
{
"file" or "inmemory" or "untitled" or "vscode-notebook-cell" or "pspath" => true,
_ => false,
};

/// <summary>
/// Gets a line from the file's contents.
/// </summary>
Expand Down
202 changes: 174 additions & 28 deletions src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Management.Automation;
using System.Security;
using System.Text;
using System.Threading;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Extensions.Logging;
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution;
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host;
using Microsoft.PowerShell.EditorServices.Services.TextDocument;
using Microsoft.PowerShell.EditorServices.Services.Workspace;
using Microsoft.PowerShell.EditorServices.Utility;
Expand Down Expand Up @@ -51,9 +55,12 @@ internal class WorkspaceService
"**/*"
};

private const string s_psPathScheme = "pspath";

private readonly ILogger logger;
private readonly Version powerShellVersion;
private readonly ConcurrentDictionary<string, ScriptFile> workspaceFiles = new();
private readonly PsesInternalHost psesInternalHost;

#endregion

Expand Down Expand Up @@ -100,11 +107,18 @@ public WorkspaceService(ILoggerFactory factory)
FollowSymlinks = true;
}

/// <summary>
/// Creates a new instance of the Workspace class backed by a PowerShell host.
/// </summary>
public WorkspaceService(ILoggerFactory factory, PsesInternalHost psesInternalHost)
: this(factory) => this.psesInternalHost = psesInternalHost;

#endregion

#region Public Methods

public IEnumerable<string> WorkspacePaths => WorkspaceFolders.Select(i => i.Uri.GetFileSystemPath());
public IEnumerable<string> WorkspacePaths => WorkspaceFolders.Select(
folder => folder.Uri.ToUri().IsFile ? folder.Uri.GetFileSystemPath() : GetPowerShellPath(folder.Uri));

/// <summary>
/// Gets an open file in the workspace. If the file isn't open but exists on the filesystem, load and return it.
Expand Down Expand Up @@ -139,18 +153,8 @@ public ScriptFile GetFile(DocumentUri documentUri)
// Make sure the file isn't already loaded into the workspace
if (!workspaceFiles.TryGetValue(keyName, out ScriptFile scriptFile))
{
// This method allows FileNotFoundException to bubble up
// if the file isn't found.
using (StreamReader streamReader = OpenStreamReader(documentUri))
{
scriptFile =
new ScriptFile(
documentUri,
streamReader,
powerShellVersion);

workspaceFiles[keyName] = scriptFile;
}
scriptFile = ScriptFile.Create(documentUri, ReadFileContents(documentUri), powerShellVersion);
workspaceFiles[keyName] = scriptFile;

logger.LogDebug("Opened file on disk: " + documentUri.ToString());
}
Expand Down Expand Up @@ -192,18 +196,10 @@ public bool TryGetFile(Uri fileUri, out ScriptFile scriptFile) =>
/// <param name="scriptFile">The out parameter that will contain the ScriptFile object.</param>
public bool TryGetFile(DocumentUri documentUri, out ScriptFile scriptFile)
{
switch (documentUri.Scheme)
if (!ScriptFile.IsSupportedScheme(documentUri.Scheme))
{
// List supported schemes here
case "file":
case "inmemory":
case "untitled":
case "vscode-notebook-cell":
break;

default:
scriptFile = null;
return false;
scriptFile = null;
return false;
}

try
Expand Down Expand Up @@ -396,11 +392,58 @@ public IEnumerable<string> EnumeratePSFiles(
int maxDepth,
bool ignoreReparsePoints)
{
string[] powerShellWorkspacePaths = GetPowerShellWorkspacePaths()
.Where(path => !string.IsNullOrEmpty(path))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();

string[] fileSystemWorkspacePaths = GetFileSystemWorkspacePaths()
.Where(path => !string.IsNullOrEmpty(path))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();

if (powerShellWorkspacePaths.Length == 0 && fileSystemWorkspacePaths.Length == 0)
{
yield break;
}

if (psesInternalHost is not null && powerShellWorkspacePaths.Length > 0)
{
PSCommand psCommand = new PSCommand()
.AddCommand(@"Microsoft.PowerShell.Management\Get-ChildItem")
.AddParameter("LiteralPath", powerShellWorkspacePaths)
.AddParameter("Recurse")
.AddParameter("ErrorAction", ActionPreference.SilentlyContinue)
.AddParameter("Force")
.AddParameter("Include", includeGlobs.Concat(VersionUtils.IsNetCore ? s_psFileExtensionsCoreFramework : s_psFileExtensionsFullFramework).ToArray())
.AddParameter("Exclude", excludeGlobs)
.AddParameter("Depth", maxDepth);

if (VersionUtils.IsNetCore)
{
psCommand.AddParameter("FollowSymlink", !ignoreReparsePoints);
}

psCommand
.AddCommand("Where-Object")
.AddParameter("Property", "PSIsContainer")
.AddParameter("EQ")
.AddParameter("Value", false);

IReadOnlyList<PSObject> results = psesInternalHost.InvokePSCommand<PSObject>(psCommand, null, CancellationToken.None);
foreach (string path in results.Select(ConvertWorkspaceItemPath).Where(path => !string.IsNullOrEmpty(path)))
{
yield return path;
}

yield break;
}

Matcher matcher = new();
foreach (string pattern in includeGlobs) { matcher.AddInclude(pattern); }
foreach (string pattern in excludeGlobs) { matcher.AddExclude(pattern); }

foreach (string rootPath in WorkspacePaths)
foreach (string rootPath in fileSystemWorkspacePaths)
{
if (!Directory.Exists(rootPath))
{
Expand Down Expand Up @@ -439,10 +482,113 @@ internal static StreamReader OpenStreamReader(DocumentUri uri)
return new StreamReader(fileStream, new UTF8Encoding(), detectEncodingFromByteOrderMarks: true);
}

internal static string ReadFileContents(DocumentUri uri)
internal string ReadFileContents(DocumentUri uri)
{
using StreamReader reader = OpenStreamReader(uri);
return reader.ReadToEnd();
if (uri.ToUri().IsFile || psesInternalHost is null)
{
using StreamReader reader = OpenStreamReader(uri);
return reader.ReadToEnd();
}

string psPath = GetPowerShellPath(uri);
try
{
IReadOnlyList<string> result = psesInternalHost.InvokePSCommand<string>(
new PSCommand()
.AddCommand(@"Microsoft.PowerShell.Management\Get-Content")
.AddParameter("LiteralPath", psPath)
.AddParameter("ErrorAction", ActionPreference.Stop),
new PowerShellExecutionOptions { ThrowOnError = true },
CancellationToken.None);

return string.Join(Environment.NewLine, result);
}
catch (ActionPreferenceStopException ex)
when (ex.ErrorRecord.CategoryInfo.Category == ErrorCategory.ObjectNotFound
&& ex.ErrorRecord.TargetObject is string[] missingFiles
&& missingFiles.Length == 1)
{
throw new FileNotFoundException(ex.ErrorRecord.ToString(), missingFiles[0], ex.ErrorRecord.Exception);
}
}

private IEnumerable<string> GetFileSystemWorkspacePaths()
Comment thread
dkattan marked this conversation as resolved.
{
if (WorkspaceFolders.Count > 0)
{
return WorkspaceFolders
.Select(folder => folder.Uri)
.Where(uri => uri.ToUri().IsFile)
.Select(uri => uri.GetFileSystemPath());
}

return string.IsNullOrEmpty(InitialWorkingDirectory)
? Array.Empty<string>()
: new[] { InitialWorkingDirectory };
}

private IEnumerable<string> GetPowerShellWorkspacePaths()
Comment thread
dkattan marked this conversation as resolved.
{
if (WorkspaceFolders.Count > 0)
{
return WorkspaceFolders
.Select(folder => folder.Uri)
.Where(uri => !uri.ToUri().IsFile)
.Select(GetPowerShellPath);
}

return Array.Empty<string>();
}

private static string ConvertWorkspaceItemPath(PSObject item)
Comment thread
dkattan marked this conversation as resolved.
{
if (item.Properties["FullName"]?.Value is string fullName && !string.IsNullOrEmpty(fullName))
{
return fullName;
}

return item.Properties["PSPath"]?.Value is string psPath && !string.IsNullOrEmpty(psPath)
? CreatePowerShellPathUri(psPath)
: null;
}

private static string GetPowerShellPath(DocumentUri uri)
Comment thread
dkattan marked this conversation as resolved.
{
Uri parsedUri = uri.ToUri();
if (parsedUri.IsFile)
{
return parsedUri.LocalPath;
}

if (string.Equals(uri.Scheme, s_psPathScheme, StringComparison.OrdinalIgnoreCase))
{
string provider = parsedUri.GetComponents(UriComponents.Host, UriFormat.Unescaped);
string path = Uri.UnescapeDataString(parsedUri.AbsolutePath);
if (path.Length >= 3 && path[0] == '/' && char.IsLetter(path[1]) && path[2] == ':')
{
path = path.TrimStart('/');
}

return string.IsNullOrEmpty(provider)
? path.TrimStart('/')
: $"{provider}::{path}";
}

throw new NotSupportedException($"Unsupported URI scheme '{uri.Scheme}'.");
}

private static string CreatePowerShellPathUri(string psPath)
Comment thread
dkattan marked this conversation as resolved.
{
string[] parts = psPath.Split(new[] { "::" }, 2, StringSplitOptions.None);
if (parts.Length != 2)
{
return $"{s_psPathScheme}:///{Uri.EscapeDataString(psPath)}";
}

string provider = parts[0].Split('\\').Last();
string normalizedPath = parts[1].Replace('\\', '/');
string encodedPath = string.Join("/", normalizedPath.Split('/').Select(Uri.EscapeDataString));
return $"{s_psPathScheme}://{Uri.EscapeDataString(provider)}/{encodedPath}";
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public class DebugServiceTests : IAsyncLifetime
private WorkspaceService workspace;
private ScriptFile debugScriptFile;
private ScriptFile oddPathScriptFile;
private ScriptFile psProviderPathScriptFile;
private ScriptFile variableScriptFile;
private readonly TestReadLine testReadLine = new();

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

// Load the test debug files.
workspace = new WorkspaceService(NullLoggerFactory.Instance);
workspace = new WorkspaceService(NullLoggerFactory.Instance, psesHost);
debugScriptFile = GetDebugScript("DebugTest.ps1");
oddPathScriptFile = GetDebugScript("Debug' W&ith $Params [Test].ps1");
variableScriptFile = GetDebugScript("VariableTest.ps1");

string variableScriptFilePath = TestUtilities.GetSharedPath(Path.Combine("Debugging", "VariableTest.ps1"));
dynamic psItem = (await psesHost.ExecutePSCommandAsync<dynamic>(
new PSCommand()
.AddCommand("Get-Item")
.AddParameter("LiteralPath", variableScriptFilePath),
CancellationToken.None)).First();

psProviderPathScriptFile = workspace.GetFile(ConvertPSPathToUri((string)psItem.PSPath.ToString()));
}

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

private static string ConvertPSPathToUri(string psPath)
Comment thread
dkattan marked this conversation as resolved.
{
string[] parts = psPath.Split(new[] { "::" }, 2, StringSplitOptions.None);
string provider = parts[0].Split('\\').Last();
string normalizedPath = parts[1].Replace('\\', '/');
string encodedPath = string.Join("/", normalizedPath.Split('/').Select(Uri.EscapeDataString));
return $"pspath://{Uri.EscapeDataString(provider)}/{encodedPath}";
}

private ScriptFile GetDebugScript(string fileName) => workspace.GetFile(TestUtilities.GetSharedPath(Path.Combine("Debugging", fileName)));

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

[Fact]
public async Task PSProviderPathsLaunchCorrectly()
{
ConfigurationDoneHandler configurationDoneHandler = new(
NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null);
await configurationDoneHandler.LaunchScriptAsync(psProviderPathScriptFile.FilePath);

IReadOnlyList<string> historyResult = await psesHost.ExecutePSCommandAsync<string>(
new PSCommand().AddScript("(Get-History).CommandLine"),
CancellationToken.None);

Assert.Equal(". $args[0]", Assert.Single(historyResult));
}

[Fact]
public async Task DebuggerVariableStringDisplaysCorrectly()
{
Expand Down
Loading
Loading