vendor tsgo

This commit is contained in:
2026-07-09 16:50:43 -04:00
parent c06ea2e5a4
commit 98978e4930
5804 changed files with 1556156 additions and 101 deletions

View 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()
}
}

View 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)
}
}

View 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...))
}

View File

@@ -0,0 +1,2 @@
metaModel.json
metaModel.schema.json

View 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);

File diff suppressed because it is too large Load Diff

View 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[];
};

View 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"
]
}

View 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),
}
}

View 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")
}

View 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,
}
}

View 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, &params); 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"
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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{".", ",", ";"}),
})
}

View 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
}

View 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 ""
}

View 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
}

View 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")
}

View 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
}

View 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()
})
})
}

View 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"
}

File diff suppressed because it is too large Load Diff

View 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")
}

View 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 ""
}

View 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, "")
}

View 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)
}

View 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)
}
}

View 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")
}
}

View 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)
}
}

View 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/",
})
}

View 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()
}