Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
8 changes: 7 additions & 1 deletion internal/fourslash/fourslash.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,14 @@ func (f *FourslashTest) nextID() int32 {
}

func (f *FourslashTest) initialize(t *testing.T, capabilities *lsproto.ClientCapabilities) {
initOptions := map[string]any{
// Hack: disable push diagnostics entirely, since the fourslash runner does not
// yet gracefully handle non-request messages.
"disablePushDiagnostics": true,
}
params := &lsproto.InitializeParams{
Locale: ptrTo("en-US"),
Locale: ptrTo("en-US"),
InitializationOptions: ptrTo[any](initOptions),
}
params.Capabilities = getCapabilitiesWithDefaults(capabilities)
// !!! check for errors?
Expand Down
56 changes: 1 addition & 55 deletions internal/ls/diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@ package ls

import (
"context"
"slices"
"strings"

"github.com/microsoft/typescript-go/internal/ast"
"github.com/microsoft/typescript-go/internal/diagnostics"
"github.com/microsoft/typescript-go/internal/diagnosticwriter"
"github.com/microsoft/typescript-go/internal/ls/lsconv"
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
Expand Down Expand Up @@ -41,64 +39,12 @@ func (l *LanguageService) toLSPDiagnostics(ctx context.Context, diagnostics ...[
lspDiagnostics := make([]*lsproto.Diagnostic, 0, size)
for _, diagSlice := range diagnostics {
for _, diag := range diagSlice {
lspDiagnostics = append(lspDiagnostics, l.toLSPDiagnostic(ctx, diag))
lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPull(ctx, l.converters, diag))
}
}
return lspDiagnostics
}

func (l *LanguageService) toLSPDiagnostic(ctx context.Context, diagnostic *ast.Diagnostic) *lsproto.Diagnostic {
clientOptions := lsproto.GetClientCapabilities(ctx).TextDocument.Diagnostic
var severity lsproto.DiagnosticSeverity
switch diagnostic.Category() {
case diagnostics.CategorySuggestion:
severity = lsproto.DiagnosticSeverityHint
case diagnostics.CategoryMessage:
severity = lsproto.DiagnosticSeverityInformation
case diagnostics.CategoryWarning:
severity = lsproto.DiagnosticSeverityWarning
default:
severity = lsproto.DiagnosticSeverityError
}

var relatedInformation []*lsproto.DiagnosticRelatedInformation
if clientOptions.RelatedInformation {
relatedInformation = make([]*lsproto.DiagnosticRelatedInformation, 0, len(diagnostic.RelatedInformation()))
for _, related := range diagnostic.RelatedInformation() {
relatedInformation = append(relatedInformation, &lsproto.DiagnosticRelatedInformation{
Location: lsproto.Location{
Uri: lsconv.FileNameToDocumentURI(related.File().FileName()),
Range: l.converters.ToLSPRange(related.File(), related.Loc()),
},
Message: related.Message(),
})
}
}

var tags []lsproto.DiagnosticTag
if len(clientOptions.TagSupport.ValueSet) > 0 && (diagnostic.ReportsUnnecessary() || diagnostic.ReportsDeprecated()) {
tags = make([]lsproto.DiagnosticTag, 0, 2)
if diagnostic.ReportsUnnecessary() && slices.Contains(clientOptions.TagSupport.ValueSet, lsproto.DiagnosticTagUnnecessary) {
tags = append(tags, lsproto.DiagnosticTagUnnecessary)
}
if diagnostic.ReportsDeprecated() && slices.Contains(clientOptions.TagSupport.ValueSet, lsproto.DiagnosticTagDeprecated) {
tags = append(tags, lsproto.DiagnosticTagDeprecated)
}
}

return &lsproto.Diagnostic{
Range: l.converters.ToLSPRange(diagnostic.File(), diagnostic.Loc()),
Code: &lsproto.IntegerOrString{
Integer: ptrTo(diagnostic.Code()),
},
Severity: &severity,
Message: messageChainToString(diagnostic),
Source: ptrTo("ts"),
RelatedInformation: ptrToSliceIfNonEmpty(relatedInformation),
Tags: ptrToSliceIfNonEmpty(tags),
}
}

func messageChainToString(diagnostic *ast.Diagnostic) string {
if len(diagnostic.MessageChain()) == 0 {
return diagnostic.Message()
Expand Down
90 changes: 90 additions & 0 deletions internal/ls/lsconv/converters.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package lsconv

import (
"context"
"fmt"
"net/url"
"slices"
"strings"
"unicode/utf16"
"unicode/utf8"

"github.com/microsoft/typescript-go/internal/ast"
"github.com/microsoft/typescript-go/internal/core"
"github.com/microsoft/typescript-go/internal/diagnostics"
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
"github.com/microsoft/typescript-go/internal/tspath"
)
Expand Down Expand Up @@ -199,3 +202,90 @@ func (c *Converters) PositionToLineAndCharacter(script Script, position core.Tex
func ptrTo[T any](v T) *T {
return &v
}

type diagnosticCapabilities struct {
relatedInformation bool
tagValueSet []lsproto.DiagnosticTag
}

// DiagnosticToLSPPull converts a diagnostic for pull diagnostics (textDocument/diagnostic)
func DiagnosticToLSPPull(ctx context.Context, converters *Converters, diagnostic *ast.Diagnostic) *lsproto.Diagnostic {
clientCaps := lsproto.GetClientCapabilities(ctx).TextDocument.Diagnostic
return diagnosticToLSP(converters, diagnostic, diagnosticCapabilities{
relatedInformation: clientCaps.RelatedInformation,
tagValueSet: clientCaps.TagSupport.ValueSet,
})
}

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

func diagnosticToLSP(converters *Converters, diagnostic *ast.Diagnostic, caps diagnosticCapabilities) *lsproto.Diagnostic {
var severity lsproto.DiagnosticSeverity
switch diagnostic.Category() {
case diagnostics.CategorySuggestion:
severity = lsproto.DiagnosticSeverityHint
case diagnostics.CategoryMessage:
severity = lsproto.DiagnosticSeverityInformation
case diagnostics.CategoryWarning:
severity = lsproto.DiagnosticSeverityWarning
default:
severity = lsproto.DiagnosticSeverityError
}

var relatedInformation []*lsproto.DiagnosticRelatedInformation
if caps.relatedInformation {
relatedInformation = make([]*lsproto.DiagnosticRelatedInformation, 0, len(diagnostic.RelatedInformation()))
for _, related := range diagnostic.RelatedInformation() {
relatedInformation = append(relatedInformation, &lsproto.DiagnosticRelatedInformation{
Location: lsproto.Location{
Uri: FileNameToDocumentURI(related.File().FileName()),
Range: converters.ToLSPRange(related.File(), related.Loc()),
},
Message: related.Message(),
})
}
}

var tags []lsproto.DiagnosticTag
if len(caps.tagValueSet) > 0 && (diagnostic.ReportsUnnecessary() || diagnostic.ReportsDeprecated()) {
tags = make([]lsproto.DiagnosticTag, 0, 2)
if diagnostic.ReportsUnnecessary() && slices.Contains(caps.tagValueSet, lsproto.DiagnosticTagUnnecessary) {
tags = append(tags, lsproto.DiagnosticTagUnnecessary)
}
if diagnostic.ReportsDeprecated() && slices.Contains(caps.tagValueSet, lsproto.DiagnosticTagDeprecated) {
tags = append(tags, lsproto.DiagnosticTagDeprecated)
}
}

// For diagnostics without a file (e.g., program diagnostics), use a zero range
var lspRange lsproto.Range
if diagnostic.File() != nil {
lspRange = converters.ToLSPRange(diagnostic.File(), diagnostic.Loc())
}

return &lsproto.Diagnostic{
Range: lspRange,
Code: &lsproto.IntegerOrString{
Integer: ptrTo(diagnostic.Code()),
},
Severity: &severity,
Message: diagnostic.Message(),
Source: ptrTo("ts"),
RelatedInformation: ptrToSliceIfNonEmpty(relatedInformation),
Tags: ptrToSliceIfNonEmpty(tags),
}
}

func ptrToSliceIfNonEmpty[T any](s []T) *[]T {
if len(s) == 0 {
return nil
}
return &s
}
28 changes: 23 additions & 5 deletions internal/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,13 @@ func (s *Server) RefreshDiagnostics(ctx context.Context) error {
return nil
}

// PublishDiagnostics implements project.Client.
func (s *Server) PublishDiagnostics(ctx context.Context, params *lsproto.PublishDiagnosticsParams) error {
notification := lsproto.TextDocumentPublishDiagnosticsInfo.NewNotificationMessage(params)
s.outgoingQueue <- notification.Message()
return nil
}

func (s *Server) RequestConfiguration(ctx context.Context) (*lsutil.UserPreferences, error) {
caps := lsproto.GetClientCapabilities(ctx)
if !caps.Workspace.Configuration {
Expand Down Expand Up @@ -716,6 +723,16 @@ func (s *Server) handleInitialized(ctx context.Context, params *lsproto.Initiali
cwd = s.cwd
}

var disablePushDiagnostics bool
if s.initializeParams != nil && s.initializeParams.InitializationOptions != nil && *s.initializeParams.InitializationOptions != nil {
// Check for disablePushDiagnostics option
if initOpts, ok := (*s.initializeParams.InitializationOptions).(map[string]any); ok {
if disable, ok := initOpts["disablePushDiagnostics"].(bool); ok {
disablePushDiagnostics = disable
}
}
}

s.session = project.NewSession(&project.SessionInit{
Options: &project.SessionOptions{
CurrentDirectory: cwd,
Expand All @@ -726,11 +743,12 @@ func (s *Server) handleInitialized(ctx context.Context, params *lsproto.Initiali
LoggingEnabled: true,
DebounceDelay: 500 * time.Millisecond,
},
FS: s.fs,
Logger: s.logger,
Client: s,
NpmExecutor: s,
ParseCache: s.parseCache,
FS: s.fs,
Logger: s.logger,
Client: s,
NpmExecutor: s,
ParseCache: s.parseCache,
DisablePushDiagnostics: disablePushDiagnostics,
})

if s.initializeParams != nil && s.initializeParams.InitializationOptions != nil && *s.initializeParams.InitializationOptions != nil {
Expand Down
1 change: 1 addition & 0 deletions internal/project/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ type Client interface {
WatchFiles(ctx context.Context, id WatcherID, watchers []*lsproto.FileSystemWatcher) error
UnwatchFiles(ctx context.Context, id WatcherID) error
RefreshDiagnostics(ctx context.Context) error
PublishDiagnostics(ctx context.Context, params *lsproto.PublishDiagnosticsParams) error
}
134 changes: 134 additions & 0 deletions internal/project/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,137 @@ func TestProject(t *testing.T) {
assert.NilError(t, err)
})
}

func TestPushDiagnostics(t *testing.T) {
t.Parallel()
if !bundled.Embedded {
t.Skip("bundled files are not embedded")
}

t.Run("publishes program diagnostics on initial program creation", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/src/tsconfig.json": `{"compilerOptions": {"baseUrl": "."}}`,
"/src/index.ts": "export const x = 1;",
}
session, utils := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)

session.WaitForBackgroundTasks()

calls := utils.Client().PublishDiagnosticsCalls()
assert.Assert(t, len(calls) > 0, "expected at least one PublishDiagnostics call")

// Find the call for tsconfig.json
var tsconfigCall *struct {
Ctx context.Context
Params *lsproto.PublishDiagnosticsParams
}
for i := range calls {
if calls[i].Params.Uri == "file:///src/tsconfig.json" {
tsconfigCall = &calls[i]
break
}
}
assert.Assert(t, tsconfigCall != nil, "expected PublishDiagnostics call for tsconfig.json")
assert.Assert(t, len(tsconfigCall.Params.Diagnostics) > 0, "expected at least one diagnostic")
})

t.Run("clears diagnostics when config fixed", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/src/tsconfig.json": `{"compilerOptions": {"baseUrl": "."}}`,
"/src/index.ts": "export const x = 1;",
"/src2/tsconfig.json": `{"compilerOptions": {}}`,
"/src2/index.ts": "export const y = 2;",
}
session, utils := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
session.WaitForBackgroundTasks()

// Open a file in a different project to trigger cleanup of the first
session.DidCloseFile(context.Background(), "file:///src/index.ts")
session.DidOpenFile(context.Background(), "file:///src2/index.ts", 1, files["/src2/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src2/index.ts"))
assert.NilError(t, err)
session.WaitForBackgroundTasks()

calls := utils.Client().PublishDiagnosticsCalls()
// Should have at least one call for the first project with diagnostics,
// and one clearing it after switching projects
var firstProjectCalls []struct {
Ctx context.Context
Params *lsproto.PublishDiagnosticsParams
}
for i := range calls {
if calls[i].Params.Uri == "file:///src/tsconfig.json" {
firstProjectCalls = append(firstProjectCalls, calls[i])
}
}
assert.Assert(t, len(firstProjectCalls) >= 2, "expected at least 2 PublishDiagnostics calls for first project")
// Last call should clear diagnostics
lastCall := firstProjectCalls[len(firstProjectCalls)-1]
assert.Equal(t, len(lastCall.Params.Diagnostics), 0, "expected empty diagnostics after project cleanup")
})

t.Run("updates diagnostics when program changes", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/src/tsconfig.json": `{"compilerOptions": {"baseUrl": "."}}`,
"/src/index.ts": "export const x = 1;",
}
session, utils := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
session.WaitForBackgroundTasks()

initialCallCount := len(utils.Client().PublishDiagnosticsCalls())

// Change the tsconfig to remove baseUrl
err = utils.FS().WriteFile("/src/tsconfig.json", `{"compilerOptions": {}}`, false)
assert.NilError(t, err)
session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{{Uri: lsproto.DocumentUri("file:///src/tsconfig.json"), Type: lsproto.FileChangeTypeChanged}})
_, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
session.WaitForBackgroundTasks()

calls := utils.Client().PublishDiagnosticsCalls()
assert.Assert(t, len(calls) > initialCallCount, "expected additional PublishDiagnostics call after change")

// Find the last call for tsconfig.json
var lastTsconfigCall *struct {
Ctx context.Context
Params *lsproto.PublishDiagnosticsParams
}
for i := len(calls) - 1; i >= 0; i-- {
if calls[i].Params.Uri == "file:///src/tsconfig.json" {
lastTsconfigCall = &calls[i]
break
}
}
assert.Assert(t, lastTsconfigCall != nil, "expected PublishDiagnostics call for tsconfig.json")
// After fixing the error, there should be no program diagnostics
assert.Equal(t, len(lastTsconfigCall.Params.Diagnostics), 0, "expected no diagnostics after removing baseUrl option")
})

t.Run("does not publish for inferred projects", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/src/index.ts": "let x: number = 'not a number';",
}
session, utils := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
session.WaitForBackgroundTasks()

calls := utils.Client().PublishDiagnosticsCalls()
// Should not have any calls since inferred projects don't have tsconfig.json
assert.Equal(t, len(calls), 0, "expected no PublishDiagnostics calls for inferred projects")
})
}
Loading
Loading