Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
6 changes: 3 additions & 3 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ func (api *API) GetSymbolAtPosition(ctx context.Context, projectId Handle[projec
return nil, errors.New("project not found")
}

languageService := ls.NewLanguageService(project, snapshot.Converters())
languageService := ls.NewLanguageService(project, snapshot.Converters(), snapshot.UserPreferences())
symbol, err := languageService.GetSymbolAtPosition(ctx, fileName, position)
if err != nil || symbol == nil {
return nil, err
Expand Down Expand Up @@ -202,7 +202,7 @@ func (api *API) GetSymbolAtLocation(ctx context.Context, projectId Handle[projec
if node == nil {
return nil, fmt.Errorf("node of kind %s not found at position %d in file %q", kind.String(), pos, sourceFile.FileName())
}
languageService := ls.NewLanguageService(project, snapshot.Converters())
languageService := ls.NewLanguageService(project, snapshot.Converters(), snapshot.UserPreferences())
symbol := languageService.GetSymbolAtLocation(ctx, node)
if symbol == nil {
return nil, nil
Expand Down Expand Up @@ -232,7 +232,7 @@ func (api *API) GetTypeOfSymbol(ctx context.Context, projectId Handle[project.Pr
if !ok {
return nil, fmt.Errorf("symbol %q not found", symbolHandle)
}
languageService := ls.NewLanguageService(project, snapshot.Converters())
languageService := ls.NewLanguageService(project, snapshot.Converters(), snapshot.UserPreferences())
t := languageService.GetTypeOfSymbol(ctx, symbol)
if t == nil {
return nil, nil
Expand Down
49 changes: 49 additions & 0 deletions internal/fourslash/fourslash.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type FourslashTest struct {
scriptInfos map[string]*scriptInfo
converters *ls.Converters

userPreferences *ls.UserPreferences
currentCaretPosition lsproto.Position
lastKnownMarkerName *string
activeFilename string
Expand Down Expand Up @@ -268,6 +269,29 @@ func sendRequest[Params, Resp any](t *testing.T, f *FourslashTest, info lsproto.
)
f.writeMsg(t, req.Message())
resp := f.readMsg(t)
if resp == nil {
return nil, *new(Resp), false
}

// currently, the only request that may be sent by the server during a client request is one `config` request
// !!! remove if `config` is handled in initialization and there are no other server-initiated requests
if resp.Kind == lsproto.MessageKindRequest {
Copy link
Member

Choose a reason for hiding this comment

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

I do think we should move this handling to initialization. Or are going to need to handle other requests as well?

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure what we will need in the future

Copy link
Member

Choose a reason for hiding this comment

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

I think you could imagine the fourslash client handling diagnostics refresh requests and potentially even watch requests.

Copy link
Member Author

@iisaduan iisaduan Sep 30, 2025

Choose a reason for hiding this comment

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

If we're going to port the fourslash/server tests, then those probably will have to be handled?

req := resp.AsRequest()
switch req.Method {
case lsproto.MethodWorkspaceConfiguration:
req := lsproto.ResponseMessage{
ID: req.ID,
JSONRPC: req.JSONRPC,
Result: []any{&f.userPreferences},
}
f.writeMsg(t, req.Message())
resp = f.readMsg(t)
default:
// other types of responses not yet used in fourslash; implement them if needed
t.Fatalf("Unexpected request received: %s", req)
}
}

if resp == nil {
return nil, *new(Resp), false
}
Expand Down Expand Up @@ -300,6 +324,21 @@ func (f *FourslashTest) readMsg(t *testing.T) *lsproto.Message {
return msg
}

func (f *FourslashTest) Configure(t *testing.T, config *ls.UserPreferences) {
f.userPreferences = config
sendNotification(t, f, lsproto.WorkspaceDidChangeConfigurationInfo, &lsproto.DidChangeConfigurationParams{
Settings: config,
})
}

func (f *FourslashTest) ConfigureWithReset(t *testing.T, config *ls.UserPreferences) (reset func()) {
originalConfig := f.userPreferences.Copy()
f.Configure(t, config)
return func() {
f.Configure(t, originalConfig)
}
}

func (f *FourslashTest) GoToMarkerOrRange(t *testing.T, markerOrRange MarkerOrRange) {
f.goToMarker(t, markerOrRange)
}
Expand Down Expand Up @@ -541,6 +580,10 @@ func (f *FourslashTest) verifyCompletionsWorker(t *testing.T, expected *Completi
Position: f.currentCaretPosition,
Context: &lsproto.CompletionContext{},
}
if expected != nil && expected.UserPreferences != nil {
reset := f.ConfigureWithReset(t, expected.UserPreferences)
defer reset()
}
resMsg, result, resultOk := sendRequest(t, f, lsproto.TextDocumentCompletionInfo, params)
if resMsg == nil {
t.Fatalf(prefix+"Nil response received for completion request", f.lastKnownMarkerName)
Expand Down Expand Up @@ -1372,6 +1415,12 @@ func (f *FourslashTest) getCurrentPositionPrefix() string {
}

func (f *FourslashTest) BaselineAutoImportsCompletions(t *testing.T, markerNames []string) {
reset := f.ConfigureWithReset(t, &ls.UserPreferences{
IncludeCompletionsForModuleExports: ptrTo(true),
IncludeCompletionsForImportStatements: ptrTo(true),
})
defer reset()

for _, markerName := range markerNames {
f.GoToMarker(t, markerName)
params := &lsproto.CompletionParams{
Expand Down
13 changes: 13 additions & 0 deletions internal/fourslash/tests/autoImportCompletion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ a/**/
},
})
f.BaselineAutoImportsCompletions(t, []string{""})
f.VerifyCompletions(t, "", &fourslash.CompletionsExpectedList{
UserPreferences: &ls.UserPreferences{
// completion autoimport preferences off; this tests if fourslash server communication correctly registers changes in user preferences
},
IsIncomplete: false,
ItemDefaults: &fourslash.CompletionsExpectedItemDefaults{
CommitCharacters: &DefaultCommitCharacters,
EditRange: Ignored,
},
Items: &fourslash.CompletionsExpectedItems{
Excludes: []string{"anotherVar"},
},
})
}

func TestAutoImportCompletion2(t *testing.T) {
Expand Down
16 changes: 11 additions & 5 deletions internal/ls/languageservice.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,27 @@ import (
)

type LanguageService struct {
host Host
converters *Converters
host Host
converters *Converters
userPreferences *UserPreferences
}

func NewLanguageService(host Host, converters *Converters) *LanguageService {
func NewLanguageService(host Host, converters *Converters, preferences *UserPreferences) *LanguageService {
return &LanguageService{
host: host,
converters: converters,
host: host,
converters: converters,
userPreferences: preferences,
}
}

func (l *LanguageService) GetProgram() *compiler.Program {
return l.host.GetProgram()
}

func (l *LanguageService) UserPreferences() *UserPreferences {
return l.userPreferences
}

func (l *LanguageService) tryGetProgramAndFile(fileName string) (*compiler.Program, *ast.SourceFile) {
program := l.GetProgram()
file := program.GetSourceFile(fileName)
Expand Down
27 changes: 27 additions & 0 deletions internal/ls/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,33 @@
AutoImportFileExcludePatterns []string

UseAliasesForRename *bool

// Inlay Hints
IncludeInlayParameterNameHints string
IncludeInlayParameterNameHintsWhenArgumentMatchesName *bool
IncludeInlayFunctionParameterTypeHints *bool
IncludeInlayVariableTypeHints *bool
IncludeInlayVariableTypeHintsWhenTypeMatchesName *bool
IncludeInlayPropertyDeclarationTypeHints *bool
IncludeInlayFunctionLikeReturnTypeHints *bool
IncludeInlayEnumMemberValueHints *bool
InteractiveInlayHints *bool
}

func (p *UserPreferences) Copy() *UserPreferences {
// not a true deep copy
if p == nil {
return nil
}
copy := *p

Check failure on line 77 in internal/ls/types.go

View workflow job for this annotation

GitHub Actions / lint (ubuntu-latest)

variable copy has same name as predeclared identifier (predeclared)
return &copy
}

func (p *UserPreferences) CopyOrDefault() *UserPreferences {
if p == nil {
return &UserPreferences{}
}
return p.Copy()
}

func (p *UserPreferences) ModuleSpecifierPreferences() modulespecifiers.UserPreferences {
Expand Down
127 changes: 117 additions & 10 deletions internal/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,101 @@ func (s *Server) RefreshDiagnostics(ctx context.Context) error {
return nil
}

func (s *Server) Configure(ctx context.Context) (*ls.UserPreferences, error) {
result, err := s.sendRequest(ctx, lsproto.MethodWorkspaceConfiguration, &lsproto.ConfigurationParams{
Items: []*lsproto.ConfigurationItem{
{
Section: ptrTo("typescript"),
Copy link
Member

Choose a reason for hiding this comment

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

Unfortunately there is also javascript and js/ts scopes we might need.

I think we're going to have to make user prefs have both variants and then return one or the other depending on the situation...

},
},
})
if err != nil {
return nil, fmt.Errorf("configure request failed: %w", err)
}
configs := result.([]any)
userPreferences := &ls.UserPreferences{}
for _, config := range configs {
if config == nil {
continue
}
if item, ok := config.(map[string]any); ok {
for name, values := range item {
switch name {
case "inlayHints":
inlayHintsPreferences := values.(map[string]any)
if v, ok := inlayHintsPreferences["parameterNames"].(map[string]any); ok && v != nil {
if enabled, ok := v["enabled"]; ok {
if enabledStr, ok := enabled.(string); ok {
userPreferences.IncludeInlayParameterNameHints = enabledStr
} else {
userPreferences.IncludeInlayParameterNameHints = ""
}
}
if supressWhenArgumentMatchesName, ok := v["suppressWhenArgumentMatchesName"]; ok {
userPreferences.IncludeInlayParameterNameHintsWhenArgumentMatchesName = ptrTo(!supressWhenArgumentMatchesName.(bool))
}
}
if v, ok := inlayHintsPreferences["parameterTypes"].(map[string]any); ok && v != nil {
if enabled, ok := v["enabled"]; ok {
userPreferences.IncludeInlayFunctionParameterTypeHints = ptrTo(enabled.(bool))
}
}
if v, ok := inlayHintsPreferences["variableTypes"].(map[string]any); ok && v != nil {
if enabled, ok := v["enabled"]; ok {
userPreferences.IncludeInlayVariableTypeHints = ptrTo(enabled.(bool))
}
if supressWhenTypeMatchesName, ok := v["suppressWhenTypeMatchesName"]; ok {
userPreferences.IncludeInlayVariableTypeHintsWhenTypeMatchesName = ptrTo(!supressWhenTypeMatchesName.(bool))
}
}
if v, ok := inlayHintsPreferences["propertyDeclarationTypes"].(map[string]any); ok && v != nil {
if enabled, ok := v["enabled"]; ok {
userPreferences.IncludeInlayPropertyDeclarationTypeHints = ptrTo(enabled.(bool))
}
}
if v, ok := inlayHintsPreferences["functionLikeReturnTypes"].(map[string]any); ok && v != nil {
if enabled, ok := v["enabled"]; ok {
userPreferences.IncludeInlayFunctionLikeReturnTypeHints = ptrTo(enabled.(bool))
}
}
if v, ok := inlayHintsPreferences["enumMemberValues"].(map[string]any); ok && v != nil {
if enabled, ok := v["enabled"]; ok {
userPreferences.IncludeInlayEnumMemberValueHints = ptrTo(enabled.(bool))
}
}
userPreferences.InteractiveInlayHints = ptrTo(true)
case "tsserver":
// !!!
case "unstable":
// !!!
case "tsc":
// !!!
case "updateImportsOnFileMove":
// !!! moveToFile
case "preferences":
// !!!
case "experimental":
// !!!
case "organizeImports":
// !!!
case "importModuleSpecifierEnding":
// !!!
}
}
continue
}
if item, ok := config.(ls.UserPreferences); ok {
// case for fourslash
userPreferences = &item
break
}
}
// !!! set defaults for services, remove after extension is updated
userPreferences.IncludeCompletionsForModuleExports = ptrTo(true)
userPreferences.IncludeCompletionsForImportStatements = ptrTo(true)
return userPreferences, nil
}

func (s *Server) Run() error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
Expand Down Expand Up @@ -440,6 +535,7 @@ var handlers = sync.OnceValue(func() handlerMap {
registerRequestHandler(handlers, lsproto.ShutdownInfo, (*Server).handleShutdown)
registerNotificationHandler(handlers, lsproto.ExitInfo, (*Server).handleExit)

registerNotificationHandler(handlers, lsproto.WorkspaceDidChangeConfigurationInfo, (*Server).handleDidChangeWorkspaceConfiguration)
Copy link
Member

Choose a reason for hiding this comment

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

This handler is set up, but I don't think the PR sets up the watch capability yet.

registerNotificationHandler(handlers, lsproto.TextDocumentDidOpenInfo, (*Server).handleDidOpen)
registerNotificationHandler(handlers, lsproto.TextDocumentDidChangeInfo, (*Server).handleDidChange)
registerNotificationHandler(handlers, lsproto.TextDocumentDidSaveInfo, (*Server).handleDidSave)
Expand Down Expand Up @@ -638,7 +734,6 @@ func (s *Server) handleInitialized(ctx context.Context, params *lsproto.Initiali
if shouldEnableWatch(s.initializeParams) {
s.watchEnabled = true
}

s.session = project.NewSession(&project.SessionInit{
Options: &project.SessionOptions{
CurrentDirectory: s.cwd,
Expand All @@ -655,6 +750,11 @@ func (s *Server) handleInitialized(ctx context.Context, params *lsproto.Initiali
NpmExecutor: s,
ParseCache: s.parseCache,
})
userPreferences, err := s.Configure(ctx)
if err != nil {
return err
}
s.session.Configure(userPreferences)
Copy link
Member

Choose a reason for hiding this comment

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

Don't we also need to register with the client here to be able to actually get config change notifications? Just like we do in WatchFiles.

Copy link
Member Author

@iisaduan iisaduan Sep 18, 2025

Choose a reason for hiding this comment

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

Re: this + the comment above (link), I didn't fully implement this function because I wanted to figure out what we're going to process in the extension first and I wasn't sure the range of how much info that the client can pass to the server (will it always pass the entire new config upon changes? or only the differences?)

// !!! temporary; remove when we have `handleDidChangeConfiguration`/implicit project config support
if s.compilerOptionsForInferredProjects != nil {
s.session.DidChangeCompilerOptionsForInferredProjects(ctx, s.compilerOptionsForInferredProjects)
Expand All @@ -672,6 +772,16 @@ func (s *Server) handleExit(ctx context.Context, params any) error {
return io.EOF
}

func (s *Server) handleDidChangeWorkspaceConfiguration(ctx context.Context, params *lsproto.DidChangeConfigurationParams) error {
// !!! update user preferences
// !!! only usable by fourslash
if item, ok := params.Settings.(*ls.UserPreferences); ok {
// case for fourslash
s.session.Configure(item)
}
return nil
}

func (s *Server) handleDidOpen(ctx context.Context, params *lsproto.DidOpenTextDocumentParams) error {
s.session.DidOpenFile(ctx, params.TextDocument.Uri, params.TextDocument.Version, params.TextDocument.Text, params.TextDocument.LanguageId)
return nil
Expand Down Expand Up @@ -757,10 +867,8 @@ func (s *Server) handleCompletion(ctx context.Context, languageService *ls.Langu
params.Position,
params.Context,
getCompletionClientCapabilities(s.initializeParams),
&ls.UserPreferences{
IncludeCompletionsForModuleExports: ptrTo(true),
IncludeCompletionsForImportStatements: ptrTo(true),
})
languageService.UserPreferences(),
)
}

func (s *Server) handleCompletionItemResolve(ctx context.Context, params *lsproto.CompletionItem, reqMsg *lsproto.RequestMessage) (lsproto.CompletionResolveResponse, error) {
Expand All @@ -778,10 +886,7 @@ func (s *Server) handleCompletionItemResolve(ctx context.Context, params *lsprot
params,
data,
getCompletionClientCapabilities(s.initializeParams),
&ls.UserPreferences{
IncludeCompletionsForModuleExports: ptrTo(true),
IncludeCompletionsForImportStatements: ptrTo(true),
},
languageService.UserPreferences(),
)
}

Expand Down Expand Up @@ -855,7 +960,9 @@ func isBlockingMethod(method lsproto.Method) bool {
lsproto.MethodTextDocumentDidChange,
lsproto.MethodTextDocumentDidSave,
lsproto.MethodTextDocumentDidClose,
lsproto.MethodWorkspaceDidChangeWatchedFiles:
lsproto.MethodWorkspaceDidChangeWatchedFiles,
lsproto.MethodWorkspaceDidChangeConfiguration,
lsproto.MethodWorkspaceConfiguration:
return true
}
return false
Expand Down
2 changes: 1 addition & 1 deletion internal/project/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
)

func (s *Session) OpenProject(ctx context.Context, configFileName string) (*Project, error) {
fileChanges, overlays, ataChanges := s.flushChanges(ctx)
fileChanges, overlays, ataChanges, _ := s.flushChanges(ctx)
newSnapshot := s.UpdateSnapshot(ctx, overlays, SnapshotChange{
fileChanges: fileChanges,
ataChanges: ataChanges,
Expand Down
Loading
Loading