Browse Source

pkg/lsp: Support textDocument/completion.

Qi Xiao 2 years ago
parent
commit
92c7e4b303
4 changed files with 175 additions and 46 deletions
  1. 4 1
      pkg/edit/completion.go
  2. 2 2
      pkg/lsp/lsp.go
  3. 54 5
      pkg/lsp/lsp_test.go
  4. 115 38
      pkg/lsp/server.go

+ 4 - 1
pkg/edit/completion.go

@@ -235,7 +235,7 @@ func initCompletion(ed *Editor, ev *eval.Evaler, nb eval.NsBuilder) {
 	argGeneratorMapVar := newMapVar(vals.EmptyMap)
 	cfg := func() complete.Config {
 		return complete.Config{
-			PureEvaler: pureEvaler{ev},
+			PureEvaler: PureEvaler(ev),
 			Filterer: adaptMatcherMap(
 				ed, ev, matcherMapVar.Get().(vals.Map)),
 			ArgGenerator: adaptArgGeneratorMap(
@@ -543,6 +543,9 @@ func lookupFn(m vals.Map, ctxName string) (eval.Callable, bool) {
 
 type pureEvaler struct{ ev *eval.Evaler }
 
+// PureEvaler returns a complete.PureEvaler from an eval.Evaler.
+func PureEvaler(ev *eval.Evaler) complete.PureEvaler { return pureEvaler{ev} }
+
 func (pureEvaler) EachExternal(f func(string)) { fsutil.EachExternal(f) }
 
 func (pureEvaler) EachSpecial(f func(string)) {

+ 2 - 2
pkg/lsp/lsp.go

@@ -24,10 +24,10 @@ func (p *Program) Run(fds [3]*os.File, _ []string) error {
 	}
 	ctx, cancel := context.WithCancel(context.Background())
 	defer cancel()
-	s := server{}
+	s := newServer()
 	conn := jsonrpc2.NewConn(ctx,
 		jsonrpc2.NewBufferedStream(transport{fds[0], fds[1]}, jsonrpc2.VSCodeObjectCodec{}),
-		s.handler())
+		handler(s))
 	<-conn.DisconnectNotify()
 	return nil
 }

+ 54 - 5
pkg/lsp/lsp_test.go

@@ -16,6 +16,8 @@ import (
 	"src.elv.sh/pkg/testutil"
 )
 
+var bgCtx = context.Background()
+
 var diagTests = []struct {
 	name      string
 	text      string
@@ -69,8 +71,7 @@ func TestDidOpenDiagnostics(t *testing.T) {
 	f := setup(t)
 	for _, test := range diagTests {
 		t.Run(test.name, func(t *testing.T) {
-			f.conn.Notify(context.Background(),
-				"textDocument/didOpen", didOpenParams(test.text))
+			f.conn.Notify(bgCtx, "textDocument/didOpen", didOpenParams(test.text))
 			checkDiag(t, f, diagParam(test.wantDiags))
 		})
 	}
@@ -78,18 +79,56 @@ func TestDidOpenDiagnostics(t *testing.T) {
 
 func TestDidChangeDiagnostics(t *testing.T) {
 	f := setup(t)
-	f.conn.Notify(context.Background(), "textDocument/didOpen", didOpenParams(""))
+	f.conn.Notify(bgCtx, "textDocument/didOpen", didOpenParams(""))
 	checkDiag(t, f, diagParam([]lsp.Diagnostic{}))
 
 	for _, test := range diagTests {
 		t.Run(test.name, func(t *testing.T) {
-			f.conn.Notify(context.Background(),
-				"textDocument/didChange", didChangeParams(test.text))
+			f.conn.Notify(bgCtx, "textDocument/didChange", didChangeParams(test.text))
 			checkDiag(t, f, diagParam(test.wantDiags))
 		})
 	}
 }
 
+var completionTests = []struct {
+	name     string
+	text     string
+	params   lsp.CompletionParams
+	wantKind lsp.CompletionItemKind
+}{
+	{"command", "", completionParams(0, 0), lsp.CIKFunction},
+	{"variable", "put $", completionParams(0, 5), lsp.CIKVariable},
+	{"bad", "put [", completionParams(0, 5), 0},
+}
+
+func TestCompletion(t *testing.T) {
+	f := setup(t)
+	testutil.Setenv(t, "PATH", "")
+
+	for _, test := range completionTests {
+		t.Run(test.name, func(t *testing.T) {
+			var items []lsp.CompletionItem
+			f.conn.Notify(bgCtx, "textDocument/didOpen", didOpenParams(test.text))
+			err := f.conn.Call(bgCtx, "textDocument/completion", test.params, &items)
+			if err != nil {
+				t.Errorf("got error %v", err)
+			}
+			if test.wantKind == 0 {
+				if len(items) > 0 {
+					t.Errorf("got %v items, want 0", len(items))
+				}
+			} else {
+				if len(items) == 0 {
+					t.Fatalf("got 0 items, want non-zero")
+				}
+				if items[0].Kind != test.wantKind {
+					t.Errorf("got kind %v, want %v", items[0].Kind, test.wantKind)
+				}
+			}
+		})
+	}
+}
+
 var jsonrpcErrorTests = []struct {
 	method  string
 	params  interface{}
@@ -98,6 +137,7 @@ var jsonrpcErrorTests = []struct {
 	{"unknown/method", struct{}{}, errMethodNotFound},
 	{"textDocument/didOpen", []int{}, errInvalidParams},
 	{"textDocument/didChange", []int{}, errInvalidParams},
+	{"textDocument/completion", []int{}, errInvalidParams},
 }
 
 func TestJSONRPCErrors(t *testing.T) {
@@ -152,6 +192,15 @@ func checkDiag(t *testing.T, f *clientFixture, want lsp.PublishDiagnosticsParams
 	}
 }
 
+func completionParams(line, char int) lsp.CompletionParams {
+	return lsp.CompletionParams{
+		TextDocumentPositionParams: lsp.TextDocumentPositionParams{
+			TextDocument: lsp.TextDocumentIdentifier{URI: testURI},
+			Position:     lsp.Position{Line: line, Character: char},
+		},
+	}
+}
+
 type clientFixture struct {
 	conn  *jsonrpc2.Conn
 	diags <-chan lsp.PublishDiagnosticsParams

+ 115 - 38
pkg/lsp/server.go

@@ -7,6 +7,9 @@ import (
 	lsp "github.com/sourcegraph/go-lsp"
 	"github.com/sourcegraph/jsonrpc2"
 	"src.elv.sh/pkg/diag"
+	"src.elv.sh/pkg/edit"
+	"src.elv.sh/pkg/edit/complete"
+	"src.elv.sh/pkg/eval"
 	"src.elv.sh/pkg/parse"
 )
 
@@ -17,30 +20,21 @@ var (
 		Code: jsonrpc2.CodeInvalidParams, Message: "invalid params"}
 )
 
-func routingHandler(methods map[string]method) jsonrpc2.Handler {
-	return jsonrpc2.HandlerWithError(func(ctx context.Context, conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) {
-		fn, ok := methods[req.Method]
-		if !ok {
-			return nil, errMethodNotFound
-		}
-		return fn(ctx, conn, *req.Params)
-	})
-}
-
-type method func(context.Context, jsonrpc2.JSONRPC2, json.RawMessage) (interface{}, error)
-
-func noop(_ context.Context, _ jsonrpc2.JSONRPC2, _ json.RawMessage) (interface{}, error) {
-	return nil, nil
+type server struct {
+	evaler  complete.PureEvaler
+	content map[lsp.DocumentURI]string
 }
 
-type server struct {
+func newServer() *server {
+	return &server{edit.PureEvaler(eval.NewEvaler()), make(map[lsp.DocumentURI]string)}
 }
 
-func (s *server) handler() jsonrpc2.Handler {
+func handler(s *server) jsonrpc2.Handler {
 	return routingHandler(map[string]method{
-		"initialize":             s.initialize,
-		"textDocument/didOpen":   s.didOpen,
-		"textDocument/didChange": s.didChange,
+		"initialize":              s.initialize,
+		"textDocument/didOpen":    s.didOpen,
+		"textDocument/didChange":  s.didChange,
+		"textDocument/completion": s.completion,
 
 		"textDocument/didClose": noop,
 		// Required by spec.
@@ -51,6 +45,24 @@ func (s *server) handler() jsonrpc2.Handler {
 	})
 }
 
+type method func(context.Context, jsonrpc2.JSONRPC2, json.RawMessage) (interface{}, error)
+
+func noop(_ context.Context, _ jsonrpc2.JSONRPC2, _ json.RawMessage) (interface{}, error) {
+	return nil, nil
+}
+
+func routingHandler(methods map[string]method) jsonrpc2.Handler {
+	return jsonrpc2.HandlerWithError(func(ctx context.Context, conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) {
+		fn, ok := methods[req.Method]
+		if !ok {
+			return nil, errMethodNotFound
+		}
+		return fn(ctx, conn, *req.Params)
+	})
+}
+
+// Handler implementations. These are all called synchronously.
+
 func (s *server) initialize(_ context.Context, _ jsonrpc2.JSONRPC2, _ json.RawMessage) (interface{}, error) {
 	return &lsp.InitializeResult{
 		Capabilities: lsp.ServerCapabilities{
@@ -71,7 +83,8 @@ func (s *server) didOpen(ctx context.Context, conn jsonrpc2.JSONRPC2, rawParams
 	}
 
 	uri, content := params.TextDocument.URI, params.TextDocument.Text
-	go update(ctx, conn, uri, content)
+	s.content[uri] = content
+	go publishDiagnostics(ctx, conn, uri, content)
 	return nil, nil
 }
 
@@ -81,18 +94,63 @@ func (s *server) didChange(ctx context.Context, conn jsonrpc2.JSONRPC2, rawParam
 		return nil, errInvalidParams
 	}
 
+	// ContentChanges includes full text since the server is only advertised to
+	// support that; see the initialize method.
 	uri, content := params.TextDocument.URI, params.ContentChanges[0].Text
-	go update(ctx, conn, uri, content)
+	s.content[uri] = content
+	go publishDiagnostics(ctx, conn, uri, content)
 	return nil, nil
 }
 
-func update(ctx context.Context, conn jsonrpc2.JSONRPC2, uri lsp.DocumentURI, content string) {
+func (s *server) completion(ctx context.Context, conn jsonrpc2.JSONRPC2, rawParams json.RawMessage) (interface{}, error) {
+	var params lsp.CompletionParams
+	if json.Unmarshal(rawParams, &params) != nil {
+		return nil, errInvalidParams
+	}
+
+	content := s.content[params.TextDocument.URI]
+	result, err := complete.Complete(
+		complete.CodeBuffer{
+			Content: content,
+			Dot:     lspPositionToIdx(content, params.Position)},
+		complete.Config{PureEvaler: s.evaler},
+	)
+
+	if err != nil {
+		return []lsp.CompletionItem{}, nil
+	}
+
+	lspItems := make([]lsp.CompletionItem, len(result.Items))
+	lspRange := lspRangeFromRange(content, result.Replace)
+	var kind lsp.CompletionItemKind
+	switch result.Name {
+	case "command":
+		kind = lsp.CIKFunction
+	case "variable":
+		kind = lsp.CIKVariable
+	default:
+		// TODO: Support more values of kind
+	}
+	for i, item := range result.Items {
+		lspItems[i] = lsp.CompletionItem{
+			Label: item.ToInsert,
+			Kind:  kind,
+			TextEdit: &lsp.TextEdit{
+				Range:   lspRange,
+				NewText: item.ToInsert,
+			},
+		}
+	}
+	return lspItems, nil
+}
+
+func publishDiagnostics(ctx context.Context, conn jsonrpc2.JSONRPC2, uri lsp.DocumentURI, content string) {
 	conn.Notify(ctx, "textDocument/publishDiagnostics",
 		lsp.PublishDiagnosticsParams{URI: uri, Diagnostics: diagnostics(uri, content)})
 }
 
-func diagnostics(fileURI lsp.DocumentURI, content string) []lsp.Diagnostic {
-	_, err := parse.Parse(parse.Source{Name: string(fileURI), Code: content}, parse.Config{})
+func diagnostics(uri lsp.DocumentURI, content string) []lsp.Diagnostic {
+	_, err := parse.Parse(parse.Source{Name: string(uri), Code: content}, parse.Config{})
 	if err == nil {
 		return []lsp.Diagnostic{}
 	}
@@ -101,7 +159,7 @@ func diagnostics(fileURI lsp.DocumentURI, content string) []lsp.Diagnostic {
 	diags := make([]lsp.Diagnostic, len(entries))
 	for i, err := range entries {
 		diags[i] = lsp.Diagnostic{
-			Range:    rangeToLSP(content, err),
+			Range:    lspRangeFromRange(content, err),
 			Severity: lsp.Error,
 			Source:   "parse",
 			Message:  err.Message,
@@ -110,41 +168,60 @@ func diagnostics(fileURI lsp.DocumentURI, content string) []lsp.Diagnostic {
 	return diags
 }
 
-func rangeToLSP(s string, r diag.Ranger) lsp.Range {
+func lspRangeFromRange(s string, r diag.Ranger) lsp.Range {
 	rg := r.Range()
 	return lsp.Range{
-		Start: position(s, rg.From),
-		End:   position(s, rg.To),
+		Start: lspPositionFromIdx(s, rg.From),
+		End:   lspPositionFromIdx(s, rg.To),
 	}
 }
 
-func position(s string, idx int) lsp.Position {
+func lspPositionToIdx(s string, pos lsp.Position) int {
+	var idx int
+	walkString(s, func(i int, p lsp.Position) bool {
+		idx = i
+		return p.Line < pos.Line || (p.Line == pos.Line && p.Character < pos.Character)
+	})
+	return idx
+}
+
+func lspPositionFromIdx(s string, idx int) lsp.Position {
 	var pos lsp.Position
+	walkString(s, func(i int, p lsp.Position) bool {
+		pos = p
+		return i < idx
+	})
+	return pos
+}
+
+// Generates (index, lspPosition) pairs in s, stopping if f returns false.
+func walkString(s string, f func(i int, p lsp.Position) bool) {
+	var p lsp.Position
 	lastCR := false
 
 	for i, r := range s {
-		if i == idx {
-			return pos
+		if !f(i, p) {
+			return
 		}
 		switch {
 		case r == '\r':
-			pos.Line++
-			pos.Character = 0
+			p.Line++
+			p.Character = 0
 		case r == '\n':
 			if lastCR {
 				// Ignore \n if it's part of a \r\n sequence
 			} else {
-				pos.Line++
-				pos.Character = 0
+				p.Line++
+				p.Character = 0
 			}
 		case r <= 0xFFFF:
 			// Encoded in UTF-16 with one unit
-			pos.Character++
+			p.Character++
 		default:
 			// Encoded in UTF-16 with two units
-			pos.Character += 2
+			p.Character += 2
 		}
 		lastCR = r == '\r'
 	}
-	return pos
+	f(len(s), p)
 }