Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ namespace Reqnroll.IdeSupport.Common.Diagnostics;

public class SynchronousFileLogger : AsynchronousFileLogger
{
public SynchronousFileLogger(string ide = "vs", string role = "ext")
: base(new FileSystemForIDE(), TraceLevel.Verbose, ide, role)
public SynchronousFileLogger(string ide = "vs", string role = "ext", TraceLevel level = TraceLevel.Warning)
: base(new FileSystemForIDE(), level, ide, role)
{
EnsureLogFolder();
}

public override void Log(LogMessage message)
{
if (message.Level > Level) return;

try
{
WriteLogMessage(message);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Diagnostics;

namespace Reqnroll.IdeSupport.LSP.Server.Hosting;

Expand All @@ -8,11 +9,21 @@ namespace Reqnroll.IdeSupport.LSP.Server.Hosting;
/// </summary>
public sealed class ClientIdeContext
{
public ClientIdeContext(string? ide) => Ide = ide;
public ClientIdeContext(string? ide, TraceLevel logLevel = TraceLevel.Warning)
{
Ide = ide;
LogLevel = logLevel;
}

/// <summary>The raw <c>--ide</c> value, or <see langword="null"/> when absent.</summary>
public string? Ide { get; }

/// <summary>
/// The file/protocol log verbosity requested via <c>--log-level</c>, defaulting to
/// <see cref="TraceLevel.Warning"/> when the client did not specify one.
/// </summary>
public TraceLevel LogLevel { get; }

/// <summary>
/// True when the connecting client is Visual Studio, whose built-in LSP semantic-token
/// colorizer cannot map custom token types — so the server pushes tokens to it instead of
Expand Down
55 changes: 46 additions & 9 deletions src/LSP/Reqnroll.IdeSupport.LSP.Server/Hosting/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MediatR;
using System.Diagnostics;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities;
Expand Down Expand Up @@ -34,10 +35,12 @@ public static async Task Main(string[] args)
// The semantic token legend no longer varies by IDE, but the identifier is retained for
// features that may need to vary their behaviour per IDE (e.g. future static-vs-dynamic
// capability registration decisions).
var ideId = args
.SkipWhile(a => !string.Equals(a, "--ide", StringComparison.OrdinalIgnoreCase))
.Skip(1)
.FirstOrDefault();
var ideId = ParseArg(args, "--ide");

// Each IDE's glue component may pass --log-level <level> (Off/Error/Warning/Info/Verbose)
// when spawning the server. Defaults to Warning when absent so a normal session doesn't
// write maximum-verbosity logs indefinitely; pass --log-level Verbose for full tracing.
var logLevel = ParseLogLevel(args);

// Write any unhandled startup exception to a file next to the LSP inspector logs
// so crashes are self-diagnosing without needing to capture stderr.
Expand All @@ -53,7 +56,7 @@ public static async Task Main(string[] args)
// Production transport: the IDE talks to the server over stdio.
options.WithInput(Console.OpenStandardInput())
.WithOutput(Console.OpenStandardOutput());
ConfigureServer(options, ideId);
ConfigureServer(options, ideId, logLevel);
});

using var preloadCts = new CancellationTokenSource();
Expand Down Expand Up @@ -104,11 +107,18 @@ public static async Task Main(string[] args)
/// <see langword="null"/> when absent. Currently unused by the semantic-token pipeline
/// (the legend is shared across IDEs); retained for features that may vary behaviour per IDE.
/// </param>
internal static void ConfigureServer(LanguageServerOptions options, string? clientIde = null)
/// <param name="logLevel">
/// The <c>--log-level</c> verbosity requested by the client, defaulting to
/// <see cref="TraceLevel.Warning"/>. Drives both the file-backed <see cref="IDeveroomLogger"/>
/// and the OmniSharp protocol-logging minimum level, so wire-level debugging and file logging
/// stay in lockstep unless a caller explicitly asks for more (e.g. <c>--log-level Verbose</c>).
/// </param>
internal static void ConfigureServer(LanguageServerOptions options, string? clientIde = null,
TraceLevel logLevel = TraceLevel.Warning)
{
options.ConfigureLogging(logging =>
{
logging.SetMinimumLevel(LogLevel.Trace);
logging.SetMinimumLevel(ToLogLevel(logLevel));
logging.AddLanguageProtocolLogging();
});

Expand All @@ -127,7 +137,7 @@ internal static void ConfigureServer(LanguageServerOptions options, string? clie
// notification to two handler instances (the transient from the scan and
// the singleton from the explicit call).
.AddMediatR(typeof(Program).Assembly)
.AddReqnrollLspCoreServices(clientIde)
.AddReqnrollLspCoreServices(clientIde, logLevel)
.AddReqnrollProjectSystem()
.AddReqnrollEditorServices()
.AddReqnrollLspHandlers();
Expand Down Expand Up @@ -184,4 +194,31 @@ internal static void ConfigureServer(LanguageServerOptions options, string? clie
return Task.CompletedTask;
});
}

/// <summary>
/// Maps the <see cref="IDeveroomLogger"/> verbosity scale onto
/// <see cref="Microsoft.Extensions.Logging.LogLevel"/> for the OmniSharp protocol-logging pipeline.
/// </summary>
internal static LogLevel ToLogLevel(TraceLevel level) => level switch
{
TraceLevel.Off => LogLevel.None,
TraceLevel.Error => LogLevel.Error,
TraceLevel.Warning => LogLevel.Warning,
TraceLevel.Info => LogLevel.Information,
TraceLevel.Verbose => LogLevel.Trace,
_ => LogLevel.Warning
};

/// <summary>Returns the value following <paramref name="flag"/> in <paramref name="args"/>, or <see langword="null"/> when absent.</summary>
internal static string? ParseArg(string[] args, string flag)
=> args
.SkipWhile(a => !string.Equals(a, flag, StringComparison.OrdinalIgnoreCase))
.Skip(1)
.FirstOrDefault();

/// <summary>Parses <c>--log-level</c> from <paramref name="args"/>, defaulting to <see cref="TraceLevel.Warning"/> when absent or unrecognized.</summary>
internal static TraceLevel ParseLogLevel(string[] args)
=> Enum.TryParse<TraceLevel>(ParseArg(args, "--log-level"), ignoreCase: true, out var parsedLevel)
? parsedLevel
: TraceLevel.Warning;
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Reqnroll.IdeSupport.Common;
Expand Down Expand Up @@ -48,10 +49,11 @@ public static class ServiceCollectionExtensions
/// <summary>
/// Registers core infrastructure and cross-cutting services.
/// </summary>
public static IServiceCollection AddReqnrollLspCoreServices(this IServiceCollection services, string? clientIde)
public static IServiceCollection AddReqnrollLspCoreServices(this IServiceCollection services, string? clientIde,
TraceLevel logLevel = TraceLevel.Warning)
{
return services
.AddSingleton(new ClientIdeContext(clientIde))
.AddSingleton(new ClientIdeContext(clientIde, logLevel))
.AddSingleton<IDeveroomLogger, LspDeveroomLogger>()
.AddSingleton<IIdeScope, LspIdeScope>()
.AddSingleton<IMonitoringService>(sp => NullMonitoringService.Instance)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public LspDeveroomLogger(ClientIdeContext clientIdeContext)
};
_inner = new DeveroomCompositeLogger()
.Add(new DeveroomDebugLogger())
.Add(new SynchronousFileLogger(idePrefix, "server"));
.Add(new SynchronousFileLogger(idePrefix, "server", clientIdeContext.LogLevel));

var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
var location = Assembly.GetExecutingAssembly().Location;
Expand Down
2 changes: 1 addition & 1 deletion src/VSCode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@
"type": "string",
"enum": ["off", "messages", "verbose"],
"default": "off",
"description": "Traces the communication between VS Code and the Reqnroll LSP server. When set to 'verbose', a log file is also written to the Reqnroll log directory."
"description": "Traces the communication between VS Code and the Reqnroll LSP server, and also controls the server's own log verbosity ('off' -> Warning, 'messages' -> Info, 'verbose' -> Verbose). When set to 'verbose', a log file is also written to the Reqnroll log directory. Requires a window reload to take effect on the running server."
}
}
},
Expand Down
4 changes: 2 additions & 2 deletions src/VSCode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import { LanguageClient, LanguageClientOptions, ServerOptions } from 'vscode-languageclient/node';
import { createTraceChannel } from './lspInspectorLogger';
import { createTraceChannel, traceServerToLogLevel } from './lspInspectorLogger';
import { ProjectManager } from './projectManager';
import { StatusBarManager } from './statusBar';
import { doToggleComment } from './commentToggle';
Expand Down Expand Up @@ -197,7 +197,7 @@ export function activate(context: vscode.ExtensionContext): void {
// ── LSP client ─────────────────────────────────────────────────────────────
const serverOptions: ServerOptions = {
command: serverPath,
args: ['--ide', 'vscode'],
args: ['--ide', 'vscode', '--log-level', traceServerToLogLevel()],
options: {
env: { ...process.env },
},
Expand Down
17 changes: 17 additions & 0 deletions src/VSCode/src/lspInspectorLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,23 @@ function parseLspTraceMessage(text: string): LspEntry | undefined {
* directory so that every JSON-RPC message captured by vscode-languageclient
* is persisted in lsp-viewer format alongside the VS Code Output panel entry.
*/
/**
* Maps the `reqnroll.trace.server` setting onto the LSP server's `--log-level` argument, so the
* one lever VS Code users already have also controls the server's own file/protocol log
* verbosity instead of only the client-side wire trace.
*/
export function traceServerToLogLevel(): 'Warning' | 'Info' | 'Verbose' {
const level = vscode.workspace.getConfiguration('reqnroll').get<string>('trace.server', 'off');
switch (level) {
case 'verbose':
return 'Verbose';
case 'messages':
return 'Info';
default:
return 'Warning';
}
}

export function createTraceChannel(): vscode.LogOutputChannel {
const level = vscode.workspace.getConfiguration('reqnroll').get<string>('trace.server', 'off');

Expand Down
1 change: 1 addition & 0 deletions src/VSCode/src/test/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as vscode from 'vscode';

// Pull in all additional test suites so the single entry-point loads them all
import './projectManager.test';
import './lspInspectorLogger.test';

suite('Reqnroll Extension Tests', () => {
const extensionId = 'reqnroll.reqnroll-ide-support';
Expand Down
35 changes: 35 additions & 0 deletions src/VSCode/src/test/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as path from 'path';
import Mocha from 'mocha';

/**
* Bootstraps the Mocha `suite`/`test` globals inside the Extension Development Host and runs the
* bundled test file. `@vscode/test-electron` loads this module's `run` export as
* `extensionTestsPath` — pointing that directly at a compiled test file (as this used to) never
* registers Mocha's BDD globals, so every suite/test call throws `ReferenceError: suite is not
* defined` before a single test runs.
*/
export function run(): Promise<void> {
// Default Mocha timeout (2000ms) races against extension.test.ts's own 2000ms activation
// wait; give suites headroom now that they actually run (this harness was previously never
// invoking them at all — see remarks above).
const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 10000 });
const testsRoot = path.resolve(__dirname);

// extension.test.js pulls in the other suites (projectManager.test, lspInspectorLogger.test)
// via its own imports, so registering just the entry point is enough.
mocha.addFile(path.resolve(testsRoot, 'extension.test.js'));

return new Promise((resolve, reject) => {
try {
mocha.run((failures) => {
if (failures > 0) {
reject(new Error(`${failures} test(s) failed.`));
} else {
resolve();
}
});
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)));
}
});
}
31 changes: 31 additions & 0 deletions src/VSCode/src/test/lspInspectorLogger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as assert from 'assert';
import * as vscode from 'vscode';
import { traceServerToLogLevel } from '../lspInspectorLogger';

suite('traceServerToLogLevel', () => {
const config = vscode.workspace.getConfiguration('reqnroll');

teardown(async () => {
await config.update('trace.server', undefined, vscode.ConfigurationTarget.Global);
});

test('defaults to Warning when the setting is unset', async () => {
await config.update('trace.server', undefined, vscode.ConfigurationTarget.Global);
assert.strictEqual(traceServerToLogLevel(), 'Warning');
});

test("maps 'off' to Warning", async () => {
await config.update('trace.server', 'off', vscode.ConfigurationTarget.Global);
assert.strictEqual(traceServerToLogLevel(), 'Warning');
});

test("maps 'messages' to Info", async () => {
await config.update('trace.server', 'messages', vscode.ConfigurationTarget.Global);
assert.strictEqual(traceServerToLogLevel(), 'Info');
});

test("maps 'verbose' to Verbose", async () => {
await config.update('trace.server', 'verbose', vscode.ConfigurationTarget.Global);
assert.strictEqual(traceServerToLogLevel(), 'Verbose');
});
});
5 changes: 3 additions & 2 deletions src/VSCode/src/test/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ async function main(): Promise<void> {
// The folder containing the Extension Manifest (package.json)
const extensionDevelopmentPath = path.resolve(__dirname, '..', '..');

// The path to the extension test script
const extensionTestsPath = path.resolve(__dirname, 'extension.test');
// The path to the test bootstrapper (registers Mocha's suite/test globals, then loads
// extension.test.js which pulls in the other suites via its own imports).
const extensionTestsPath = path.resolve(__dirname, 'index');

// Download VS Code, unzip it and run the integration test
await runTests({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ internal static string ResolveServerExePath(string extensionAssemblyLocation)
"LSPServer",
"Reqnroll.IdeSupport.LSP.Server.exe");

/// <summary>
/// The command-line arguments passed to the LSP server process: <c>--ide</c> selects the
/// semantic token profile, <c>--log-level</c> keeps the server's own file/protocol logging in
/// step with the client's default rather than the server falling back to its own default
/// independently. Extracted as a constant so it's unit-testable without spawning a process.
/// </summary>
internal const string ServerArguments = "--ide visualstudio --log-level Warning";

private async Task<IDuplexPipe?> StartAsync()
{
var serverExe = ResolveServerExePath(typeof(LspServerConnectionService).Assembly.Location);
Expand All @@ -134,9 +142,7 @@ internal static string ResolveServerExePath(string extensionAssemblyLocation)
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
// Tell the LSP server which IDE is connecting so it selects the correct
// semantic token profile (legend + DeveroomTag→token type mapping).
Arguments = "--ide visualstudio",
Arguments = ServerArguments,
};

_serverProcess = Process.Start(psi)
Expand Down
Loading
Loading