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
26 changes: 24 additions & 2 deletions tsc/internal/ls/diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,29 @@ func (l *LanguageService) ProvideDiagnostics(ctx context.Context, uri lsproto.Do
}, nil
}

// ProvidePushDiagnostics computes diagnostics for a file in the format used by
// textDocument/publishDiagnostics.
func (l *LanguageService) ProvidePushDiagnostics(ctx context.Context, uri lsproto.DocumentUri) []*lsproto.Diagnostic {
if l.UserPreferences().EnableValidation.IsFalse() {
return nil
}

program, file := l.tryGetProgramAndFile(uri.FileName())
if file == nil {
return nil
}

return l.toLSPDiagnosticsWith(ctx, lsconv.DiagnosticToLSPPush, getAllDiagnostics(ctx, program, file))
}

func (l *LanguageService) toLSPDiagnostics(ctx context.Context, diagnostics ...[]*ast.Diagnostic) []*lsproto.Diagnostic {
return l.toLSPDiagnosticsWith(ctx, lsconv.DiagnosticToLSPPull, diagnostics...)
}

// toLSPDiagnosticsWith normalizes file diagnostics (style checks as warnings,
// synthesized content-mapper aggregation) and converts them with the given
// pull or push converter, so both client kinds receive equivalent diagnostics.
func (l *LanguageService) toLSPDiagnosticsWith(ctx context.Context, convert func(context.Context, *lsconv.Converters, *ast.Diagnostic, bool) *lsproto.Diagnostic, diagnostics ...[]*ast.Diagnostic) []*lsproto.Diagnostic {
reportStyleChecksAsWarnings := l.UserPreferences().ReportStyleChecksAsWarnings.IsTrue()
size := 0
for _, diagSlice := range diagnostics {
Expand All @@ -68,12 +90,12 @@ func (l *LanguageService) toLSPDiagnostics(ctx context.Context, diagnostics ...[
synthesizedByFile.Set(diag.File(), append(synthesizedByFile.GetOrZero(diag.File()), diag))
continue
}
lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPull(ctx, l.converters, diag, reportStyleChecksAsWarnings))
lspDiagnostics = append(lspDiagnostics, convert(ctx, l.converters, diag, reportStyleChecksAsWarnings))
}
}
for file, diags := range synthesizedByFile.Entries() {
aggregate := aggregateSynthesizedDiagnostics(file, diags)
lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPull(ctx, l.converters, aggregate, reportStyleChecksAsWarnings))
lspDiagnostics = append(lspDiagnostics, convert(ctx, l.converters, aggregate, reportStyleChecksAsWarnings))
}
return lspDiagnostics
}
Expand Down
9 changes: 5 additions & 4 deletions tsc/internal/ls/lsconv/converters.go
Original file line number Diff line number Diff line change
Expand Up @@ -461,13 +461,14 @@ func DiagnosticToLSPPull(ctx context.Context, converters *Converters, diagnostic
}

// DiagnosticToLSPPush converts a diagnostic for push diagnostics (textDocument/publishDiagnostics)
func DiagnosticToLSPPush(ctx context.Context, converters *Converters, diagnostic *ast.Diagnostic) *lsproto.Diagnostic {
func DiagnosticToLSPPush(ctx context.Context, converters *Converters, diagnostic *ast.Diagnostic, reportStyleChecksAsWarnings bool) *lsproto.Diagnostic {
clientCaps := lsproto.GetClientCapabilities(ctx)
clientDiagnosticCaps := clientCaps.TextDocument.PublishDiagnostics
return diagnosticToLSP(ctx, converters, diagnostic, diagnosticOptions{
relatedInformation: clientDiagnosticCaps.RelatedInformation,
tagValueSet: clientDiagnosticCaps.TagSupport.ValueSet,
visualStudio: clientCaps.VSSupportsVisualStudioExtensions,
reportStyleChecksAsWarnings: reportStyleChecksAsWarnings,
relatedInformation: clientDiagnosticCaps.RelatedInformation,
tagValueSet: clientDiagnosticCaps.TagSupport.ValueSet,
visualStudio: clientCaps.VSSupportsVisualStudioExtensions,
})
}

Expand Down
29 changes: 19 additions & 10 deletions tsc/internal/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -1729,19 +1729,28 @@ func (s *Server) handleInitialized(ctx context.Context, params *lsproto.Initiali

s.telemetryEnabled = enableTelemetry

// textDocument/publishDiagnostics is a baseline notification; its capability
// only advertises extensions like versionSupport, so it is not checked here.
var clientSupportsPullDiagnostics bool
if capabilities := s.initializeParams.Capabilities; capabilities != nil && capabilities.TextDocument != nil {
clientSupportsPullDiagnostics = capabilities.TextDocument.Diagnostic != nil
}
pushFileDiagnostics := !disablePushDiagnostics && !clientSupportsPullDiagnostics

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 2e7b1b3: TestPushFileDiagnosticsGate in tsc/internal/lsp runs the real initialize handshake and covers all three cases: a push-only client receives per-file publishes, a pull-capable client receives none, and disablePushDiagnostics disables them.


s.session = project.NewSession(&project.SessionInit{
BackgroundCtx: lsproto.WithClientCapabilities(s.backgroundCtx, &s.clientCapabilities),
Options: &project.SessionOptions{
CurrentDirectory: cwd,
DefaultLibraryPath: s.defaultLibraryPath,
TypingsLocation: s.typingsLocation,
PositionEncoding: s.positionEncoding,
WatchEnabled: s.watchEnabled,
LoggingEnabled: true,
TelemetryEnabled: enableTelemetry,
DebounceDelay: 500 * time.Millisecond,
PushDiagnosticsEnabled: !disablePushDiagnostics,
RunExternalCode: runExternalCode,
CurrentDirectory: cwd,
DefaultLibraryPath: s.defaultLibraryPath,
TypingsLocation: s.typingsLocation,
PositionEncoding: s.positionEncoding,
WatchEnabled: s.watchEnabled,
LoggingEnabled: true,
TelemetryEnabled: enableTelemetry,
DebounceDelay: 500 * time.Millisecond,
PushDiagnosticsEnabled: !disablePushDiagnostics,
PushFileDiagnosticsEnabled: pushFileDiagnostics,
RunExternalCode: runExternalCode,
},
FS: s.fs,
Logger: s.logger,
Expand Down
137 changes: 137 additions & 0 deletions tsc/internal/lsp/server_pushdiagnostics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package lsp_test

import (
"context"
"io"
"sync"
"testing"

"github.com/microsoft/TypeScript/tsc/internal/bundled"
"github.com/microsoft/TypeScript/tsc/internal/lsp"
"github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto"
"github.com/microsoft/TypeScript/tsc/internal/testutil/lsptestutil"
"github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest"
"gotest.tools/v3/assert"
)

// TestPushFileDiagnosticsGate verifies the initialization gate for per-file push
// diagnostics: clients without pull-diagnostics support receive them, while
// pull-capable clients and clients that set disablePushDiagnostics do not.
func TestPushFileDiagnosticsGate(t *testing.T) {
t.Parallel()
if !bundled.Embedded {
t.Skip("bundled files are not embedded")
}

const fileContent = `const x: number = "";`
files := map[string]string{
"/home/project/tsconfig.json": `{}`,
"/home/project/index.ts": fileContent,
}
uri := lsproto.DocumentUri("file:///home/project/index.ts")

// run initializes a server with the given capabilities and options, opens the
// file, waits for background tasks and a transport round trip, and returns the
// publishDiagnostics params received for the file.
run := func(t *testing.T, caps *lsproto.ClientCapabilities, initializationOptions *lsproto.InitializationOptions) []*lsproto.PublishDiagnosticsParams {
onServerRequest := func(_ context.Context, req *lsproto.RequestMessage) *lsproto.ResponseMessage {
switch req.Method {
case lsproto.MethodWorkspaceConfiguration:
return &lsproto.ResponseMessage{ID: req.ID, JSONRPC: req.JSONRPC, Result: []any{nil, nil, nil, nil}}
case lsproto.MethodClientRegisterCapability, lsproto.MethodClientUnregisterCapability:
return &lsproto.ResponseMessage{ID: req.ID, JSONRPC: req.JSONRPC, Result: lsproto.Null{}}
default:
return nil
}
}

fs := bundled.WrapFS(vfstest.FromMap(files, false))
client, closeClient := lsptestutil.NewLSPClient(t, lsp.ServerOptions{
Err: io.Discard,
Cwd: "/home/project",
FS: fs,
DefaultLibraryPath: bundled.LibPath(),
}, onServerRequest)
t.Cleanup(func() { assert.NilError(t, closeClient()) })

var mu sync.Mutex
var published []*lsproto.PublishDiagnosticsParams
client.OnServerNotification = func(_ context.Context, req *lsproto.RequestMessage) {
if req.Method == lsproto.MethodTextDocumentPublishDiagnostics {
if params, err := lsproto.UnmarshalParams[*lsproto.PublishDiagnosticsParams](req); err == nil && params.Uri == uri {
mu.Lock()
published = append(published, params)
mu.Unlock()
}
}
}

var initOptionsOrNull *lsproto.InitializationOptionsOrNull
if initializationOptions != nil {
initOptionsOrNull = &lsproto.InitializationOptionsOrNull{InitializationOptions: initializationOptions}
}
initMsg, _, ok := lsptestutil.SendRequest(t, client, lsproto.InitializeInfo, &lsproto.InitializeParams{
Capabilities: caps,
InitializationOptions: initOptionsOrNull,
})
assert.Assert(t, ok && initMsg.AsResponse().Error == nil, "initialize failed")
lsptestutil.SendNotification(t, client, lsproto.InitializedInfo, &lsproto.InitializedParams{})
<-client.Server.InitComplete()

lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
TextDocument: &lsproto.TextDocumentItem{Uri: uri, LanguageId: "typescript", Version: 1, Text: fileContent},
})

// A request round trip ensures the didOpen has been processed.
msg, _, ok := lsptestutil.SendRequest(t, client, lsproto.CustomProjectInfoInfo, &lsproto.ProjectInfoParams{
TextDocument: lsproto.TextDocumentIdentifier{Uri: uri},
})
assert.Assert(t, ok && msg.AsResponse().Error == nil)
client.Server.Session().WaitForBackgroundTasks()

// Another round trip drains notifications written by the background tasks,
// since the client router processes the ordered output stream sequentially.
msg, _, ok = lsptestutil.SendRequest(t, client, lsproto.CustomProjectInfoInfo, &lsproto.ProjectInfoParams{
TextDocument: lsproto.TextDocumentIdentifier{Uri: uri},
})
assert.Assert(t, ok && msg.AsResponse().Error == nil)

mu.Lock()
defer mu.Unlock()
return append([]*lsproto.PublishDiagnosticsParams(nil), published...)
}

pushOnlyCaps := &lsproto.ClientCapabilities{
TextDocument: &lsproto.TextDocumentClientCapabilities{
PublishDiagnostics: &lsproto.PublishDiagnosticsClientCapabilities{},
},
}

t.Run("push-only client receives file diagnostics", func(t *testing.T) {
t.Parallel()
published := run(t, pushOnlyCaps, nil)
assert.Assert(t, len(published) > 0, "expected publishDiagnostics for the file")
last := published[len(published)-1]
assert.Equal(t, len(last.Diagnostics), 1)
})

t.Run("pull-capable client receives no file diagnostics pushes", func(t *testing.T) {
t.Parallel()
pullCaps := &lsproto.ClientCapabilities{
TextDocument: &lsproto.TextDocumentClientCapabilities{
PublishDiagnostics: &lsproto.PublishDiagnosticsClientCapabilities{},
Diagnostic: &lsproto.DiagnosticClientCapabilities{},
},
}
published := run(t, pullCaps, nil)
assert.Equal(t, len(published), 0)
})

t.Run("disablePushDiagnostics disables file diagnostics pushes", func(t *testing.T) {
t.Parallel()
published := run(t, pushOnlyCaps, &lsproto.InitializationOptions{
DisablePushDiagnostics: new(true),
})
assert.Equal(t, len(published), 0)
})
}
Loading