internal: types and functions for the raw latest version

Add RawLatestInfo, which describes the raw latest version of a module,
and provide functions to compute deprecation and retractions from it.

For golang/go#44437

Change-Id: I2f1aa03bf190b961642828fdc7b708e2ee47a20a
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/295196
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
Reviewed-by: Julie Qiu <julie@golang.org>
diff --git a/internal/raw_latest.go b/internal/raw_latest.go
new file mode 100644
index 0000000..c38daff
--- /dev/null
+++ b/internal/raw_latest.go
@@ -0,0 +1,58 @@
+// Copyright 2021 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 internal
+
+import (
+	"strings"
+
+	"golang.org/x/mod/modfile"
+	"golang.org/x/mod/semver"
+)
+
+// RawLatestInfo describes the "raw" latest version of a module:
+// the latest version without considering retractions or the like.
+// The go.mod file of the raw latest version establishes whether
+// the module is deprecated, and what versions are retracted.
+type RawLatestInfo struct {
+	ModulePath string
+	Version    string
+	GoModFile  *modfile.File
+}
+
+// PopulateModule uses the RawLatestInfo to populate fields of the given module.
+func (r *RawLatestInfo) PopulateModule(m *Module) {
+	m.Deprecated, m.DeprecationComment = isDeprecated(r.GoModFile)
+	m.Retracted, m.RetractionRationale = isRetracted(r.GoModFile, m.Version)
+}
+
+// isDeprecated reports whether the go.mod deprecates this module.
+// It looks for "Deprecated" comments in the line comments before and next to
+// the module declaration. If it finds one, it returns true along with the text
+// after "Deprecated:". Otherwise it returns false, "".
+func isDeprecated(mf *modfile.File) (bool, string) {
+	const prefix = "Deprecated:"
+
+	if mf.Module == nil {
+		return false, ""
+	}
+	for _, comment := range append(mf.Module.Syntax.Before, mf.Module.Syntax.Suffix...) {
+		text := strings.TrimSpace(strings.TrimPrefix(comment.Token, "//"))
+		if strings.HasPrefix(text, prefix) {
+			return true, strings.TrimSpace(text[len(prefix):])
+		}
+	}
+	return false, ""
+}
+
+// isRetracted reports whether the go.mod file retracts the version.
+// If so, it returns true along with the rationale for the retraction.
+func isRetracted(mf *modfile.File, resolvedVersion string) (bool, string) {
+	for _, r := range mf.Retract {
+		if semver.Compare(resolvedVersion, r.Low) >= 0 && semver.Compare(resolvedVersion, r.High) <= 0 {
+			return true, r.Rationale
+		}
+	}
+	return false, ""
+}
diff --git a/internal/raw_latest_test.go b/internal/raw_latest_test.go
new file mode 100644
index 0000000..a5c2b42
--- /dev/null
+++ b/internal/raw_latest_test.go
@@ -0,0 +1,93 @@
+// Copyright 2021 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 internal
+
+import (
+	"testing"
+
+	"golang.org/x/mod/modfile"
+)
+
+func TestIsDeprecated(t *testing.T) {
+	for _, test := range []struct {
+		name        string
+		in          string
+		wantIs      bool
+		wantComment string
+	}{
+		{"no comment", `module m`, false, ""},
+		{"valid comment",
+			`
+			// Deprecated: use v2
+			module m
+		`, true, "use v2"},
+		{"take first",
+			`
+			// Deprecated: use v2
+			// Deprecated: use v3
+			module m
+		`, true, "use v2"},
+		{"ignore others",
+			`
+			// c1
+			// Deprecated: use v2
+			// c2
+			module m
+		`, true, "use v2"},
+		{"must be capitalized",
+			`
+			// c1
+			// deprecated: use v2
+			// c2
+			module m
+		`, false, ""},
+		{"suffix",
+			`
+			// c1
+			module m // Deprecated: use v2
+		`, true, "use v2",
+		},
+	} {
+		mf, err := modfile.Parse("test", []byte(test.in), nil)
+		if err != nil {
+			t.Fatal(err)
+		}
+		gotIs, gotComment := isDeprecated(mf)
+		if gotIs != test.wantIs || gotComment != test.wantComment {
+			t.Errorf("%s: got (%t, %q), want(%t, %q)", test.name, gotIs, gotComment, test.wantIs, test.wantComment)
+		}
+	}
+}
+
+func TestIsRetracted(t *testing.T) {
+	for _, test := range []struct {
+		name          string
+		file          string
+		wantIs        bool
+		wantRationale string
+	}{
+		{"no retract", "module M", false, ""},
+		{"retracted", "module M\nretract v1.2.3", true, ""},
+		{"retracted with comment", "module M\nretract v1.2.3 // bad  ", true, "bad"},
+		{"retracted range", "module M\nretract [v1.2.0, v1.3.0] // bad", true, "bad"},
+		{
+			"not retracted", `
+				module M
+				retract [v1.2.0, v1.2.2]
+				retract [v1.4.0, v1.99.0]
+			`,
+			false, "",
+		},
+	} {
+		mf, err := modfile.Parse("test", []byte(test.file), nil)
+		if err != nil {
+			t.Fatal(err)
+		}
+		gotIs, gotRationale := isRetracted(mf, "v1.2.3")
+		if gotIs != test.wantIs || gotRationale != test.wantRationale {
+			t.Errorf("%s: got (%t, %q), want(%t, %q)", test.name, gotIs, gotRationale, test.wantIs, test.wantRationale)
+		}
+	}
+}