[lsp] Document links for FIDL library imports

This adds the "textDocument/documentLink" capability to the language
server. It linkifies platform imports into links to their SDK docs.
This CL also adds the `lsp` package, essentially a shim around go-lsp
that adds some missing LSP types.

Test: go test ./...
Change-Id: Id44a727b57c70d05663b5dfb5d263cb179f53556
Reviewed-on: https://fuchsia-review.googlesource.com/c/fidl-misc/+/395575
Reviewed-by: Benjamin Prosnitz <bprosnitz@google.com>
Reviewed-by: Pascal Perez <pascallouis@google.com>
diff --git a/fidl-lsp/analysis/library.go b/fidl-lsp/analysis/library.go
index dd74fa2..fbde7ef 100644
--- a/fidl-lsp/analysis/library.go
+++ b/fidl-lsp/analysis/library.go
@@ -4,14 +4,12 @@
 
 package analysis
 
-import fidlcommon "fidl-lsp/third_party/common"
-
 // Map from fully qualified name to type.kind, e.g.
 // "fuchsia.hardware.camera/Device": "interface"
 type decls map[string]string
 
 type dependency struct {
-	Name         fidlcommon.LibraryName
+	Name         string
 	Declarations decls
 }
 
diff --git a/fidl-lsp/langserver/handler.go b/fidl-lsp/langserver/handler.go
index 62bda4e..8ba6a13 100644
--- a/fidl-lsp/langserver/handler.go
+++ b/fidl-lsp/langserver/handler.go
@@ -11,6 +11,7 @@
 	"log"
 	"os"
 
+	fidlcommon "fidl-lsp/third_party/common"
 	"github.com/sourcegraph/go-lsp"
 	"github.com/sourcegraph/jsonrpc2"
 
@@ -35,7 +36,7 @@
 // LangHandler also has an *analysis.Analyzer,
 // which is used to compile and extract insights from files being edited.
 type LangHandler struct {
-	cfg      lsp.ServerCapabilities
+	cfg      Config
 	init     *lsp.InitializeParams
 	shutdown bool
 
@@ -49,22 +50,35 @@
 // Assert that LangHandler implements the jsonrpc2.Handler interface
 var _ jsonrpc2.Handler = (*LangHandler)(nil)
 
+type Config struct {
+	serverCapabilities serverCapabilities
+	baseRefURI         string
+}
+
+func (c *Config) FormatRefURI(libraryName fidlcommon.LibraryName) string {
+	return c.baseRefURI + libraryName.FullyQualifiedName()
+}
+
 // NewDefaultConfig returns a default configuration for the language server.
-func NewDefaultConfig() lsp.ServerCapabilities {
-	return lsp.ServerCapabilities{
-		DocumentFormattingProvider: true,
-		TextDocumentSync: &lsp.TextDocumentSyncOptionsOrKind{
-			Options: &lsp.TextDocumentSyncOptions{
-				OpenClose: true,
-				Change:    lsp.TDSKIncremental,
+func NewDefaultConfig() Config {
+	return Config{
+		serverCapabilities: serverCapabilities{
+			DocumentFormattingProvider: true,
+			DocumentLinkProvider:       &documentLinkOptions{},
+			TextDocumentSync: &lsp.TextDocumentSyncOptionsOrKind{
+				Options: &lsp.TextDocumentSyncOptions{
+					OpenClose: true,
+					Change:    lsp.TDSKIncremental,
+				},
 			},
 		},
+		baseRefURI: "https://fuchsia.dev/reference/fidl/",
 	}
 }
 
 // NewLangHandler returns a LangHandler with the given configuration, logger,
 // and set of CompiledLibraries.
-func NewLangHandler(cfg lsp.ServerCapabilities, trace *log.Logger, analyzer *analysis.Analyzer) *LangHandler {
+func NewLangHandler(cfg Config, trace *log.Logger, analyzer *analysis.Analyzer) *LangHandler {
 	return &LangHandler{
 		log:      trace,
 		cfg:      cfg,
@@ -143,7 +157,7 @@
 			return nil, err
 		}
 		h.init = &params
-		return lsp.InitializeResult{Capabilities: h.cfg}, nil
+		return initializeResult{Capabilities: h.cfg.serverCapabilities}, nil
 
 	case "initialized":
 		// A notification that the client is ready to receive requests. Ignore
@@ -209,6 +223,17 @@
 		h.publishDiagnostics(ctx, conn, params.TextDocument.URI)
 		return nil, nil
 
+	case "textDocument/documentLink":
+		if req.Params == nil {
+			return nil, &jsonrpc2.Error{Code: jsonrpc2.CodeInvalidParams}
+		}
+		var params documentLinkParams
+		if err := json.Unmarshal(*req.Params, &params); err != nil {
+			return nil, err
+		}
+
+		return h.handleDocumentLinks(params)
+
 	case "textDocument/formatting":
 		if req.Params == nil {
 			return nil, &jsonrpc2.Error{Code: jsonrpc2.CodeInvalidParams}
diff --git a/fidl-lsp/langserver/links.go b/fidl-lsp/langserver/links.go
new file mode 100644
index 0000000..f189383
--- /dev/null
+++ b/fidl-lsp/langserver/links.go
@@ -0,0 +1,42 @@
+// Copyright 2020 The Fuchsia 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 langserver
+
+import (
+	"fmt"
+
+	"github.com/sourcegraph/go-lsp"
+
+	"fidl-lsp/state"
+)
+
+func (h *LangHandler) handleDocumentLinks(params documentLinkParams) ([]documentLink, error) {
+	file, err := h.fs.File(state.FileID(params.TextDocument.URI))
+	if err != nil {
+		return []documentLink{}, fmt.Errorf("could not find file `%s`", params.TextDocument.URI)
+	}
+	imports := state.ParsePlatformImportsMatch(file)
+	links := make([]documentLink, 0)
+	for _, m := range imports {
+		links = append(links, documentLink{
+			Range: lsp.Range{
+				Start: lsp.Position{
+					Line:      m.Range.Start.Line,
+					Character: m.Range.Start.Character,
+				},
+				End: lsp.Position{
+					Line:      m.Range.End.Line,
+					Character: m.Range.End.Character,
+				},
+			},
+			Target:  lsp.DocumentURI(h.cfg.FormatRefURI(m.Lib)),
+			Tooltip: fmt.Sprintf("Fuchsia SDK Docs for library %s", m.Lib.FullyQualifiedName()),
+		})
+	}
+
+	return links, nil
+}
+
+// TODO: fxr/ --> fxrev.dev/, fxb/ --> fxbug.dev/?
diff --git a/fidl-lsp/langserver/links_test.go b/fidl-lsp/langserver/links_test.go
new file mode 100644
index 0000000..65eccf6
--- /dev/null
+++ b/fidl-lsp/langserver/links_test.go
@@ -0,0 +1,70 @@
+// Copyright 2020 The Fuchsia 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 langserver
+
+import (
+	"testing"
+
+	"github.com/sourcegraph/go-lsp"
+
+	"fidl-lsp/state"
+)
+
+func TestLinks(t *testing.T) {
+	fileText := `
+library test;
+
+using fuchsia.io;
+using non.platform.import;
+`
+
+	handler := NewHandlerWithFiles(TestFile{
+		Name: "test.fidl",
+		Text: fileText,
+	})
+
+	links, err := handler.handleDocumentLinks(documentLinkParams{
+		TextDocument: lsp.TextDocumentIdentifier{
+			URI: lsp.DocumentURI("test.fidl"),
+		},
+	})
+	if err != nil {
+		t.Fatalf("failed to get document links: %s", err)
+	}
+	expLinks := map[string]lsp.DocumentURI{
+		"fuchsia.io": lsp.DocumentURI("https://fuchsia.dev/reference/fidl/fuchsia.io"),
+	}
+	if len(links) != len(expLinks) {
+		t.Fatalf("incorrect number of links; expected 1, actual %d", len(links))
+	}
+	for expSpan, expLink := range expLinks {
+		found := false
+		for _, link := range links {
+			start, err := state.OffsetInFile(
+				fileText,
+				state.Position{Line: link.Range.Start.Line, Character: link.Range.Start.Character},
+			)
+			if err != nil {
+				t.Fatalf("could not get offset of link in file: %s", err)
+			}
+			end, err := state.OffsetInFile(
+				fileText,
+				state.Position{Line: link.Range.End.Line, Character: link.Range.End.Character},
+			)
+			if err != nil {
+				t.Fatalf("could not get offset of link in file: %s", err)
+			}
+			span := fileText[start:end]
+			if span == expSpan && link.Target == expLink {
+				found = true
+				break
+			}
+		}
+		// Check if it's in links
+		if !found {
+			t.Fatalf("missing expected link `%s` on span `%s`", expLink, expSpan)
+		}
+	}
+}
diff --git a/fidl-lsp/langserver/lsp.go b/fidl-lsp/langserver/lsp.go
new file mode 100644
index 0000000..07e0f55
--- /dev/null
+++ b/fidl-lsp/langserver/lsp.go
@@ -0,0 +1,67 @@
+// Copyright 2020 The Fuchsia 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 langserver
+
+import "github.com/sourcegraph/go-lsp"
+
+// `go-lsp` does not include all the necessary types for an LSP server. For
+// example, the types associated with the "textDocument/documentLink" language
+// feature are missing. This file includes those missing definitions.
+
+type serverCapabilities struct {
+	TextDocumentSync                 *lsp.TextDocumentSyncOptionsOrKind   `json:"textDocumentSync,omitempty"`
+	HoverProvider                    bool                                 `json:"hoverProvider,omitempty"`
+	CompletionProvider               *lsp.CompletionOptions               `json:"completionProvider,omitempty"`
+	SignatureHelpProvider            *lsp.SignatureHelpOptions            `json:"signatureHelpProvider,omitempty"`
+	DefinitionProvider               bool                                 `json:"definitionProvider,omitempty"`
+	TypeDefinitionProvider           bool                                 `json:"typeDefinitionProvider,omitempty"`
+	ReferencesProvider               bool                                 `json:"referencesProvider,omitempty"`
+	DocumentHighlightProvider        bool                                 `json:"documentHighlightProvider,omitempty"`
+	DocumentSymbolProvider           bool                                 `json:"documentSymbolProvider,omitempty"`
+	WorkspaceSymbolProvider          bool                                 `json:"workspaceSymbolProvider,omitempty"`
+	ImplementationProvider           bool                                 `json:"implementationProvider,omitempty"`
+	CodeActionProvider               bool                                 `json:"codeActionProvider,omitempty"`
+	CodeLensProvider                 *lsp.CodeLensOptions                 `json:"codeLensProvider,omitempty"`
+	DocumentFormattingProvider       bool                                 `json:"documentFormattingProvider,omitempty"`
+	DocumentRangeFormattingProvider  bool                                 `json:"documentRangeFormattingProvider,omitempty"`
+	DocumentOnTypeFormattingProvider *lsp.DocumentOnTypeFormattingOptions `json:"documentOnTypeFormattingProvider,omitempty"`
+	DocumentLinkProvider             *documentLinkOptions                 `json:"documentLinkProvider,omitempty"`
+	RenameProvider                   bool                                 `json:"renameProvider,omitempty"`
+	ExecuteCommandProvider           *lsp.ExecuteCommandOptions           `json:"executeCommandProvider,omitempty"`
+	SemanticHighlighting             *lsp.SemanticHighlightingOptions     `json:"semanticHighlighting,omitempty"`
+
+	// XWorkspaceReferencesProvider indicates the server provides support for
+	// xworkspace/references. This is a Sourcegraph extension.
+	XWorkspaceReferencesProvider bool `json:"xworkspaceReferencesProvider,omitempty"`
+
+	// XDefinitionProvider indicates the server provides support for
+	// textDocument/xdefinition. This is a Sourcegraph extension.
+	XDefinitionProvider bool `json:"xdefinitionProvider,omitempty"`
+
+	// XWorkspaceSymbolByProperties indicates the server provides support for
+	// querying symbols by properties with WorkspaceSymbolParams.symbol. This
+	// is a Sourcegraph extension.
+	XWorkspaceSymbolByProperties bool `json:"xworkspaceSymbolByProperties,omitempty"`
+
+	Experimental interface{} `json:"experimental,omitempty"`
+}
+
+type initializeResult struct {
+	Capabilities serverCapabilities `json:"capabilities,omitempty"`
+}
+
+type documentLinkOptions struct {
+	ResolveProvider bool `json:"resolveProvider,omitempty"`
+}
+
+type documentLinkParams struct {
+	TextDocument lsp.TextDocumentIdentifier `json:"textDocument"`
+}
+
+type documentLink struct {
+	Range   lsp.Range       `json:"range"`
+	Target  lsp.DocumentURI `json:"target"`
+	Tooltip string          `json:"tooltip"`
+}
diff --git a/fidl-lsp/state/fs.go b/fidl-lsp/state/fs.go
index 47b6db0..b018490 100644
--- a/fidl-lsp/state/fs.go
+++ b/fidl-lsp/state/fs.go
@@ -86,11 +86,11 @@
 // ApplyChanges applies the specified changes to the relevant in-memory file.
 func (fs *FileSystem) ApplyChanges(path FileID, changes []Change) error {
 	for _, change := range changes {
-		start, err := offsetInFile(fs.files[path], change.Range.Start)
+		start, err := OffsetInFile(fs.files[path], change.Range.Start)
 		if err != nil {
 			return err
 		}
-		end, err := offsetInFile(fs.files[path], change.Range.End)
+		end, err := OffsetInFile(fs.files[path], change.Range.End)
 		if err != nil {
 			return err
 		}
@@ -105,7 +105,9 @@
 	return nil
 }
 
-func offsetInFile(contents string, pos Position) (uint, error) {
+// OffsetInFile converts a Position (line, character) to a rune offset in
+// `contents`.
+func OffsetInFile(contents string, pos Position) (uint, error) {
 	var (
 		line, col int
 		offset    uint