tools/gopls: add command line support for links

This adds support for calling links from the gopls command line,
e.g.

$ gopls links ~/tmp/foo/main.go

Optional arguments are:
-json, which emits range and uri in JSON
With no arguments, a unique list of links are emitted.

Updates golang/go#32875

Change-Id: I1e7cbf00a636c05ccf21bd544d9a5b7742d5d70b
GitHub-Last-Rev: 7ed1e4612186bce4077d3c73f2407cf6def211d9
GitHub-Pull-Request: golang/tools#181
Reviewed-on: https://go-review.googlesource.com/c/tools/+/203297
Reviewed-by: Rebecca Stambler <rstambler@golang.org>
Run-TryBot: Rebecca Stambler <rstambler@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
diff --git a/internal/lsp/cmd/cmd.go b/internal/lsp/cmd/cmd.go
index 519c196..0d00c38 100644
--- a/internal/lsp/cmd/cmd.go
+++ b/internal/lsp/cmd/cmd.go
@@ -143,6 +143,7 @@
 		&bug{},
 		&check{app: app},
 		&format{app: app},
+		&links{app: app},
 		&imports{app: app},
 		&query{app: app},
 		&references{app: app},
diff --git a/internal/lsp/cmd/links.go b/internal/lsp/cmd/links.go
new file mode 100644
index 0000000..a93ae8f
--- /dev/null
+++ b/internal/lsp/cmd/links.go
@@ -0,0 +1,77 @@
+// Copyright 2019 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"os"
+
+	"golang.org/x/tools/internal/lsp/protocol"
+	"golang.org/x/tools/internal/span"
+	"golang.org/x/tools/internal/tool"
+	errors "golang.org/x/xerrors"
+)
+
+// links implements the links verb for gopls.
+type links struct {
+	JSON bool `flag:"json" help:"emit document links in JSON format"`
+
+	app *Application
+}
+
+func (l *links) Name() string      { return "links" }
+func (l *links) Usage() string     { return "<filename>" }
+func (l *links) ShortHelp() string { return "list links in a file" }
+func (l *links) DetailedHelp(f *flag.FlagSet) {
+	fmt.Fprintf(f.Output(), `
+Example: list links contained within a file:
+
+  $ gopls links internal/lsp/cmd/check.go
+
+gopls links flags are:
+`)
+	f.PrintDefaults()
+}
+
+// Run finds all the links within a document
+// - if -json is specified, outputs location range and uri
+// - otherwise, prints the a list of unique links
+func (l *links) Run(ctx context.Context, args ...string) error {
+	if len(args) != 1 {
+		return tool.CommandLineErrorf("links expects 1 argument")
+	}
+	conn, err := l.app.connect(ctx)
+	if err != nil {
+		return err
+	}
+	defer conn.terminate(ctx)
+
+	from := span.Parse(args[0])
+	uri := from.URI()
+	file := conn.AddFile(ctx, uri)
+	if file.err != nil {
+		return file.err
+	}
+	results, err := conn.DocumentLink(ctx, &protocol.DocumentLinkParams{
+		TextDocument: protocol.TextDocumentIdentifier{
+			URI: protocol.NewURI(uri),
+		},
+	})
+	if err != nil {
+		return errors.Errorf("%v: %v", from, err)
+	}
+	if l.JSON {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "\t")
+		return enc.Encode(results)
+	}
+	for _, v := range results {
+		fmt.Println(v.Target)
+	}
+	return nil
+}
diff --git a/internal/lsp/cmd/test/cmdtest.go b/internal/lsp/cmd/test/cmdtest.go
index 2a6276a..adced2a 100644
--- a/internal/lsp/cmd/test/cmdtest.go
+++ b/internal/lsp/cmd/test/cmdtest.go
@@ -82,10 +82,6 @@
 	//TODO: add command line symbol tests when it works
 }
 
-func (r *runner) Link(t *testing.T, uri span.URI, wantLinks []tests.Link) {
-	//TODO: add command line link tests when it works
-}
-
 func (r *runner) Implementation(t *testing.T, spn span.Span, imp tests.Implementations) {
 	//TODO: add implements tests when it works
 }
diff --git a/internal/lsp/cmd/test/links.go b/internal/lsp/cmd/test/links.go
new file mode 100644
index 0000000..79a6799
--- /dev/null
+++ b/internal/lsp/cmd/test/links.go
@@ -0,0 +1,36 @@
+// Copyright 2019 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package cmdtest
+
+import (
+	"encoding/json"
+	"testing"
+
+	"golang.org/x/tools/internal/lsp/cmd"
+	"golang.org/x/tools/internal/lsp/protocol"
+	"golang.org/x/tools/internal/lsp/tests"
+	"golang.org/x/tools/internal/span"
+	"golang.org/x/tools/internal/tool"
+)
+
+func (r *runner) Link(t *testing.T, uri span.URI, wantLinks []tests.Link) {
+	m, err := r.data.Mapper(uri)
+	if err != nil {
+		t.Fatal(err)
+	}
+	args := []string{"links", "-json", uri.Filename()}
+	app := cmd.New("gopls-test", r.data.Config.Dir, r.data.Exported.Config.Env, r.options)
+	out := CaptureStdOut(t, func() {
+		_ = tool.Run(r.ctx, app, args)
+	})
+	var got []protocol.DocumentLink
+	err = json.Unmarshal([]byte(out), &got)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if diff := tests.DiffLinks(m, wantLinks, got); diff != "" {
+		t.Error(diff)
+	}
+}
diff --git a/internal/lsp/lsp_test.go b/internal/lsp/lsp_test.go
index 0a8fc0f..7e3a555 100644
--- a/internal/lsp/lsp_test.go
+++ b/internal/lsp/lsp_test.go
@@ -797,7 +797,7 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	gotLinks, err := r.server.DocumentLink(r.ctx, &protocol.DocumentLinkParams{
+	got, err := r.server.DocumentLink(r.ctx, &protocol.DocumentLinkParams{
 		TextDocument: protocol.TextDocumentIdentifier{
 			URI: protocol.NewURI(uri),
 		},
@@ -805,41 +805,8 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	var notePositions []token.Position
-	links := make(map[span.Span]string, len(wantLinks))
-	for _, link := range wantLinks {
-		links[link.Src] = link.Target
-		notePositions = append(notePositions, link.NotePosition)
-	}
-
-	for _, link := range gotLinks {
-		spn, err := m.RangeSpan(link.Range)
-		if err != nil {
-			t.Fatal(err)
-		}
-		linkInNote := false
-		for _, notePosition := range notePositions {
-			// Drop the links found inside expectation notes arguments as this links are not collected by expect package
-			if notePosition.Line == spn.Start().Line() &&
-				notePosition.Column <= spn.Start().Column() {
-				delete(links, spn)
-				linkInNote = true
-			}
-		}
-		if linkInNote {
-			continue
-		}
-		if target, ok := links[spn]; ok {
-			delete(links, spn)
-			if target != link.Target {
-				t.Errorf("for %v want %v, got %v\n", spn, link.Target, target)
-			}
-		} else {
-			t.Errorf("unexpected link %v:%v\n", spn, link.Target)
-		}
-	}
-	for spn, target := range links {
-		t.Errorf("missing link %v:%v\n", spn, target)
+	if diff := tests.DiffLinks(m, wantLinks, got); diff != "" {
+		t.Error(diff)
 	}
 }
 
diff --git a/internal/lsp/tests/links.go b/internal/lsp/tests/links.go
new file mode 100644
index 0000000..07fc3ef
--- /dev/null
+++ b/internal/lsp/tests/links.go
@@ -0,0 +1,55 @@
+// Copyright 2019 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package tests
+
+import (
+	"fmt"
+	"go/token"
+
+	"golang.org/x/tools/internal/lsp/protocol"
+	"golang.org/x/tools/internal/span"
+)
+
+// DiffLinks takes the links we got and checks if they are located within the source or a Note.
+// If the link is within a Note, the link is removed.
+// Returns an diff comment if there are differences and empty string if no diffs
+func DiffLinks(mapper *protocol.ColumnMapper, wantLinks []Link, gotLinks []protocol.DocumentLink) string {
+	var notePositions []token.Position
+	links := make(map[span.Span]string, len(wantLinks))
+	for _, link := range wantLinks {
+		links[link.Src] = link.Target
+		notePositions = append(notePositions, link.NotePosition)
+	}
+	for _, link := range gotLinks {
+		spn, err := mapper.RangeSpan(link.Range)
+		if err != nil {
+			return fmt.Sprintf("%v", err)
+		}
+		linkInNote := false
+		for _, notePosition := range notePositions {
+			// Drop the links found inside expectation notes arguments as this links are not collected by expect package
+			if notePosition.Line == spn.Start().Line() &&
+				notePosition.Column <= spn.Start().Column() {
+				delete(links, spn)
+				linkInNote = true
+			}
+		}
+		if linkInNote {
+			continue
+		}
+		if target, ok := links[spn]; ok {
+			delete(links, spn)
+			if target != link.Target {
+				return fmt.Sprintf("for %v want %v, got %v\n", spn, link.Target, target)
+			}
+		} else {
+			return fmt.Sprintf("unexpected link %v:%v\n", spn, link.Target)
+		}
+	}
+	for spn, target := range links {
+		return fmt.Sprintf("missing link %v:%v\n", spn, target)
+	}
+	return ""
+}