Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
36 changes: 36 additions & 0 deletions internal/fourslash/fourslash.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type FourslashTest struct {
testData *TestData // !!! consolidate test files from test data and script info
baselines map[string]*strings.Builder
rangesByText *collections.MultiMap[string, *RangeMarker]
config *ls.UserPreferences

scriptInfos map[string]*scriptInfo
converters *ls.Converters
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.config},
}
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,13 @@ func (f *FourslashTest) readMsg(t *testing.T) *lsproto.Message {
return msg
}

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

func (f *FourslashTest) GoToMarkerOrRange(t *testing.T, markerOrRange MarkerOrRange) {
f.goToMarker(t, markerOrRange)
}
Expand Down Expand Up @@ -541,6 +572,11 @@ func (f *FourslashTest) verifyCompletionsWorker(t *testing.T, expected *Completi
Position: f.currentCaretPosition,
Context: &lsproto.CompletionContext{},
}
if expected == nil {
f.configure(t, nil)
} else {
f.configure(t, expected.UserPreferences)
}
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
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
20 changes: 15 additions & 5 deletions internal/ls/languageservice.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,31 @@ 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) UpdateUserPreferences(preferences *UserPreferences) {
l.userPreferences = preferences
}

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
18 changes: 18 additions & 0 deletions internal/ls/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,24 @@ type UserPreferences struct {
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) GetOrDefault() UserPreferences {
if p == nil {
return UserPreferences{}
}
return *p
}

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"),
},
},
})
Comment on lines +221 to +227
Copy link
Member Author

@iisaduan iisaduan Sep 17, 2025

Choose a reason for hiding this comment

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

I think I see what you mean, are you asking if instead of requesting the config for one entry, "typescript", we request a list of preferences, one for every preference area that we expect from vscode:

Suggested change
result, err := s.sendRequest(ctx, lsproto.MethodWorkspaceConfiguration, &lsproto.ConfigurationParams{
Items: []*lsproto.ConfigurationItem{
{
Section: ptrTo("typescript"),
},
},
})
result, err := s.sendRequest(ctx, lsproto.MethodWorkspaceConfiguration, &lsproto.ConfigurationParams{
Items: []*lsproto.ConfigurationItem{
{
Section: ptrTo("typescript.inlayHints"),
},
{
Section: ptrTo("typescript.preferences"),
},
{
Section: ptrTo("typescript.unstable"),
},
.... etc
},
})

and process like that?

Copy link
Member Author

Choose a reason for hiding this comment

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

or even ask for every individual setting like "typescript.inlayHints.parameterNames.enabled", "typescript.inlayHints.supressWhenTypeMatchesName.enabled", etc

Copy link
Member

Choose a reason for hiding this comment

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

Yes, though I think we have to also request javascript too, and maybe even js/ts?

Then best case, we register a config watch on them and then keep them up to date.

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":
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if we could generate this code, maybe from the package.json in the extension when we have that.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, re: the conversation I had with jake above, I'm doing some thinking about other ways we could do this. This function as-is definitely isn't permanent

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)
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 {
Copy link
Member

Choose a reason for hiding this comment

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

Can't we do the same kind of parsing here that we're doing in Configure above?

// 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