diff --git a/microsoft/typescript-go b/microsoft/typescript-go index 7cf22f66..b33ab79e 160000 --- a/microsoft/typescript-go +++ b/microsoft/typescript-go @@ -1 +1 @@ -Subproject commit 7cf22f66ba7489920686d27951a48bbac3cb6e9a +Subproject commit b33ab79ed54a8a8e61205da8a0672db80fe5a992 diff --git a/pkg/checker/flow.go b/pkg/checker/flow.go index f2218ca7..08fe598c 100644 --- a/pkg/checker/flow.go +++ b/pkg/checker/flow.go @@ -2405,7 +2405,7 @@ func (c *Checker) typeMaybeAssignableTo(source *Type, target *Type) bool { func (c *Checker) getTypePredicateArgument(predicate *TypePredicate, callExpression *ast.Node) *ast.Node { if predicate.kind == TypePredicateKindIdentifier || predicate.kind == TypePredicateKindAssertsIdentifier { arguments := callExpression.Arguments() - if int(predicate.parameterIndex) < len(arguments) { + if predicate.parameterIndex >= 0 && int(predicate.parameterIndex) < len(arguments) { return arguments[predicate.parameterIndex] } } else { @@ -2496,7 +2496,7 @@ func (c *Checker) isReachableFlowNodeWorker(f *FlowState, flow *ast.FlowNode, no case flags&ast.FlowFlagsCall != 0: if signature := c.getEffectsSignature(flow.Node); signature != nil { if predicate := c.getTypePredicateOfSignature(signature); predicate != nil && predicate.kind == TypePredicateKindAssertsIdentifier && predicate.t == nil { - if arguments := flow.Node.Arguments(); int(predicate.parameterIndex) < len(arguments) && c.isFalseExpression(arguments[predicate.parameterIndex]) { + if arguments := flow.Node.Arguments(); predicate.parameterIndex >= 0 && int(predicate.parameterIndex) < len(arguments) && c.isFalseExpression(arguments[predicate.parameterIndex]) { return false } } diff --git a/pkg/fourslash/fourslash.go b/pkg/fourslash/fourslash.go index ebee40ad..f29f796b 100644 --- a/pkg/fourslash/fourslash.go +++ b/pkg/fourslash/fourslash.go @@ -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? diff --git a/pkg/ls/diagnostics.go b/pkg/ls/diagnostics.go index 1c4dbbc1..7fba289b 100644 --- a/pkg/ls/diagnostics.go +++ b/pkg/ls/diagnostics.go @@ -2,12 +2,8 @@ package ls import ( "context" - "slices" - "strings" "github.com/buke/typescript-go-internal/pkg/ast" - "github.com/buke/typescript-go-internal/pkg/diagnostics" - "github.com/buke/typescript-go-internal/pkg/diagnosticwriter" "github.com/buke/typescript-go-internal/pkg/ls/lsconv" "github.com/buke/typescript-go-internal/pkg/lsp/lsproto" ) @@ -41,76 +37,8 @@ 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() - } - var b strings.Builder - diagnosticwriter.WriteFlattenedASTDiagnosticMessage(&b, diagnostic, "\n") - return b.String() -} - -func ptrToSliceIfNonEmpty[T any](s []T) *[]T { - if len(s) == 0 { - return nil - } - return &s -} diff --git a/pkg/ls/lsconv/converters.go b/pkg/ls/lsconv/converters.go index 00c1903a..62509ea1 100644 --- a/pkg/ls/lsconv/converters.go +++ b/pkg/ls/lsconv/converters.go @@ -1,6 +1,7 @@ package lsconv import ( + "context" "fmt" "net/url" "slices" @@ -8,7 +9,10 @@ import ( "unicode/utf16" "unicode/utf8" + "github.com/buke/typescript-go-internal/pkg/ast" "github.com/buke/typescript-go-internal/pkg/core" + "github.com/buke/typescript-go-internal/pkg/diagnostics" + "github.com/buke/typescript-go-internal/pkg/diagnosticwriter" "github.com/buke/typescript-go-internal/pkg/lsp/lsproto" "github.com/buke/typescript-go-internal/pkg/tspath" ) @@ -199,3 +203,99 @@ 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: 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() + } + var b strings.Builder + diagnosticwriter.WriteFlattenedASTDiagnosticMessage(&b, diagnostic, "\n") + return b.String() +} + +func ptrToSliceIfNonEmpty[T any](s []T) *[]T { + if len(s) == 0 { + return nil + } + return &s +} diff --git a/pkg/lsp/server.go b/pkg/lsp/server.go index a8b1d50b..38503573 100644 --- a/pkg/lsp/server.go +++ b/pkg/lsp/server.go @@ -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 { @@ -716,15 +723,26 @@ 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, - DefaultLibraryPath: s.defaultLibraryPath, - TypingsLocation: s.typingsLocation, - PositionEncoding: s.positionEncoding, - WatchEnabled: s.watchEnabled, - LoggingEnabled: true, - DebounceDelay: 500 * time.Millisecond, + CurrentDirectory: cwd, + DefaultLibraryPath: s.defaultLibraryPath, + TypingsLocation: s.typingsLocation, + PositionEncoding: s.positionEncoding, + WatchEnabled: s.watchEnabled, + LoggingEnabled: true, + DebounceDelay: 500 * time.Millisecond, + PushDiagnosticsEnabled: !disablePushDiagnostics, }, FS: s.fs, Logger: s.logger, @@ -733,36 +751,28 @@ func (s *Server) handleInitialized(ctx context.Context, params *lsproto.Initiali ParseCache: s.parseCache, }) - if s.initializeParams != nil && s.initializeParams.InitializationOptions != nil && *s.initializeParams.InitializationOptions != nil { - // handle userPreferences from initializationOptions - userPreferences := s.session.NewUserPreferences() - userPreferences.Parse(*s.initializeParams.InitializationOptions) - s.session.InitializeWithConfig(userPreferences) - } else { - // request userPreferences if not provided at initialization - userPreferences, err := s.RequestConfiguration(ctx) - if err != nil { - return err - } - s.session.InitializeWithConfig(userPreferences) + userPreferences, err := s.RequestConfiguration(ctx) + if err != nil { + return err + } + s.session.InitializeWithConfig(userPreferences) - _, err = sendClientRequest(ctx, s, lsproto.ClientRegisterCapabilityInfo, &lsproto.RegistrationParams{ - Registrations: []*lsproto.Registration{ - { - Id: "typescript-config-watch-id", - Method: string(lsproto.MethodWorkspaceDidChangeConfiguration), - RegisterOptions: ptrTo(any(lsproto.DidChangeConfigurationRegistrationOptions{ - Section: &lsproto.StringOrStrings{ - // !!! Both the 'javascript' and 'js/ts' scopes need to be watched for settings as well. - Strings: &[]string{"typescript"}, - }, - })), - }, + _, err = sendClientRequest(ctx, s, lsproto.ClientRegisterCapabilityInfo, &lsproto.RegistrationParams{ + Registrations: []*lsproto.Registration{ + { + Id: "typescript-config-watch-id", + Method: string(lsproto.MethodWorkspaceDidChangeConfiguration), + RegisterOptions: ptrTo(any(lsproto.DidChangeConfigurationRegistrationOptions{ + Section: &lsproto.StringOrStrings{ + // !!! Both the 'javascript' and 'js/ts' scopes need to be watched for settings as well. + Strings: &[]string{"typescript"}, + }, + })), }, - }) - if err != nil { - return fmt.Errorf("failed to register configuration change watcher: %w", err) - } + }, + }) + if err != nil { + return fmt.Errorf("failed to register configuration change watcher: %w", err) } // !!! temporary. diff --git a/pkg/module/resolver.go b/pkg/module/resolver.go index 67ccb7f0..a7d00ffc 100644 --- a/pkg/module/resolver.go +++ b/pkg/module/resolver.go @@ -1957,11 +1957,11 @@ func matchesPatternWithTrailer(target string, name string) bool { if strings.HasSuffix(target, "*") { return false } - starPos := strings.Index(target, "*") - if starPos == -1 { + before, after, ok := strings.Cut(target, "*") + if !ok { return false } - return strings.HasPrefix(name, target[:starPos]) && strings.HasSuffix(name, target[starPos+1:]) + return strings.HasPrefix(name, before) && strings.HasSuffix(name, after) } /** True if `extension` is one of the supported `extensions`. */ diff --git a/pkg/modulespecifiers/specifiers.go b/pkg/modulespecifiers/specifiers.go index 8ed8ba5b..dc91d68a 100644 --- a/pkg/modulespecifiers/specifiers.go +++ b/pkg/modulespecifiers/specifiers.go @@ -1193,9 +1193,7 @@ func tryGetModuleNameFromExportsOrImports( return tspath.CombinePaths(packageName, fragmentWithJsExtension) } case MatchingModePattern: - starPos := strings.Index(pathOrPattern, "*") - leadingSlice := pathOrPattern[0:starPos] - trailingSlice := pathOrPattern[starPos+1:] + leadingSlice, trailingSlice, _ := strings.Cut(pathOrPattern, "*") caseSensitive := host.UseCaseSensitiveFileNames() if canTryTsExtension && stringutil.HasPrefix(targetFilePath, leadingSlice, caseSensitive) && stringutil.HasSuffix(targetFilePath, trailingSlice, caseSensitive) { starReplacement := targetFilePath[len(leadingSlice) : len(targetFilePath)-len(trailingSlice)] diff --git a/pkg/project/client.go b/pkg/project/client.go index 6e180ee1..1be7b865 100644 --- a/pkg/project/client.go +++ b/pkg/project/client.go @@ -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 } diff --git a/pkg/project/project_test.go b/pkg/project/project_test.go index bc03c9ad..2d53c96a 100644 --- a/pkg/project/project_test.go +++ b/pkg/project/project_test.go @@ -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 project is removed", 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") + }) +} diff --git a/pkg/project/session.go b/pkg/project/session.go index 1df1e3d3..c31c9a4b 100644 --- a/pkg/project/session.go +++ b/pkg/project/session.go @@ -14,6 +14,7 @@ import ( "github.com/buke/typescript-go-internal/pkg/compiler" "github.com/buke/typescript-go-internal/pkg/core" "github.com/buke/typescript-go-internal/pkg/ls" + "github.com/buke/typescript-go-internal/pkg/ls/lsconv" "github.com/buke/typescript-go-internal/pkg/ls/lsutil" "github.com/buke/typescript-go-internal/pkg/lsp/lsproto" "github.com/buke/typescript-go-internal/pkg/project/ata" @@ -37,13 +38,14 @@ const ( // SessionOptions are the immutable initialization options for a session. // Snapshots may reference them as a pointer since they never change. type SessionOptions struct { - CurrentDirectory string - DefaultLibraryPath string - TypingsLocation string - PositionEncoding lsproto.PositionEncodingKind - WatchEnabled bool - LoggingEnabled bool - DebounceDelay time.Duration + CurrentDirectory string + DefaultLibraryPath string + TypingsLocation string + PositionEncoding lsproto.PositionEncodingKind + WatchEnabled bool + LoggingEnabled bool + PushDiagnosticsEnabled bool + DebounceDelay time.Duration } type SessionInit struct { @@ -146,7 +148,6 @@ func NewSession(init *SessionInit) *Session { extendedConfigCache: extendedConfigCache, programCounter: &programCounter{}, backgroundQueue: background.NewQueue(), - snapshotID: atomic.Uint64{}, snapshot: NewSnapshot( uint64(0), &SnapshotFS{ @@ -439,6 +440,7 @@ func (s *Session) UpdateSnapshot(ctx context.Context, overlays map[tspath.Path]* s.logger.Log(err) } } + s.publishProgramDiagnostics(oldSnapshot, newSnapshot) }) return newSnapshot @@ -689,6 +691,57 @@ func (s *Session) NpmInstall(cwd string, npmInstallArgs []string) ([]byte, error return s.npmExecutor.NpmInstall(cwd, npmInstallArgs) } +func (s *Session) publishProgramDiagnostics(oldSnapshot *Snapshot, newSnapshot *Snapshot) { + if !s.options.PushDiagnosticsEnabled { + return + } + + ctx := context.Background() + collections.DiffOrderedMaps( + oldSnapshot.ProjectCollection.ProjectsByPath(), + newSnapshot.ProjectCollection.ProjectsByPath(), + func(configFilePath tspath.Path, addedProject *Project) { + if !shouldPublishProgramDiagnostics(addedProject, newSnapshot.ID()) { + return + } + s.publishProjectDiagnostics(ctx, string(configFilePath), addedProject.Program.GetProgramDiagnostics(), newSnapshot.converters) + }, + func(configFilePath tspath.Path, removedProject *Project) { + if removedProject.Kind != KindConfigured { + return + } + s.publishProjectDiagnostics(ctx, string(configFilePath), nil, oldSnapshot.converters) + }, + func(configFilePath tspath.Path, oldProject, newProject *Project) { + if !shouldPublishProgramDiagnostics(newProject, newSnapshot.ID()) { + return + } + s.publishProjectDiagnostics(ctx, string(configFilePath), newProject.Program.GetProgramDiagnostics(), newSnapshot.converters) + }, + ) +} + +func shouldPublishProgramDiagnostics(p *Project, snapshotID uint64) bool { + if p.Kind != KindConfigured || p.Program == nil || p.ProgramLastUpdate != snapshotID { + return false + } + return p.ProgramUpdateKind > ProgramUpdateKindCloned +} + +func (s *Session) publishProjectDiagnostics(ctx context.Context, configFilePath string, diagnostics []*ast.Diagnostic, converters *lsconv.Converters) { + lspDiagnostics := make([]*lsproto.Diagnostic, 0, len(diagnostics)) + for _, diag := range diagnostics { + lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPush(ctx, converters, diag)) + } + + if err := s.client.PublishDiagnostics(ctx, &lsproto.PublishDiagnosticsParams{ + Uri: lsconv.FileNameToDocumentURI(configFilePath), + Diagnostics: lspDiagnostics, + }); err != nil && s.options.LoggingEnabled { + s.logger.Logf("Error publishing diagnostics: %v", err) + } +} + func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { for _, project := range newSnapshot.ProjectCollection.Projects() { if project.ShouldTriggerATA(newSnapshot.ID()) { diff --git a/pkg/testutil/projecttestutil/clientmock_generated.go b/pkg/testutil/projecttestutil/clientmock_generated.go index 8986f945..40d3c5c4 100644 --- a/pkg/testutil/projecttestutil/clientmock_generated.go +++ b/pkg/testutil/projecttestutil/clientmock_generated.go @@ -21,6 +21,9 @@ var _ project.Client = &ClientMock{} // // // make and configure a mocked project.Client // mockedClient := &ClientMock{ +// PublishDiagnosticsFunc: func(ctx context.Context, params *lsproto.PublishDiagnosticsParams) error { +// panic("mock out the PublishDiagnostics method") +// }, // RefreshDiagnosticsFunc: func(ctx context.Context) error { // panic("mock out the RefreshDiagnostics method") // }, @@ -37,6 +40,9 @@ var _ project.Client = &ClientMock{} // // } type ClientMock struct { + // PublishDiagnosticsFunc mocks the PublishDiagnostics method. + PublishDiagnosticsFunc func(ctx context.Context, params *lsproto.PublishDiagnosticsParams) error + // RefreshDiagnosticsFunc mocks the RefreshDiagnostics method. RefreshDiagnosticsFunc func(ctx context.Context) error @@ -48,6 +54,13 @@ type ClientMock struct { // calls tracks calls to the methods. calls struct { + // PublishDiagnostics holds details about calls to the PublishDiagnostics method. + PublishDiagnostics []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Params is the params argument value. + Params *lsproto.PublishDiagnosticsParams + } // RefreshDiagnostics holds details about calls to the RefreshDiagnostics method. RefreshDiagnostics []struct { // Ctx is the ctx argument value. @@ -70,11 +83,49 @@ type ClientMock struct { Watchers []*lsproto.FileSystemWatcher } } + lockPublishDiagnostics sync.RWMutex lockRefreshDiagnostics sync.RWMutex lockUnwatchFiles sync.RWMutex lockWatchFiles sync.RWMutex } +// PublishDiagnostics calls PublishDiagnosticsFunc. +func (mock *ClientMock) PublishDiagnostics(ctx context.Context, params *lsproto.PublishDiagnosticsParams) error { + callInfo := struct { + Ctx context.Context + Params *lsproto.PublishDiagnosticsParams + }{ + Ctx: ctx, + Params: params, + } + mock.lockPublishDiagnostics.Lock() + mock.calls.PublishDiagnostics = append(mock.calls.PublishDiagnostics, callInfo) + mock.lockPublishDiagnostics.Unlock() + if mock.PublishDiagnosticsFunc == nil { + var errOut error + return errOut + } + return mock.PublishDiagnosticsFunc(ctx, params) +} + +// PublishDiagnosticsCalls gets all the calls that were made to PublishDiagnostics. +// Check the length with: +// +// len(mockedClient.PublishDiagnosticsCalls()) +func (mock *ClientMock) PublishDiagnosticsCalls() []struct { + Ctx context.Context + Params *lsproto.PublishDiagnosticsParams +} { + var calls []struct { + Ctx context.Context + Params *lsproto.PublishDiagnosticsParams + } + mock.lockPublishDiagnostics.RLock() + calls = mock.calls.PublishDiagnostics + mock.lockPublishDiagnostics.RUnlock() + return calls +} + // RefreshDiagnostics calls RefreshDiagnosticsFunc. func (mock *ClientMock) RefreshDiagnostics(ctx context.Context) error { callInfo := struct { diff --git a/pkg/testutil/projecttestutil/projecttestutil.go b/pkg/testutil/projecttestutil/projecttestutil.go index a66305b7..60ceec26 100644 --- a/pkg/testutil/projecttestutil/projecttestutil.go +++ b/pkg/testutil/projecttestutil/projecttestutil.go @@ -209,12 +209,13 @@ func SetupWithOptionsAndTypingsInstaller(files map[string]any, options *project. // Use provided options or create default ones if options == nil { options = &project.SessionOptions{ - CurrentDirectory: "/", - DefaultLibraryPath: bundled.LibPath(), - TypingsLocation: TestTypingsLocation, - PositionEncoding: lsproto.PositionEncodingKindUTF8, - WatchEnabled: true, - LoggingEnabled: true, + CurrentDirectory: "/", + DefaultLibraryPath: bundled.LibPath(), + TypingsLocation: TestTypingsLocation, + PositionEncoding: lsproto.PositionEncodingKindUTF8, + WatchEnabled: true, + LoggingEnabled: true, + PushDiagnosticsEnabled: true, } } diff --git a/testdata/baselines/reference/compiler/assertsPredicateParameterMismatch.errors.txt b/testdata/baselines/reference/compiler/assertsPredicateParameterMismatch.errors.txt new file mode 100644 index 00000000..39b13ba3 --- /dev/null +++ b/testdata/baselines/reference/compiler/assertsPredicateParameterMismatch.errors.txt @@ -0,0 +1,23 @@ +assertsPredicateParameterMismatch.ts(7,12): error TS1225: Cannot find parameter 'condition'. + + +==== assertsPredicateParameterMismatch.ts (1 errors) ==== + // This test verifies that the checker doesn't panic when an assertion predicate + // references a parameter name that doesn't match any actual function parameter. + // This specifically tests the code path in isReachableFlowNodeWorker. + + function assertCondition( + _condition: boolean + ): asserts condition { // "condition" doesn't match parameter "_condition" + ~~~~~~~~~ +!!! error TS1225: Cannot find parameter 'condition'. + if (!_condition) { + throw new Error('Condition failed'); + } + } + + function test(): void { + assertCondition(false); + console.log("unreachable"); + } + \ No newline at end of file diff --git a/testdata/baselines/reference/compiler/assertsPredicateParameterMismatch.symbols b/testdata/baselines/reference/compiler/assertsPredicateParameterMismatch.symbols new file mode 100644 index 00000000..bbeb60cc --- /dev/null +++ b/testdata/baselines/reference/compiler/assertsPredicateParameterMismatch.symbols @@ -0,0 +1,34 @@ +//// [tests/cases/compiler/assertsPredicateParameterMismatch.ts] //// + +=== assertsPredicateParameterMismatch.ts === +// This test verifies that the checker doesn't panic when an assertion predicate +// references a parameter name that doesn't match any actual function parameter. +// This specifically tests the code path in isReachableFlowNodeWorker. + +function assertCondition( +>assertCondition : Symbol(assertCondition, Decl(assertsPredicateParameterMismatch.ts, 0, 0)) + + _condition: boolean +>_condition : Symbol(_condition, Decl(assertsPredicateParameterMismatch.ts, 4, 25)) + +): asserts condition { // "condition" doesn't match parameter "_condition" + if (!_condition) { +>_condition : Symbol(_condition, Decl(assertsPredicateParameterMismatch.ts, 4, 25)) + + throw new Error('Condition failed'); +>Error : Symbol(Error, Decl(lib.es5.d.ts, --, --), Decl(lib.es5.d.ts, --, --)) + } +} + +function test(): void { +>test : Symbol(test, Decl(assertsPredicateParameterMismatch.ts, 10, 1)) + + assertCondition(false); +>assertCondition : Symbol(assertCondition, Decl(assertsPredicateParameterMismatch.ts, 0, 0)) + + console.log("unreachable"); +>console.log : Symbol(Console.log, Decl(lib.dom.d.ts, --, --)) +>console : Symbol(console, Decl(lib.dom.d.ts, --, --)) +>log : Symbol(Console.log, Decl(lib.dom.d.ts, --, --)) +} + diff --git a/testdata/baselines/reference/compiler/assertsPredicateParameterMismatch.types b/testdata/baselines/reference/compiler/assertsPredicateParameterMismatch.types new file mode 100644 index 00000000..1f05e06e --- /dev/null +++ b/testdata/baselines/reference/compiler/assertsPredicateParameterMismatch.types @@ -0,0 +1,41 @@ +//// [tests/cases/compiler/assertsPredicateParameterMismatch.ts] //// + +=== assertsPredicateParameterMismatch.ts === +// This test verifies that the checker doesn't panic when an assertion predicate +// references a parameter name that doesn't match any actual function parameter. +// This specifically tests the code path in isReachableFlowNodeWorker. + +function assertCondition( +>assertCondition : (_condition: boolean) => asserts condition + + _condition: boolean +>_condition : boolean + +): asserts condition { // "condition" doesn't match parameter "_condition" + if (!_condition) { +>!_condition : boolean +>_condition : boolean + + throw new Error('Condition failed'); +>new Error('Condition failed') : Error +>Error : ErrorConstructor +>'Condition failed' : "Condition failed" + } +} + +function test(): void { +>test : () => void + + assertCondition(false); +>assertCondition(false) : void +>assertCondition : (_condition: boolean) => asserts condition +>false : false + + console.log("unreachable"); +>console.log("unreachable") : void +>console.log : (...data: any[]) => void +>console : Console +>log : (...data: any[]) => void +>"unreachable" : "unreachable" +} + diff --git a/testdata/baselines/reference/compiler/typePredicateParameterMismatch.errors.txt b/testdata/baselines/reference/compiler/typePredicateParameterMismatch.errors.txt new file mode 100644 index 00000000..6c3037be --- /dev/null +++ b/testdata/baselines/reference/compiler/typePredicateParameterMismatch.errors.txt @@ -0,0 +1,25 @@ +typePredicateParameterMismatch.ts(10,4): error TS1225: Cannot find parameter 'value'. + + +==== typePredicateParameterMismatch.ts (1 errors) ==== + // This test verifies that the checker doesn't panic when a type predicate + // references a parameter name that doesn't match any actual function parameter. + + type TypeA = { kind: 'a' }; + type TypeB = { kind: 'b' }; + type UnionType = TypeA | TypeB; + + function isTypeA( + _value: UnionType + ): value is TypeA { // "value" doesn't match parameter "_value" + ~~~~~ +!!! error TS1225: Cannot find parameter 'value'. + return true; + } + + function test(input: UnionType): void { + if (isTypeA(input)) { + console.log(input.kind); + } + } + \ No newline at end of file diff --git a/testdata/baselines/reference/compiler/typePredicateParameterMismatch.symbols b/testdata/baselines/reference/compiler/typePredicateParameterMismatch.symbols new file mode 100644 index 00000000..6efd8255 --- /dev/null +++ b/testdata/baselines/reference/compiler/typePredicateParameterMismatch.symbols @@ -0,0 +1,51 @@ +//// [tests/cases/compiler/typePredicateParameterMismatch.ts] //// + +=== typePredicateParameterMismatch.ts === +// This test verifies that the checker doesn't panic when a type predicate +// references a parameter name that doesn't match any actual function parameter. + +type TypeA = { kind: 'a' }; +>TypeA : Symbol(TypeA, Decl(typePredicateParameterMismatch.ts, 0, 0)) +>kind : Symbol(kind, Decl(typePredicateParameterMismatch.ts, 3, 14)) + +type TypeB = { kind: 'b' }; +>TypeB : Symbol(TypeB, Decl(typePredicateParameterMismatch.ts, 3, 27)) +>kind : Symbol(kind, Decl(typePredicateParameterMismatch.ts, 4, 14)) + +type UnionType = TypeA | TypeB; +>UnionType : Symbol(UnionType, Decl(typePredicateParameterMismatch.ts, 4, 27)) +>TypeA : Symbol(TypeA, Decl(typePredicateParameterMismatch.ts, 0, 0)) +>TypeB : Symbol(TypeB, Decl(typePredicateParameterMismatch.ts, 3, 27)) + +function isTypeA( +>isTypeA : Symbol(isTypeA, Decl(typePredicateParameterMismatch.ts, 5, 31)) + + _value: UnionType +>_value : Symbol(_value, Decl(typePredicateParameterMismatch.ts, 7, 17)) +>UnionType : Symbol(UnionType, Decl(typePredicateParameterMismatch.ts, 4, 27)) + +): value is TypeA { // "value" doesn't match parameter "_value" +>TypeA : Symbol(TypeA, Decl(typePredicateParameterMismatch.ts, 0, 0)) + + return true; +} + +function test(input: UnionType): void { +>test : Symbol(test, Decl(typePredicateParameterMismatch.ts, 11, 1)) +>input : Symbol(input, Decl(typePredicateParameterMismatch.ts, 13, 14)) +>UnionType : Symbol(UnionType, Decl(typePredicateParameterMismatch.ts, 4, 27)) + + if (isTypeA(input)) { +>isTypeA : Symbol(isTypeA, Decl(typePredicateParameterMismatch.ts, 5, 31)) +>input : Symbol(input, Decl(typePredicateParameterMismatch.ts, 13, 14)) + + console.log(input.kind); +>console.log : Symbol(Console.log, Decl(lib.dom.d.ts, --, --)) +>console : Symbol(console, Decl(lib.dom.d.ts, --, --)) +>log : Symbol(Console.log, Decl(lib.dom.d.ts, --, --)) +>input.kind : Symbol(kind, Decl(typePredicateParameterMismatch.ts, 3, 14), Decl(typePredicateParameterMismatch.ts, 4, 14)) +>input : Symbol(input, Decl(typePredicateParameterMismatch.ts, 13, 14)) +>kind : Symbol(kind, Decl(typePredicateParameterMismatch.ts, 3, 14), Decl(typePredicateParameterMismatch.ts, 4, 14)) + } +} + diff --git a/testdata/baselines/reference/compiler/typePredicateParameterMismatch.types b/testdata/baselines/reference/compiler/typePredicateParameterMismatch.types new file mode 100644 index 00000000..92c77ed7 --- /dev/null +++ b/testdata/baselines/reference/compiler/typePredicateParameterMismatch.types @@ -0,0 +1,48 @@ +//// [tests/cases/compiler/typePredicateParameterMismatch.ts] //// + +=== typePredicateParameterMismatch.ts === +// This test verifies that the checker doesn't panic when a type predicate +// references a parameter name that doesn't match any actual function parameter. + +type TypeA = { kind: 'a' }; +>TypeA : TypeA +>kind : "a" + +type TypeB = { kind: 'b' }; +>TypeB : TypeB +>kind : "b" + +type UnionType = TypeA | TypeB; +>UnionType : UnionType + +function isTypeA( +>isTypeA : (_value: UnionType) => value is TypeA + + _value: UnionType +>_value : UnionType + +): value is TypeA { // "value" doesn't match parameter "_value" + return true; +>true : true +} + +function test(input: UnionType): void { +>test : (input: UnionType) => void +>input : UnionType + + if (isTypeA(input)) { +>isTypeA(input) : boolean +>isTypeA : (_value: UnionType) => value is TypeA +>input : UnionType + + console.log(input.kind); +>console.log(input.kind) : void +>console.log : (...data: any[]) => void +>console : Console +>log : (...data: any[]) => void +>input.kind : "a" | "b" +>input : UnionType +>kind : "a" | "b" + } +} + diff --git a/testdata/tests/cases/compiler/assertsPredicateParameterMismatch.ts b/testdata/tests/cases/compiler/assertsPredicateParameterMismatch.ts new file mode 100644 index 00000000..9491593b --- /dev/null +++ b/testdata/tests/cases/compiler/assertsPredicateParameterMismatch.ts @@ -0,0 +1,19 @@ +// @strict: true +// @noemit: true + +// This test verifies that the checker doesn't panic when an assertion predicate +// references a parameter name that doesn't match any actual function parameter. +// This specifically tests the code path in isReachableFlowNodeWorker. + +function assertCondition( + _condition: boolean +): asserts condition { // "condition" doesn't match parameter "_condition" + if (!_condition) { + throw new Error('Condition failed'); + } +} + +function test(): void { + assertCondition(false); + console.log("unreachable"); +} diff --git a/testdata/tests/cases/compiler/typePredicateParameterMismatch.ts b/testdata/tests/cases/compiler/typePredicateParameterMismatch.ts new file mode 100644 index 00000000..c4729d19 --- /dev/null +++ b/testdata/tests/cases/compiler/typePredicateParameterMismatch.ts @@ -0,0 +1,21 @@ +// @strict: true +// @noemit: true + +// This test verifies that the checker doesn't panic when a type predicate +// references a parameter name that doesn't match any actual function parameter. + +type TypeA = { kind: 'a' }; +type TypeB = { kind: 'b' }; +type UnionType = TypeA | TypeB; + +function isTypeA( + _value: UnionType +): value is TypeA { // "value" doesn't match parameter "_value" + return true; +} + +function test(input: UnionType): void { + if (isTypeA(input)) { + console.log(input.kind); + } +}