internal/lsp: add links search in comments and string literals

Add to "textDocument/documentLink" request handler ability to search
URLs in string literals and comments.

Fixes golang/go#32339

Change-Id: Ic67ad7bd94feba0bb67ab090a8903e30b2dff996
Reviewed-on: https://go-review.googlesource.com/c/tools/+/185219
Run-TryBot: Rebecca Stambler <rstambler@golang.org>
Reviewed-by: Rebecca Stambler <rstambler@golang.org>
diff --git a/internal/lsp/link.go b/internal/lsp/link.go
index 90fd2ba..734d3eb 100644
--- a/internal/lsp/link.go
+++ b/internal/lsp/link.go
@@ -7,9 +7,14 @@
 import (
 	"context"
 	"fmt"
+	"go/ast"
+	"go/token"
+	"regexp"
 	"strconv"
+	"sync"
 
 	"golang.org/x/tools/internal/lsp/protocol"
+	"golang.org/x/tools/internal/lsp/source"
 	"golang.org/x/tools/internal/span"
 )
 
@@ -24,26 +29,99 @@
 	if file == nil {
 		return nil, fmt.Errorf("no AST for %v", uri)
 	}
-	// Add a Godoc link for each imported package.
-	var result []protocol.DocumentLink
-	for _, imp := range file.Imports {
-		spn, err := span.NewRange(view.Session().Cache().FileSet(), imp.Pos(), imp.End()).Span()
-		if err != nil {
-			return nil, err
+
+	var links []protocol.DocumentLink
+
+	ast.Inspect(file, func(node ast.Node) bool {
+		switch n := node.(type) {
+		case *ast.ImportSpec:
+			target, err := strconv.Unquote(n.Path.Value)
+			if err != nil {
+				view.Session().Logger().Errorf(ctx, "cannot unquote import path %s: %v", n.Path.Value, err)
+				return false
+			}
+			target = "https://godoc.org/" + target
+			l, err := toProtocolLink(view, m, target, n.Pos(), n.End())
+			view.Session().Logger().Errorf(ctx, "cannot initialize DocumentLink %s: %v", n.Path.Value, err)
+			links = append(links, l)
+			return false
+		case *ast.BasicLit:
+			if n.Kind != token.STRING {
+				return false
+			}
+			l, err := findLinksInString(n.Value, n.Pos(), view, m)
+			if err != nil {
+				view.Session().Logger().Errorf(ctx, "cannot find links in string: %v", err)
+				return false
+			}
+			links = append(links, l...)
+			return false
 		}
-		rng, err := m.Range(spn)
-		if err != nil {
-			return nil, err
+		return true
+	})
+
+	for _, commentGroup := range file.Comments {
+		for _, comment := range commentGroup.List {
+			l, err := findLinksInString(comment.Text, comment.Pos(), view, m)
+			if err != nil {
+				view.Session().Logger().Errorf(ctx, "cannot find links in comment: %v", err)
+				continue
+			}
+			links = append(links, l...)
 		}
-		target, err := strconv.Unquote(imp.Path.Value)
-		if err != nil {
-			continue
-		}
-		target = "https://godoc.org/" + target
-		result = append(result, protocol.DocumentLink{
-			Range:  rng,
-			Target: target,
-		})
 	}
-	return result, nil
+
+	return links, nil
+}
+
+const urlRegexpString = "(http|ftp|https)://([\\w_-]+(?:(?:\\.[\\w_-]+)+))([\\w.,@?^=%&:/~+#-]*[\\w@?^=%&/~+#-])?"
+
+var (
+	urlRegexp  *regexp.Regexp
+	regexpOnce sync.Once
+	regexpErr  error
+)
+
+func getURLRegexp() (*regexp.Regexp, error) {
+	regexpOnce.Do(func() {
+		urlRegexp, regexpErr = regexp.Compile(urlRegexpString)
+	})
+	return urlRegexp, regexpErr
+}
+
+func toProtocolLink(view source.View, mapper *protocol.ColumnMapper, target string, start, end token.Pos) (protocol.DocumentLink, error) {
+	spn, err := span.NewRange(view.Session().Cache().FileSet(), start, end).Span()
+	if err != nil {
+		return protocol.DocumentLink{}, err
+	}
+	rng, err := mapper.Range(spn)
+	if err != nil {
+		return protocol.DocumentLink{}, err
+	}
+	l := protocol.DocumentLink{
+		Range:  rng,
+		Target: target,
+	}
+	return l, nil
+}
+
+func findLinksInString(src string, pos token.Pos, view source.View, mapper *protocol.ColumnMapper) ([]protocol.DocumentLink, error) {
+	var links []protocol.DocumentLink
+	re, err := getURLRegexp()
+	if err != nil {
+		return nil, fmt.Errorf("cannot create regexp for links: %s", err.Error())
+	}
+	for _, urlIndex := range re.FindAllIndex([]byte(src), -1) {
+		start := urlIndex[0]
+		end := urlIndex[1]
+		startPos := token.Pos(int(pos) + start)
+		endPos := token.Pos(int(pos) + end)
+		target := src[start:end]
+		l, err := toProtocolLink(view, mapper, target, startPos, endPos)
+		if err != nil {
+			return nil, err
+		}
+		links = append(links, l)
+	}
+	return links, nil
 }
diff --git a/internal/lsp/lsp_test.go b/internal/lsp/lsp_test.go
index b056fe9..f448059 100644
--- a/internal/lsp/lsp_test.go
+++ b/internal/lsp/lsp_test.go
@@ -762,15 +762,30 @@
 		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 {
diff --git a/internal/lsp/testdata/links/links.go b/internal/lsp/testdata/links/links.go
index b97da74..44053b3 100644
--- a/internal/lsp/testdata/links/links.go
+++ b/internal/lsp/testdata/links/links.go
@@ -3,10 +3,17 @@
 import (
 	"fmt" //@link(re`".*"`,"https://godoc.org/fmt")
 
-	"golang.org/x/tools/internal/lsp/foo" //@link(re`".*"`,"https://godoc.org/golang.org/x/tools/internal/lsp/foo")
+	"golang.org/x/tools/internal/lsp/foo" //@link(re`".*"`,`https://godoc.org/golang.org/x/tools/internal/lsp/foo`)
 )
 
 var (
 	_ fmt.Formatter
 	_ foo.StructFoo
 )
+
+// Foo function
+func Foo() string {
+	/*https://example.com/comment */ //@link("https://example.com/comment","https://example.com/comment")
+	url := "https://example.com/string_literal" //@link("https://example.com/string_literal","https://example.com/string_literal")
+	return url
+}
diff --git a/internal/lsp/tests/tests.go b/internal/lsp/tests/tests.go
index bfa1bc2..4f09d72 100644
--- a/internal/lsp/tests/tests.go
+++ b/internal/lsp/tests/tests.go
@@ -15,6 +15,7 @@
 	"strings"
 	"testing"
 
+	"golang.org/x/tools/go/expect"
 	"golang.org/x/tools/go/packages"
 	"golang.org/x/tools/go/packages/packagestest"
 	"golang.org/x/tools/internal/lsp/source"
@@ -37,7 +38,7 @@
 	ExpectedRenamesCount           = 16
 	ExpectedSymbolsCount           = 1
 	ExpectedSignaturesCount        = 21
-	ExpectedLinksCount             = 2
+	ExpectedLinksCount             = 4
 )
 
 const (
@@ -117,8 +118,9 @@
 }
 
 type Link struct {
-	Src    span.Span
-	Target string
+	Src          span.Span
+	Target       string
+	NotePosition token.Position
 }
 
 type Golden struct {
@@ -527,10 +529,12 @@
 	}
 }
 
-func (data *Data) collectLinks(spn span.Span, link string) {
+func (data *Data) collectLinks(spn span.Span, link string, note *expect.Note, fset *token.FileSet) {
+	position := fset.Position(note.Pos)
 	uri := spn.URI()
 	data.Links[uri] = append(data.Links[uri], Link{
-		Src:    spn,
-		Target: link,
+		Src:          spn,
+		Target:       link,
+		NotePosition: position,
 	})
 }