csharp-ls Language Server

repository·main·Indexed 21 days ago

https://github.com/razzmatazz/csharp-language-server

An LSP implementation providing C# language features such as code completion, diagnostics, and refactoring. It supports .NET Core 3, .NET Framework 4.8, and requires the .NET 10 SDK or later. Key features include Roslyn analyzer support, Razor (.cshtml) document support, and decompiled metadata URIs.

Tokens
34.2K
Snippets
64
Records
140
Agent score
75%

What's inside csharp-ls

  1. Understand the csharp-language-server project structure

    main

    The project is organized into several distinct layers that separate the LSP protocol, the JSON-RPC transport, and the Roslyn integration:

    • src/CSharpLanguageServer/: The main server project (targeting net10.0).
      • Lsp/: The LSP protocol layer. Server.fs is the central wiring file for capabilities and handler maps.
      • Runtime/: Handles JSON-RPC transport and request scheduling.
      • Roslyn/: Manages integration with Microsoft.CodeAnalysis (Roslyn) for C# compilation and workspace APIs.
      • Handlers/: Contains individual LSP method handlers (one per feature).
    • tests/CSharpLanguageServer.Tests/: The NUnit test project containing integration tests and a test harness.
  2. Suppressing redundant hints in 'as' casts and literals

    main

    The language server automatically suppresses inlay hints in the following syntactically redundant scenarios:

    1. 'as' Casts If the target type of an as expression is already present in the statement, the hint is suppressed.

    // No hint for 'other' because 'DBBankAccount' is already present
    var other = obj as DBBankAccount;

    2. String Literals and Interpolated Strings Plain string literals and interpolated strings have their types implied by their syntax, so hints are suppressed.

    // No hint for 'uniqueAccountKey' (type: string)
    var uniqueAccountKey = "account_number_unique";
    
    // No hint for 'logMessage' (type: string?)
    var logMessage = $"{level}: {message}";
    public override bool Equals(object obj)
    {
        var other = obj as DBBankAccount;
        return other != null && this.Id == other.Id;
    }
    
    var uniqueAccountKey = "account_number_unique";
    var logMessage = $"{level}: {message}";
  3. Identify and suppress redundant format-string parameter hints

    main

    In C#, calls to formatting methods (like string.Format, Logger.DebugFormat, Logger.InfoFormat, or Logger.ErrorFormat) often include a format string with {n} placeholders. When these methods are called, the language server may provide inlay hints like format:, arg0:, arg1:, etc.

    Because the format string itself already explicitly defines the positional correspondence via the {n} placeholders, these parameter name hints are often redundant and add zero information.

    Suppression Rule Candidate: Suppress parameter-name hints for arguments following a composite/format string parameter on well-known formatting methods or any method where the preceding string-literal argument contains {n}-style placeholders.

    // Redundant hints: format:, arg0:, arg1:
    Logger.DebugFormat(
        "{0}: cannot do the thing; reason",
        nameof(SomeMethodAsync));
    
    // Redundant hints: format:, arg0:, arg1:, arg2:
    throw new ResourceOpException(
        string.Format(
            "{0}: mode could not be set to `{1}`: {2}",
            nameof(this.SetModeAsync), mode, modeChangeCondition));
  4. Understand how inlay hint suppression rules work

    main

    The csharp-language-server implements several heuristic rules to reduce 'superfluous' or redundant inlay hints (both parameter names and variable types) to improve code readability. These rules are primarily implemented within the toInlayHint function in Handlers/InlayHint.fs and affect the textDocument/inlayHint LSP capability.

    Parameter Name Suppression Rules

    • Same-name suppression: Hints are suppressed if the argument expression's identifier matches the parameter name (e.g., this.Foo vs parameter foo).
    • Uninformative names: Hints are suppressed for short names (length $\le 2$) or numbered suffixes (e.g., arg0, val1, path2).
    • Generic 'obj' names: Explicitly suppresses hints for common generic parameter names like obj.
    • Single lambda-argument calls: For methods with exactly one effective argument, the parameter hint is suppressed if that argument is a lambda expression (e.g., Where(x => ...)). Note that trailing CancellationToken arguments are ignored when calculating the 'effective' argument count.
    • Sole 'value' parameter: Suppresses the value: hint for methods where the parameter name is value and it is the only effective argument (e.g., Contains(value)).

    Variable Type Suppression Rules

    Type hints (e.g., : string) are suppressed when the type is already explicitly visible in the initializer:

    • Generic invocations: When the type is in the explicit type-argument list (e.g., Enum.Parse<DayOfWeek>(...)).
    • Object creation: When using new Type(...) or new Type { ... } (excludes target-typed new()).
    • Static invocation qualifiers: When the type is the qualifier of a static method (e.g., string.Format(...)).
    • 'as' casts: When the target type of an as expression matches the inferred type (e.g., obj as Widget).
    • Literals: For numeric, string, character, boolean, and interpolated string literals.
    • Fluent chains: When the element type was already spelled out earlier in the same fluent invocation chain (e.g., db.Query<DBSalesOrder>()...ToListAsync()).
  5. Find references in SourceGeneratedDocuments (Roslyn Bug #63375)

    main

    A known limitation in Roslyn (specifically version 5.3.0) is that SymbolFinder.FindReferencesAsync(symbol, solution) does not automatically search SourceGeneratedDocument objects. It only iterates through regular project.Documents.

    To find references that might exist in generated code (like Razor), you must use the four-argument overload of FindReferencesAsync and explicitly provide a set of documents that includes both regular project documents and source-generated documents.

    // To include generated documents in a reference search:
    var allDocs = solution.Projects
        .SelectMany(p => p.Documents)                        // regular docs
        .Concat(await GetAllSourceGeneratedDocsAsync(...))   // generated docs
        .ToImmutableHashSet();
    
    SymbolFinder.FindReferencesAsync(symbol, solution, allDocs, ct);
  6. Why `Async.RunSynchronously(timeout)` fails for JSON-RPC calls

    main

    In this project's transport layer, using Async.RunSynchronously(timeout=N) does not provide a hard wall-clock guarantee for stopping a hung JSON-RPC call.

    This is because the transport uses a MailboxProcessor created without a CancellationToken. Consequently, PostAndAsyncReply takes a 'fast path' (AwaitResult_NoDirectCancelOrTimeout) that does not register a callback on the ambient cancellation token. When the RunSynchronously timer fires, it signals cancellation, but the underlying async workflow is uncooperative and never wakes up, causing the thread to hang indefinitely.

    Key distinction:

    • Async.RunSynchronously(timeout): Uses cooperative cancellation. It requests cancellation and trusts the code to notice. If the code is uncooperative (like the current transport fast path), it hangs.
    • Task.Wait(timeout): Uses an OS-level wait handle. It releases the calling thread after N ms regardless of the task's state, though the underlying async work may continue to run in the background.
  7. How per-call timeouts are managed by the actor

    main

    To prevent state leaks and ensure reliable timeouts, the JSON-RPC transport manages deadlines internally within the actor rather than relying on the built-in PostAndAsyncReply timeout parameter.

    The mechanism works as follows:

    1. Embedding Deadlines: A sendJsonRpcCallWithTimeout function accepts an optional TimeSpan. This timeout is embedded into the SendCall event.
    2. Recording Deadlines: When the actor processes a SendCall, it calculates an absolute deadline (DateTimeOffset.UtcNow + timeout) and stores it in a PendingCall entry within PendingOutboundCalls.
    3. Global Timer: The actor owns a single System.Threading.Timer. This timer is armed to fire at the earliest deadline among all pending calls.
    4. Timeout Processing: When the timer fires, it posts a CheckTimeouts event to the actor. The actor sweeps PendingOutboundCalls, identifies expired entries, replies to them with a timeout error (code -32000), and removes them from the map.
    5. Rescheduling: After sweeping, the actor re-arms the single timer for the next earliest remaining deadline using timer.Change.
  8. Enable LSP Tracing

    main

    csharp-ls supports LSP $/setTrace and $/logTrace notifications. If a client sets tracing to messages or verbose, the server forwards its internal log output via $/logTrace notifications.

    Note: This is independent of the --loglevel flag, which only controls stderr console output. In VS Code, you can enable this via the [langId].trace.server setting.

  9. Configure solution path override for reconfiguration

    main
    If the server is already running and you need to switch the loaded solution, use the workspace/configuration LSP request to update the solutionPathOverride field. Unlike the initial startup phase, the server's reconfiguration logic ensures that state.Config is up to date before the new workspace is loaded during the request queue drain.
  10. How the language server reduces redundant inlay hints

    main

    The C# language server implements a series of isTypeSpelledOutIn* validation filters to suppress redundant inlay hints. These filters identify cases where the type information provided by a hint is already explicitly present in the code's syntax, making the hint superfluous.

    Redundancy is detected in several patterns:

    • Generic Invocations: When the type argument is already present in the method call (e.g., Query<T>()).
    • Object Creation: When the type is part of the constructor call.
    • Static Invocations: When the type is part of the static qualifier.
    • As-casts: When the target type of an as expression is already spelled out on the same line.
    • Literals and Interpolated Strings: When the type is directly implied by the syntax (e.g., a string literal or an interpolated string).
    • Invocation Chains: When the element type of a materialized result (like .ToListAsync()) was already explicitly defined earlier in the same fluent chain (e.g., in a Query<T>() call).
  11. Understand test performance bottlenecks and flakiness

    main

    The test suite may experience performance issues or flakiness due to three primary patterns:

    1. Unconditional Sleeps: Large Thread.Sleep calls (e.g., 8000ms, 4000ms, 1000ms) used in diagnostic tests to wait for server responses. These cause over-waiting on fast machines and races on slow ones.
    2. Document Mutation Delays: Repeated 250ms sleeps following Change() or Save() calls in DocumentSyncTests.fs before pulling diagnostics.
    3. Razor Race Workarounds: 250ms sleeps used after client.Open(*.cshtml) to mitigate Razor support races.

    Additionally, the suite may encounter semaphore starvation if activeClientsSemaphore is not held for the full lifetime of a test, leading to excessive concurrent server processes and CPU contention (e.g., testReadyToReconfiguringToConfiguredPhaseTransition taking 60+ seconds).

  12. Understand System.Text.Json deserialization ambiguity

    main
    The migration maintains a "first non-throwing wins" strategy for deserializing types like U2/U3/U4. This can lead to ambiguity if two arms in a union have overlapping JSON shapes. This behavior is consistent with the previous Newtonsoft implementation (tryReadAllMatchingFields) and is a known characteristic of the current deserialization strategy.