vendor tsgo
This commit is contained in:
94
tools/tsgo/internal/lsp/dynamic_queue.go
Normal file
94
tools/tsgo/internal/lsp/dynamic_queue.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Inspired by Brian C. Mills' "Rethinking Classical Concurrency Patterns" talk:
|
||||
// https://www.youtube.com/watch?v=5zXAHh5tJqQ
|
||||
//
|
||||
// This queue is a state machine, where each state is a channel, "idle" or "ready".
|
||||
// Only one caller ever has the actual state struct at a time. The Get function
|
||||
// will wait until the "ready" channel holds the state. Putting an item
|
||||
// means grabbing the state from any channel, modifying it, and putting it
|
||||
// back on the "ready" channel. Since this is all managed via contexts, any method
|
||||
// can be cancelled while waiting for the state.
|
||||
|
||||
type dynamicQueue[T any] struct {
|
||||
idle chan *dynamicQueueState[T]
|
||||
ready chan *dynamicQueueState[T]
|
||||
}
|
||||
|
||||
type dynamicQueueState[T any] struct {
|
||||
items []T
|
||||
}
|
||||
|
||||
func newDynamicQueue[T any]() *dynamicQueue[T] {
|
||||
q := &dynamicQueue[T]{
|
||||
idle: make(chan *dynamicQueueState[T], 1),
|
||||
ready: make(chan *dynamicQueueState[T], 1),
|
||||
}
|
||||
q.idle <- &dynamicQueueState[T]{}
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *dynamicQueue[T]) Put(ctx context.Context, item T) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
state, err := q.getAny(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
state.items = append(state.items, item)
|
||||
q.ready <- state
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *dynamicQueue[T]) Get(ctx context.Context) (T, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
|
||||
state, err := q.getReady(ctx)
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
|
||||
item := state.items[0]
|
||||
var zero T
|
||||
state.items[0] = zero
|
||||
state.items = state.items[1:]
|
||||
|
||||
if len(state.items) == 0 {
|
||||
state.items = nil
|
||||
q.idle <- state
|
||||
} else {
|
||||
q.ready <- state
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (q *dynamicQueue[T]) getAny(ctx context.Context) (*dynamicQueueState[T], error) {
|
||||
select {
|
||||
case state := <-q.idle:
|
||||
return state, nil
|
||||
case state := <-q.ready:
|
||||
return state, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (q *dynamicQueue[T]) getReady(ctx context.Context) (*dynamicQueueState[T], error) {
|
||||
select {
|
||||
case state := <-q.ready:
|
||||
return state, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
78
tools/tsgo/internal/lsp/dynamic_queue_test.go
Normal file
78
tools/tsgo/internal/lsp/dynamic_queue_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDynamicQueueFIFO(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := t.Context()
|
||||
q := newDynamicQueue[int]()
|
||||
|
||||
for i := range 1000 {
|
||||
if err := q.Put(ctx, i); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range 1000 {
|
||||
got, err := q.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != i {
|
||||
t.Fatalf("Get() = %d, want %d", got, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicQueueGetCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
q := newDynamicQueue[int]()
|
||||
got, err := q.Get(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Get() error = %v, want %v", err, context.Canceled)
|
||||
}
|
||||
if got != 0 {
|
||||
t.Fatalf("Get() = %d, want zero value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicQueuePutCancellationWhileStateUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q := newDynamicQueue[int]()
|
||||
state, err := q.getAny(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
putErr := q.Put(ctx, 1)
|
||||
if !errors.Is(putErr, context.Canceled) {
|
||||
t.Fatalf("Put() error = %v, want %v", putErr, context.Canceled)
|
||||
}
|
||||
|
||||
q.idle <- state
|
||||
|
||||
err = q.Put(t.Context(), 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := q.Get(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 2 {
|
||||
t.Fatalf("Get() = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
184
tools/tsgo/internal/lsp/logger.go
Normal file
184
tools/tsgo/internal/lsp/logger.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
||||
"github.com/microsoft/typescript-go/internal/project/logging"
|
||||
)
|
||||
|
||||
var _ logging.Logger = (*logger)(nil)
|
||||
|
||||
type logger struct {
|
||||
server *Server
|
||||
mu sync.Mutex
|
||||
verbosity lsproto.LogVerbosity
|
||||
}
|
||||
|
||||
func newLogger(server *Server) *logger {
|
||||
return &logger{
|
||||
server: server,
|
||||
verbosity: lsproto.LogVerbosityInfo,
|
||||
}
|
||||
}
|
||||
|
||||
// maxVerbosityForMessageType returns the least-verbose log level at which
|
||||
// messages of the given LSP MessageType should still be sent.
|
||||
func maxVerbosityForMessageType(msgType lsproto.MessageType) lsproto.LogVerbosity {
|
||||
switch msgType {
|
||||
case lsproto.MessageTypeError:
|
||||
return lsproto.LogVerbosityError
|
||||
case lsproto.MessageTypeWarning:
|
||||
return lsproto.LogVerbosityWarning
|
||||
case lsproto.MessageTypeInfo:
|
||||
return lsproto.LogVerbosityInfo
|
||||
case lsproto.MessageTypeDebug:
|
||||
return lsproto.LogVerbosityDebug
|
||||
default:
|
||||
return lsproto.LogVerbosityInfo
|
||||
}
|
||||
}
|
||||
|
||||
// isValidLogVerbosity reports whether v is one of the defined LogVerbosity values.
|
||||
func isValidLogVerbosity(v lsproto.LogVerbosity) bool {
|
||||
return v >= lsproto.LogVerbosityOff && v <= lsproto.LogVerbosityError
|
||||
}
|
||||
|
||||
func (l *logger) sendLogMessage(msgType lsproto.MessageType, message string) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !l.server.initStarted.Load() {
|
||||
fmt.Fprintln(l.server.stderr, message)
|
||||
return
|
||||
}
|
||||
|
||||
// Don't send messages that the client will filter out anyway.
|
||||
l.mu.Lock()
|
||||
verbosity := l.verbosity
|
||||
l.mu.Unlock()
|
||||
if verbosity == lsproto.LogVerbosityOff || verbosity > maxVerbosityForMessageType(msgType) {
|
||||
return
|
||||
}
|
||||
|
||||
notification := lsproto.WindowLogMessageInfo.NewNotificationMessage(&lsproto.LogMessageParams{
|
||||
Type: msgType,
|
||||
Message: message,
|
||||
})
|
||||
|
||||
if err := l.server.outgoingQueue.Put(l.server.backgroundCtx, notification.Message()); err != nil {
|
||||
if l.server.backgroundCtx.Err() != nil {
|
||||
fmt.Fprintln(l.server.stderr, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *logger) Log(msg ...any) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.sendLogMessage(lsproto.MessageTypeInfo, fmt.Sprint(msg...))
|
||||
}
|
||||
|
||||
func (l *logger) Logf(format string, args ...any) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.sendLogMessage(lsproto.MessageTypeInfo, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *logger) Verbose() logging.Logger {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.verbosity == lsproto.LogVerbosityOff || l.verbosity > lsproto.LogVerbosityDebug {
|
||||
return nil
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *logger) IsVerbose() bool {
|
||||
if l == nil {
|
||||
return false
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.verbosity >= lsproto.LogVerbosityTrace && l.verbosity <= lsproto.LogVerbosityDebug
|
||||
}
|
||||
|
||||
func (l *logger) SetVerbose(verbose bool) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if verbose {
|
||||
l.verbosity = lsproto.LogVerbosityDebug
|
||||
} else {
|
||||
l.verbosity = lsproto.LogVerbosityInfo
|
||||
}
|
||||
}
|
||||
|
||||
func (l *logger) IsTracing() bool {
|
||||
if l == nil {
|
||||
return false
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.verbosity == lsproto.LogVerbosityTrace
|
||||
}
|
||||
|
||||
func (l *logger) SetVerbosity(verbosity lsproto.LogVerbosity) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.verbosity = verbosity
|
||||
}
|
||||
|
||||
func (l *logger) Error(msg ...any) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.sendLogMessage(lsproto.MessageTypeError, fmt.Sprint(msg...))
|
||||
}
|
||||
|
||||
func (l *logger) Errorf(format string, args ...any) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.sendLogMessage(lsproto.MessageTypeError, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *logger) Warn(msg ...any) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.sendLogMessage(lsproto.MessageTypeWarning, fmt.Sprint(msg...))
|
||||
}
|
||||
|
||||
func (l *logger) Warnf(format string, args ...any) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.sendLogMessage(lsproto.MessageTypeWarning, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *logger) Info(msg ...any) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.sendLogMessage(lsproto.MessageTypeInfo, fmt.Sprint(msg...))
|
||||
}
|
||||
|
||||
func (l *logger) Infof(format string, args ...any) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.sendLogMessage(lsproto.MessageTypeInfo, fmt.Sprintf(format, args...))
|
||||
}
|
||||
2
tools/tsgo/internal/lsp/lsproto/_generate/.gitignore
vendored
Normal file
2
tools/tsgo/internal/lsp/lsproto/_generate/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
metaModel.json
|
||||
metaModel.schema.json
|
||||
39
tools/tsgo/internal/lsp/lsproto/_generate/fetchModel.mts
Normal file
39
tools/tsgo/internal/lsp/lsproto/_generate/fetchModel.mts
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env -S node --experimental-strip-types
|
||||
|
||||
// Usage: node --experimental-strip-types fetchModel.mts
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
|
||||
const __filename = url.fileURLToPath(new URL(import.meta.url));
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const metaModelPath = path.join(__dirname, "metaModel.json");
|
||||
const metaModelSchemaPath = path.join(__dirname, "metaModelSchema.mts");
|
||||
|
||||
// Resolve the vscode-languageclient version from the root package-lock.json.
|
||||
const lockfilePath = path.resolve(__dirname, "../../../../package-lock.json");
|
||||
const lockfile = JSON.parse(fs.readFileSync(lockfilePath, "utf-8"));
|
||||
const clientVersion: string = lockfile.packages["node_modules/vscode-languageclient"].version;
|
||||
|
||||
const ref = `release/client/${clientVersion}`;
|
||||
console.log(`Using vscode-languageclient@${clientVersion}`);
|
||||
|
||||
const metaModelURL = `https://raw.githubusercontent.com/microsoft/vscode-languageserver-node/${ref}/protocol/metaModel.json`;
|
||||
const metaModelSchemaURL = `https://raw.githubusercontent.com/microsoft/vscode-languageserver-node/${ref}/tools/src/metaModel.ts`;
|
||||
|
||||
const metaModelResponse = await fetch(metaModelURL);
|
||||
const metaModel = await metaModelResponse.text();
|
||||
fs.writeFileSync(metaModelPath, metaModel);
|
||||
|
||||
const metaModelSchemaResponse = await fetch(metaModelSchemaURL);
|
||||
let metaModelSchema = await metaModelSchemaResponse.text();
|
||||
|
||||
// Patch the schema to add omitzeroValue property to Property type
|
||||
metaModelSchema = metaModelSchema.replace(
|
||||
/(\t \* Whether the property is deprecated or not\. If deprecated\n\t \* the property contains the deprecation message\.\n\t \*\/\n\tdeprecated\?: string;)\n}/m,
|
||||
`$1\n\n\t/**\n\t * Whether this property uses omitzero without being a pointer.\n\t * Custom extension for special value types.\n\t */\n\tomitzeroValue?: boolean;\n}`,
|
||||
);
|
||||
|
||||
fs.writeFileSync(metaModelSchemaPath, metaModelSchema);
|
||||
3436
tools/tsgo/internal/lsp/lsproto/_generate/generate.mts
Normal file
3436
tools/tsgo/internal/lsp/lsproto/_generate/generate.mts
Normal file
File diff suppressed because it is too large
Load Diff
635
tools/tsgo/internal/lsp/lsproto/_generate/metaModelSchema.mts
Normal file
635
tools/tsgo/internal/lsp/lsproto/_generate/metaModelSchema.mts
Normal file
@@ -0,0 +1,635 @@
|
||||
/* --------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
* ------------------------------------------------------------------------------------------ */
|
||||
|
||||
export type BaseTypes = 'URI' | 'DocumentUri' | 'integer' | 'uinteger' | 'decimal' | 'RegExp' | 'string' | 'boolean' | 'null';
|
||||
|
||||
export type TypeKind = 'base' | 'reference' | 'array' | 'map' | 'and' | 'or' | 'tuple' | 'literal' | 'stringLiteral' | 'integerLiteral' | 'booleanLiteral';
|
||||
|
||||
/**
|
||||
* Indicates in which direction a message is sent in the protocol.
|
||||
*/
|
||||
export type MessageDirection = 'clientToServer' | 'serverToClient' | 'both';
|
||||
|
||||
/**
|
||||
* Represents a base type like `string` or `DocumentUri`.
|
||||
*/
|
||||
export type BaseType = {
|
||||
kind: 'base';
|
||||
name: BaseTypes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a reference to another type (e.g. `TextDocument`).
|
||||
* This is either a `Structure`, a `Enumeration` or a `TypeAlias`
|
||||
* in the same meta model.
|
||||
*/
|
||||
export type ReferenceType = {
|
||||
kind: 'reference';
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents an array type (e.g. `TextDocument[]`).
|
||||
*/
|
||||
export type ArrayType = {
|
||||
kind: 'array';
|
||||
element: Type;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a type that can be used as a key in a
|
||||
* map type. If a reference type is used then the
|
||||
* type must either resolve to a `string` or `integer`
|
||||
* type. (e.g. `type ChangeAnnotationIdentifier === string`).
|
||||
*/
|
||||
export type MapKeyType = { kind: 'base'; name: 'URI' | 'DocumentUri' | 'string' | 'integer' } | ReferenceType;
|
||||
|
||||
/**
|
||||
* Represents a JSON object map
|
||||
* (e.g. `interface Map<K extends string | integer, V> { [key: K] => V; }`).
|
||||
*/
|
||||
export type MapType = {
|
||||
kind: 'map';
|
||||
key: MapKeyType;
|
||||
value: Type;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents an `and` type
|
||||
* (e.g. TextDocumentParams & WorkDoneProgressParams`).
|
||||
*/
|
||||
export type AndType = {
|
||||
kind: 'and';
|
||||
items: Type[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents an `or` type
|
||||
* (e.g. `Location | LocationLink`).
|
||||
*/
|
||||
export type OrType = {
|
||||
kind: 'or';
|
||||
items: Type[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a `tuple` type
|
||||
* (e.g. `[integer, integer]`).
|
||||
*/
|
||||
export type TupleType = {
|
||||
kind: 'tuple';
|
||||
items: Type[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a literal structure
|
||||
* (e.g. `property: { start: uinteger; end: uinteger; }`).
|
||||
*/
|
||||
export type StructureLiteralType = {
|
||||
kind: 'literal';
|
||||
value: StructureLiteral;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a string literal type
|
||||
* (e.g. `kind: 'rename'`).
|
||||
*/
|
||||
export type StringLiteralType = {
|
||||
kind: 'stringLiteral';
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type IntegerLiteralType = {
|
||||
/**
|
||||
* Represents an integer literal type
|
||||
* (e.g. `kind: 1`).
|
||||
*/
|
||||
kind: 'integerLiteral';
|
||||
value: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a boolean literal type
|
||||
* (e.g. `kind: true`).
|
||||
*/
|
||||
export type BooleanLiteralType = {
|
||||
kind: 'booleanLiteral';
|
||||
value: boolean;
|
||||
};
|
||||
|
||||
export type Type = BaseType | ReferenceType | ArrayType | MapType | AndType | OrType | TupleType | StructureLiteralType | StringLiteralType | IntegerLiteralType | BooleanLiteralType;
|
||||
|
||||
/**
|
||||
* Represents a LSP request
|
||||
*/
|
||||
export type Request = {
|
||||
/**
|
||||
* The request's method name.
|
||||
*/
|
||||
method: string;
|
||||
|
||||
/**
|
||||
* The type name of the request if any.
|
||||
*/
|
||||
typeName?: string;
|
||||
|
||||
/**
|
||||
* The parameter type(s) if any.
|
||||
*/
|
||||
params?: Type | Type[];
|
||||
|
||||
/**
|
||||
* The result type.
|
||||
*/
|
||||
result: Type;
|
||||
|
||||
/**
|
||||
* Optional partial result type if the request
|
||||
* supports partial result reporting.
|
||||
*/
|
||||
partialResult?: Type;
|
||||
|
||||
/**
|
||||
* An optional error data type.
|
||||
*/
|
||||
errorData?: Type;
|
||||
|
||||
/**
|
||||
* Optional a dynamic registration method if it
|
||||
* different from the request's method.
|
||||
*/
|
||||
registrationMethod?: string;
|
||||
|
||||
/**
|
||||
* Optional registration options if the request
|
||||
* supports dynamic registration.
|
||||
*/
|
||||
registrationOptions?: Type;
|
||||
|
||||
/**
|
||||
* The direction in which this request is sent
|
||||
* in the protocol.
|
||||
*/
|
||||
messageDirection: MessageDirection;
|
||||
|
||||
/**
|
||||
* An optional documentation;
|
||||
*/
|
||||
documentation?: string;
|
||||
|
||||
/**
|
||||
* Since when (release number) this request is
|
||||
* available. Is undefined if not known.
|
||||
*/
|
||||
since?: string;
|
||||
|
||||
/**
|
||||
* All since tags in case there was more than one tag.
|
||||
* Is undefined if not known.
|
||||
*/
|
||||
sinceTags?: string[];
|
||||
|
||||
/**
|
||||
* Whether this is a proposed feature. If omitted
|
||||
* the feature is final.
|
||||
*/
|
||||
proposed?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the request is deprecated or not. If deprecated
|
||||
* the property contains the deprecation message.
|
||||
*/
|
||||
deprecated?: string;
|
||||
|
||||
/**
|
||||
* The client capability property path if any.
|
||||
*/
|
||||
clientCapability?: string;
|
||||
|
||||
/**
|
||||
* The server capability property path if any.
|
||||
*/
|
||||
serverCapability?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a LSP notification
|
||||
*/
|
||||
export type Notification = {
|
||||
/**
|
||||
* The notifications's method name.
|
||||
*/
|
||||
method: string;
|
||||
|
||||
/**
|
||||
* The type name of the notifications if any.
|
||||
*/
|
||||
typeName?: string;
|
||||
|
||||
/**
|
||||
* The parameter type(s) if any.
|
||||
*/
|
||||
params?: Type | Type[];
|
||||
|
||||
/**
|
||||
* Optional a dynamic registration method if it
|
||||
* different from the notifications's method.
|
||||
*/
|
||||
registrationMethod?: string;
|
||||
|
||||
/**
|
||||
* Optional registration options if the notification
|
||||
* supports dynamic registration.
|
||||
*/
|
||||
registrationOptions?: Type;
|
||||
|
||||
/**
|
||||
* The direction in which this notification is sent
|
||||
* in the protocol.
|
||||
*/
|
||||
messageDirection: MessageDirection;
|
||||
|
||||
/**
|
||||
* An optional documentation;
|
||||
*/
|
||||
documentation?: string;
|
||||
|
||||
/**
|
||||
* Since when (release number) this notification is
|
||||
* available. Is undefined if not known.
|
||||
*/
|
||||
since?: string;
|
||||
|
||||
/**
|
||||
* All since tags in case there was more than one tag.
|
||||
* Is undefined if not known.
|
||||
*/
|
||||
sinceTags?: string[];
|
||||
|
||||
/**
|
||||
* Whether this is a proposed notification. If omitted
|
||||
* the notification is final.
|
||||
*/
|
||||
proposed?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the notification is deprecated or not. If deprecated
|
||||
* the property contains the deprecation message.
|
||||
*/
|
||||
deprecated?: string;
|
||||
|
||||
/**
|
||||
* The client capability property path if any.
|
||||
*/
|
||||
clientCapability?: string;
|
||||
|
||||
/**
|
||||
* The server capability property path if any.
|
||||
*/
|
||||
serverCapability?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents an object property.
|
||||
*/
|
||||
export type Property = {
|
||||
/**
|
||||
* The property name;
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The type of the property
|
||||
*/
|
||||
type: Type;
|
||||
|
||||
/**
|
||||
* Whether the property is optional. If
|
||||
* omitted, the property is mandatory.
|
||||
*/
|
||||
optional?: boolean;
|
||||
|
||||
/**
|
||||
* An optional documentation.
|
||||
*/
|
||||
documentation?: string;
|
||||
|
||||
/**
|
||||
* Since when (release number) this property is
|
||||
* available. Is undefined if not known.
|
||||
*/
|
||||
since?: string;
|
||||
|
||||
/**
|
||||
* All since tags in case there was more than one tag.
|
||||
* Is undefined if not known.
|
||||
*/
|
||||
sinceTags?: string[];
|
||||
|
||||
/**
|
||||
* Whether this is a proposed property. If omitted,
|
||||
* the structure is final.
|
||||
*/
|
||||
proposed?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the property is deprecated or not. If deprecated
|
||||
* the property contains the deprecation message.
|
||||
*/
|
||||
deprecated?: string;
|
||||
|
||||
/**
|
||||
* Whether this property uses omitzero without being a pointer.
|
||||
* Custom extension for special value types.
|
||||
*/
|
||||
omitzeroValue?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines the structure of an object literal.
|
||||
*/
|
||||
export type Structure = {
|
||||
/**
|
||||
* The name of the structure.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Structures extended from. This structures form
|
||||
* a polymorphic type hierarchy.
|
||||
*/
|
||||
extends?: Type[];
|
||||
|
||||
/**
|
||||
* Structures to mix in. The properties of these
|
||||
* structures are `copied` into this structure.
|
||||
* Mixins don't form a polymorphic type hierarchy in
|
||||
* LSP.
|
||||
*/
|
||||
mixins?: Type[];
|
||||
|
||||
/**
|
||||
* The properties.
|
||||
*/
|
||||
properties: Property[];
|
||||
|
||||
/**
|
||||
* An optional documentation;
|
||||
*/
|
||||
documentation?: string;
|
||||
|
||||
/**
|
||||
* Since when (release number) this structure is
|
||||
* available. Is undefined if not known.
|
||||
*/
|
||||
since?: string;
|
||||
|
||||
/**
|
||||
* All since tags in case there was more than one tag.
|
||||
* Is undefined if not known.
|
||||
*/
|
||||
sinceTags?: string[];
|
||||
|
||||
/**
|
||||
* Whether this is a proposed structure. If omitted,
|
||||
* the structure is final.
|
||||
*/
|
||||
proposed?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the structure is deprecated or not. If deprecated
|
||||
* the property contains the deprecation message.
|
||||
*/
|
||||
deprecated?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines an unnamed structure of an object literal.
|
||||
*/
|
||||
export type StructureLiteral = {
|
||||
|
||||
/**
|
||||
* The properties.
|
||||
*/
|
||||
properties: Property[];
|
||||
|
||||
/**
|
||||
* An optional documentation.
|
||||
*/
|
||||
documentation?: string;
|
||||
|
||||
/**
|
||||
* Since when (release number) this structure is
|
||||
* available. Is undefined if not known.
|
||||
*/
|
||||
since?: string;
|
||||
|
||||
/**
|
||||
* All since tags in case there was more than one tag.
|
||||
* Is undefined if not known.
|
||||
*/
|
||||
sinceTags?: string[];
|
||||
|
||||
/**
|
||||
* Whether this is a proposed structure. If omitted,
|
||||
* the structure is final.
|
||||
*/
|
||||
proposed?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the literal is deprecated or not. If deprecated
|
||||
* the property contains the deprecation message.
|
||||
*/
|
||||
deprecated?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines a type alias.
|
||||
* (e.g. `type Definition = Location | LocationLink`)
|
||||
*/
|
||||
export type TypeAlias = {
|
||||
/**
|
||||
* The name of the type alias.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The aliased type.
|
||||
*/
|
||||
type: Type;
|
||||
|
||||
/**
|
||||
* An optional documentation.
|
||||
*/
|
||||
documentation?: string;
|
||||
|
||||
/**
|
||||
* Since when (release number) this structure is
|
||||
* available. Is undefined if not known.
|
||||
*/
|
||||
since?: string;
|
||||
|
||||
/**
|
||||
* All since tags in case there was more than one tag.
|
||||
* Is undefined if not known.
|
||||
*/
|
||||
sinceTags?: string[];
|
||||
|
||||
/**
|
||||
* Whether this is a proposed type alias. If omitted,
|
||||
* the type alias is final.
|
||||
*/
|
||||
proposed?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the type alias is deprecated or not. If deprecated
|
||||
* the property contains the deprecation message.
|
||||
*/
|
||||
deprecated?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines an enumeration entry.
|
||||
*/
|
||||
export type EnumerationEntry = {
|
||||
/**
|
||||
* The name of the enum item.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The value.
|
||||
*/
|
||||
value: string | number;
|
||||
|
||||
/**
|
||||
* An optional documentation.
|
||||
*/
|
||||
documentation?: string;
|
||||
|
||||
/**
|
||||
* Since when (release number) this enumeration entry is
|
||||
* available. Is undefined if not known.
|
||||
*/
|
||||
since?: string;
|
||||
|
||||
/**
|
||||
* All since tags in case there was more than one tag.
|
||||
* Is undefined if not known.
|
||||
*/
|
||||
sinceTags?: string[];
|
||||
|
||||
/**
|
||||
* Whether this is a proposed enumeration entry. If omitted,
|
||||
* the enumeration entry is final.
|
||||
*/
|
||||
proposed?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the enum entry is deprecated or not. If deprecated
|
||||
* the property contains the deprecation message.
|
||||
*/
|
||||
deprecated?: string;
|
||||
};
|
||||
|
||||
export type EnumerationType = { kind: 'base'; name: 'string' | 'integer' | 'uinteger' };
|
||||
|
||||
/**
|
||||
* Defines an enumeration.
|
||||
*/
|
||||
export type Enumeration = {
|
||||
/**
|
||||
* The name of the enumeration.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The type of the elements.
|
||||
*/
|
||||
type: EnumerationType;
|
||||
|
||||
/**
|
||||
* The enum values.
|
||||
*/
|
||||
values: EnumerationEntry[];
|
||||
|
||||
/**
|
||||
* Whether the enumeration supports custom values (e.g. values which are not
|
||||
* part of the set defined in `values`). If omitted no custom values are
|
||||
* supported.
|
||||
*/
|
||||
supportsCustomValues?: boolean;
|
||||
|
||||
/**
|
||||
* An optional documentation.
|
||||
*/
|
||||
documentation?: string;
|
||||
|
||||
/**
|
||||
* Since when (release number) this enumeration is
|
||||
* available. Is undefined if not known.
|
||||
*/
|
||||
since?: string;
|
||||
|
||||
/**
|
||||
* All since tags in case there was more than one tag.
|
||||
* Is undefined if not known.
|
||||
*/
|
||||
sinceTags?: string[];
|
||||
|
||||
/**
|
||||
* Whether this is a proposed enumeration. If omitted,
|
||||
* the enumeration is final.
|
||||
*/
|
||||
proposed?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the enumeration is deprecated or not. If deprecated
|
||||
* the property contains the deprecation message.
|
||||
*/
|
||||
deprecated?: string;
|
||||
};
|
||||
|
||||
export type MetaData = {
|
||||
/**
|
||||
* The protocol version.
|
||||
*/
|
||||
version: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The actual meta model.
|
||||
*/
|
||||
export type MetaModel = {
|
||||
/**
|
||||
* Additional meta data.
|
||||
*/
|
||||
metaData: MetaData;
|
||||
|
||||
/**
|
||||
* The requests.
|
||||
*/
|
||||
requests: Request[];
|
||||
|
||||
/**
|
||||
* The notifications.
|
||||
*/
|
||||
notifications: Notification[];
|
||||
|
||||
/**
|
||||
* The structures.
|
||||
*/
|
||||
structures: Structure[];
|
||||
|
||||
/**
|
||||
* The enumerations.
|
||||
*/
|
||||
enumerations: Enumeration[];
|
||||
|
||||
/**
|
||||
* The type aliases.
|
||||
*/
|
||||
typeAliases: TypeAlias[];
|
||||
};
|
||||
20
tools/tsgo/internal/lsp/lsproto/_generate/tsconfig.json
Normal file
20
tools/tsgo/internal/lsp/lsproto/_generate/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["node"],
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
},
|
||||
"include": [
|
||||
"*.mts",
|
||||
"*.mjs"
|
||||
]
|
||||
}
|
||||
33
tools/tsgo/internal/lsp/lsproto/baseproto.go
Normal file
33
tools/tsgo/internal/lsp/lsproto/baseproto.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package lsproto
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/jsonrpc"
|
||||
)
|
||||
|
||||
// https://microsoft.github.io/language-server-protocol/specifications/base/0.9/specification/
|
||||
|
||||
// BaseReader wraps jsonrpc.Reader for backwards compatibility.
|
||||
type BaseReader struct {
|
||||
*jsonrpc.Reader
|
||||
}
|
||||
|
||||
// NewBaseReader creates a new BaseReader.
|
||||
func NewBaseReader(r io.Reader) *BaseReader {
|
||||
return &BaseReader{
|
||||
Reader: jsonrpc.NewReader(r),
|
||||
}
|
||||
}
|
||||
|
||||
// BaseWriter wraps jsonrpc.Writer for backwards compatibility.
|
||||
type BaseWriter struct {
|
||||
*jsonrpc.Writer
|
||||
}
|
||||
|
||||
// NewBaseWriter creates a new BaseWriter.
|
||||
func NewBaseWriter(w io.Writer) *BaseWriter {
|
||||
return &BaseWriter{
|
||||
Writer: jsonrpc.NewWriter(w),
|
||||
}
|
||||
}
|
||||
151
tools/tsgo/internal/lsp/lsproto/baseproto_test.go
Normal file
151
tools/tsgo/internal/lsp/lsproto/baseproto_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package lsproto_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestBaseReader(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
value []byte
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
input: []byte("Content-Length: 0\r\n\r\n"),
|
||||
err: "jsonrpc: no content length",
|
||||
},
|
||||
{
|
||||
name: "early end",
|
||||
input: []byte("oops"),
|
||||
err: "EOF",
|
||||
},
|
||||
{
|
||||
name: "negative length",
|
||||
input: []byte("Content-Length: -1\r\n\r\n"),
|
||||
err: "jsonrpc: invalid content length: negative value -1",
|
||||
},
|
||||
{
|
||||
name: "invalid content",
|
||||
input: []byte("Content-Length: 1\r\n\r\n{"),
|
||||
value: []byte("{"),
|
||||
},
|
||||
{
|
||||
name: "valid content",
|
||||
input: []byte("Content-Length: 2\r\n\r\n{}"),
|
||||
value: []byte("{}"),
|
||||
},
|
||||
{
|
||||
name: "extra header values",
|
||||
input: []byte("Content-Length: 2\r\nExtra: 1\r\n\r\n{}"),
|
||||
value: []byte("{}"),
|
||||
},
|
||||
{
|
||||
name: "too long content length",
|
||||
input: []byte("Content-Length: 100\r\n\r\n{}"),
|
||||
err: "jsonrpc: read content: unexpected EOF",
|
||||
},
|
||||
{
|
||||
name: "missing content length",
|
||||
input: []byte("Content-Length: \r\n\r\n{}"),
|
||||
err: "jsonrpc: invalid content length: parse error: strconv.ParseInt: parsing \"\": invalid syntax",
|
||||
},
|
||||
{
|
||||
name: "invalid header",
|
||||
input: []byte("Nope\r\n\r\n{}"),
|
||||
err: "jsonrpc: invalid header: \"Nope\\r\\n\"",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := lsproto.NewBaseReader(bytes.NewReader(tt.input))
|
||||
|
||||
out, err := r.Read()
|
||||
if tt.err != "" {
|
||||
assert.Error(t, err, tt.err)
|
||||
}
|
||||
assert.DeepEqual(t, out, tt.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseReaderMultipleReads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := []byte(
|
||||
"Content-Length: 4\r\n\r\n1234" +
|
||||
"Content-Length: 2\r\n\r\n{}",
|
||||
)
|
||||
r := lsproto.NewBaseReader(bytes.NewReader(data))
|
||||
|
||||
v1, err := r.Read()
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, v1, []byte("1234"))
|
||||
|
||||
v2, err := r.Read()
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, v2, []byte("{}"))
|
||||
|
||||
_, err = r.Read()
|
||||
assert.Error(t, err, "EOF")
|
||||
}
|
||||
|
||||
type errorReader struct{}
|
||||
|
||||
func (*errorReader) Read([]byte) (int, error) {
|
||||
return 0, errors.New("test error")
|
||||
}
|
||||
|
||||
func TestBaseWriter(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
value []byte
|
||||
input []byte
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
value: []byte("{}"),
|
||||
input: []byte("Content-Length: 2\r\n\r\n{}"),
|
||||
},
|
||||
{
|
||||
name: "bigger object",
|
||||
value: []byte("{\"key\":\"value\"}"),
|
||||
input: []byte("Content-Length: 15\r\n\r\n{\"key\":\"value\"}"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var b bytes.Buffer
|
||||
w := lsproto.NewBaseWriter(&b)
|
||||
err := w.Write(tt.value)
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, b.Bytes(), tt.input)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWriterWriteError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := lsproto.NewBaseWriter(&errorWriter{})
|
||||
err := w.Write([]byte("{}"))
|
||||
assert.Error(t, err, "test error")
|
||||
}
|
||||
|
||||
type errorWriter struct{}
|
||||
|
||||
func (*errorWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("test error")
|
||||
}
|
||||
130
tools/tsgo/internal/lsp/lsproto/jsonrpc.go
Normal file
130
tools/tsgo/internal/lsp/lsproto/jsonrpc.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package lsproto
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/json"
|
||||
"github.com/microsoft/typescript-go/internal/jsonrpc"
|
||||
)
|
||||
|
||||
// NewID creates an ID from an IntegerOrString value.
|
||||
// This wrapper exists because lsproto has its own IntegerOrString type.
|
||||
func NewID(rawValue IntegerOrString) *jsonrpc.ID {
|
||||
if rawValue.String != nil {
|
||||
return jsonrpc.NewIDString(*rawValue.String)
|
||||
}
|
||||
return jsonrpc.NewIDInt(*rawValue.Integer)
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Kind jsonrpc.MessageKind
|
||||
msg any
|
||||
}
|
||||
|
||||
func (m *Message) AsRequest() *RequestMessage {
|
||||
return m.msg.(*RequestMessage)
|
||||
}
|
||||
|
||||
func (m *Message) AsResponse() *ResponseMessage {
|
||||
return m.msg.(*ResponseMessage)
|
||||
}
|
||||
|
||||
func (m *Message) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
JSONRPC jsonrpc.JSONRPCVersion `json:"jsonrpc"`
|
||||
Method Method `json:"method"`
|
||||
ID *jsonrpc.ID `json:"id,omitzero"`
|
||||
Params json.Value `json:"params"`
|
||||
// We don't have a method in the response, so we have no idea what to decode.
|
||||
// Store the raw text and let the caller decode it.
|
||||
Result json.Value `json:"result,omitzero"`
|
||||
Error *jsonrpc.ResponseError `json:"error,omitzero"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrorCodeInvalidRequest, err)
|
||||
}
|
||||
if raw.ID != nil && raw.Method == "" {
|
||||
m.Kind = jsonrpc.MessageKindResponse
|
||||
m.msg = &ResponseMessage{
|
||||
ID: raw.ID,
|
||||
Result: raw.Result,
|
||||
Error: raw.Error,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var params any
|
||||
if len(raw.Params) > 0 {
|
||||
params = raw.Params
|
||||
}
|
||||
|
||||
if raw.ID == nil {
|
||||
m.Kind = jsonrpc.MessageKindNotification
|
||||
} else {
|
||||
m.Kind = jsonrpc.MessageKindRequest
|
||||
}
|
||||
|
||||
m.msg = &RequestMessage{
|
||||
ID: raw.ID,
|
||||
Method: raw.Method,
|
||||
Params: params,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Message) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(m.msg)
|
||||
}
|
||||
|
||||
type RequestMessage struct {
|
||||
JSONRPC jsonrpc.JSONRPCVersion `json:"jsonrpc"`
|
||||
ID *jsonrpc.ID `json:"id,omitzero"`
|
||||
Method Method `json:"method"`
|
||||
Params any `json:"params,omitzero"`
|
||||
}
|
||||
|
||||
func (r *RequestMessage) Message() *Message {
|
||||
kind := jsonrpc.MessageKindRequest
|
||||
if r.ID == nil {
|
||||
kind = jsonrpc.MessageKindNotification
|
||||
}
|
||||
return &Message{
|
||||
Kind: kind,
|
||||
msg: r,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RequestMessage) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
JSONRPC jsonrpc.JSONRPCVersion `json:"jsonrpc"`
|
||||
ID *jsonrpc.ID `json:"id"`
|
||||
Method Method `json:"method"`
|
||||
Params json.Value `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrorCodeInvalidRequest, err)
|
||||
}
|
||||
|
||||
r.ID = raw.ID
|
||||
r.Method = raw.Method
|
||||
if len(raw.Params) > 0 {
|
||||
r.Params = raw.Params
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type ResponseMessage struct {
|
||||
JSONRPC jsonrpc.JSONRPCVersion `json:"jsonrpc"`
|
||||
ID *jsonrpc.ID `json:"id"`
|
||||
Result any `json:"result,omitzero"`
|
||||
Error *jsonrpc.ResponseError `json:"error,omitzero"`
|
||||
}
|
||||
|
||||
func (r *ResponseMessage) Message() *Message {
|
||||
return &Message{
|
||||
Kind: jsonrpc.MessageKindResponse,
|
||||
msg: r,
|
||||
}
|
||||
}
|
||||
312
tools/tsgo/internal/lsp/lsproto/lsp.go
Normal file
312
tools/tsgo/internal/lsp/lsproto/lsp.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package lsproto
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/bundled"
|
||||
"github.com/microsoft/typescript-go/internal/json"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/jsonrpc"
|
||||
"github.com/microsoft/typescript-go/internal/tspath"
|
||||
)
|
||||
|
||||
type DocumentUri string // !!!
|
||||
|
||||
func (uri DocumentUri) FileName() string {
|
||||
if bundled.IsBundled(string(uri)) {
|
||||
return string(uri)
|
||||
}
|
||||
if strings.HasPrefix(string(uri), "file://") {
|
||||
parsed, err := url.Parse(string(uri))
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("invalid file URI: %s", uri))
|
||||
}
|
||||
if parsed.Host != "" {
|
||||
return "//" + parsed.Host + parsed.Path
|
||||
}
|
||||
return fixWindowsURIPath(parsed.Path)
|
||||
}
|
||||
|
||||
// Leave all other URIs escaped so we can round-trip them.
|
||||
|
||||
scheme, path, ok := strings.Cut(string(uri), ":")
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("invalid URI: %s", uri))
|
||||
}
|
||||
|
||||
authority := "ts-nul-authority"
|
||||
if rest, ok := strings.CutPrefix(path, "//"); ok {
|
||||
authority, path, ok = strings.Cut(rest, "/")
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("invalid URI: %s", uri))
|
||||
}
|
||||
}
|
||||
|
||||
return "^/" + scheme + "/" + authority + "/" + path
|
||||
}
|
||||
|
||||
func (uri DocumentUri) Path(useCaseSensitiveFileNames bool) tspath.Path {
|
||||
fileName := uri.FileName()
|
||||
return tspath.ToPath(fileName, "", useCaseSensitiveFileNames)
|
||||
}
|
||||
|
||||
func fixWindowsURIPath(path string) string {
|
||||
if rest, ok := strings.CutPrefix(path, "/"); ok {
|
||||
if volume, rest, ok := tspath.SplitVolumePath(rest); ok {
|
||||
return volume + rest
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
type HasTextDocumentURI interface {
|
||||
TextDocumentURI() DocumentUri
|
||||
}
|
||||
|
||||
type HasTextDocumentPosition interface {
|
||||
HasTextDocumentURI
|
||||
TextDocumentPosition() Position
|
||||
}
|
||||
|
||||
type HasLocations interface {
|
||||
GetLocations() *[]Location
|
||||
}
|
||||
|
||||
type HasLocation interface {
|
||||
GetLocation() Location
|
||||
}
|
||||
|
||||
type URI string // !!!
|
||||
|
||||
type Method string
|
||||
|
||||
func errNotObject(k json.Kind) error {
|
||||
return fmt.Errorf("expected object start, but encountered %v", k)
|
||||
}
|
||||
|
||||
func errNull(field string) error {
|
||||
return fmt.Errorf("null value is not allowed for field %q", field)
|
||||
}
|
||||
|
||||
func errMissing(props []string) error {
|
||||
return fmt.Errorf("missing required properties: %s", strings.Join(props, ", "))
|
||||
}
|
||||
|
||||
func errInvalidKind(typeName string, got json.Kind) error {
|
||||
return fmt.Errorf("invalid %s: got %v", typeName, got)
|
||||
}
|
||||
|
||||
func errInvalidValue(typeName string, data []byte) error {
|
||||
return fmt.Errorf("invalid %s: %s", typeName, data)
|
||||
}
|
||||
|
||||
func errLiteralMismatch(typeName string, expected string, got []byte) error {
|
||||
return fmt.Errorf("expected %s value %s, got %s", typeName, expected, got)
|
||||
}
|
||||
|
||||
func assertOnlyOne(message string, count int) {
|
||||
if count != 1 {
|
||||
panic(message)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAtMostOne(message string, count int) {
|
||||
if count > 1 {
|
||||
panic(message)
|
||||
}
|
||||
}
|
||||
|
||||
// jsonKeyCheck compares a raw JSON key token (including quotes) against a Go string.
|
||||
func jsonKeyCheck(name []byte, key string) bool {
|
||||
return len(name) == len(key)+2 && name[0] == '"' && string(name[1:len(name)-1]) == key
|
||||
}
|
||||
|
||||
// jsonObjectRawField scans the top-level keys of a JSON object looking for the
|
||||
// given field name, and returns its raw JSON value (e.g. `"full"` with quotes).
|
||||
// Returns nil if the field is not found.
|
||||
func jsonObjectRawField(data []byte, field string) json.Value {
|
||||
dec := json.NewDecoder(bytes.NewBuffer(data))
|
||||
if dec.PeekKind() != '{' {
|
||||
return nil
|
||||
}
|
||||
if _, err := dec.ReadToken(); err != nil {
|
||||
return nil
|
||||
}
|
||||
for dec.PeekKind() != '}' {
|
||||
name, err := dec.ReadValue()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if jsonKeyCheck(name, field) {
|
||||
val, err := dec.ReadValue()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return val
|
||||
}
|
||||
if err := dec.SkipValue(); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsonObjectHasKey scans the top-level keys of a JSON object looking for any of the
|
||||
// given keys. Returns the index of the first key found, or -1 if none match.
|
||||
// Bails early on first match without decoding any values.
|
||||
func jsonObjectHasKey(data []byte, keys ...string) int {
|
||||
dec := json.NewDecoder(bytes.NewBuffer(data))
|
||||
if dec.PeekKind() != '{' {
|
||||
return -1
|
||||
}
|
||||
if _, err := dec.ReadToken(); err != nil {
|
||||
return -1
|
||||
}
|
||||
for dec.PeekKind() != '}' {
|
||||
name, err := dec.ReadValue()
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
for i, key := range keys {
|
||||
if jsonKeyCheck(name, key) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
if err := dec.SkipValue(); err != nil {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Inspired by https://www.youtube.com/watch?v=dab3I-HcTVk
|
||||
|
||||
type RequestInfo[Params, Resp any] struct {
|
||||
_ [0]Params
|
||||
_ [0]Resp
|
||||
Method Method
|
||||
}
|
||||
|
||||
func (info RequestInfo[Params, Resp]) UnmarshalResult(result any) (Resp, error) {
|
||||
raw, ok := result.(json.Value)
|
||||
if !ok {
|
||||
return *new(Resp), fmt.Errorf("expected json.Value, got %T", result)
|
||||
}
|
||||
|
||||
var r Resp
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return *new(Resp), err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (info RequestInfo[Params, Resp]) NewRequestMessage(id *jsonrpc.ID, params Params) *RequestMessage {
|
||||
return &RequestMessage{
|
||||
ID: id,
|
||||
Method: info.Method,
|
||||
Params: params,
|
||||
}
|
||||
}
|
||||
|
||||
type NotificationInfo[Params any] struct {
|
||||
_ [0]Params
|
||||
Method Method
|
||||
}
|
||||
|
||||
func (info NotificationInfo[Params]) NewNotificationMessage(params Params) *RequestMessage {
|
||||
return &RequestMessage{
|
||||
Method: info.Method,
|
||||
Params: params,
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalParams decodes the params of an inbound request or notification
|
||||
// message into the requested type. Inbound messages store their params as a
|
||||
// raw [json.Value] (see [Message.UnmarshalJSON]); decoding is deferred to the
|
||||
// point of dispatch so that param types for methods the server never handles
|
||||
// are not forced into the binary.
|
||||
//
|
||||
// A [NoParams] method must be given no params; every other method must be given
|
||||
// params as an object or array. A violation returns [ErrorCodeInvalidParams].
|
||||
func UnmarshalParams[T any](req *RequestMessage) (T, error) {
|
||||
var params T
|
||||
var raw json.Value
|
||||
if req.Params != nil {
|
||||
v, ok := req.Params.(json.Value)
|
||||
if !ok {
|
||||
return params, fmt.Errorf("%w: unexpected params type %T", ErrorCodeInvalidParams, req.Params)
|
||||
}
|
||||
raw = v
|
||||
}
|
||||
|
||||
// params is the zero value of T; this asserts on its type, i.e. whether the
|
||||
// method was declared with NoParams.
|
||||
if _, declaresNoParams := any(params).(NoParams); declaresNoParams {
|
||||
if len(raw) != 0 {
|
||||
return params, fmt.Errorf("%w: expected no params, got %s", ErrorCodeInvalidParams, raw)
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// The base protocol defines params as `array | object`; reject anything else
|
||||
// (absent, null, or a scalar).
|
||||
if k := raw.Kind(); k != '{' && k != '[' {
|
||||
return params, fmt.Errorf("%w: params must be an object or array", ErrorCodeInvalidParams)
|
||||
}
|
||||
if err := json.Unmarshal(raw, ¶ms); err != nil {
|
||||
return params, fmt.Errorf("%w: %w", ErrorCodeInvalidParams, err)
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
type Null struct{}
|
||||
|
||||
func (Null) UnmarshalJSONFrom(dec *json.Decoder) error {
|
||||
data, err := dec.ReadValue()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if string(data) != "null" {
|
||||
return fmt.Errorf("expected null, got %s", data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Null) MarshalJSONTo(enc *json.Encoder) error {
|
||||
return enc.WriteToken(json.Null)
|
||||
}
|
||||
|
||||
type NoParams struct{}
|
||||
|
||||
func (NoParams) IsZero() bool { return true }
|
||||
|
||||
type clientCapabilitiesKey struct{}
|
||||
|
||||
func WithClientCapabilities(ctx context.Context, caps *ResolvedClientCapabilities) context.Context {
|
||||
return context.WithValue(ctx, clientCapabilitiesKey{}, caps)
|
||||
}
|
||||
|
||||
func GetClientCapabilities(ctx context.Context) *ResolvedClientCapabilities {
|
||||
if caps, _ := ctx.Value(clientCapabilitiesKey{}).(*ResolvedClientCapabilities); caps != nil {
|
||||
return caps
|
||||
}
|
||||
return &ResolvedClientCapabilities{}
|
||||
}
|
||||
|
||||
// PreferredMarkupKind returns the first (most preferred) markup kind from the given formats,
|
||||
// or MarkupKindPlainText if the slice is empty.
|
||||
func PreferredMarkupKind(formats []MarkupKind) MarkupKind {
|
||||
if len(formats) > 0 {
|
||||
return formats[0]
|
||||
}
|
||||
return MarkupKindPlainText
|
||||
}
|
||||
|
||||
const (
|
||||
CodeActionKindSourceRemoveUnusedImports CodeActionKind = "source.removeUnusedImports"
|
||||
CodeActionKindSourceSortImports CodeActionKind = "source.sortImports"
|
||||
)
|
||||
17262
tools/tsgo/internal/lsp/lsproto/lsp_generated.go
Normal file
17262
tools/tsgo/internal/lsp/lsproto/lsp_generated.go
Normal file
File diff suppressed because it is too large
Load Diff
1036
tools/tsgo/internal/lsp/lsproto/lsp_json_test.go
Normal file
1036
tools/tsgo/internal/lsp/lsproto/lsp_json_test.go
Normal file
File diff suppressed because it is too large
Load Diff
84
tools/tsgo/internal/lsp/lsproto/lsp_test.go
Normal file
84
tools/tsgo/internal/lsp/lsproto/lsp_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package lsproto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/json"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestUnmarshalCompletionItem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const message = `{
|
||||
"label": "pageXOffset",
|
||||
"insertTextFormat": 1,
|
||||
"textEdit": {
|
||||
"newText": "pageXOffset",
|
||||
"insert": {
|
||||
"start": {
|
||||
"line": 4,
|
||||
"character": 0
|
||||
},
|
||||
"end": {
|
||||
"line": 4,
|
||||
"character": 4
|
||||
}
|
||||
},
|
||||
"replace": {
|
||||
"start": {
|
||||
"line": 4,
|
||||
"character": 0
|
||||
},
|
||||
"end": {
|
||||
"line": 4,
|
||||
"character": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
"kind": 6,
|
||||
"sortText": "15",
|
||||
"commitCharacters": [
|
||||
".",
|
||||
",",
|
||||
";"
|
||||
]
|
||||
}`
|
||||
|
||||
var result CompletionItem
|
||||
err := json.Unmarshal([]byte(message), &result)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.DeepEqual(t, result, CompletionItem{
|
||||
Label: "pageXOffset",
|
||||
InsertTextFormat: new(InsertTextFormatPlainText),
|
||||
TextEdit: &TextEditOrInsertReplaceEdit{
|
||||
InsertReplaceEdit: &InsertReplaceEdit{
|
||||
NewText: "pageXOffset",
|
||||
Insert: Range{
|
||||
Start: Position{
|
||||
Line: 4,
|
||||
Character: 0,
|
||||
},
|
||||
End: Position{
|
||||
Line: 4,
|
||||
Character: 4,
|
||||
},
|
||||
},
|
||||
Replace: Range{
|
||||
Start: Position{
|
||||
Line: 4,
|
||||
Character: 0,
|
||||
},
|
||||
End: Position{
|
||||
Line: 4,
|
||||
Character: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Kind: new(CompletionItemKindVariable),
|
||||
SortText: new("15"),
|
||||
CommitCharacters: new([]string{".", ",", ";"}),
|
||||
})
|
||||
}
|
||||
164
tools/tsgo/internal/lsp/lsproto/structcodec.go
Normal file
164
tools/tsgo/internal/lsp/lsproto/structcodec.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package lsproto
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/json"
|
||||
)
|
||||
|
||||
// This file provides a single reflection-driven object decoder that replaces
|
||||
// the per-type UnmarshalJSONFrom methods the generator emits for plain
|
||||
// structs. It preserves the same strictness those methods enforce: the value
|
||||
// must be an object, all required fields must be present, and a JSON null is
|
||||
// rejected for nilable fields whose spec is not nullable. Required fields, and
|
||||
// the rare spec-nullable fields, are marked with an `lsp:"required"` /
|
||||
// `lsp:"nullable"` struct tag (the spec nullability that decides this is not
|
||||
// otherwise recoverable from the json tag); any other nilable field rejects
|
||||
// null by default. The per-type spec is resolved once via reflection and
|
||||
// cached, so the only per-call work is the object scan.
|
||||
|
||||
type structFieldSpec struct {
|
||||
index int
|
||||
requiredID int // bit position among required fields, or -1
|
||||
rejectNull bool
|
||||
}
|
||||
|
||||
type structSpec struct {
|
||||
byName map[string]structFieldSpec
|
||||
requiredNames []string
|
||||
requiredMask uint64
|
||||
}
|
||||
|
||||
var structSpecCache sync.Map // reflect.Type -> *structSpec
|
||||
|
||||
func specFor(t reflect.Type) *structSpec {
|
||||
if cached, ok := structSpecCache.Load(t); ok {
|
||||
return cached.(*structSpec)
|
||||
}
|
||||
spec := &structSpec{byName: make(map[string]structFieldSpec, t.NumField())}
|
||||
for i := range t.NumField() {
|
||||
f := t.Field(i)
|
||||
jsonName, _, _ := strings.Cut(f.Tag.Get("json"), ",")
|
||||
if jsonName == "" || jsonName == "-" {
|
||||
continue
|
||||
}
|
||||
fs := structFieldSpec{index: i, requiredID: -1}
|
||||
var nullable bool
|
||||
for marker := range strings.SplitSeq(f.Tag.Get("lsp"), ",") {
|
||||
switch marker {
|
||||
case "required":
|
||||
fs.requiredID = len(spec.requiredNames)
|
||||
spec.requiredMask |= 1 << fs.requiredID
|
||||
spec.requiredNames = append(spec.requiredNames, jsonName)
|
||||
case "nullable":
|
||||
nullable = true
|
||||
}
|
||||
}
|
||||
// A nilable field (pointer/slice/map) rejects an explicit JSON null
|
||||
// unless the spec marks it nullable.
|
||||
switch f.Type.Kind() {
|
||||
case reflect.Pointer, reflect.Slice, reflect.Map:
|
||||
fs.rejectNull = !nullable
|
||||
}
|
||||
spec.byName[jsonName] = fs
|
||||
}
|
||||
actual, _ := structSpecCache.LoadOrStore(t, spec)
|
||||
return actual.(*structSpec)
|
||||
}
|
||||
|
||||
// unmarshalStruct decodes a JSON object into the struct pointed to by v,
|
||||
// enforcing object-kind, required-field, and non-nullable-field strictness as
|
||||
// declared by lsp struct tags. Up to 64 required fields are supported.
|
||||
func unmarshalStruct(v any, dec *json.Decoder) error {
|
||||
rv := reflect.ValueOf(v).Elem()
|
||||
spec := specFor(rv.Type())
|
||||
|
||||
if k := dec.PeekKind(); k != '{' {
|
||||
return errNotObject(k)
|
||||
}
|
||||
if _, err := dec.ReadToken(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var seen uint64
|
||||
for dec.PeekKind() != '}' {
|
||||
name, err := dec.ReadValue()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// name includes surrounding quotes; m[string(b)] is a no-alloc lookup.
|
||||
fs, ok := spec.byName[string(name[1:len(name)-1])]
|
||||
if !ok {
|
||||
if err := dec.SkipValue(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if fs.requiredID >= 0 {
|
||||
seen |= 1 << fs.requiredID
|
||||
}
|
||||
if fs.rejectNull && dec.PeekKind() == 'n' {
|
||||
return errNull(string(name[1 : len(name)-1]))
|
||||
}
|
||||
if err := json.UnmarshalDecode(dec, rv.Field(fs.index).Addr().Interface()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := dec.ReadToken(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if missing := spec.requiredMask &^ seen; missing != 0 {
|
||||
var missingProps []string
|
||||
for id, n := range spec.requiredNames {
|
||||
if missing&(1<<id) != 0 {
|
||||
missingProps = append(missingProps, n)
|
||||
}
|
||||
}
|
||||
return errMissing(missingProps)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// marshalUnion encodes a union struct whose fields are all pointers, exactly
|
||||
// one of which is set. It writes the single non-nil field; if nullable, an
|
||||
// empty union marshals as null, otherwise an empty union is a programming
|
||||
// error. The name is only used for the panic message.
|
||||
func marshalUnion(v any, enc *json.Encoder, name string, nullable bool) error {
|
||||
rv := reflect.ValueOf(v).Elem()
|
||||
var set reflect.Value
|
||||
count := 0
|
||||
for _, f := range rv.Fields() {
|
||||
if !f.IsNil() {
|
||||
count++
|
||||
if !set.IsValid() {
|
||||
set = f
|
||||
}
|
||||
}
|
||||
}
|
||||
if nullable {
|
||||
assertAtMostOne("more than one element of "+name+" is set", count)
|
||||
if !set.IsValid() {
|
||||
return enc.WriteToken(json.Null)
|
||||
}
|
||||
} else {
|
||||
assertOnlyOne("exactly one element of "+name+" should be set", count)
|
||||
}
|
||||
return json.MarshalEncode(enc, set.Interface())
|
||||
}
|
||||
|
||||
// countNonNil returns the number of non-nil pointer/slice/map fields in the
|
||||
// struct pointed to by v. Used to assert externally-tagged unions have exactly
|
||||
// one arm set.
|
||||
func countNonNil(v any) int {
|
||||
rv := reflect.ValueOf(v).Elem()
|
||||
count := 0
|
||||
for _, f := range rv.Fields() {
|
||||
if !f.IsNil() {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
37
tools/tsgo/internal/lsp/lsproto/util.go
Normal file
37
tools/tsgo/internal/lsp/lsproto/util.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package lsproto
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
)
|
||||
|
||||
// Implements a cmp.Compare like function for two Position
|
||||
// ComparePositions(pos, other) == cmp.Compare(pos, other)
|
||||
func ComparePositions(pos, other Position) int {
|
||||
if lineComp := cmp.Compare(pos.Line, other.Line); lineComp != 0 {
|
||||
return lineComp
|
||||
}
|
||||
return cmp.Compare(pos.Character, other.Character)
|
||||
}
|
||||
|
||||
// Implements a cmp.Compare like function for two Range
|
||||
// CompareRanges(lsRange, other) == cmp.Compare(lsRange, other)
|
||||
//
|
||||
// Range.Start is compared before Range.End
|
||||
func CompareRanges(lsRange, other Range) int {
|
||||
if startComp := ComparePositions(lsRange.Start, other.Start); startComp != 0 {
|
||||
return startComp
|
||||
}
|
||||
return ComparePositions(lsRange.End, other.End)
|
||||
}
|
||||
|
||||
// AsString returns the plain text of a StringOrMarkupContent, reading the
|
||||
// MarkupContent value when the message is not a plain string.
|
||||
func (m StringOrMarkupContent) AsString() string {
|
||||
if m.String != nil {
|
||||
return *m.String
|
||||
}
|
||||
if m.MarkupContent != nil {
|
||||
return m.MarkupContent.Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
596
tools/tsgo/internal/lsp/lspwatcher/lspwatcher.go
Normal file
596
tools/tsgo/internal/lsp/lspwatcher/lspwatcher.go
Normal file
@@ -0,0 +1,596 @@
|
||||
// Package lspwatcher implements an in-process file watcher used as a
|
||||
// drop-in replacement for LSP-based file watching when the client does not
|
||||
// support dynamic registration of file watchers.
|
||||
package lspwatcher
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/fswatch"
|
||||
"github.com/microsoft/typescript-go/internal/ls/lsconv"
|
||||
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
||||
"github.com/microsoft/typescript-go/internal/project/logging"
|
||||
"github.com/microsoft/typescript-go/internal/tspath"
|
||||
"github.com/microsoft/typescript-go/internal/vfs"
|
||||
)
|
||||
|
||||
// throttleWindow mirrors VS Code's parcel watcher integration: give the
|
||||
// first batch a short grace window so adjacent filesystem bursts coalesce.
|
||||
const throttleWindow = 75 * time.Millisecond
|
||||
|
||||
type watcherBackend interface {
|
||||
WatchDirectory(dir string, fn fswatch.WatchCallback, opts ...fswatch.WatchOption) (io.Closer, error)
|
||||
}
|
||||
|
||||
type defaultWatcherBackend struct {
|
||||
watcher fswatch.Watcher
|
||||
}
|
||||
|
||||
func (d defaultWatcherBackend) WatchDirectory(dir string, fn fswatch.WatchCallback, opts ...fswatch.WatchOption) (io.Closer, error) {
|
||||
return d.watcher.WatchDirectory(dir, fn, opts...)
|
||||
}
|
||||
|
||||
// Watcher manages a set of file system subscriptions identified by
|
||||
// WatcherID strings (matching the LSP server's project.WatcherID type).
|
||||
// Events are delivered to onChanges in batches as `*lsproto.FileEvent`,
|
||||
// shaped exactly like a `workspace/didChangeWatchedFiles` notification.
|
||||
type Watcher struct {
|
||||
fs vfs.FS
|
||||
backend watcherBackend
|
||||
onChanges func(changes []*lsproto.FileEvent)
|
||||
logger logging.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
// watches holds the watches associated with each LSP WatcherID. A single id
|
||||
// may map to more than one watch because each FileSystemWatcher in the
|
||||
// registration becomes its own watch (different roots and kinds).
|
||||
watches map[string][]*watch
|
||||
closed bool
|
||||
|
||||
// Pending batch state, protected by mu.
|
||||
pending map[string]*lsproto.FileEvent
|
||||
flushTimer *time.Timer
|
||||
}
|
||||
|
||||
// watch represents one FileSystemWatcher from the LSP registration.
|
||||
//
|
||||
// The directory the session asks to watch may not exist yet (common in
|
||||
// granular mode, where each probed-but-missing package directory becomes a
|
||||
// watch) or may be deleted while watched. To honor the watch across those
|
||||
// transitions, a watch maintains either:
|
||||
//
|
||||
// - a "target" subscription rooted directly at the requested directory, once
|
||||
// it exists, or
|
||||
// - an "ancestor" subscription on the nearest existing ancestor
|
||||
// (non-recursive), used to detect the requested directory — or an
|
||||
// intermediate path component — being created, after which the watch
|
||||
// descends toward and eventually promotes to the target.
|
||||
//
|
||||
// When the target materializes, synthetic create events are emitted for it
|
||||
// (and, depending on whether the watch is recursive, its immediate children or
|
||||
// its whole subtree) so the session re-resolves files that appeared in the gap
|
||||
// before the real subscription was installed.
|
||||
//
|
||||
// All path fields are tspath-style (forward-slash) absolute paths.
|
||||
type watch struct {
|
||||
watcher *Watcher
|
||||
requestedDirectory string // directory requested by the LSP layer (possibly a symlink)
|
||||
kind lsproto.WatchKind
|
||||
recursive bool // whether the target subscription should be recursive
|
||||
|
||||
mu sync.Mutex
|
||||
subscription io.Closer // current subscription (target or ancestor); nil if none
|
||||
watchedDirectory string // canonicalized directory 'subscription' is rooted at
|
||||
watchingTarget bool // whether 'subscription' is rooted at the target directory
|
||||
closed bool
|
||||
}
|
||||
|
||||
// New constructs a Watcher backed by internal/fswatch's platform-default
|
||||
// watcher implementation.
|
||||
func New(fs vfs.FS, onChanges func(changes []*lsproto.FileEvent), logger logging.Logger) *Watcher {
|
||||
return NewWithFSWatcher(fs, fswatch.Default(), onChanges, logger)
|
||||
}
|
||||
|
||||
// NewWithFSWatcher constructs a Watcher backed by the provided fswatch.Watcher.
|
||||
// Use this to select a specific backend (e.g. fswatch.Kqueue()) instead of the
|
||||
// platform default.
|
||||
func NewWithFSWatcher(fs vfs.FS, watcher fswatch.Watcher, onChanges func(changes []*lsproto.FileEvent), logger logging.Logger) *Watcher {
|
||||
return newWithBackend(fs, defaultWatcherBackend{watcher: watcher}, onChanges, logger)
|
||||
}
|
||||
|
||||
func newWithBackend(fs vfs.FS, backend watcherBackend, onChanges func(changes []*lsproto.FileEvent), logger logging.Logger) *Watcher {
|
||||
return &Watcher{
|
||||
fs: fs,
|
||||
backend: backend,
|
||||
onChanges: onChanges,
|
||||
logger: logger,
|
||||
watches: make(map[string][]*watch),
|
||||
}
|
||||
}
|
||||
|
||||
// WatchFiles subscribes to each FileSystemWatcher under the given id.
|
||||
//
|
||||
// A watcher whose directory does not exist yet is not an error: an ancestor
|
||||
// watch is installed on the nearest existing ancestor and the subscription is
|
||||
// reported as successful, so the session's notion of "this watcher is alive"
|
||||
// stays true for the subscription's whole lifetime. Only a genuine backend
|
||||
// failure (e.g. resource exhaustion while watching an existing directory)
|
||||
// causes WatchFiles to roll back the whole id and return an error, so the
|
||||
// session's pending/retry path re-registers it on the next reevaluation.
|
||||
func (w *Watcher) WatchFiles(id string, fileSystemWatchers []*lsproto.FileSystemWatcher) error {
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return errors.New("lspwatcher: closed")
|
||||
}
|
||||
if _, exists := w.watches[id]; exists {
|
||||
w.mu.Unlock()
|
||||
return fmt.Errorf("lspwatcher: watcher %q already exists", id)
|
||||
}
|
||||
// Mark the id as existing before installing any watches so a concurrent
|
||||
// WatchFiles for the same id is rejected above.
|
||||
w.watches[id] = nil
|
||||
w.mu.Unlock()
|
||||
|
||||
var failed bool
|
||||
for _, fileSystemWatcher := range fileSystemWatchers {
|
||||
directory, ok := watchRoot(fileSystemWatcher)
|
||||
if !ok || directory == "" {
|
||||
w.logger.Logf("lspwatcher: skipping watcher %q: unrecognized pattern %q", id, watchPatternString(fileSystemWatcher))
|
||||
continue
|
||||
}
|
||||
newWatch := &watch{
|
||||
watcher: w,
|
||||
requestedDirectory: directory,
|
||||
kind: effectiveKind(fileSystemWatcher),
|
||||
recursive: isRecursiveGlob(fileSystemWatcher),
|
||||
}
|
||||
if err := newWatch.reconcile(false /*emitSynthetic*/); err != nil {
|
||||
w.logger.Logf("lspwatcher: failed to register watcher %q for %q: %v", id, directory, err)
|
||||
newWatch.close()
|
||||
failed = true
|
||||
break
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.watches[id] = append(w.watches[id], newWatch)
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
if failed {
|
||||
// Roll back the whole id so the session's retry (MarkPending) can
|
||||
// cleanly re-register it. The session treats an id as a single unit.
|
||||
_ = w.UnwatchFiles(id)
|
||||
return fmt.Errorf("lspwatcher: failed to register one or more watchers for %q", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnwatchFiles tears down all subscriptions associated with id.
|
||||
func (w *Watcher) UnwatchFiles(id string) error {
|
||||
w.mu.Lock()
|
||||
watches, ok := w.watches[id]
|
||||
if !ok {
|
||||
w.mu.Unlock()
|
||||
return fmt.Errorf("lspwatcher: no watcher with id %q", id)
|
||||
}
|
||||
delete(w.watches, id)
|
||||
w.mu.Unlock()
|
||||
for _, watch := range watches {
|
||||
watch.close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close removes every subscription. Safe to call multiple times.
|
||||
func (w *Watcher) Close() {
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.closed = true
|
||||
watchesByID := w.watches
|
||||
w.watches = nil
|
||||
if w.flushTimer != nil {
|
||||
w.flushTimer.Stop()
|
||||
w.flushTimer = nil
|
||||
}
|
||||
w.pending = nil
|
||||
w.mu.Unlock()
|
||||
for _, watches := range watchesByID {
|
||||
for _, watch := range watches {
|
||||
watch.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// close tears down the watch's current subscription and prevents any in-flight
|
||||
// reconcile from reinstalling one.
|
||||
func (w *watch) close() {
|
||||
w.mu.Lock()
|
||||
w.closed = true
|
||||
subscription := w.subscription
|
||||
w.subscription = nil
|
||||
w.watchedDirectory = ""
|
||||
w.mu.Unlock()
|
||||
if subscription != nil {
|
||||
_ = subscription.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// reconcile installs or advances this watch toward the target directory based
|
||||
// on the current filesystem state. It is called at registration, whenever a
|
||||
// ancestor watch observes activity, and after a target watch is terminated by
|
||||
// deletion.
|
||||
//
|
||||
// emitSynthetic controls whether promoting to the target emits synthetic
|
||||
// create events: false for the initial install when the target already exists
|
||||
// (the session already knows about those files), true for any missing→present
|
||||
// recovery.
|
||||
//
|
||||
// It returns a non-nil error only on a genuine backend failure to install a
|
||||
// watch; a missing target directory is handled by installing an ancestor watch
|
||||
// and returns nil.
|
||||
func (w *watch) reconcile(emitSyntheticCreates bool) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
watcher := w.watcher
|
||||
for {
|
||||
if w.closed {
|
||||
return nil
|
||||
}
|
||||
if watcher.fs.DirectoryExists(w.requestedDirectory) {
|
||||
if w.watchingTarget && w.subscription != nil {
|
||||
return nil // already watching the target
|
||||
}
|
||||
targetDirectory := w.requestedDirectory
|
||||
var options []fswatch.WatchOption
|
||||
if w.recursive {
|
||||
options = append(options, fswatch.WithRecursive())
|
||||
}
|
||||
subscription, err := watcher.backend.WatchDirectory(targetDirectory, w.targetCallback(targetDirectory), options...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
previous := w.subscription
|
||||
w.subscription = subscription
|
||||
w.watchedDirectory = targetDirectory
|
||||
w.watchingTarget = true
|
||||
if previous != nil {
|
||||
_ = previous.Close()
|
||||
}
|
||||
if emitSyntheticCreates {
|
||||
watcher.emitSyntheticCreates(targetDirectory, w.kind, w.recursive)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ancestor, ok := nearestExistingAncestor(watcher.fs, w.requestedDirectory)
|
||||
if !ok {
|
||||
// Nothing exists to watch (even the root is gone); drop any subscription.
|
||||
if w.subscription != nil {
|
||||
previous := w.subscription
|
||||
w.subscription = nil
|
||||
w.watchedDirectory = ""
|
||||
w.watchingTarget = false
|
||||
_ = previous.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ancestorDirectory := ancestor
|
||||
if !w.watchingTarget && w.subscription != nil && w.watchedDirectory == ancestorDirectory {
|
||||
return nil // already watching the correct ancestor
|
||||
}
|
||||
subscription, err := watcher.backend.WatchDirectory(ancestorDirectory, w.ancestorCallback())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
previous := w.subscription
|
||||
w.subscription = subscription
|
||||
w.watchedDirectory = ancestorDirectory
|
||||
w.watchingTarget = false
|
||||
if previous != nil {
|
||||
_ = previous.Close()
|
||||
}
|
||||
// The target may have appeared between the DirectoryExists check above
|
||||
// and installing this ancestor subscription (e.g. an atomic tree
|
||||
// creation), so loop to descend further or promote immediately. Any
|
||||
// promotion from here on is a missing→present transition, so synthesize
|
||||
// creates.
|
||||
emitSyntheticCreates = true
|
||||
}
|
||||
}
|
||||
|
||||
// targetCallback returns the fswatch callback for a target watch rooted at
|
||||
// watchedReal. It forwards events to the session and, on ErrWatchTerminated
|
||||
// (the watched directory was deleted), falls back to watching the nearest
|
||||
// existing ancestor so the watch re-attaches when the directory is recreated.
|
||||
func (w *watch) targetCallback(watchedDirectory string) fswatch.WatchCallback {
|
||||
watcher := w.watcher
|
||||
return func(events []fswatch.Event, err error) {
|
||||
terminated := false
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, fswatch.ErrOverflow):
|
||||
watcher.logger.Logf("lspwatcher: watch overflow in %q (some events may have been dropped): %v", watchedDirectory, err)
|
||||
case errors.Is(err, fswatch.ErrWatchTerminated):
|
||||
terminated = true
|
||||
watcher.logger.Logf("lspwatcher: watch terminated in %q (directory removed): %v", watchedDirectory, err)
|
||||
default:
|
||||
watcher.logger.Logf("lspwatcher: watch error in %q: %v", watchedDirectory, err)
|
||||
}
|
||||
}
|
||||
if len(events) > 0 {
|
||||
watcher.forwardEvents(w.kind, events)
|
||||
}
|
||||
if terminated {
|
||||
// The delete event for the directory was forwarded above; now
|
||||
// re-attach to the nearest existing ancestor.
|
||||
w.handleTerminated()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleTerminated clears the dead target watch (the backend has already
|
||||
// removed it) and re-evaluates, falling back to an ancestor watch on the nearest
|
||||
// existing ancestor so the watch re-attaches when the directory reappears.
|
||||
// Clearing the state first is essential: reconcile would otherwise see
|
||||
// watchingTarget && subscription != nil and conclude the target is already
|
||||
// watched, even though the subscription is dead — losing recovery if the
|
||||
// directory is recreated before reconcile runs.
|
||||
func (w *watch) handleTerminated() {
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
previous := w.subscription
|
||||
w.subscription = nil
|
||||
w.watchedDirectory = ""
|
||||
w.watchingTarget = false
|
||||
w.mu.Unlock()
|
||||
if previous != nil {
|
||||
_ = previous.Close()
|
||||
}
|
||||
_ = w.reconcile(true /*emitSyntheticCreates*/)
|
||||
}
|
||||
|
||||
// ancestorCallback returns the fswatch callback for an ancestor watch. Ancestor
|
||||
// watches exist only to detect the target — or an intermediate path component —
|
||||
// being created; their events are about ancestor directories the session
|
||||
// doesn't track, so they are ignored and the watch is simply re-evaluated.
|
||||
func (w *watch) ancestorCallback() fswatch.WatchCallback {
|
||||
return func(events []fswatch.Event, err error) {
|
||||
_ = w.reconcile(true /*emitSyntheticCreates*/)
|
||||
}
|
||||
}
|
||||
|
||||
// nearestExistingAncestor returns the deepest existing directory that is dir or
|
||||
// an ancestor of dir, walking upward. ok is false only if nothing in the chain
|
||||
// (including the root) exists.
|
||||
func nearestExistingAncestor(fs vfs.FS, dir string) (string, bool) {
|
||||
for {
|
||||
if fs.DirectoryExists(dir) {
|
||||
return dir, true
|
||||
}
|
||||
parent := tspath.GetDirectoryPath(dir)
|
||||
if parent == dir {
|
||||
return "", false
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// forwardEvents translates fswatch events into LSP file events and enqueues
|
||||
// them for the next debounced flush.
|
||||
func (w *Watcher) forwardEvents(kind lsproto.WatchKind, events []fswatch.Event) {
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if w.pending == nil {
|
||||
w.pending = make(map[string]*lsproto.FileEvent, len(events))
|
||||
}
|
||||
for _, event := range events {
|
||||
var changeType lsproto.FileChangeType
|
||||
switch event.Kind {
|
||||
case fswatch.EventUpdate:
|
||||
// fswatch intentionally doesn't distinguish create vs update.
|
||||
// For LSP consumers this is fine: callers infer create/update
|
||||
// from their own cache and both should invalidate stale state.
|
||||
if kind&(lsproto.WatchKindCreate|lsproto.WatchKindChange) == 0 {
|
||||
continue
|
||||
}
|
||||
changeType = lsproto.FileChangeTypeChanged
|
||||
case fswatch.EventDelete:
|
||||
if kind&lsproto.WatchKindDelete == 0 {
|
||||
continue
|
||||
}
|
||||
changeType = lsproto.FileChangeTypeDeleted
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
path := tspath.NormalizeSlashes(event.Path)
|
||||
uri := lsconv.FileNameToDocumentURI(path)
|
||||
w.pending[string(uri)] = &lsproto.FileEvent{
|
||||
Uri: uri,
|
||||
Type: changeType,
|
||||
}
|
||||
}
|
||||
w.scheduleFlushLocked()
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// emitSyntheticCreates enqueues synthetic create events after a target watch is
|
||||
// (re)installed following a missing→present transition, so the session
|
||||
// re-resolves files that appeared before the real watch existed. The target
|
||||
// directory itself is always included; for a non-recursive watch its immediate
|
||||
// children are added, and for a recursive watch its whole subtree is walked.
|
||||
// Nothing is emitted if the watch doesn't request create notifications.
|
||||
func (w *Watcher) emitSyntheticCreates(directory string, kind lsproto.WatchKind, recursive bool) {
|
||||
if kind&lsproto.WatchKindCreate == 0 {
|
||||
return
|
||||
}
|
||||
paths := []string{directory}
|
||||
if recursive {
|
||||
_ = w.fs.WalkDir(directory, func(path string, entry vfs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
normalizedPath := tspath.NormalizeSlashes(path)
|
||||
if normalizedPath == directory {
|
||||
return nil
|
||||
}
|
||||
paths = append(paths, normalizedPath)
|
||||
return nil
|
||||
})
|
||||
} else {
|
||||
entries := w.fs.GetAccessibleEntries(directory)
|
||||
for _, name := range entries.Files {
|
||||
paths = append(paths, tspath.CombinePaths(directory, name))
|
||||
}
|
||||
for _, name := range entries.Directories {
|
||||
paths = append(paths, tspath.CombinePaths(directory, name))
|
||||
}
|
||||
}
|
||||
w.enqueueSyntheticCreates(paths)
|
||||
}
|
||||
|
||||
// enqueueSyntheticCreates adds synthetic create events for paths, without
|
||||
// clobbering a more specific event already pending for the same path (e.g. a
|
||||
// real delete).
|
||||
func (w *Watcher) enqueueSyntheticCreates(paths []string) {
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if w.pending == nil {
|
||||
w.pending = make(map[string]*lsproto.FileEvent, len(paths))
|
||||
}
|
||||
for _, path := range paths {
|
||||
uri := lsconv.FileNameToDocumentURI(path)
|
||||
if _, ok := w.pending[string(uri)]; ok {
|
||||
continue
|
||||
}
|
||||
w.pending[string(uri)] = &lsproto.FileEvent{
|
||||
Uri: uri,
|
||||
Type: lsproto.FileChangeTypeCreated,
|
||||
}
|
||||
}
|
||||
w.scheduleFlushLocked()
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// scheduleFlushLocked arms the debounce flush timer if it isn't already armed.
|
||||
// Callers must hold w.mu.
|
||||
func (w *Watcher) scheduleFlushLocked() {
|
||||
if w.flushTimer == nil {
|
||||
w.flushTimer = time.AfterFunc(throttleWindow, w.flush)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) flush() {
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
pending := w.pending
|
||||
w.pending = nil
|
||||
w.flushTimer = nil
|
||||
w.mu.Unlock()
|
||||
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
changes := make([]*lsproto.FileEvent, 0, len(pending))
|
||||
for _, event := range pending {
|
||||
changes = append(changes, event)
|
||||
}
|
||||
w.onChanges(changes)
|
||||
}
|
||||
|
||||
// watchRoot extracts the directory the fswatch subscription should be
|
||||
// rooted at from a FileSystemWatcher. The patterns the project layer
|
||||
// produces are of the form `<dir>/**/*` (recursive) or `<dir>/*`
|
||||
// (non-recursive, used by granular watch mode), either as a Pattern
|
||||
// with a fully-qualified directory or as a RelativePattern with a
|
||||
// file:// BaseUri, so the heuristic of "everything before the first
|
||||
// glob meta character" is reliable. Use [isRecursiveGlob] to determine
|
||||
// whether the subscription should be recursive.
|
||||
//
|
||||
// Returned roots are tspath-normalized (forward-slash) absolute paths.
|
||||
func watchRoot(fileSystemWatcher *lsproto.FileSystemWatcher) (string, bool) {
|
||||
if fileSystemWatcher.GlobPattern.Pattern != nil {
|
||||
return rootFromGlob(*fileSystemWatcher.GlobPattern.Pattern), true
|
||||
}
|
||||
if relativePattern := fileSystemWatcher.GlobPattern.RelativePattern; relativePattern != nil {
|
||||
var base string
|
||||
if relativePattern.BaseUri.URI != nil {
|
||||
base = lsproto.DocumentUri(*relativePattern.BaseUri.URI).FileName()
|
||||
} else {
|
||||
return "", false
|
||||
}
|
||||
pattern := tspath.CombinePaths(base, relativePattern.Pattern)
|
||||
return rootFromGlob(pattern), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func rootFromGlob(pattern string) string {
|
||||
pattern = tspath.NormalizeSlashes(pattern)
|
||||
metaIndex := -1
|
||||
for i := range len(pattern) {
|
||||
switch pattern[i] {
|
||||
case '*', '?', '[', '{':
|
||||
metaIndex = i
|
||||
}
|
||||
if metaIndex != -1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if metaIndex == -1 {
|
||||
return tspath.NormalizePath(strings.TrimRight(pattern, "/"))
|
||||
}
|
||||
directory := strings.TrimRight(pattern[:metaIndex], "/")
|
||||
if directory == "" {
|
||||
return ""
|
||||
}
|
||||
return tspath.NormalizePath(directory)
|
||||
}
|
||||
|
||||
func watchPatternString(fileSystemWatcher *lsproto.FileSystemWatcher) string {
|
||||
if fileSystemWatcher.GlobPattern.Pattern != nil {
|
||||
return *fileSystemWatcher.GlobPattern.Pattern
|
||||
}
|
||||
if relativePattern := fileSystemWatcher.GlobPattern.RelativePattern; relativePattern != nil {
|
||||
var base string
|
||||
if relativePattern.BaseUri.URI != nil {
|
||||
base = string(*relativePattern.BaseUri.URI)
|
||||
}
|
||||
return base + "/" + relativePattern.Pattern
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isRecursiveGlob reports whether a FileSystemWatcher's pattern requests
|
||||
// recursive watching (contains a `**` segment). Granular watch mode emits
|
||||
// non-recursive `<dir>/*` patterns, which watch only the immediate directory.
|
||||
func isRecursiveGlob(fileSystemWatcher *lsproto.FileSystemWatcher) bool {
|
||||
return strings.Contains(watchPatternString(fileSystemWatcher), "**")
|
||||
}
|
||||
|
||||
func effectiveKind(fileSystemWatcher *lsproto.FileSystemWatcher) lsproto.WatchKind {
|
||||
if fileSystemWatcher.Kind != nil {
|
||||
return *fileSystemWatcher.Kind
|
||||
}
|
||||
return lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete
|
||||
}
|
||||
805
tools/tsgo/internal/lsp/lspwatcher/lspwatcher_test.go
Normal file
805
tools/tsgo/internal/lsp/lspwatcher/lspwatcher_test.go
Normal file
@@ -0,0 +1,805 @@
|
||||
package lspwatcher
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/bundled"
|
||||
"github.com/microsoft/typescript-go/internal/fswatch"
|
||||
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
||||
"github.com/microsoft/typescript-go/internal/project/logging"
|
||||
"github.com/microsoft/typescript-go/internal/tspath"
|
||||
"github.com/microsoft/typescript-go/internal/vfs/osvfs"
|
||||
)
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool, msg string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", msg)
|
||||
}
|
||||
|
||||
func TestWatcher_CreateChangeDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
batches [][]*lsproto.FileEvent
|
||||
)
|
||||
w := New(bundled.WrapFS(osvfs.FS()), func(changes []*lsproto.FileEvent) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
batches = append(batches, changes)
|
||||
}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
pattern := tspath.NormalizeSlashes(dir) + "/**/*"
|
||||
kind := lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete
|
||||
if err := w.WatchFiles("test", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
Kind: &kind,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
file := filepath.Join(dir, "a.ts")
|
||||
if err := os.WriteFile(file, []byte("export {}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
collected := func() []*lsproto.FileEvent {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
var all []*lsproto.FileEvent
|
||||
for _, b := range batches {
|
||||
all = append(all, b...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
waitFor(t, func() bool {
|
||||
for _, e := range collected() {
|
||||
if e.Type == lsproto.FileChangeTypeChanged {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, "update event")
|
||||
|
||||
if err := os.Remove(file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, func() bool {
|
||||
for _, e := range collected() {
|
||||
if e.Type == lsproto.FileChangeTypeDeleted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, "delete event")
|
||||
|
||||
if err := w.UnwatchFiles("test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_KindFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
dirNorm := tspath.NormalizeSlashes(dir)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
got []*lsproto.FileEvent
|
||||
)
|
||||
backend := newFakeBackend()
|
||||
w := newWithBackend(bundled.WrapFS(osvfs.FS()), backend, func(changes []*lsproto.FileEvent) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
got = append(got, changes...)
|
||||
}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
pattern := dirNorm + "/**/*"
|
||||
kind := lsproto.WatchKindDelete
|
||||
if err := w.WatchFiles("test", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
Kind: &kind,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend.emitAll([]fswatch.Event{
|
||||
{Kind: fswatch.EventUpdate, Path: filepath.FromSlash(filepath.Join(dirNorm, "x.ts"))},
|
||||
{Kind: fswatch.EventDelete, Path: filepath.FromSlash(filepath.Join(dirNorm, "x.ts"))},
|
||||
}, nil)
|
||||
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, e := range got {
|
||||
if e.Type == lsproto.FileChangeTypeDeleted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, "delete event")
|
||||
|
||||
mu.Lock()
|
||||
for _, e := range got {
|
||||
if e.Type != lsproto.FileChangeTypeDeleted {
|
||||
t.Errorf("unexpected non-delete event: %+v", e)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func TestRootFromGlob(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
pattern string
|
||||
want string
|
||||
}{
|
||||
{"/abs/path/**/*", "/abs/path"},
|
||||
{"/abs/path/", "/abs/path"},
|
||||
{"/abs/path/?.ts", "/abs/path"},
|
||||
{"/abs/path/{a,b}/*", "/abs/path"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := rootFromGlob(c.pattern); got != c.want {
|
||||
t.Errorf("rootFromGlob(%q) = %q, want %q", c.pattern, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeBackend struct {
|
||||
mu sync.Mutex
|
||||
byDir map[string]fswatch.WatchCallback
|
||||
closed map[string]int
|
||||
optCount map[string]int
|
||||
failDirs map[string]error
|
||||
}
|
||||
|
||||
func newFakeBackend() *fakeBackend {
|
||||
return &fakeBackend{
|
||||
byDir: make(map[string]fswatch.WatchCallback),
|
||||
closed: make(map[string]int),
|
||||
optCount: make(map[string]int),
|
||||
failDirs: make(map[string]error),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeBackend) WatchDirectory(dir string, fn fswatch.WatchCallback, opts ...fswatch.WatchOption) (io.Closer, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if err := f.failDirs[dir]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.byDir[dir] = fn
|
||||
f.optCount[dir] = len(opts)
|
||||
return fakeWatch{closeFn: func() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.byDir, dir)
|
||||
f.closed[dir]++
|
||||
return nil
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// watchedDirs returns the directories currently subscribed, for assertions.
|
||||
func (f *fakeBackend) watchedDirs() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
dirs := make([]string, 0, len(f.byDir))
|
||||
for d := range f.byDir {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
func (f *fakeBackend) isWatching(dir string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
_, ok := f.byDir[dir]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (f *fakeBackend) emit(dir string, events []fswatch.Event, err error) {
|
||||
f.mu.Lock()
|
||||
cb := f.byDir[dir]
|
||||
f.mu.Unlock()
|
||||
if cb != nil {
|
||||
cb(events, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeBackend) emitAll(events []fswatch.Event, err error) {
|
||||
f.mu.Lock()
|
||||
cbs := make([]fswatch.WatchCallback, 0, len(f.byDir))
|
||||
for _, cb := range f.byDir {
|
||||
cbs = append(cbs, cb)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
for _, cb := range cbs {
|
||||
cb(events, err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeWatch struct{ closeFn func() error }
|
||||
|
||||
func (w fakeWatch) Close() error { return w.closeFn() }
|
||||
|
||||
func TestWatcher_BookkeepingAndOverflow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
dirNorm := tspath.NormalizeSlashes(dir)
|
||||
pattern := dirNorm + "/**/*"
|
||||
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
got []*lsproto.FileEvent
|
||||
)
|
||||
w := newWithBackend(fs, backend, func(changes []*lsproto.FileEvent) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
got = append(got, changes...)
|
||||
}, logging.NewLogger(os.Stderr))
|
||||
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
}}); err == nil {
|
||||
t.Fatal("expected duplicate-id error")
|
||||
}
|
||||
|
||||
backend.emitAll([]fswatch.Event{
|
||||
{Kind: fswatch.EventUpdate, Path: filepath.FromSlash(filepath.Join(dirNorm, "a.ts"))},
|
||||
}, fswatch.ErrOverflow)
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(got) > 0
|
||||
}, "events after overflow")
|
||||
|
||||
if err := w.UnwatchFiles("missing"); err == nil {
|
||||
t.Fatal("expected unknown-id error")
|
||||
}
|
||||
if err := w.UnwatchFiles("id"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.WatchFiles("id2", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Close()
|
||||
if err := w.WatchFiles("id3", nil); err == nil {
|
||||
t.Fatal("expected closed error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_NonRecursiveGlobIsNotRecursive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
dirNorm := tspath.NormalizeSlashes(dir)
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subNorm := tspath.NormalizeSlashes(filepath.Join(dir, "sub"))
|
||||
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
w := newWithBackend(fs, backend, func([]*lsproto.FileEvent) {}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
recursive := dirNorm + "/**/*"
|
||||
nonRecursive := subNorm + "/*"
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{
|
||||
{GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &recursive}},
|
||||
{GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &nonRecursive}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backend.mu.Lock()
|
||||
defer backend.mu.Unlock()
|
||||
if got := backend.optCount[dirNorm]; got != 1 {
|
||||
t.Errorf("recursive glob %q: expected 1 watch option (WithRecursive), got %d", recursive, got)
|
||||
}
|
||||
if got := backend.optCount[subNorm]; got != 0 {
|
||||
t.Errorf("non-recursive glob %q: expected 0 watch options, got %d", nonRecursive, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_RealBackend_MissingThenCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := t.TempDir()
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
batches [][]*lsproto.FileEvent
|
||||
)
|
||||
w := New(fs, func(changes []*lsproto.FileEvent) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
batches = append(batches, changes)
|
||||
}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
// Watch a directory that does not exist yet.
|
||||
target := tspath.NormalizeSlashes(filepath.Join(base, "pkg"))
|
||||
pattern := target + "/*"
|
||||
kind := lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete
|
||||
if err := w.WatchFiles("test", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
Kind: &kind,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Give the ancestor watch time to install.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Create the target directory and a file inside it. The real backend's
|
||||
// ancestor watch should fire, promote to the target, and
|
||||
// the file should ultimately surface.
|
||||
if err := os.MkdirAll(filepath.Join(base, "pkg"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(base, "pkg", "index.ts"), []byte("export {}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
collected := func() []*lsproto.FileEvent {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
var all []*lsproto.FileEvent
|
||||
for _, b := range batches {
|
||||
all = append(all, b...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
waitFor(t, func() bool {
|
||||
for _, e := range collected() {
|
||||
if strings.HasSuffix(string(e.Uri), "/pkg/index.ts") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, "event for file created in a previously-missing directory")
|
||||
}
|
||||
|
||||
func TestWatcher_MissingDirectoryTracksAncestor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
w := newWithBackend(fs, backend, func([]*lsproto.FileEvent) {}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
base := t.TempDir()
|
||||
baseNorm := tspath.NormalizeSlashes(base)
|
||||
target := tspath.NormalizeSlashes(filepath.Join(base, "pkg"))
|
||||
pattern := target + "/*"
|
||||
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A missing target directory installs an ancestor watch on the nearest
|
||||
// existing ancestor (the base dir), not on the target.
|
||||
if !backend.isWatching(baseNorm) {
|
||||
t.Fatalf("expected ancestor watch on ancestor %q, watched: %v", baseNorm, backend.watchedDirs())
|
||||
}
|
||||
if dirs := backend.watchedDirs(); len(dirs) != 1 {
|
||||
t.Fatalf("expected exactly one (ancestor) watch, got %v", dirs)
|
||||
}
|
||||
|
||||
if err := w.UnwatchFiles("id"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dirs := backend.watchedDirs(); len(dirs) != 0 {
|
||||
t.Fatalf("expected all watches closed after unwatch, got %v", dirs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_MissingDirectoryPromotesOnCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
got []*lsproto.FileEvent
|
||||
)
|
||||
w := newWithBackend(fs, backend, func(changes []*lsproto.FileEvent) {
|
||||
mu.Lock()
|
||||
got = append(got, changes...)
|
||||
mu.Unlock()
|
||||
}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
base := t.TempDir()
|
||||
baseNorm := tspath.NormalizeSlashes(base)
|
||||
target := tspath.NormalizeSlashes(filepath.Join(base, "pkg"))
|
||||
pattern := target + "/*"
|
||||
kind := lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete
|
||||
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
Kind: &kind,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create the target directory with a file, then notify the ancestor watch.
|
||||
if err := os.MkdirAll(filepath.Join(base, "pkg"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(base, "pkg", "index.ts"), []byte("export {}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend.emit(baseNorm, []fswatch.Event{
|
||||
{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "pkg")},
|
||||
}, nil)
|
||||
|
||||
waitFor(t, func() bool { return backend.isWatching(target) }, "promotion to target watch")
|
||||
|
||||
// Synthetic creates must cover the target dir and its immediate child so
|
||||
// the session re-resolves files created before the watch was installed.
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
var sawDir, sawChild bool
|
||||
for _, e := range got {
|
||||
if e.Type != lsproto.FileChangeTypeCreated {
|
||||
continue
|
||||
}
|
||||
s := string(e.Uri)
|
||||
if strings.HasSuffix(s, "/pkg") {
|
||||
sawDir = true
|
||||
}
|
||||
if strings.HasSuffix(s, "/pkg/index.ts") {
|
||||
sawChild = true
|
||||
}
|
||||
}
|
||||
return sawDir && sawChild
|
||||
}, "synthetic create events for target and child")
|
||||
}
|
||||
|
||||
func TestWatcher_MultiLevelDescend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
w := newWithBackend(fs, backend, func([]*lsproto.FileEvent) {}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
base := t.TempDir()
|
||||
baseNorm := tspath.NormalizeSlashes(base)
|
||||
target := tspath.NormalizeSlashes(filepath.Join(base, "a", "b", "c"))
|
||||
pattern := target + "/*"
|
||||
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !backend.isWatching(baseNorm) {
|
||||
t.Fatalf("expected initial ancestor watch on %q, got %v", baseNorm, backend.watchedDirs())
|
||||
}
|
||||
|
||||
// Reveal one path component at a time; the ancestor watch should descend.
|
||||
mkdirAndPath := func(rel string) string {
|
||||
p := filepath.Join(base, rel)
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tspath.NormalizeSlashes(p)
|
||||
}
|
||||
|
||||
aDir := mkdirAndPath("a")
|
||||
backend.emit(baseNorm, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "a")}}, nil)
|
||||
waitFor(t, func() bool { return backend.isWatching(aDir) }, "descend to a")
|
||||
|
||||
abDir := mkdirAndPath(filepath.Join("a", "b"))
|
||||
backend.emit(aDir, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "a", "b")}}, nil)
|
||||
waitFor(t, func() bool { return backend.isWatching(abDir) }, "descend to a/b")
|
||||
|
||||
abcDir := mkdirAndPath(filepath.Join("a", "b", "c"))
|
||||
backend.emit(abDir, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "a", "b", "c")}}, nil)
|
||||
waitFor(t, func() bool { return backend.isWatching(abcDir) }, "promote to target a/b/c")
|
||||
}
|
||||
|
||||
func TestWatcher_AtomicTreeCreateRace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
w := newWithBackend(fs, backend, func([]*lsproto.FileEvent) {}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
base := t.TempDir()
|
||||
baseNorm := tspath.NormalizeSlashes(base)
|
||||
target := tspath.NormalizeSlashes(filepath.Join(base, "a", "b", "c"))
|
||||
pattern := target + "/*"
|
||||
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The whole tree appears at once (e.g. an extraction/symlink). A single
|
||||
// notification on the base ancestor watch must descend all the way and
|
||||
// promote to the target in one reconcile pass.
|
||||
if err := os.MkdirAll(filepath.Join(base, "a", "b", "c"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend.emit(baseNorm, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "a")}}, nil)
|
||||
|
||||
waitFor(t, func() bool { return backend.isWatching(target) }, "promote to target in one pass")
|
||||
}
|
||||
|
||||
func TestWatcher_SyntheticCreateDepth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, recursive := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "non-recursive", true: "recursive"}[recursive], func(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
got []*lsproto.FileEvent
|
||||
)
|
||||
w := newWithBackend(fs, backend, func(changes []*lsproto.FileEvent) {
|
||||
mu.Lock()
|
||||
got = append(got, changes...)
|
||||
mu.Unlock()
|
||||
}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
base := t.TempDir()
|
||||
baseNorm := tspath.NormalizeSlashes(base)
|
||||
target := tspath.NormalizeSlashes(filepath.Join(base, "pkg"))
|
||||
kind := lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete
|
||||
var pattern string
|
||||
if recursive {
|
||||
pattern = target + "/**/*"
|
||||
} else {
|
||||
pattern = target + "/*"
|
||||
}
|
||||
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
Kind: &kind,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Materialize the target with a nested file under a subdirectory.
|
||||
if err := os.MkdirAll(filepath.Join(base, "pkg", "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(base, "pkg", "top.ts"), []byte("export {}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(base, "pkg", "sub", "deep.ts"), []byte("export {}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend.emit(baseNorm, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "pkg")}}, nil)
|
||||
|
||||
waitFor(t, func() bool { return backend.isWatching(target) }, "promotion")
|
||||
|
||||
created := func() map[string]bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
m := map[string]bool{}
|
||||
for _, e := range got {
|
||||
if e.Type == lsproto.FileChangeTypeCreated {
|
||||
m[string(e.Uri)] = true
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Both modes must synthesize the immediate child.
|
||||
waitFor(t, func() bool {
|
||||
for s := range created() {
|
||||
if strings.HasSuffix(s, "/pkg/top.ts") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, "synthetic create for immediate child")
|
||||
|
||||
// Only the recursive watch should synthesize the deep descendant.
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
sawDeep := func() bool {
|
||||
for s := range created() {
|
||||
if strings.HasSuffix(s, "/pkg/sub/deep.ts") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if recursive {
|
||||
for time.Now().Before(deadline) && !sawDeep() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if !sawDeep() {
|
||||
t.Errorf("recursive watch should synthesize deep descendant; got %v", created())
|
||||
}
|
||||
} else {
|
||||
time.Sleep(300 * time.Millisecond) // allow any erroneous deep event to arrive
|
||||
if sawDeep() {
|
||||
t.Errorf("non-recursive watch must not synthesize deep descendant; got %v", created())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_TerminatedFallsBackAndRecovers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
got []*lsproto.FileEvent
|
||||
)
|
||||
w := newWithBackend(fs, backend, func(changes []*lsproto.FileEvent) {
|
||||
mu.Lock()
|
||||
got = append(got, changes...)
|
||||
mu.Unlock()
|
||||
}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
base := t.TempDir()
|
||||
baseNorm := tspath.NormalizeSlashes(base)
|
||||
target := tspath.NormalizeSlashes(filepath.Join(base, "pkg"))
|
||||
if err := os.MkdirAll(filepath.Join(base, "pkg"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pattern := target + "/*"
|
||||
kind := lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete
|
||||
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
Kind: &kind,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !backend.isWatching(target) {
|
||||
t.Fatalf("expected target watch on %q, got %v", target, backend.watchedDirs())
|
||||
}
|
||||
|
||||
// Delete the directory and deliver ErrWatchTerminated together with the
|
||||
// directory's own delete event (as the real backends do).
|
||||
if err := os.RemoveAll(filepath.Join(base, "pkg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend.emit(target, []fswatch.Event{
|
||||
{Kind: fswatch.EventDelete, Path: filepath.Join(base, "pkg")},
|
||||
}, errors.Join(fswatch.ErrWatchTerminated, errors.New("removed")))
|
||||
|
||||
// The delete must be forwarded, and the watch must fall back to watching
|
||||
// the ancestor.
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, e := range got {
|
||||
if e.Type == lsproto.FileChangeTypeDeleted && strings.HasSuffix(string(e.Uri), "/pkg") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, "forwarded delete of terminated dir")
|
||||
waitFor(t, func() bool { return backend.isWatching(baseNorm) && !backend.isWatching(target) }, "fallback to ancestor watch")
|
||||
|
||||
// Recreate the directory; the ancestor watch must promote back to target.
|
||||
if err := os.MkdirAll(filepath.Join(base, "pkg"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend.emit(baseNorm, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "pkg")}}, nil)
|
||||
waitFor(t, func() bool { return backend.isWatching(target) }, "recovery to target watch after recreation")
|
||||
}
|
||||
|
||||
func TestWatcher_GenuineFailureRollsBackForRetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
w := newWithBackend(fs, backend, func([]*lsproto.FileEvent) {}, logging.NewLogger(os.Stderr))
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
dir := t.TempDir()
|
||||
dirNorm := tspath.NormalizeSlashes(dir)
|
||||
pattern := dirNorm + "/*"
|
||||
|
||||
// Inject a genuine backend failure for the existing directory.
|
||||
backend.mu.Lock()
|
||||
backend.failDirs[dirNorm] = errors.New("too many open files")
|
||||
backend.mu.Unlock()
|
||||
|
||||
err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from genuine backend failure")
|
||||
}
|
||||
|
||||
// The id must have been rolled back so a retry can re-register cleanly
|
||||
// (rather than hitting the duplicate-id error). Clear the injected failure
|
||||
// to simulate the resource pressure easing on retry.
|
||||
backend.mu.Lock()
|
||||
delete(backend.failDirs, dirNorm)
|
||||
backend.mu.Unlock()
|
||||
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
}}); err != nil {
|
||||
t.Fatalf("retry after rollback should succeed, got %v", err)
|
||||
}
|
||||
if !backend.isWatching(dirNorm) {
|
||||
t.Fatalf("expected watch on %q after successful retry, got %v", dirNorm, backend.watchedDirs())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_WatchTerminatedDoesNotDropEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
dirNorm := tspath.NormalizeSlashes(dir)
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
backend := newFakeBackend()
|
||||
var got []*lsproto.FileEvent
|
||||
var mu sync.Mutex
|
||||
w := newWithBackend(fs, backend, func(changes []*lsproto.FileEvent) {
|
||||
mu.Lock()
|
||||
got = append(got, changes...)
|
||||
mu.Unlock()
|
||||
}, logging.NewLogger(os.Stderr))
|
||||
|
||||
pattern := dirNorm + "/**/*"
|
||||
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
||||
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backend.emitAll([]fswatch.Event{
|
||||
{Kind: fswatch.EventUpdate, Path: filepath.FromSlash(filepath.Join(dirNorm, "b.ts"))},
|
||||
}, errors.Join(fswatch.ErrWatchTerminated, errors.New("simulated")))
|
||||
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(got) > 0
|
||||
}, "events with watch-terminated error")
|
||||
}
|
||||
217
tools/tsgo/internal/lsp/progress.go
Normal file
217
tools/tsgo/internal/lsp/progress.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/collections"
|
||||
"github.com/microsoft/typescript-go/internal/core"
|
||||
"github.com/microsoft/typescript-go/internal/diagnostics"
|
||||
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
||||
)
|
||||
|
||||
type progressEvent struct {
|
||||
message *diagnostics.Message
|
||||
args []any
|
||||
finish bool
|
||||
}
|
||||
|
||||
// progressReporter abstracts the LSP transport operations needed by
|
||||
// projectLoadingProgress so the progress logic can be tested without a
|
||||
// full Server instance.
|
||||
type progressReporter interface {
|
||||
// done returns a channel that is closed when the server is shutting down.
|
||||
done() <-chan struct{}
|
||||
// localize converts a diagnostic message to a display string.
|
||||
localize(msg *diagnostics.Message, args ...any) string
|
||||
// createWorkDoneProgress asks the client to create a progress token.
|
||||
createWorkDoneProgress(token string)
|
||||
// sendProgress sends a $/progress notification.
|
||||
sendProgress(token string, value lsproto.WorkDoneProgressBeginOrReportOrEnd)
|
||||
}
|
||||
|
||||
// serverProgressReporter adapts *Server to the progressReporter interface.
|
||||
type serverProgressReporter struct {
|
||||
server *Server
|
||||
}
|
||||
|
||||
func (r *serverProgressReporter) done() <-chan struct{} {
|
||||
return r.server.backgroundCtx.Done()
|
||||
}
|
||||
|
||||
func (r *serverProgressReporter) localize(msg *diagnostics.Message, args ...any) string {
|
||||
return msg.Localize(r.server.locale, args...)
|
||||
}
|
||||
|
||||
func (r *serverProgressReporter) createWorkDoneProgress(token string) {
|
||||
_ = sendClientRequestFireAndForget(r.server, lsproto.WindowWorkDoneProgressCreateInfo, &lsproto.WorkDoneProgressCreateParams{
|
||||
Token: lsproto.IntegerOrString{String: &token},
|
||||
})
|
||||
}
|
||||
|
||||
func (r *serverProgressReporter) sendProgress(token string, value lsproto.WorkDoneProgressBeginOrReportOrEnd) {
|
||||
_ = sendNotification(r.server, lsproto.ProgressInfo, &lsproto.ProgressParams{
|
||||
Token: lsproto.IntegerOrString{String: &token},
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
// projectLoadingProgress manages LSP WorkDoneProgress indicators for
|
||||
// long-running operations. A single persistent goroutine processes
|
||||
// start/finish events, maintains a ref-counted map of active operations,
|
||||
// and sends progress messages in order.
|
||||
//
|
||||
// To avoid flickering on fast operations, the indicator is not shown
|
||||
// until progressDelay has elapsed since the first start event. If all
|
||||
// operations complete before then, no progress UI is displayed.
|
||||
//
|
||||
// start/finish may block if the internal buffer (64 events) is full,
|
||||
// but will bail out if the server's background context is cancelled.
|
||||
type projectLoadingProgress struct {
|
||||
reporter progressReporter
|
||||
ch chan progressEvent
|
||||
delay time.Duration // time to wait before showing progress UI
|
||||
}
|
||||
|
||||
func newProjectLoadingProgress(server *Server, delay time.Duration) *projectLoadingProgress {
|
||||
return newProjectLoadingProgressFromReporter(&serverProgressReporter{server: server}, delay)
|
||||
}
|
||||
|
||||
func newProjectLoadingProgressFromReporter(reporter progressReporter, delay time.Duration) *projectLoadingProgress {
|
||||
p := &projectLoadingProgress{
|
||||
reporter: reporter,
|
||||
ch: make(chan progressEvent, 64),
|
||||
delay: delay,
|
||||
}
|
||||
go p.run()
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *projectLoadingProgress) start(message *diagnostics.Message, args ...any) {
|
||||
select {
|
||||
case p.ch <- progressEvent{message: message, args: args}:
|
||||
// Sent successfully.
|
||||
case <-p.reporter.done():
|
||||
// Server shutting down; drop the event.
|
||||
}
|
||||
}
|
||||
|
||||
func (p *projectLoadingProgress) finish(message *diagnostics.Message, args ...any) {
|
||||
select {
|
||||
case p.ch <- progressEvent{message: message, args: args, finish: true}:
|
||||
// Sent successfully.
|
||||
case <-p.reporter.done():
|
||||
// Server shutting down; drop the event.
|
||||
}
|
||||
}
|
||||
|
||||
// run is the persistent goroutine that processes all progress events.
|
||||
// It owns all mutable state: no external synchronization needed.
|
||||
func (p *projectLoadingProgress) run() {
|
||||
var (
|
||||
loading collections.OrderedMap[string, int]
|
||||
token string // current token; empty if no progress active
|
||||
tokenID int
|
||||
begun bool // whether "begin" has been sent for the current token
|
||||
)
|
||||
|
||||
var delay *time.Timer
|
||||
delayC := func() <-chan time.Time {
|
||||
if delay == nil {
|
||||
return nil
|
||||
}
|
||||
return delay.C
|
||||
}
|
||||
stopDelay := func() {
|
||||
if delay != nil {
|
||||
delay.Stop()
|
||||
delay = nil
|
||||
}
|
||||
}
|
||||
delayFired := false // true after the delay timer fires
|
||||
|
||||
for {
|
||||
select {
|
||||
case ev := <-p.ch:
|
||||
text := p.reporter.localize(ev.message, ev.args...)
|
||||
if !ev.finish {
|
||||
count := loading.GetOrZero(text)
|
||||
loading.Set(text, count+1)
|
||||
if token == "" {
|
||||
tokenID++
|
||||
token = fmt.Sprintf("tsgo-loading-%d", tokenID)
|
||||
begun = false
|
||||
if p.delay <= 0 {
|
||||
delayFired = true
|
||||
p.reporter.createWorkDoneProgress(token)
|
||||
} else {
|
||||
delayFired = false
|
||||
delay = time.NewTimer(p.delay)
|
||||
}
|
||||
}
|
||||
if delayFired {
|
||||
begun = p.beginOrReport(token, text, begun)
|
||||
}
|
||||
} else {
|
||||
count := loading.GetOrZero(text)
|
||||
if count <= 1 {
|
||||
loading.Delete(text)
|
||||
} else {
|
||||
loading.Set(text, count-1)
|
||||
}
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
if loading.Size() == 0 {
|
||||
if begun {
|
||||
p.reporter.sendProgress(token, lsproto.WorkDoneProgressBeginOrReportOrEnd{
|
||||
End: &lsproto.WorkDoneProgressEnd{},
|
||||
})
|
||||
}
|
||||
stopDelay()
|
||||
token = ""
|
||||
} else if delayFired {
|
||||
first := core.FirstOrNilSeq(loading.Keys())
|
||||
p.reporter.sendProgress(token, lsproto.WorkDoneProgressBeginOrReportOrEnd{
|
||||
Report: &lsproto.WorkDoneProgressReport{
|
||||
Message: &first,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
case <-delayC():
|
||||
delayFired = true
|
||||
if token != "" && loading.Size() > 0 {
|
||||
p.reporter.createWorkDoneProgress(token)
|
||||
first := core.FirstOrNilSeq(loading.Keys())
|
||||
begun = p.beginOrReport(token, first, begun)
|
||||
}
|
||||
|
||||
case <-p.reporter.done():
|
||||
stopDelay()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// beginOrReport sends WorkDoneProgressBegin if not yet begun, otherwise
|
||||
// sends WorkDoneProgressReport. Returns true to indicate begun state.
|
||||
func (p *projectLoadingProgress) beginOrReport(token, text string, begun bool) bool {
|
||||
if !begun {
|
||||
title := p.reporter.localize(diagnostics.Loading)
|
||||
p.reporter.sendProgress(token, lsproto.WorkDoneProgressBeginOrReportOrEnd{
|
||||
Begin: &lsproto.WorkDoneProgressBegin{
|
||||
Title: title,
|
||||
Message: &text,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
p.reporter.sendProgress(token, lsproto.WorkDoneProgressBeginOrReportOrEnd{
|
||||
Report: &lsproto.WorkDoneProgressReport{
|
||||
Message: &text,
|
||||
},
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
456
tools/tsgo/internal/lsp/progress_test.go
Normal file
456
tools/tsgo/internal/lsp/progress_test.go
Normal file
@@ -0,0 +1,456 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/diagnostics"
|
||||
"github.com/microsoft/typescript-go/internal/locale"
|
||||
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
||||
)
|
||||
|
||||
type progressCall struct {
|
||||
method string // "create", "begin", "report", "end"
|
||||
token string
|
||||
title string // begin only
|
||||
msg string // begin/report only
|
||||
}
|
||||
|
||||
type fakeProgressReporter struct {
|
||||
mu sync.Mutex
|
||||
calls []progressCall
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (f *fakeProgressReporter) done() <-chan struct{} {
|
||||
return f.ctx.Done()
|
||||
}
|
||||
|
||||
func (f *fakeProgressReporter) localize(msg *diagnostics.Message, args ...any) string {
|
||||
return msg.Localize(locale.Default, args...)
|
||||
}
|
||||
|
||||
func (f *fakeProgressReporter) createWorkDoneProgress(token string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, progressCall{method: "create", token: token})
|
||||
}
|
||||
|
||||
func (f *fakeProgressReporter) sendProgress(token string, value lsproto.WorkDoneProgressBeginOrReportOrEnd) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
switch {
|
||||
case value.Begin != nil:
|
||||
msg := ""
|
||||
if value.Begin.Message != nil {
|
||||
msg = *value.Begin.Message
|
||||
}
|
||||
f.calls = append(f.calls, progressCall{method: "begin", token: token, title: value.Begin.Title, msg: msg})
|
||||
case value.Report != nil:
|
||||
msg := ""
|
||||
if value.Report.Message != nil {
|
||||
msg = *value.Report.Message
|
||||
}
|
||||
f.calls = append(f.calls, progressCall{method: "report", token: token, msg: msg})
|
||||
case value.End != nil:
|
||||
f.calls = append(f.calls, progressCall{method: "end", token: token})
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeProgressReporter) getCalls() []progressCall {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]progressCall(nil), f.calls...)
|
||||
}
|
||||
|
||||
func TestProgress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("StartFinishBeforeDelay", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
reporter := &fakeProgressReporter{ctx: ctx}
|
||||
p := newProjectLoadingProgressFromReporter(reporter, 500*time.Millisecond)
|
||||
|
||||
p.start(diagnostics.Project_0, "myProject")
|
||||
synctest.Wait()
|
||||
|
||||
// Finish before the delay fires — no UI should appear.
|
||||
p.finish(diagnostics.Project_0, "myProject")
|
||||
synctest.Wait()
|
||||
|
||||
// Advance time past the delay to ensure no progress is sent.
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
synctest.Wait()
|
||||
|
||||
calls := reporter.getCalls()
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected no progress calls for fast operation, got %v", calls)
|
||||
}
|
||||
|
||||
cancel()
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ShowsAfterDelay", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
reporter := &fakeProgressReporter{ctx: ctx}
|
||||
p := newProjectLoadingProgressFromReporter(reporter, 500*time.Millisecond)
|
||||
|
||||
p.start(diagnostics.Project_0, "myProject")
|
||||
synctest.Wait()
|
||||
|
||||
// Let the delay fire.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
synctest.Wait()
|
||||
|
||||
calls := reporter.getCalls()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 calls (create + begin), got %d: %v", len(calls), calls)
|
||||
}
|
||||
if calls[0].method != "create" {
|
||||
t.Fatalf("expected create, got %v", calls[0])
|
||||
}
|
||||
if calls[1].method != "begin" {
|
||||
t.Fatalf("expected begin, got %v", calls[1])
|
||||
}
|
||||
if calls[1].title != diagnostics.Loading.String() {
|
||||
t.Fatalf("expected title %q, got %q", diagnostics.Loading.String(), calls[1].title)
|
||||
}
|
||||
|
||||
// Finish the operation.
|
||||
p.finish(diagnostics.Project_0, "myProject")
|
||||
synctest.Wait()
|
||||
|
||||
calls = reporter.getCalls()
|
||||
last := calls[len(calls)-1]
|
||||
if last.method != "end" {
|
||||
t.Fatalf("expected end, got %v", last)
|
||||
}
|
||||
|
||||
cancel()
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ReportsMultipleOperations", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
reporter := &fakeProgressReporter{ctx: ctx}
|
||||
p := newProjectLoadingProgressFromReporter(reporter, 100*time.Millisecond)
|
||||
|
||||
// Start two different operations.
|
||||
p.start(diagnostics.Project_0, "projA")
|
||||
p.start(diagnostics.Project_0, "projB")
|
||||
synctest.Wait()
|
||||
|
||||
// Let the delay fire.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
synctest.Wait()
|
||||
|
||||
calls := reporter.getCalls()
|
||||
// Should have: create, begin (with first message).
|
||||
if len(calls) < 2 {
|
||||
t.Fatalf("expected at least 2 calls, got %d: %v", len(calls), calls)
|
||||
}
|
||||
if calls[0].method != "create" {
|
||||
t.Fatalf("expected create, got %v", calls[0])
|
||||
}
|
||||
if calls[1].method != "begin" {
|
||||
t.Fatalf("expected begin, got %v", calls[1])
|
||||
}
|
||||
|
||||
// Finish one — should send a report with the remaining operation.
|
||||
p.finish(diagnostics.Project_0, "projA")
|
||||
synctest.Wait()
|
||||
|
||||
calls = reporter.getCalls()
|
||||
found := false
|
||||
for _, c := range calls {
|
||||
if c.method == "report" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected a report after partial finish, got %v", calls)
|
||||
}
|
||||
|
||||
// Finish the second — should send end.
|
||||
p.finish(diagnostics.Project_0, "projB")
|
||||
synctest.Wait()
|
||||
|
||||
calls = reporter.getCalls()
|
||||
last := calls[len(calls)-1]
|
||||
if last.method != "end" {
|
||||
t.Fatalf("expected end, got %v", last)
|
||||
}
|
||||
|
||||
cancel()
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("RefCounting", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
reporter := &fakeProgressReporter{ctx: ctx}
|
||||
p := newProjectLoadingProgressFromReporter(reporter, 100*time.Millisecond)
|
||||
|
||||
// Start the same operation twice (ref count = 2).
|
||||
p.start(diagnostics.Project_0, "proj")
|
||||
p.start(diagnostics.Project_0, "proj")
|
||||
synctest.Wait()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
synctest.Wait()
|
||||
|
||||
// Finish once (ref count = 1) — should NOT end.
|
||||
p.finish(diagnostics.Project_0, "proj")
|
||||
synctest.Wait()
|
||||
|
||||
calls := reporter.getCalls()
|
||||
for _, c := range calls {
|
||||
if c.method == "end" {
|
||||
t.Fatalf("unexpected end with ref count > 0: %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Finish again (ref count = 0) — should end.
|
||||
p.finish(diagnostics.Project_0, "proj")
|
||||
synctest.Wait()
|
||||
|
||||
calls = reporter.getCalls()
|
||||
last := calls[len(calls)-1]
|
||||
if last.method != "end" {
|
||||
t.Fatalf("expected end when ref count reaches 0, got %v", last)
|
||||
}
|
||||
|
||||
cancel()
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("NewTokenAfterEnd", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
reporter := &fakeProgressReporter{ctx: ctx}
|
||||
p := newProjectLoadingProgressFromReporter(reporter, 100*time.Millisecond)
|
||||
|
||||
// First cycle.
|
||||
p.start(diagnostics.Project_0, "proj")
|
||||
synctest.Wait()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
synctest.Wait()
|
||||
|
||||
calls := reporter.getCalls()
|
||||
firstToken := calls[0].token
|
||||
|
||||
p.finish(diagnostics.Project_0, "proj")
|
||||
synctest.Wait()
|
||||
|
||||
// Second cycle — should get a new token.
|
||||
p.start(diagnostics.Project_0, "proj2")
|
||||
synctest.Wait()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
synctest.Wait()
|
||||
|
||||
calls = reporter.getCalls()
|
||||
var secondToken string
|
||||
for _, c := range calls {
|
||||
if c.method == "create" && c.token != firstToken {
|
||||
secondToken = c.token
|
||||
break
|
||||
}
|
||||
}
|
||||
if secondToken == "" {
|
||||
t.Fatalf("expected a new token for second cycle, got calls: %v", calls)
|
||||
}
|
||||
if firstToken == secondToken {
|
||||
t.Fatalf("expected different tokens, both were %q", firstToken)
|
||||
}
|
||||
|
||||
p.finish(diagnostics.Project_0, "proj2")
|
||||
synctest.Wait()
|
||||
|
||||
cancel()
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("StartBeforeDelayThenMoreAfterDelay", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
reporter := &fakeProgressReporter{ctx: ctx}
|
||||
p := newProjectLoadingProgressFromReporter(reporter, 200*time.Millisecond)
|
||||
|
||||
// Start before delay.
|
||||
p.start(diagnostics.Project_0, "projA")
|
||||
synctest.Wait()
|
||||
|
||||
// Let delay fire.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
synctest.Wait()
|
||||
|
||||
calls := reporter.getCalls()
|
||||
if len(calls) < 2 {
|
||||
t.Fatalf("expected create + begin after delay, got %v", calls)
|
||||
}
|
||||
|
||||
// Start another operation after delay — should send a report immediately.
|
||||
p.start(diagnostics.Project_0, "projB")
|
||||
synctest.Wait()
|
||||
|
||||
calls = reporter.getCalls()
|
||||
last := calls[len(calls)-1]
|
||||
if last.method != "report" {
|
||||
t.Fatalf("expected report for new start after delay, got %v", last)
|
||||
}
|
||||
|
||||
// Clean up.
|
||||
p.finish(diagnostics.Project_0, "projA")
|
||||
p.finish(diagnostics.Project_0, "projB")
|
||||
synctest.Wait()
|
||||
|
||||
cancel()
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("FinishWithNoActiveToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
reporter := &fakeProgressReporter{ctx: ctx}
|
||||
p := newProjectLoadingProgressFromReporter(reporter, 100*time.Millisecond)
|
||||
|
||||
// Finish without any prior start — should be a no-op.
|
||||
p.finish(diagnostics.Project_0, "proj")
|
||||
synctest.Wait()
|
||||
|
||||
calls := reporter.getCalls()
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected no calls for orphan finish, got %v", calls)
|
||||
}
|
||||
|
||||
cancel()
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ShutdownDuringStartAndFinish", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
reporter := &fakeProgressReporter{ctx: ctx}
|
||||
p := newProjectLoadingProgressFromReporter(reporter, 100*time.Millisecond)
|
||||
|
||||
// Cancel context so the run goroutine exits.
|
||||
cancel()
|
||||
synctest.Wait()
|
||||
|
||||
// Fill the channel buffer so start/finish block on send.
|
||||
for range cap(p.ch) {
|
||||
p.ch <- progressEvent{message: diagnostics.Project_0, args: []any{"fill"}}
|
||||
}
|
||||
|
||||
// These should return immediately via the done() path
|
||||
// since the channel is full and the context is cancelled.
|
||||
p.start(diagnostics.Project_0, "proj")
|
||||
p.finish(diagnostics.Project_0, "proj")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ShutdownWithActiveTimer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
reporter := &fakeProgressReporter{ctx: ctx}
|
||||
p := newProjectLoadingProgressFromReporter(reporter, 500*time.Millisecond)
|
||||
|
||||
// Start an operation so the delay timer is created.
|
||||
p.start(diagnostics.Project_0, "proj")
|
||||
synctest.Wait()
|
||||
|
||||
// Shutdown while the delay timer is still pending.
|
||||
cancel()
|
||||
synctest.Wait()
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ZeroDelay", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
reporter := &fakeProgressReporter{ctx: ctx}
|
||||
p := newProjectLoadingProgressFromReporter(reporter, 0)
|
||||
|
||||
// With zero delay, progress should begin immediately.
|
||||
p.start(diagnostics.Project_0, "proj")
|
||||
synctest.Wait()
|
||||
|
||||
calls := reporter.getCalls()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 calls (create + begin), got %d: %v", len(calls), calls)
|
||||
}
|
||||
if calls[0].method != "create" {
|
||||
t.Fatalf("expected create, got %v", calls[0])
|
||||
}
|
||||
if calls[1].method != "begin" {
|
||||
t.Fatalf("expected begin, got %v", calls[1])
|
||||
}
|
||||
if calls[1].msg != "Project 'proj'" {
|
||||
t.Fatalf("expected message %q, got %q", "Project 'proj'", calls[1].msg)
|
||||
}
|
||||
|
||||
// Start+finish should still produce begin and end.
|
||||
p.finish(diagnostics.Project_0, "proj")
|
||||
synctest.Wait()
|
||||
|
||||
calls = reporter.getCalls()
|
||||
last := calls[len(calls)-1]
|
||||
if last.method != "end" {
|
||||
t.Fatalf("expected end, got %v", last)
|
||||
}
|
||||
|
||||
cancel()
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("FinishBeforeDelayNoBegun", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
reporter := &fakeProgressReporter{ctx: ctx}
|
||||
p := newProjectLoadingProgressFromReporter(reporter, 500*time.Millisecond)
|
||||
|
||||
// Start, then finish before delay — begun is false, so no end is sent.
|
||||
p.start(diagnostics.Project_0, "proj")
|
||||
synctest.Wait()
|
||||
p.finish(diagnostics.Project_0, "proj")
|
||||
synctest.Wait()
|
||||
|
||||
calls := reporter.getCalls()
|
||||
for _, c := range calls {
|
||||
if c.method == "end" {
|
||||
t.Fatalf("unexpected end when begun=false: %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
cancel()
|
||||
})
|
||||
})
|
||||
}
|
||||
226
tools/tsgo/internal/lsp/replay_test.go
Normal file
226
tools/tsgo/internal/lsp/replay_test.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package lsp_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/bundled"
|
||||
"github.com/microsoft/typescript-go/internal/core"
|
||||
"github.com/microsoft/typescript-go/internal/json"
|
||||
"github.com/microsoft/typescript-go/internal/jsonrpc"
|
||||
"github.com/microsoft/typescript-go/internal/ls/lsconv"
|
||||
"github.com/microsoft/typescript-go/internal/lsp"
|
||||
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
||||
"github.com/microsoft/typescript-go/internal/testutil/lsptestutil"
|
||||
"github.com/microsoft/typescript-go/internal/vfs/osvfs"
|
||||
)
|
||||
|
||||
var (
|
||||
replay = flag.String("replay", "", "Path to replay file")
|
||||
testDir = flag.String("testDir", "", "Path to project directory")
|
||||
simple = flag.Bool("simple", false, "Replay only file opening and closing, plus the final request")
|
||||
superSimple = flag.Bool("superSimple", false, "Replay only the final file opening and the final request")
|
||||
)
|
||||
|
||||
type initialArguments struct {
|
||||
RootDirUriPlaceholder string `json:"rootDirUriPlaceholder"`
|
||||
RootDirPlaceholder string `json:"rootDirPlaceholder"`
|
||||
}
|
||||
|
||||
type rawMessage struct {
|
||||
Kind string `json:"kind"`
|
||||
Method string `json:"method"`
|
||||
Params json.Value `json:"params"`
|
||||
}
|
||||
|
||||
func TestReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
if replay == nil || *replay == "" {
|
||||
t.Skip("no replay file specified")
|
||||
}
|
||||
if testDir == nil || *testDir == "" {
|
||||
t.Fatal("testDir must be specified")
|
||||
}
|
||||
testDirUri := lsconv.FileNameToDocumentURI(*testDir)
|
||||
|
||||
fs := bundled.WrapFS(osvfs.FS())
|
||||
defaultLibraryPath := bundled.LibPath()
|
||||
typingsLocation := osvfs.GetGlobalTypingsCacheLocation()
|
||||
serverOpts := lsp.ServerOptions{
|
||||
Err: os.Stderr,
|
||||
Cwd: core.Must(os.Getwd()),
|
||||
FS: fs,
|
||||
DefaultLibraryPath: defaultLibraryPath,
|
||||
TypingsLocation: typingsLocation,
|
||||
NpmInstall: func(cwd string, args []string) ([]byte, error) {
|
||||
cmd := exec.Command("npm", args...)
|
||||
cmd.Dir = cwd
|
||||
return cmd.Output()
|
||||
},
|
||||
}
|
||||
|
||||
client, closeClient := lsptestutil.NewLSPClient(t, serverOpts, nil)
|
||||
defer func() {
|
||||
err := closeClient()
|
||||
if err != nil {
|
||||
t.Errorf("goroutine error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
f, err := os.Open(*replay)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read replay file: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
|
||||
|
||||
if !scanner.Scan() {
|
||||
t.Fatalf("replay file is empty")
|
||||
}
|
||||
|
||||
rootDirPlaceholder := "@PROJECT_ROOT@"
|
||||
rootDirUriPlaceholder := "@PROJECT_ROOT_URI@"
|
||||
firstLine := scanner.Bytes()
|
||||
var initObj initialArguments
|
||||
err = json.Unmarshal(firstLine, &initObj)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse initial arguments: %v", err)
|
||||
}
|
||||
|
||||
if initObj.RootDirPlaceholder != "" {
|
||||
rootDirPlaceholder = initObj.RootDirPlaceholder
|
||||
}
|
||||
if initObj.RootDirUriPlaceholder != "" {
|
||||
rootDirUriPlaceholder = initObj.RootDirUriPlaceholder
|
||||
}
|
||||
|
||||
rootDirReplacer := strings.NewReplacer(
|
||||
rootDirPlaceholder, *testDir,
|
||||
rootDirUriPlaceholder, string(testDirUri),
|
||||
)
|
||||
|
||||
var messages []*rawMessage
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
line = rootDirReplacer.Replace(line)
|
||||
var rawMsg rawMessage
|
||||
err := json.Unmarshal([]byte(line), &rawMsg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse message: %v", err)
|
||||
}
|
||||
messages = append(messages, &rawMsg)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatalf("error scanning replay file: %v", err)
|
||||
}
|
||||
|
||||
if simple != nil && *simple {
|
||||
// Include only initialization, file opening/changing/closing, and shutdown messages, plus the final request.
|
||||
var newMessages []*rawMessage
|
||||
var i int
|
||||
for i = 0; i < len(messages) && isInitializationMessage(messages[i]); i++ {
|
||||
newMessages = append(newMessages, messages[i])
|
||||
}
|
||||
var j int
|
||||
for j = len(messages) - 1; j >= 0 && isExitMessage(messages[j]); j-- {
|
||||
}
|
||||
for k := i; k <= j; k++ {
|
||||
msg := messages[k]
|
||||
if msg.Method == "textDocument/didOpen" || msg.Method == "textDocument/didChange" || msg.Method == "textDocument/didClose" {
|
||||
newMessages = append(newMessages, msg)
|
||||
}
|
||||
}
|
||||
for k := max(i, j); k < len(messages); k++ {
|
||||
newMessages = append(newMessages, messages[k])
|
||||
}
|
||||
messages = newMessages
|
||||
} else if superSimple != nil && *superSimple {
|
||||
// Include only initialization, shutdown, the last file open and the final request.
|
||||
// We assume here the final request will be for the file that was opened last.
|
||||
var newMessages []*rawMessage
|
||||
var i int
|
||||
for i = 0; i < len(messages) && isInitializationMessage(messages[i]); i++ {
|
||||
newMessages = append(newMessages, messages[i])
|
||||
}
|
||||
|
||||
var j int
|
||||
for j = len(messages) - 1; j >= 0 && isExitMessage(messages[j]); j-- {
|
||||
}
|
||||
var openIdx int
|
||||
for openIdx = j; openIdx >= i; openIdx-- {
|
||||
msg := messages[openIdx]
|
||||
if msg.Method == "textDocument/didOpen" {
|
||||
newMessages = append(newMessages, msg)
|
||||
break
|
||||
}
|
||||
}
|
||||
for k := max(openIdx+1, j); k < len(messages); k++ {
|
||||
newMessages = append(newMessages, messages[k])
|
||||
}
|
||||
messages = newMessages
|
||||
}
|
||||
|
||||
for _, rawMsg := range messages {
|
||||
var kind jsonrpc.MessageKind
|
||||
var reqID *jsonrpc.ID
|
||||
switch rawMsg.Kind {
|
||||
case "request":
|
||||
kind = jsonrpc.MessageKindRequest
|
||||
reqID = lsproto.NewID(lsproto.IntegerOrString{Integer: new(client.NextID())})
|
||||
case "notification":
|
||||
kind = jsonrpc.MessageKindNotification
|
||||
default:
|
||||
t.Fatalf("unknown message kind: %s", rawMsg.Kind)
|
||||
}
|
||||
|
||||
var rpcMsg struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID *jsonrpc.ID `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.Value `json:"params"`
|
||||
}
|
||||
rpcMsg.JSONRPC = "2.0"
|
||||
rpcMsg.ID = reqID
|
||||
rpcMsg.Method = rawMsg.Method
|
||||
rpcMsg.Params = rawMsg.Params
|
||||
rpcData, err := json.Marshal(rpcMsg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal rpc message: %v", err)
|
||||
}
|
||||
|
||||
var msg lsproto.Message
|
||||
err = json.Unmarshal(rpcData, &msg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to unmarshal rpc message into lsproto.Message: %v", err)
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case jsonrpc.MessageKindRequest:
|
||||
response, ok := client.SendRequestWorker(t, msg.AsRequest(), reqID)
|
||||
if !ok {
|
||||
t.Fatalf("failed to send request for method %s", rawMsg.Method)
|
||||
}
|
||||
if response.Error != nil {
|
||||
t.Fatalf("server returned error for method %s params %s:\n%v", rawMsg.Method, rawMsg.Params, response.Error)
|
||||
}
|
||||
case jsonrpc.MessageKindNotification:
|
||||
client.WriteMsg(t, &msg)
|
||||
default:
|
||||
t.Fatalf("unknown message kind: %s", rawMsg.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isInitializationMessage(msg *rawMessage) bool {
|
||||
return msg.Method == "initialize" || msg.Method == "initialized"
|
||||
}
|
||||
|
||||
func isExitMessage(msg *rawMessage) bool {
|
||||
return msg.Method == "exit" || msg.Method == "shutdown"
|
||||
}
|
||||
1883
tools/tsgo/internal/lsp/server.go
Normal file
1883
tools/tsgo/internal/lsp/server.go
Normal file
File diff suppressed because it is too large
Load Diff
281
tools/tsgo/internal/lsp/server_completion_test.go
Normal file
281
tools/tsgo/internal/lsp/server_completion_test.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package lsp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/bundled"
|
||||
"github.com/microsoft/typescript-go/internal/core"
|
||||
"github.com/microsoft/typescript-go/internal/ls/lsconv"
|
||||
"github.com/microsoft/typescript-go/internal/ls/lsutil"
|
||||
"github.com/microsoft/typescript-go/internal/lsp"
|
||||
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
||||
"github.com/microsoft/typescript-go/internal/testutil/lsptestutil"
|
||||
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func initCompletionClient(t *testing.T, files map[string]string, prefs *lsutil.UserPreferences) *lsptestutil.LSPClient {
|
||||
t.Helper()
|
||||
|
||||
fs := bundled.WrapFS(vfstest.FromMap(files, false))
|
||||
|
||||
onServerRequest := func(_ context.Context, req *lsproto.RequestMessage) *lsproto.ResponseMessage {
|
||||
switch req.Method {
|
||||
case lsproto.MethodWorkspaceConfiguration:
|
||||
return &lsproto.ResponseMessage{
|
||||
ID: req.ID,
|
||||
JSONRPC: req.JSONRPC,
|
||||
Result: []any{prefs},
|
||||
}
|
||||
case lsproto.MethodClientRegisterCapability, lsproto.MethodClientUnregisterCapability:
|
||||
return &lsproto.ResponseMessage{
|
||||
ID: req.ID,
|
||||
JSONRPC: req.JSONRPC,
|
||||
Result: lsproto.Null{},
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client, closeClient := lsptestutil.NewLSPClient(t, lsp.ServerOptions{
|
||||
Err: io.Discard,
|
||||
Cwd: "/home/projects",
|
||||
FS: fs,
|
||||
DefaultLibraryPath: bundled.LibPath(),
|
||||
}, onServerRequest)
|
||||
t.Cleanup(func() { _ = closeClient() })
|
||||
|
||||
initMsg, _, ok := lsptestutil.SendRequest(t, client, lsproto.InitializeInfo, &lsproto.InitializeParams{
|
||||
Capabilities: &lsproto.ClientCapabilities{},
|
||||
})
|
||||
assert.Assert(t, ok && initMsg.AsResponse().Error == nil, "Initialize failed")
|
||||
lsptestutil.SendNotification(t, client, lsproto.InitializedInfo, &lsproto.InitializedParams{})
|
||||
<-client.Server.InitComplete()
|
||||
|
||||
lsptestutil.SendNotification(t, client, lsproto.WorkspaceDidChangeConfigurationInfo, &lsproto.DidChangeConfigurationParams{
|
||||
Settings: map[string]any{"typescript": prefs},
|
||||
})
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
func completionItems(resp lsproto.CompletionResponse) []*lsproto.CompletionItem {
|
||||
if resp.List != nil {
|
||||
return resp.List.Items
|
||||
}
|
||||
if resp.Items != nil {
|
||||
return *resp.Items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findCompletionItem(items []*lsproto.CompletionItem, label string) *lsproto.CompletionItem {
|
||||
for _, item := range items {
|
||||
if item.Label == label {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verifies that completion succeeds on a file that was already closed
|
||||
// by the time the server processes the completion request.
|
||||
func TestCompletionAfterFileClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !bundled.Embedded {
|
||||
t.Skip("bundled files are not embedded")
|
||||
}
|
||||
|
||||
prefs := &lsutil.UserPreferences{
|
||||
IncludeCompletionsForModuleExports: core.TSTrue,
|
||||
IncludeCompletionsForImportStatements: core.TSTrue,
|
||||
}
|
||||
client := initCompletionClient(t, map[string]string{
|
||||
"/home/projects/tsconfig.json": `{"compilerOptions": {"module": "esnext", "target": "esnext"}}`,
|
||||
"/home/projects/a.ts": "export const someVar = 10;",
|
||||
"/home/projects/b.ts": "s",
|
||||
}, prefs)
|
||||
|
||||
aURI := lsconv.FileNameToDocumentURI("/home/projects/a.ts")
|
||||
bURI := lsconv.FileNameToDocumentURI("/home/projects/b.ts")
|
||||
lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
|
||||
TextDocument: &lsproto.TextDocumentItem{Uri: aURI, LanguageId: "typescript", Text: "export const someVar = 10;"},
|
||||
})
|
||||
lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
|
||||
TextDocument: &lsproto.TextDocumentItem{Uri: bURI, LanguageId: "typescript", Text: "s"},
|
||||
})
|
||||
|
||||
lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidCloseInfo, &lsproto.DidCloseTextDocumentParams{
|
||||
TextDocument: lsproto.TextDocumentIdentifier{Uri: bURI},
|
||||
})
|
||||
|
||||
msg, resp, ok := lsptestutil.SendRequest(t, client, lsproto.TextDocumentCompletionInfo, &lsproto.CompletionParams{
|
||||
TextDocument: lsproto.TextDocumentIdentifier{Uri: bURI},
|
||||
Position: lsproto.Position{Line: 0, Character: 1},
|
||||
Context: &lsproto.CompletionContext{},
|
||||
})
|
||||
assert.Assert(t, ok, "expected a response")
|
||||
assert.Assert(t, msg.AsResponse().Error == nil)
|
||||
item := findCompletionItem(completionItems(resp), "someVar")
|
||||
assert.Assert(t, item != nil)
|
||||
assert.Assert(t, item.Data != nil && item.Data.AutoImport != nil)
|
||||
assert.Equal(t, item.Data.AutoImport.ModuleSpecifier, "./a")
|
||||
}
|
||||
|
||||
// Completion request is enqueued first, then a close notification is sent.
|
||||
// This guarantees the completion enters the input channel before the close.
|
||||
func TestCompletionWithConcurrentFileClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !bundled.Embedded {
|
||||
t.Skip("bundled files are not embedded")
|
||||
}
|
||||
|
||||
prefs := &lsutil.UserPreferences{
|
||||
IncludeCompletionsForModuleExports: core.TSTrue,
|
||||
IncludeCompletionsForImportStatements: core.TSTrue,
|
||||
}
|
||||
client := initCompletionClient(t, map[string]string{
|
||||
"/home/projects/tsconfig.json": `{"compilerOptions": {"module": "esnext", "target": "esnext"}}`,
|
||||
"/home/projects/a.ts": "export const someVar = 10;",
|
||||
"/home/projects/b.ts": "s",
|
||||
}, prefs)
|
||||
|
||||
aURI := lsconv.FileNameToDocumentURI("/home/projects/a.ts")
|
||||
bURI := lsconv.FileNameToDocumentURI("/home/projects/b.ts")
|
||||
lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
|
||||
TextDocument: &lsproto.TextDocumentItem{Uri: aURI, LanguageId: "typescript", Text: "export const someVar = 10;"},
|
||||
})
|
||||
lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
|
||||
TextDocument: &lsproto.TextDocumentItem{Uri: bURI, LanguageId: "typescript", Text: "s"},
|
||||
})
|
||||
|
||||
waitForCompletion := lsptestutil.SendRequestAsync(t, client, lsproto.TextDocumentCompletionInfo, &lsproto.CompletionParams{
|
||||
TextDocument: lsproto.TextDocumentIdentifier{Uri: bURI},
|
||||
Position: lsproto.Position{Line: 0, Character: 1},
|
||||
Context: &lsproto.CompletionContext{},
|
||||
})
|
||||
|
||||
lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidCloseInfo, &lsproto.DidCloseTextDocumentParams{
|
||||
TextDocument: lsproto.TextDocumentIdentifier{Uri: bURI},
|
||||
})
|
||||
|
||||
msg, resp, ok := waitForCompletion()
|
||||
assert.Assert(t, ok, "expected a response")
|
||||
assert.Assert(t, msg.AsResponse().Error == nil)
|
||||
item := findCompletionItem(completionItems(resp), "someVar")
|
||||
assert.Assert(t, item != nil)
|
||||
assert.Assert(t, item.Data != nil && item.Data.AutoImport != nil)
|
||||
assert.Equal(t, item.Data.AutoImport.ModuleSpecifier, "./a")
|
||||
}
|
||||
|
||||
func TestCompletionForUnopenedFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !bundled.Embedded {
|
||||
t.Skip("bundled files are not embedded")
|
||||
}
|
||||
|
||||
prefs := &lsutil.UserPreferences{}
|
||||
client := initCompletionClient(t, map[string]string{
|
||||
"/home/projects/tsconfig.json": `{"compilerOptions": {"module": "esnext", "target": "esnext"}}`,
|
||||
"/home/projects/c.ts": "let xyz = 1;\nxy",
|
||||
}, prefs)
|
||||
|
||||
cURI := lsconv.FileNameToDocumentURI("/home/projects/c.ts")
|
||||
msg, resp, ok := lsptestutil.SendRequest(t, client, lsproto.TextDocumentCompletionInfo, &lsproto.CompletionParams{
|
||||
TextDocument: lsproto.TextDocumentIdentifier{Uri: cURI},
|
||||
Position: lsproto.Position{Line: 1, Character: 2},
|
||||
Context: &lsproto.CompletionContext{},
|
||||
})
|
||||
assert.Assert(t, ok, "expected a response")
|
||||
assert.Assert(t, msg.AsResponse().Error == nil)
|
||||
assert.Assert(t, findCompletionItem(completionItems(resp), "xyz") != nil)
|
||||
}
|
||||
|
||||
func TestAutoImportCompletionForUnopenedFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !bundled.Embedded {
|
||||
t.Skip("bundled files are not embedded")
|
||||
}
|
||||
|
||||
prefs := &lsutil.UserPreferences{
|
||||
IncludeCompletionsForModuleExports: core.TSTrue,
|
||||
IncludeCompletionsForImportStatements: core.TSTrue,
|
||||
}
|
||||
client := initCompletionClient(t, map[string]string{
|
||||
"/home/projects/tsconfig.json": `{"compilerOptions": {"module": "esnext", "target": "esnext"}}`,
|
||||
"/home/projects/a.ts": "export const someVar = 10;",
|
||||
"/home/projects/c.ts": "s",
|
||||
}, prefs)
|
||||
|
||||
cURI := lsconv.FileNameToDocumentURI("/home/projects/c.ts")
|
||||
msg, resp, ok := lsptestutil.SendRequest(t, client, lsproto.TextDocumentCompletionInfo, &lsproto.CompletionParams{
|
||||
TextDocument: lsproto.TextDocumentIdentifier{Uri: cURI},
|
||||
Position: lsproto.Position{Line: 0, Character: 1},
|
||||
Context: &lsproto.CompletionContext{},
|
||||
})
|
||||
assert.Assert(t, ok, "expected a response")
|
||||
assert.Assert(t, msg.AsResponse().Error == nil)
|
||||
item := findCompletionItem(completionItems(resp), "someVar")
|
||||
assert.Assert(t, item != nil)
|
||||
assert.Assert(t, item.Data != nil && item.Data.AutoImport != nil)
|
||||
assert.Equal(t, item.Data.AutoImport.ModuleSpecifier, "./a")
|
||||
}
|
||||
|
||||
// TestCompletionSnapshotFreezing verifies that the auto-import retry uses the
|
||||
// snapshot captured in the sync phase, not a newer one that includes a
|
||||
// concurrent DidChange. Without snapshot freezing the retry would flush the
|
||||
// pending change, making position/prefix inconsistent with the request.
|
||||
func TestCompletionSnapshotFreezing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !bundled.Embedded {
|
||||
t.Skip("bundled files are not embedded")
|
||||
}
|
||||
|
||||
prefs := &lsutil.UserPreferences{
|
||||
IncludeCompletionsForModuleExports: core.TSTrue,
|
||||
IncludeCompletionsForImportStatements: core.TSTrue,
|
||||
}
|
||||
client := initCompletionClient(t, map[string]string{
|
||||
"/home/projects/tsconfig.json": `{"compilerOptions": {"module": "esnext", "target": "esnext"}}`,
|
||||
"/home/projects/a.ts": "export const someVar = 10;",
|
||||
"/home/projects/b.ts": "someV",
|
||||
}, prefs)
|
||||
|
||||
aURI := lsconv.FileNameToDocumentURI("/home/projects/a.ts")
|
||||
bURI := lsconv.FileNameToDocumentURI("/home/projects/b.ts")
|
||||
lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
|
||||
TextDocument: &lsproto.TextDocumentItem{Uri: aURI, LanguageId: "typescript", Text: "export const someVar = 10;"},
|
||||
})
|
||||
lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
|
||||
TextDocument: &lsproto.TextDocumentItem{Uri: bURI, LanguageId: "typescript", Text: "someV"},
|
||||
})
|
||||
|
||||
waitForCompletion := lsptestutil.SendRequestAsync(t, client, lsproto.TextDocumentCompletionInfo, &lsproto.CompletionParams{
|
||||
TextDocument: lsproto.TextDocumentIdentifier{Uri: bURI},
|
||||
Position: lsproto.Position{Line: 0, Character: 5},
|
||||
Context: &lsproto.CompletionContext{},
|
||||
})
|
||||
|
||||
lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidChangeInfo, &lsproto.DidChangeTextDocumentParams{
|
||||
TextDocument: lsproto.VersionedTextDocumentIdentifier{Uri: bURI, Version: 2},
|
||||
ContentChanges: []lsproto.TextDocumentContentChangePartialOrWholeDocument{
|
||||
{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: "notMatching"}},
|
||||
},
|
||||
})
|
||||
|
||||
msg, resp, ok := waitForCompletion()
|
||||
assert.Assert(t, ok, "expected a response")
|
||||
assert.Assert(t, msg.AsResponse().Error == nil)
|
||||
item := findCompletionItem(completionItems(resp), "someVar")
|
||||
assert.Assert(t, item != nil, "expected someVar in completions (snapshot freezing should preserve original content)")
|
||||
assert.Assert(t, item.Data != nil && item.Data.AutoImport != nil)
|
||||
assert.Equal(t, item.Data.AutoImport.ModuleSpecifier, "./a")
|
||||
}
|
||||
134
tools/tsgo/internal/lsp/server_progress_test.go
Normal file
134
tools/tsgo/internal/lsp/server_progress_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package lsp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/bundled"
|
||||
"github.com/microsoft/typescript-go/internal/lsp"
|
||||
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
||||
"github.com/microsoft/typescript-go/internal/testutil/lsptestutil"
|
||||
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestProgressNotificationsEndToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !bundled.Embedded {
|
||||
t.Skip("bundled files are not embedded")
|
||||
}
|
||||
|
||||
fs := bundled.WrapFS(vfstest.FromMap(map[string]string{
|
||||
"/home/projects/tsconfig.json": `{}`,
|
||||
"/home/projects/index.ts": "export const x = 1;",
|
||||
}, false))
|
||||
|
||||
// Collect $/progress notifications. Signal when "end" arrives.
|
||||
var mu sync.Mutex
|
||||
var progressNotifications []*lsproto.ProgressParams
|
||||
endReceived := make(chan struct{}, 1)
|
||||
|
||||
onServerRequest := func(_ context.Context, req *lsproto.RequestMessage) *lsproto.ResponseMessage {
|
||||
switch req.Method {
|
||||
case lsproto.MethodClientRegisterCapability, lsproto.MethodClientUnregisterCapability, lsproto.MethodWindowWorkDoneProgressCreate:
|
||||
return &lsproto.ResponseMessage{
|
||||
ID: req.ID,
|
||||
JSONRPC: req.JSONRPC,
|
||||
Result: lsproto.Null{},
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client, closeClient := lsptestutil.NewLSPClient(t, lsp.ServerOptions{
|
||||
Err: io.Discard,
|
||||
Cwd: "/home/projects",
|
||||
FS: fs,
|
||||
DefaultLibraryPath: bundled.LibPath(),
|
||||
}, onServerRequest)
|
||||
defer func() { assert.NilError(t, closeClient()) }()
|
||||
|
||||
client.OnServerNotification = func(_ context.Context, req *lsproto.RequestMessage) {
|
||||
if req.Method == lsproto.MethodProgress {
|
||||
if params, err := lsproto.UnmarshalParams[*lsproto.ProgressParams](req); err == nil && params != nil {
|
||||
mu.Lock()
|
||||
progressNotifications = append(progressNotifications, params)
|
||||
isEnd := params.Value.End != nil
|
||||
mu.Unlock()
|
||||
if isEnd {
|
||||
select {
|
||||
case endReceived <- struct{}{}:
|
||||
// Signaled.
|
||||
default:
|
||||
// Already signaled.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initMsg, _, ok := lsptestutil.SendRequest(t, client, lsproto.InitializeInfo, &lsproto.InitializeParams{
|
||||
Capabilities: &lsproto.ClientCapabilities{
|
||||
Window: &lsproto.WindowClientCapabilities{
|
||||
WorkDoneProgress: new(true),
|
||||
},
|
||||
},
|
||||
})
|
||||
assert.Assert(t, ok && initMsg.AsResponse().Error == nil, "Initialize failed")
|
||||
lsptestutil.SendNotification(t, client, lsproto.InitializedInfo, &lsproto.InitializedParams{})
|
||||
<-client.Server.InitComplete()
|
||||
|
||||
uri := lsproto.DocumentUri("file:///home/projects/index.ts")
|
||||
lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
|
||||
TextDocument: &lsproto.TextDocumentItem{Uri: uri, LanguageId: "typescript", Text: "export const x = 1;"},
|
||||
})
|
||||
|
||||
// Send a request to ensure the server has processed the didOpen and loaded the project.
|
||||
msg, resp, ok := lsptestutil.SendRequest(t, client, lsproto.CustomProjectInfoInfo, &lsproto.ProjectInfoParams{
|
||||
TextDocument: lsproto.TextDocumentIdentifier{Uri: uri},
|
||||
})
|
||||
assert.Assert(t, ok, "expected a response")
|
||||
assert.Assert(t, msg.AsResponse().Error == nil)
|
||||
assert.Equal(t, resp.ConfigFilePath, "/home/projects/tsconfig.json")
|
||||
|
||||
// Wait for the "end" progress notification before reading.
|
||||
select {
|
||||
case <-endReceived:
|
||||
// Got it.
|
||||
case <-t.Context().Done():
|
||||
t.Fatal("timed out waiting for progress end notification")
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
notifications := make([]*lsproto.ProgressParams, len(progressNotifications))
|
||||
copy(notifications, progressNotifications)
|
||||
mu.Unlock()
|
||||
|
||||
assert.Assert(t, len(notifications) >= 2, "expected at least begin+end progress notifications, got %d", len(notifications))
|
||||
|
||||
// First notification should be a "begin".
|
||||
assert.Assert(t, notifications[0].Value.Begin != nil, "expected first progress notification to be 'begin'")
|
||||
assert.Equal(t, notifications[0].Value.Begin.Title, "Loading")
|
||||
|
||||
// Last notification should be an "end".
|
||||
last := notifications[len(notifications)-1]
|
||||
assert.Assert(t, last.Value.End != nil, "expected last progress notification to be 'end'")
|
||||
|
||||
// All notifications should share the same token.
|
||||
firstToken := tokenString(notifications[0].Token)
|
||||
assert.Assert(t, firstToken != "", "expected non-empty progress token")
|
||||
for i, n := range notifications {
|
||||
assert.Equal(t, tokenString(n.Token), firstToken, "notification %d has different token", i)
|
||||
}
|
||||
}
|
||||
|
||||
func tokenString(t lsproto.IntegerOrString) string {
|
||||
if t.String != nil {
|
||||
return *t.String
|
||||
}
|
||||
return ""
|
||||
}
|
||||
99
tools/tsgo/internal/lsp/server_projectinfo_test.go
Normal file
99
tools/tsgo/internal/lsp/server_projectinfo_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package lsp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/bundled"
|
||||
"github.com/microsoft/typescript-go/internal/lsp"
|
||||
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
||||
"github.com/microsoft/typescript-go/internal/testutil/lsptestutil"
|
||||
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func initProjectInfoClient(t *testing.T, files map[string]string) *lsptestutil.LSPClient {
|
||||
t.Helper()
|
||||
|
||||
fs := bundled.WrapFS(vfstest.FromMap(files, false))
|
||||
|
||||
onServerRequest := func(_ context.Context, req *lsproto.RequestMessage) *lsproto.ResponseMessage {
|
||||
switch req.Method {
|
||||
case lsproto.MethodClientRegisterCapability, lsproto.MethodClientUnregisterCapability, lsproto.MethodWindowWorkDoneProgressCreate:
|
||||
return &lsproto.ResponseMessage{
|
||||
ID: req.ID,
|
||||
JSONRPC: req.JSONRPC,
|
||||
Result: lsproto.Null{},
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client, closeClient := lsptestutil.NewLSPClient(t, lsp.ServerOptions{
|
||||
Err: io.Discard,
|
||||
Cwd: "/home/projects",
|
||||
FS: fs,
|
||||
DefaultLibraryPath: bundled.LibPath(),
|
||||
}, onServerRequest)
|
||||
t.Cleanup(func() { _ = closeClient() })
|
||||
|
||||
initMsg, _, ok := lsptestutil.SendRequest(t, client, lsproto.InitializeInfo, &lsproto.InitializeParams{
|
||||
Capabilities: &lsproto.ClientCapabilities{},
|
||||
})
|
||||
assert.Assert(t, ok && initMsg.AsResponse().Error == nil, "Initialize failed")
|
||||
lsptestutil.SendNotification(t, client, lsproto.InitializedInfo, &lsproto.InitializedParams{})
|
||||
<-client.Server.InitComplete()
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
func TestProjectInfoConfiguredProject(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !bundled.Embedded {
|
||||
t.Skip("bundled files are not embedded")
|
||||
}
|
||||
|
||||
client := initProjectInfoClient(t, map[string]string{
|
||||
"/home/projects/tsconfig.json": `{}`,
|
||||
"/home/projects/index.ts": "export const x = 1;",
|
||||
})
|
||||
|
||||
uri := lsproto.DocumentUri("file:///home/projects/index.ts")
|
||||
lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
|
||||
TextDocument: &lsproto.TextDocumentItem{Uri: uri, LanguageId: "typescript", Text: "export const x = 1;"},
|
||||
})
|
||||
|
||||
msg, resp, ok := lsptestutil.SendRequest(t, client, lsproto.CustomProjectInfoInfo, &lsproto.ProjectInfoParams{
|
||||
TextDocument: lsproto.TextDocumentIdentifier{Uri: uri},
|
||||
})
|
||||
assert.Assert(t, ok, "expected a response")
|
||||
assert.Assert(t, msg.AsResponse().Error == nil)
|
||||
assert.Equal(t, resp.ConfigFilePath, "/home/projects/tsconfig.json")
|
||||
}
|
||||
|
||||
func TestProjectInfoInferredProject(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !bundled.Embedded {
|
||||
t.Skip("bundled files are not embedded")
|
||||
}
|
||||
|
||||
client := initProjectInfoClient(t, map[string]string{
|
||||
"/home/projects/index.ts": "export const x = 1;",
|
||||
})
|
||||
|
||||
uri := lsproto.DocumentUri("file:///home/projects/index.ts")
|
||||
lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
|
||||
TextDocument: &lsproto.TextDocumentItem{Uri: uri, LanguageId: "typescript", Text: "export const x = 1;"},
|
||||
})
|
||||
|
||||
msg, resp, ok := lsptestutil.SendRequest(t, client, lsproto.CustomProjectInfoInfo, &lsproto.ProjectInfoParams{
|
||||
TextDocument: lsproto.TextDocumentIdentifier{Uri: uri},
|
||||
})
|
||||
assert.Assert(t, ok, "expected a response")
|
||||
assert.Assert(t, msg.AsResponse().Error == nil)
|
||||
assert.Equal(t, resp.ConfigFilePath, "")
|
||||
}
|
||||
131
tools/tsgo/internal/lsp/server_projectreference_updates_test.go
Normal file
131
tools/tsgo/internal/lsp/server_projectreference_updates_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package lsp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/bundled"
|
||||
"github.com/microsoft/typescript-go/internal/ls/lsconv"
|
||||
"github.com/microsoft/typescript-go/internal/ls/lsutil"
|
||||
"github.com/microsoft/typescript-go/internal/lsp"
|
||||
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
||||
"github.com/microsoft/typescript-go/internal/testutil/lsptestutil"
|
||||
"github.com/microsoft/typescript-go/internal/vfs/iovfs"
|
||||
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func initMutableLSPClient(t *testing.T, files map[string]string, prefs *lsutil.UserPreferences) (*lsptestutil.LSPClient, *vfstest.MapFS) {
|
||||
t.Helper()
|
||||
|
||||
base := vfstest.FromMap(files, false)
|
||||
baseFS := base.(iovfs.FsWithSys).FSys().(*vfstest.MapFS)
|
||||
fs := bundled.WrapFS(base)
|
||||
|
||||
onServerRequest := func(_ context.Context, req *lsproto.RequestMessage) *lsproto.ResponseMessage {
|
||||
switch req.Method {
|
||||
case lsproto.MethodWorkspaceConfiguration:
|
||||
return &lsproto.ResponseMessage{
|
||||
ID: req.ID,
|
||||
JSONRPC: req.JSONRPC,
|
||||
Result: []any{prefs},
|
||||
}
|
||||
case lsproto.MethodClientRegisterCapability, lsproto.MethodClientUnregisterCapability:
|
||||
return &lsproto.ResponseMessage{
|
||||
ID: req.ID,
|
||||
JSONRPC: req.JSONRPC,
|
||||
Result: lsproto.Null{},
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client, closeClient := lsptestutil.NewLSPClient(t, lsp.ServerOptions{
|
||||
Err: io.Discard,
|
||||
Cwd: "/root",
|
||||
FS: fs,
|
||||
DefaultLibraryPath: bundled.LibPath(),
|
||||
}, onServerRequest)
|
||||
t.Cleanup(func() { _ = closeClient() })
|
||||
|
||||
initMsg, _, ok := lsptestutil.SendRequest(t, client, lsproto.InitializeInfo, &lsproto.InitializeParams{
|
||||
Capabilities: &lsproto.ClientCapabilities{},
|
||||
})
|
||||
assert.Assert(t, ok && initMsg.AsResponse().Error == nil, "Initialize failed")
|
||||
lsptestutil.SendNotification(t, client, lsproto.InitializedInfo, &lsproto.InitializedParams{})
|
||||
<-client.Server.InitComplete()
|
||||
|
||||
lsptestutil.SendNotification(t, client, lsproto.WorkspaceDidChangeConfigurationInfo, &lsproto.DidChangeConfigurationParams{
|
||||
Settings: map[string]any{"typescript": prefs},
|
||||
})
|
||||
|
||||
return client, baseFS
|
||||
}
|
||||
|
||||
func TestReferencesAfterAncestorProjectConfigDeletion1(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !bundled.Embedded {
|
||||
t.Skip("bundled files are not embedded")
|
||||
}
|
||||
|
||||
client, fs := initMutableLSPClient(t, map[string]string{
|
||||
"/root/tsconfig.json": `{
|
||||
"files": [],
|
||||
"references": [{ "path": "./project" }]
|
||||
}`,
|
||||
"/root/project/tsconfig.json": `{
|
||||
"compilerOptions": { "composite": true },
|
||||
"include": ["src/**/*.ts"]
|
||||
}`,
|
||||
"/root/project/src/main.ts": "export function helloWorld() {}\nhelloWorld()\n",
|
||||
}, &lsutil.UserPreferences{})
|
||||
|
||||
mainURI := lsconv.FileNameToDocumentURI("/root/project/src/main.ts")
|
||||
lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
|
||||
TextDocument: &lsproto.TextDocumentItem{Uri: mainURI, LanguageId: "typescript", Text: "export function helloWorld() {}\nhelloWorld()\n"},
|
||||
})
|
||||
|
||||
// Prime the child project so opening a file creates the ancestor configured-project placeholder.
|
||||
msg, _, ok := lsptestutil.SendRequest(t, client, lsproto.TextDocumentDocumentSymbolInfo, &lsproto.DocumentSymbolParams{
|
||||
TextDocument: lsproto.TextDocumentIdentifier{Uri: mainURI},
|
||||
})
|
||||
assert.Assert(t, ok, "expected response")
|
||||
assert.Assert(t, msg.AsResponse().Error == nil)
|
||||
|
||||
assert.NilError(t, fs.Remove("root/tsconfig.json"))
|
||||
lsptestutil.SendNotification(t, client, lsproto.WorkspaceDidChangeWatchedFilesInfo, &lsproto.DidChangeWatchedFilesParams{
|
||||
Changes: []*lsproto.FileEvent{{
|
||||
Uri: lsconv.FileNameToDocumentURI("/root/tsconfig.json"),
|
||||
Type: lsproto.FileChangeTypeDeleted,
|
||||
}},
|
||||
})
|
||||
|
||||
msg, resp, ok := lsptestutil.SendRequest(t, client, lsproto.TextDocumentReferencesInfo, &lsproto.ReferenceParams{
|
||||
TextDocument: lsproto.TextDocumentIdentifier{Uri: mainURI},
|
||||
Position: lsproto.Position{Line: 1, Character: 3},
|
||||
Context: &lsproto.ReferenceContext{IncludeDeclaration: true},
|
||||
})
|
||||
assert.Assert(t, ok, "expected response")
|
||||
assert.Assert(t, msg.AsResponse().Error == nil)
|
||||
assert.Assert(t, resp.Locations != nil)
|
||||
assert.Equal(t, len(*resp.Locations), 2)
|
||||
assert.DeepEqual(t, []lsproto.Location{
|
||||
{
|
||||
Uri: mainURI,
|
||||
Range: lsproto.Range{
|
||||
Start: lsproto.Position{Line: 0, Character: 16},
|
||||
End: lsproto.Position{Line: 0, Character: 26},
|
||||
},
|
||||
},
|
||||
{
|
||||
Uri: mainURI,
|
||||
Range: lsproto.Range{
|
||||
Start: lsproto.Position{Line: 1, Character: 0},
|
||||
End: lsproto.Position{Line: 1, Character: 10},
|
||||
},
|
||||
},
|
||||
}, *resp.Locations)
|
||||
}
|
||||
93
tools/tsgo/internal/lsp/server_semantictokens_test.go
Normal file
93
tools/tsgo/internal/lsp/server_semantictokens_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package lsp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/bundled"
|
||||
"github.com/microsoft/typescript-go/internal/lsp"
|
||||
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
||||
"github.com/microsoft/typescript-go/internal/testutil/lsptestutil"
|
||||
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
// TestSemanticTokensCRLF reproduces a crash where semantic tokens panics with
|
||||
// "token spans multiple lines" when the editor opens a file with CRLF line endings
|
||||
// but the project originally loaded the file from disk with LF line endings.
|
||||
//
|
||||
// The SourceFile AST keeps positions from the LF text, but the converter's
|
||||
// line map is recomputed from the CRLF overlay, causing a mismatch.
|
||||
func TestSemanticTokensCRLF(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !bundled.Embedded {
|
||||
t.Skip("bundled files are not embedded")
|
||||
}
|
||||
|
||||
// Enough lines so the cumulative \r\n vs \n offset difference
|
||||
// causes an LF-based position to land on a \r in the CRLF text.
|
||||
fileOnDisk := "var x\nvar x\nvar x\nvar x\nvar x\nvar x\nconst a = 1\n"
|
||||
fileFromEditor := strings.ReplaceAll(fileOnDisk, "\n", "\r\n")
|
||||
|
||||
files := map[string]string{
|
||||
"/home/projects/tsconfig.json": `{}`,
|
||||
"/home/projects/test.ts": fileOnDisk,
|
||||
"/home/projects/other.ts": "export {}",
|
||||
}
|
||||
fs := bundled.WrapFS(vfstest.FromMap(files, false))
|
||||
|
||||
onServerRequest := func(_ context.Context, req *lsproto.RequestMessage) *lsproto.ResponseMessage {
|
||||
if req.Method == lsproto.MethodClientRegisterCapability || req.Method == lsproto.MethodClientUnregisterCapability {
|
||||
return &lsproto.ResponseMessage{ID: req.ID, JSONRPC: req.JSONRPC, Result: lsproto.Null{}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
client, closeClient := lsptestutil.NewLSPClient(t, lsp.ServerOptions{
|
||||
Err: io.Discard, Cwd: "/home/projects", FS: fs, DefaultLibraryPath: bundled.LibPath(),
|
||||
}, onServerRequest)
|
||||
t.Cleanup(func() { _ = closeClient() })
|
||||
|
||||
initMsg, _, ok := lsptestutil.SendRequest(t, client, lsproto.InitializeInfo, &lsproto.InitializeParams{
|
||||
Capabilities: &lsproto.ClientCapabilities{
|
||||
TextDocument: &lsproto.TextDocumentClientCapabilities{
|
||||
SemanticTokens: &lsproto.SemanticTokensClientCapabilities{
|
||||
Requests: &lsproto.ClientSemanticTokensRequestOptions{
|
||||
Full: &lsproto.BooleanOrClientSemanticTokensRequestFullDelta{Boolean: new(true)},
|
||||
},
|
||||
TokenTypes: []string{"namespace", "type", "class", "enum", "interface", "struct", "typeParameter", "parameter", "variable", "property", "enumMember", "event", "function", "method", "macro", "keyword", "modifier", "comment", "string", "number", "regexp", "operator", "decorator"},
|
||||
TokenModifiers: []string{"declaration", "definition", "readonly", "static", "deprecated", "abstract", "async", "modification", "documentation", "defaultLibrary", "local"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
assert.Assert(t, ok && initMsg.AsResponse().Error == nil, "Initialize failed")
|
||||
lsptestutil.SendNotification(t, client, lsproto.InitializedInfo, &lsproto.InitializedParams{})
|
||||
<-client.Server.InitComplete()
|
||||
|
||||
// Open another project file to force the project to load test.ts from disk (LF).
|
||||
otherUri := lsproto.DocumentUri("file:///home/projects/other.ts")
|
||||
lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
|
||||
TextDocument: &lsproto.TextDocumentItem{Uri: otherUri, LanguageId: "typescript", Text: files["/home/projects/other.ts"]},
|
||||
})
|
||||
msg1, _, _ := lsptestutil.SendRequest(t, client, lsproto.TextDocumentSemanticTokensFullInfo, &lsproto.SemanticTokensParams{
|
||||
TextDocument: lsproto.TextDocumentIdentifier{Uri: otherUri},
|
||||
})
|
||||
assert.Assert(t, msg1.AsResponse().Error == nil, "Initial request failed")
|
||||
|
||||
// Open test.ts with CRLF content; the project already parsed it from disk (LF).
|
||||
uri := lsproto.DocumentUri("file:///home/projects/test.ts")
|
||||
lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
|
||||
TextDocument: &lsproto.TextDocumentItem{Uri: uri, LanguageId: "typescript", Text: fileFromEditor},
|
||||
})
|
||||
|
||||
// This panics: AST positions are LF-based but the line map is CRLF-based.
|
||||
msg, _, _ := lsptestutil.SendRequest(t, client, lsproto.TextDocumentSemanticTokensFullInfo, &lsproto.SemanticTokensParams{
|
||||
TextDocument: lsproto.TextDocumentIdentifier{Uri: uri},
|
||||
})
|
||||
if msg.AsResponse().Error != nil {
|
||||
t.Fatalf("Semantic tokens request failed: %s", msg.AsResponse().Error.Message)
|
||||
}
|
||||
}
|
||||
127
tools/tsgo/internal/lsp/server_shutdown_test.go
Normal file
127
tools/tsgo/internal/lsp/server_shutdown_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/bundled"
|
||||
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
||||
"github.com/microsoft/typescript-go/internal/project"
|
||||
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
|
||||
)
|
||||
|
||||
type shutdownTestReader struct{}
|
||||
|
||||
func (shutdownTestReader) Read() (*lsproto.Message, error) { return nil, io.EOF }
|
||||
|
||||
type shutdownTestWriter struct{}
|
||||
|
||||
func (shutdownTestWriter) Write(*lsproto.Message) error { return nil }
|
||||
|
||||
// TestServerShutdownNoDeadlock verifies that operations after shutdown
|
||||
// don't block.
|
||||
func TestServerShutdownNoDeadlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !bundled.Embedded {
|
||||
t.Skip("bundled files are not embedded")
|
||||
}
|
||||
|
||||
fs := bundled.WrapFS(vfstest.FromMap(map[string]string{
|
||||
"/test/tsconfig.json": "{}",
|
||||
"/test/index.ts": "const x = 1;",
|
||||
}, false))
|
||||
|
||||
server := NewServer(&ServerOptions{
|
||||
In: shutdownTestReader{},
|
||||
Out: shutdownTestWriter{},
|
||||
Err: io.Discard,
|
||||
Cwd: "/test",
|
||||
FS: fs,
|
||||
DefaultLibraryPath: bundled.LibPath(),
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
server.backgroundCtx = ctx
|
||||
|
||||
// Start write loop to drain queue
|
||||
writeLoopDone := make(chan struct{})
|
||||
go func() {
|
||||
_ = server.writeLoop(ctx)
|
||||
close(writeLoopDone)
|
||||
}()
|
||||
|
||||
// Create session with the server's lifecycle context
|
||||
server.initStarted.Store(true)
|
||||
server.session = project.NewSession(&project.SessionInit{
|
||||
BackgroundCtx: ctx,
|
||||
Options: &project.SessionOptions{
|
||||
CurrentDirectory: "/test",
|
||||
DefaultLibraryPath: bundled.LibPath(),
|
||||
PositionEncoding: lsproto.PositionEncodingKindUTF8,
|
||||
WatchEnabled: false,
|
||||
LoggingEnabled: true,
|
||||
},
|
||||
FS: fs,
|
||||
Logger: server.logger,
|
||||
})
|
||||
|
||||
// Open a file to establish a project
|
||||
server.session.DidOpenFile(ctx, "file:///test/index.ts", 1, "const x = 1;", lsproto.LanguageKindTypeScript)
|
||||
server.session.WaitForBackgroundTasks()
|
||||
|
||||
// Shutdown (cancel context and wait for write loop to exit)
|
||||
cancel()
|
||||
<-writeLoopDone
|
||||
|
||||
// Trigger operations that would log (these should not block)
|
||||
server.session.DidChangeFile(ctx, "file:///test/index.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
|
||||
{
|
||||
WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{
|
||||
Text: "const x = 2;",
|
||||
},
|
||||
},
|
||||
})
|
||||
_, _ = server.session.GetLanguageService(ctx, "file:///test/index.ts")
|
||||
server.session.WaitForBackgroundTasks()
|
||||
|
||||
server.session.Close()
|
||||
}
|
||||
|
||||
func TestServerOutgoingQueueDoesNotBlockWithoutWriter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := NewServer(&ServerOptions{
|
||||
In: shutdownTestReader{},
|
||||
Out: shutdownTestWriter{},
|
||||
Err: io.Discard,
|
||||
Cwd: "/test",
|
||||
})
|
||||
server.backgroundCtx = t.Context()
|
||||
|
||||
msg := lsproto.WindowLogMessageInfo.NewNotificationMessage(&lsproto.LogMessageParams{
|
||||
Type: lsproto.MessageTypeInfo,
|
||||
Message: "queued",
|
||||
}).Message()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
for range 1000 {
|
||||
if err := server.send(msg); err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
done <- nil
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-t.Context().Done():
|
||||
t.Fatal("sending outgoing messages blocked without a writer")
|
||||
}
|
||||
}
|
||||
96
tools/tsgo/internal/lsp/stack_sanitizer.go
Normal file
96
tools/tsgo/internal/lsp/stack_sanitizer.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/core"
|
||||
)
|
||||
|
||||
// VS Code's telemetry pipeline redacts any string matching
|
||||
// /(key|token|sig|secret|signature|password|passwd|pwd|android:value)[^a-zA-Z0-9]/i
|
||||
// as `<REDACTED: Generic Secret>`, which trips on innocuous Go frames like
|
||||
// `getSignatureHelp(`. Insert `X_X` after each trigger keyword that we know
|
||||
// can appear in our sanitized output, when followed by punctuation we
|
||||
// actually emit (`(`, `[`, `.`, `|`); reverse by removing the marker (replace
|
||||
// `X_X` with the empty string) on the dashboard.
|
||||
var genericSecretKeywordRegex = regexp.MustCompile(`(?i)(key|token|signature|sig|pwd)([(\[.|])`)
|
||||
|
||||
func defeatGenericSecretRegex(s string) string {
|
||||
return genericSecretKeywordRegex.ReplaceAllString(s, "${1}X_X${2}")
|
||||
}
|
||||
|
||||
func sanitizeStackTrace(stack string) string {
|
||||
// TODO: should we just look for the first '(' and
|
||||
// just strip everything before the prior newline?
|
||||
startIndex := strings.Index(stack, "runtime/debug.Stack()")
|
||||
if startIndex < 0 {
|
||||
return ""
|
||||
}
|
||||
stack = stack[startIndex:]
|
||||
|
||||
result := &strings.Builder{}
|
||||
|
||||
for lineNum, line := range core.Enumerate(strings.Lines(stack)) {
|
||||
if lineNum > 0 {
|
||||
result.WriteByte('\n')
|
||||
}
|
||||
|
||||
i := 0
|
||||
// Skip whitespace
|
||||
for i < len(line) {
|
||||
if line[i] != ' ' && line[i] != '\t' {
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
result.WriteString(line[:i])
|
||||
|
||||
line = line[i:]
|
||||
|
||||
ourModuleIndex := strings.Index(line, "typescript-go/internal")
|
||||
if ourModuleIndex >= 0 {
|
||||
line = line[ourModuleIndex:]
|
||||
writeSanitizedModuleOrPath(line, result)
|
||||
} else {
|
||||
result.WriteString("(REDACTED FRAME)")
|
||||
}
|
||||
}
|
||||
|
||||
return defeatGenericSecretRegex(result.String())
|
||||
}
|
||||
|
||||
func writeSanitizedModuleOrPath(line string, result *strings.Builder) {
|
||||
// We don't expect things like \r, but it doesn't hurt to trim just in case.
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
if plusHex := strings.Index(line, " +0x"); plusHex >= 0 {
|
||||
line = line[:plusHex]
|
||||
} else if inGoroutine := strings.LastIndex(line, " in goroutine "); inGoroutine >= 0 {
|
||||
line = line[:inGoroutine]
|
||||
}
|
||||
|
||||
for segmentIndex, segment := range strings.Split(line, "/") {
|
||||
if segmentIndex > 0 {
|
||||
result.WriteString("|>")
|
||||
}
|
||||
|
||||
// See if the string ends with ), and strip out all the arguments.
|
||||
if strings.HasSuffix(segment, ")") {
|
||||
openParenIndex := strings.LastIndexByte(segment, '(')
|
||||
if openParenIndex < 0 {
|
||||
// Closing parenthesis, but no opening - bail out.
|
||||
result.WriteString("???")
|
||||
continue
|
||||
}
|
||||
|
||||
segment = segment[:openParenIndex]
|
||||
result.WriteString(segment)
|
||||
result.WriteString("()")
|
||||
continue
|
||||
}
|
||||
|
||||
result.WriteString(segment)
|
||||
}
|
||||
}
|
||||
132
tools/tsgo/internal/lsp/stack_sanitizer_test.go
Normal file
132
tools/tsgo/internal/lsp/stack_sanitizer_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/testutil/baseline"
|
||||
)
|
||||
|
||||
// This test uses non-trimmed paths to emulate debug builds.
|
||||
// Most users won't actually see this.
|
||||
func TestSanitizedDebugStackTraceCompletionsRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := `goroutine 1196 [running]:
|
||||
runtime/debug.Stack()
|
||||
/usr/local/go/src/runtime/debug/stack.go:26 +0x8e
|
||||
github.com/microsoft/typescript-go/internal/lsp.(*Server).recover(0xc0001dae08, {0x14bc418, 0xc00bc60960}, 0xc00baf16e0)
|
||||
/workspaces/typescript-go/internal/lsp/server.go:777 +0x65
|
||||
panic({0x1077b40?, 0x1abcb70?})
|
||||
/usr/local/go/src/runtime/panic.go:783 +0x136
|
||||
github.com/microsoft/typescript-go/internal/ls.(*LanguageService).getCompletionData.func15()
|
||||
/workspaces/typescript-go/internal/ls/completions.go:1303 +0xfa
|
||||
github.com/microsoft/typescript-go/internal/ls.(*LanguageService).getCompletionData.func18()
|
||||
/workspaces/typescript-go/internal/ls/completions.go:1548 +0x2df
|
||||
github.com/microsoft/typescript-go/internal/ls.(*LanguageService).getCompletionData(0xc004b08240, {0x14bc418, 0xc00bc60a20}, 0xc0069ef908, 0xc000272008, 0x1b, 0xc002b28e00)
|
||||
/workspaces/typescript-go/internal/ls/completions.go:1581 +0x2b92
|
||||
github.com/microsoft/typescript-go/internal/ls.(*LanguageService).getCompletionsAtPosition(0xc004b08240, {0x14bc418, 0xc00bc60a20}, 0xc000272008, 0x1b, 0x0)
|
||||
/workspaces/typescript-go/internal/ls/completions.go:347 +0x690
|
||||
github.com/microsoft/typescript-go/internal/ls.(*LanguageService).ProvideCompletion(0xc004b08240, {0x14bc418, 0xc00bc60a20}, {0xc0092e02a0, 0x28}, {0x2, 0x4}, 0xc004580c30)
|
||||
/workspaces/typescript-go/internal/ls/completions.go:47 +0x207
|
||||
github.com/microsoft/typescript-go/internal/lsp.(*Server).handleCompletion(0xc0001dae08, {0x14bc418, 0xc00bc60960}, 0xc004b08240, 0xc00baf14d0)
|
||||
/workspaces/typescript-go/internal/lsp/server.go:1102 +0xe5
|
||||
github.com/microsoft/typescript-go/internal/lsp.registerLanguageServiceWithAutoImportsRequestHandler[...].func1({0x14bc418, 0xc00bc60960}, 0xc00baf16e0)
|
||||
/workspaces/typescript-go/internal/lsp/server.go:682 +0x32a
|
||||
github.com/microsoft/typescript-go/internal/lsp.(*Server).handleRequestOrNotification(0xc0001dae08, {0x14bc418, 0xc00bc60960}, 0xc00baf16e0)
|
||||
/workspaces/typescript-go/internal/lsp/server.go:531 +0x11e
|
||||
github.com/microsoft/typescript-go/internal/lsp.(*Server).dispatchLoop.func1()
|
||||
/workspaces/typescript-go/internal/lsp/server.go:414 +0x65
|
||||
created by github.com/microsoft/typescript-go/internal/lsp.(*Server).dispatchLoop in goroutine 19
|
||||
/workspaces/typescript-go/internal/lsp/server.go:438 +0x60`
|
||||
|
||||
baseline.Run(t, "completionsDebugStackTrace.md", sanitizedStackTraceBaselineContents(t, input, sanitizeStackTrace(input)), baseline.Options{
|
||||
Subfolder: "lsp/stackSanitizer/",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSanitizedReleaseStackTraceCompletionsRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := `runtime error: invalid memory address or nil pointer dereference
|
||||
goroutine 2331 [running]:
|
||||
runtime/debug.Stack()
|
||||
runtime/debug/stack.go:26 +0x5e
|
||||
github.com/microsoft/typescript-go/internal/lsp.(*Server).recover(0xc0001c6e08, {0x441ae5?, 0xc000e976c0?}, 0xc00ab6c7b0)
|
||||
github.com/microsoft/typescript-go/internal/lsp/server.go:777 +0x58
|
||||
panic({0xc323a0?, 0x1780b90?})
|
||||
runtime/panic.go:783 +0x132
|
||||
github.com/microsoft/typescript-go/internal/ls.(*LanguageService).getCompletionData.func15()
|
||||
github.com/microsoft/typescript-go/internal/ls/completions.go:1303 +0xba
|
||||
github.com/microsoft/typescript-go/internal/ls.(*LanguageService).getCompletionData.func18(...)
|
||||
github.com/microsoft/typescript-go/internal/ls/completions.go:1548
|
||||
github.com/microsoft/typescript-go/internal/ls.(*LanguageService).getCompletionData(0xc008329200, {0x10f6688, 0xc00c2871d0}, 0xc00190b308, 0xc0001fe008, 0x1b, 0xc0008a2f00)
|
||||
github.com/microsoft/typescript-go/internal/ls/completions.go:1581 +0x1ed4
|
||||
github.com/microsoft/typescript-go/internal/ls.(*LanguageService).getCompletionsAtPosition(0xc008329200, {0x10f6688, 0xc00c2871d0}, 0xc0001fe008, 0x1b, 0x0)
|
||||
github.com/microsoft/typescript-go/internal/ls/completions.go:347 +0x35f
|
||||
github.com/microsoft/typescript-go/internal/ls.(*LanguageService).ProvideCompletion(0xc008329200, {0x10f6688, 0xc00c287110}, {0xc00b472030?, 0xc00c287110?}, {0xb472030?, 0xc0?}, 0xc00c3ea000)
|
||||
github.com/microsoft/typescript-go/internal/ls/completions.go:47 +0x11c
|
||||
github.com/microsoft/typescript-go/internal/lsp.(*Server).handleCompletion(0x418834?, {0x10f6688?, 0xc00c287110?}, 0xc00b472030?, 0x10f6688?)
|
||||
github.com/microsoft/typescript-go/internal/lsp/server.go:1105 +0x39
|
||||
github.com/microsoft/typescript-go/internal/lsp.init.func1.registerLanguageServiceWithAutoImportsRequestHandler[...].28({0x10f6688, 0xc00c287110}, 0xc00ab6c7b0)
|
||||
github.com/microsoft/typescript-go/internal/lsp/server.go:682 +0x16c
|
||||
github.com/microsoft/typescript-go/internal/lsp.(*Server).handleRequestOrNotification(0xc0001c6e08, {0x10f66c0?, 0xc006589180?}, 0xc00ab6c7b0)
|
||||
github.com/microsoft/typescript-go/internal/lsp/server.go:531 +0x1c6
|
||||
github.com/microsoft/typescript-go/internal/lsp.(*Server).dispatchLoop.func1()
|
||||
github.com/microsoft/typescript-go/internal/lsp/server.go:414 +0x3a
|
||||
created by github.com/microsoft/typescript-go/internal/lsp.(*Server).dispatchLoop in goroutine 35
|
||||
github.com/microsoft/typescript-go/internal/lsp/server.go:438 +0x9f1`
|
||||
|
||||
baseline.Run(t, "completionsReleaseStackTrace.md", sanitizedStackTraceBaselineContents(t, input, sanitizeStackTrace(input)), baseline.Options{
|
||||
Subfolder: "lsp/stackSanitizer/",
|
||||
})
|
||||
}
|
||||
|
||||
func sanitizedStackTraceBaselineContents(t *testing.T, input string, output string) string {
|
||||
builder := strings.Builder{}
|
||||
builder.WriteString("Test name: `")
|
||||
builder.WriteString(t.Name())
|
||||
builder.WriteString("`\n\n# Unsanitized input:\n\n````\n")
|
||||
builder.WriteString(input)
|
||||
builder.WriteString("\n````\n\n# Sanitized output:\n\n````\n")
|
||||
builder.WriteString(output)
|
||||
builder.WriteString("\n````\n")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// Mirror of the "Generic Secret" pattern from VS Code's
|
||||
// removePropertiesWithPossibleUserInfo. If this matches the sanitized output,
|
||||
// VS Code's telemetry pipeline will replace the entire string with
|
||||
// `<REDACTED: Generic Secret>`, destroying the stack trace.
|
||||
var vscodeGenericSecretRegex = regexp.MustCompile(`(?i)(key|token|sig|secret|signature|password|passwd|pwd|android:value)[^a-zA-Z0-9]`)
|
||||
|
||||
func TestSanitizedStackTraceDefeatsVSCodeGenericSecretRegex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Frame names contain identifiers that contain trigger keywords:
|
||||
// `getSignatureHelp` (signature), `LookupKey` (key), `validateToken` (token),
|
||||
// `signRequest` (sig), `setPwd` (pwd), and a file `signature.go`.
|
||||
input := `goroutine 7 [running]:
|
||||
runtime/debug.Stack()
|
||||
runtime/debug/stack.go:26 +0x5e
|
||||
github.com/microsoft/typescript-go/internal/ls.(*LanguageService).getSignatureHelp(0x1)
|
||||
github.com/microsoft/typescript-go/internal/ls/signature.go:42 +0x10
|
||||
github.com/microsoft/typescript-go/internal/ls.LookupKey(0x2)
|
||||
github.com/microsoft/typescript-go/internal/ls/keys.go:7 +0x10
|
||||
github.com/microsoft/typescript-go/internal/ls.validateToken(0x3)
|
||||
github.com/microsoft/typescript-go/internal/ls/token.go:9 +0x10
|
||||
github.com/microsoft/typescript-go/internal/ls.signRequest(0x4)
|
||||
github.com/microsoft/typescript-go/internal/ls/sig.go:11 +0x10
|
||||
github.com/microsoft/typescript-go/internal/ls.setPwd(0x5)
|
||||
github.com/microsoft/typescript-go/internal/ls/pwd.go:13 +0x10`
|
||||
|
||||
output := sanitizeStackTrace(input)
|
||||
if loc := vscodeGenericSecretRegex.FindStringIndex(output); loc != nil {
|
||||
t.Fatalf("sanitized stack trace would be redacted by VS Code's Generic Secret regex at %v: %q\nfull output:\n%s", loc, output[loc[0]:loc[1]], output)
|
||||
}
|
||||
|
||||
baseline.Run(t, "genericSecretWorkaround.md", sanitizedStackTraceBaselineContents(t, input, output), baseline.Options{
|
||||
Subfolder: "lsp/stackSanitizer/",
|
||||
})
|
||||
}
|
||||
14
tools/tsgo/internal/lsp/testmain_test.go
Normal file
14
tools/tsgo/internal/lsp/testmain_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/microsoft/typescript-go/internal/core"
|
||||
"github.com/microsoft/typescript-go/internal/testutil/baseline"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
core.ApplyDebugStackLimit()
|
||||
defer baseline.Track()()
|
||||
m.Run()
|
||||
}
|
||||
Reference in New Issue
Block a user