Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions OneWare.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
<Project Path="tests/OneWare.Dock.HeadlessTests/OneWare.Dock.HeadlessTests.csproj" />
<Project Path="tests/OneWare.Essentials.UnitTests/OneWare.Essentials.UnitTests.csproj" />
<Project Path="tests/OneWare.PackageManager.UnitTests/OneWare.PackageManager.UnitTests.csproj" />
<Project Path="tests/OneWare.Python.UnitTests/OneWare.Python.UnitTests.csproj" />
<Project Path="tests/OneWare.Studio.Desktop.UnitTests/OneWare.Studio.Desktop.UnitTests.csproj" />
<Project Path="tests/OneWare.Terminal.UnitTests/OneWare.Terminal.UnitTests.csproj" />
<Project Path="tests/OneWare.TestPlugin/OneWare.TestPlugin.csproj" />
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,32 @@ Packages that are already installed are left untouched, and a profile that canno
skipped rather than blocking startup. A package entry without a `version` installs the latest stable version. Settings
that are only read while the application starts take effect on the next launch.

### Python support

Python source and stub files (`.py` / `.pyi`) use [Pyrefly](https://pyrefly.org/). With automatic binary downloads enabled
under **Settings > Experimental > Environment**, opening a Python file installs the pinned native language server.
Alternatively, install **Pyrefly** from Package Manager or set **Settings > Languages > Python > Pyrefly Path**.
Native packages are provided for Windows, Linux, and macOS on x64 and ARM64; pip and Node are not needed to host the server.
The former pylsp integration and its executable-path setting are no longer used.

Use **Code > Python > Select Interpreter...** or the **Python: Select Interpreter** command to choose an existing
interpreter for the active Python file's workspace (or the active project). The picker lists detected environments and
supports browsing, refreshing, and returning to **Automatic**. Selections are saved in this machine's IDE settings,
not written into the project.

Automatic resolution uses the workspace's `.venv`, then `venv`, then **Settings > Languages > Python > Default Python
interpreter**, then Python on PATH. An explicit workspace selection overrides that chain. Missing explicit selections
are reported and pause Python language analysis rather than silently selecting a different interpreter. With no Python
installation and no explicit selection, available Pyrefly analysis remains usable, but third-party imports cannot be
resolved reliably. OneWare does not install Python runtimes or create virtual environments.

Interpreter changes update the workspace's analysis without reopening files. Pyrefly project configuration can take
precedence over the IDE's interpreter fallback, including an explicit project interpreter or `skip-interpreter-query`.
Type-checking strictness follows Pyrefly's project configuration and upstream defaults.
The interpreter selection configures language analysis, not terminal activation or a Python run/debug configuration.
Basic newline indentation and `#` commenting work independently of the server. Pyrefly does not supply document
formatting; Ruff/formatter integration is not included.

## Nuget

| Package | Download |
Expand Down
77 changes: 51 additions & 26 deletions src/OneWare.Essentials/LanguageService/LanguageServiceLsp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ public abstract class LanguageServiceLsp(string name, string? workspace) : Langu
protected string? Arguments { get; set; }
protected string? ExecutablePath { get; set; }

protected virtual void ConfigureClientOptions(LanguageClientOptions options)
{
}

public virtual IReadOnlyCollection<KeyValuePair<string, string>> GetExtraEnvironmentVariables()
{
return new List<KeyValuePair<string, string>>();
Expand All @@ -48,22 +52,17 @@ public virtual IReadOnlyCollection<KeyValuePair<string, string>> GetExtraEnviron
public override async Task ActivateAsync()
{
if (IsActivated) return;
IsActivated = true;

if (ExecutablePath == null)
if (string.IsNullOrWhiteSpace(ExecutablePath))
{
ContainerLocator.Container.Resolve<ILogger>().Warning(
$"Tried to activate Language Server {Name} without executable!", new NotSupportedException(), false);
return;
}

if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
PlatformHelper.ChmodFile(ExecutablePath);

_cancellation = new CancellationTokenSource();

if (ExecutablePath.StartsWith("wss://") || ExecutablePath.StartsWith("ws://"))
{
IsActivated = true;
_cancellation = new CancellationTokenSource();
var websocket = new ClientWebSocket();
try
{
Expand All @@ -88,6 +87,8 @@ public override async Task ActivateAsync()
return;
}

IsActivated = true;
_cancellation = new CancellationTokenSource();
var argumentArray = Arguments != null
? Arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.RemoveEmptyEntries)
: Array.Empty<string>();
Expand All @@ -105,30 +106,49 @@ public override async Task ActivateAsync()

try
{
_process = ContainerLocator.Container.Resolve<IChildProcessService>().StartChildProcess(processStartInfo);
var reader = new StreamReader(_process.StandardError);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
PlatformHelper.ChmodFile(PlatformHelper.GetFullPath(ExecutablePath) ?? ExecutablePath);

var process = ContainerLocator.Container.Resolve<IChildProcessService>().StartChildProcess(processStartInfo);
_process = process;
var cancellation = _cancellation;
var reader = new StreamReader(process.StandardError);
_ = Task.Run(() =>
{
while (_process.HasStandardError && !reader.EndOfStream && !_cancellation.IsCancellationRequested)
while (process.HasStandardError && !reader.EndOfStream && !cancellation.IsCancellationRequested)
Console.WriteLine("ERR:" + reader.ReadToEnd());
}, _cancellation.Token);
}, cancellation.Token);

await InitAsync(_process.StandardOutput, _process.StandardInput);
await InitAsync(process.StandardOutput, process.StandardInput);
if (!IsLanguageServiceReady)
{
if (ReferenceEquals(_process, process)) await CleanupServerAsync();
return;
}

await _process.WaitForExitAsync();
await process.WaitForExitAsync();

await DeactivateAsync();
if (ReferenceEquals(_process, process)) await CleanupServerAsync();
}
catch (Exception e)
{
ContainerLocator.Container.Resolve<ILogger>()?.Error(e.Message, e);
IsActivated = false;
await CleanupServerAsync();
}
}

public override async Task DeactivateAsync()
public override Task DeactivateAsync() => CleanupServerAsync();

private async Task CleanupServerAsync()
{
IsActivated = false;
IsLanguageServiceReady = false;
var client = Client;
Client = null;
var process = _process;
_process = null;
var cancellation = _cancellation;
_cancellation = null;

lock (_pullDiagnosticsRequests)
{
Expand All @@ -139,14 +159,12 @@ public override async Task DeactivateAsync()

await Dispatcher.UIThread.InvokeAsync(async () =>
{
if (Client == null) return;
if (client == null) return;
try
{
Client.SendExit();
client.SendExit();

await Task.Delay(200);
Client = null;
IsLanguageServiceReady = false;
}
catch (Exception e)
{
Expand All @@ -156,13 +174,13 @@ await Dispatcher.UIThread.InvokeAsync(async () =>
ContainerLocator.Container.Resolve<IErrorService>()?.Clear(Name);
});
await base.DeactivateAsync();
_cancellation?.Cancel();
_process?.Kill();
cancellation?.Cancel();
process?.Kill();
}

private async Task InitAsync(Stream input, Stream output, Action<LanguageClientOptions>? customOptions = null)
{
Client = LanguageClient.PreInit(options =>
var client = LanguageClient.PreInit(options =>
{
options.WithClientInfo(new ClientInfo { Name = "OneWare.Core" });
options.WithInput(input).WithOutput(output);
Expand Down Expand Up @@ -335,16 +353,22 @@ private async Task InitAsync(Stream input, Stream output, Action<LanguageClientO
});

customOptions?.Invoke(options);
ConfigureClientOptions(options);
}
);
Client = client;

var cancelToken = new CancellationToken();
var cancelToken = _cancellation?.Token ?? CancellationToken.None;

ContainerLocator.Container.Resolve<ILogger>()?.Log("Preinit finished " + Name);

try
{
await Client.Initialize(cancelToken).ConfigureAwait(false);
await client.Initialize(cancelToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancelToken.IsCancellationRequested)
{
return;
}
catch (Exception e)
{
Expand All @@ -353,6 +377,7 @@ private async Task InitAsync(Stream input, Stream output, Action<LanguageClientO
return;
}

if (!ReferenceEquals(Client, client) || cancelToken.IsCancellationRequested) return;
ContainerLocator.Container.Resolve<ILogger>()?.Log("init finished " + Name);

IsLanguageServiceReady = true;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using OneWare.Essentials.PackageManager;
using Microsoft.Extensions.Logging;
using OneWare.Essentials.Helpers;
using OneWare.Essentials.PackageManager;
using OneWare.Essentials.PackageManager.Compatibility;
using OneWare.Essentials.Services;

namespace OneWare.Essentials.LanguageService;
Expand All @@ -7,7 +10,15 @@ public abstract class LanguageServiceLspAutoDownload : LanguageServiceLsp
{
private readonly Package _package;
private readonly IPackageService _packageService;
private readonly object _lifecycleGate = new();
private Task? _activationTask;
private bool _activating;
private bool _restarting;
private bool _pendingPathActivation;
private bool _enableAutoDownload;
private bool _activationRequested;
private int _activationGeneration;
private DateTime _lastInstallAttempt = DateTime.MinValue;

protected LanguageServiceLspAutoDownload(IObservable<string> executablePath, Package package, string name,
string? workspace, IPackageService packageService, IObservable<bool> enableAutoDownload,
Expand All @@ -17,21 +28,132 @@ protected LanguageServiceLspAutoDownload(IObservable<string> executablePath, Pac
_package = package;
_packageService = packageService;

// Set Arguments before subscribing so the value is available when
// ActivateAsync() fires synchronously on the first observable emission.
Arguments = arguments;

enableAutoDownload.Subscribe(x => { _enableAutoDownload = x; });
executablePath.Subscribe(x =>
{
ExecutablePath = x;
if (File.Exists(ExecutablePath)) _ = ActivateAsync();
lock (_lifecycleGate)
{
if (ExecutablePath == x) return;
ExecutablePath = x;
// LanguageManager activates after construction, not during initial subscription.
if (!_activationRequested || !File.Exists(ExecutablePath)) return;
_pendingPathActivation = true;
if (_restarting) return;
if (IsActivated)
_ = RestartAsync();
else if (!_activating)
_ = ActivateAsync();
}
});
}

public override async Task ActivateAsync()
public override Task ActivateAsync()
{
if (!File.Exists(ExecutablePath) && _enableAutoDownload) await _packageService.InstallAsync(_package);
await base.ActivateAsync();
lock (_lifecycleGate)
{
if (_restarting || _activating) return Task.CompletedTask;
_activationRequested = true;
_activating = true;
return _activationTask = ActivateCoreAsync(_activationGeneration);
}
}

private async Task ActivateCoreAsync(int generation)
{
try
{
if (IsActivated) return;
if (!PlatformHelper.Exists(ExecutablePath ?? "") && _enableAutoDownload)
{
// Opening more documents must not repeatedly retry a failed/offline download.
if (DateTime.UtcNow - _lastInstallAttempt < TimeSpan.FromSeconds(30)) return;
_lastInstallAttempt = DateTime.UtcNow;
var result = await _packageService.InstallAsync(_package);
if (result.Status is not (PackageInstallResultReason.Installed or PackageInstallResultReason.AlreadyInstalled))
{
ContainerLocator.Container?.Resolve<ILogger>()
.Warning($"Could not install {Name}: {result.Status}. Install it from Package Manager or set its executable path.");
return;
}
}

Task activation;
lock (_lifecycleGate)
{
if (!_activationRequested || generation != _activationGeneration) return;
_pendingPathActivation = false;
activation = ActivateServerAsync();
}
await activation;
}
finally
{
lock (_lifecycleGate)
{
_activating = false;
// An installer can publish the replacement while the killed server is still cleaning up.
if (_pendingPathActivation && _activationRequested && !_restarting &&
generation == _activationGeneration && File.Exists(ExecutablePath))
{
_pendingPathActivation = false;
_ = ActivateAsync();
}
}
}
}

protected virtual Task ActivateServerAsync() => base.ActivateAsync();

public override Task DeactivateAsync()
{
lock (_lifecycleGate)
{
_activationRequested = false;
_pendingPathActivation = false;
_activationGeneration++;
return DeactivateServerAsync();
}
}

protected virtual Task DeactivateServerAsync() => base.DeactivateAsync();

public override async Task RestartAsync()
{
Task? outgoing;
Task deactivation;
int generation;
lock (_lifecycleGate)
{
if (_restarting) return;
_restarting = true;
outgoing = _activationTask;
deactivation = DeactivateAsync();
generation = _activationGeneration;
}

var stopped = false;
Task activation = Task.CompletedTask;
try
{
await deactivation;
// Wait for this particular outgoing server/install, never a replacement's lifetime.
if (outgoing != null) await outgoing;
stopped = true;
}
finally
{
lock (_lifecycleGate)
{
_restarting = false;
if (stopped && generation == _activationGeneration)
{
_lastInstallAttempt = DateTime.MinValue;
activation = ActivateAsync();
}
}
}
await activation;
}
}
3 changes: 3 additions & 0 deletions src/OneWare.Python/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("OneWare.Python.UnitTests")]
Loading
Loading