// refs.go is the reference-token grammar (RFC 0011 §14): one syntax
// that chat, forum posts, tickets and landings all speak, parsed here
// so the four surfaces cannot drift. Parsing is pure text work —
// whether a token names anything real is the Resolver's question, and
// an unresolvable token stays plain text.
package markup
import (
"regexp"
"strings"
)
// Reference kinds. "hash" is deliberately unresolved: a bare artifact
// name may be a check-in, a ticket or a forum post, and only the index
// knows which. The resolver answers that, not the grammar.
const (
RefChat = "chat"
RefHash = "hash"
RefPerson = "person"
RefTicket = "ticket"
RefCheckin = "checkin"
RefLanding = "landing"
RefForum = "forum"
RefWiki = "wiki"
)
// RefToken is one parsed reference. Repo is empty for a token that
// names something in the reader's current repository; a non-empty Repo
// is a cross-repository reference, which resolves only when the reader
// may read that repository.
type RefToken struct {
Repo string
Kind string
ID string
}
// Canonical is the token's one spelling — what rides in data-ref, what
// a backlink row stores, and what an API client is handed. Two spellings
// of the same reference (a pasted URL and a typed `#412`) canonicalize
// to the same string, so the two never look like two references.
func (t RefToken) Canonical() string {
prefix := ""
if t.Repo != "" {
prefix = t.Repo + ":"
}
switch t.Kind {
case RefChat:
return prefix + "#" + t.ID
case RefHash:
return prefix + "[" + t.ID + "]"
case RefPerson:
return "@" + t.ID
default:
return prefix + t.Kind + "/" + t.ID
}
}
const repoPat = `[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}`
var (
// A chat sequence, optionally repo-qualified. Fossil spells a
// message id `#N` in /chat-query; quarry makes it a link.
seqTok = regexp.MustCompile(`(?:^|[^\w/#:-])((?:` + repoPat + `:)?#[1-9][0-9]{0,18})\b`)
// An artifact name, REPO-QUALIFIED. An unqualified `[hash]` or bare
// hash is left to the older hashRef rule, which has resolved
// check-ins since 0002 and renders them its own way — the grammar
// still PARSES those spellings (ParseRef), but claiming them here
// would silently restyle every hash reference in the corpus.
hashTok = regexp.MustCompile(`(?:^|[^\w/:-])(` + repoPat + `:\[[0-9a-fA-F]{6,64}\])`)
qhashTok = regexp.MustCompile(`(?:^|[^\w/:-])(` + repoPat + `:[0-9a-fA-F]{10,64})\b`)
// A pasted quarry URL, absolute or site-relative.
urlTok = regexp.MustCompile(`(?:^|[^\w])((?:https?://[^\s/]+)?/r/` + repoPat + `/(?:chat|ticket|landing|forum|checkin|wiki)/[^\s<>"'()\[\]]+)`)
// A mention. Shares its shape with mentionRef, which owns rendering.
personTok = regexp.MustCompile(`^@([a-zA-Z0-9][a-zA-Z0-9._-]{0,63})$`)
oneSeq = regexp.MustCompile(`^(?:(` + repoPat + `):)?#([1-9][0-9]{0,18})$`)
oneHash = regexp.MustCompile(`^(?:(` + repoPat + `):)?\[([0-9a-fA-F]{6,64})\]$`)
oneBare = regexp.MustCompile(`^(?:(` + repoPat + `):)?([0-9a-fA-F]{10,64})$`)
oneURL = regexp.MustCompile(`^(?:https?://[^\s/]+)?/r/(` + repoPat + `)/(chat|ticket|landing|forum|checkin|wiki)/([^\s?#]+)`)
// The canonical spelling of a kinded reference, which is what
// data-ref carries and what a `ref=` parameter is handed back. A
// token quarry writes must parse as the token quarry wrote.
oneQual = regexp.MustCompile(`^(?:(` + repoPat + `):)?(ticket|landing|forum|checkin|wiki|chat)/([^\s?#]+)$`)
)
// ParseRef reads one whole token. It is the grammar's only entry point:
// scanRefs finds candidates in prose and hands each one here, and the
// ops layer parses a `ref=` query parameter the same way, so a token
// typed into a URL bar and a token typed into a message mean the same.
func ParseRef(s string) (RefToken, bool) {
s = strings.TrimSpace(s)
if s == "" {
return RefToken{}, false
}
if m := oneURL.FindStringSubmatch(s); m != nil {
kind, id := m[2], strings.Trim(m[3], "/")
if id == "" {
return RefToken{}, false
}
if kind == RefChat && !isSeq(id) {
return RefToken{}, false
}
return RefToken{Repo: m[1], Kind: kind, ID: id}, true
}
if m := oneQual.FindStringSubmatch(s); m != nil {
if m[2] == RefChat && !isSeq(m[3]) {
return RefToken{}, false
}
return RefToken{Repo: m[1], Kind: m[2], ID: m[3]}, true
}
if m := oneSeq.FindStringSubmatch(s); m != nil {
return RefToken{Repo: m[1], Kind: RefChat, ID: m[2]}, true
}
if m := oneHash.FindStringSubmatch(s); m != nil {
return RefToken{Repo: m[1], Kind: RefHash, ID: strings.ToLower(m[2])}, true
}
if m := oneBare.FindStringSubmatch(s); m != nil {
return RefToken{Repo: m[1], Kind: RefHash, ID: strings.ToLower(m[2])}, true
}
if m := personTok.FindStringSubmatch(s); m != nil {
return RefToken{Kind: RefPerson, ID: m[1]}, true
}
return RefToken{}, false
}
func isSeq(s string) bool {
if s == "" || s[0] == '0' {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
// refSpan is one token found in prose, at its exact byte span.
type refSpan struct {
start, end int
tok RefToken
}
// scanRefs finds every reference token in one run of text, in order and
// without overlaps: a longer URL match wins over a shorter hash match
// inside it, because the URL is what the author wrote.
func scanRefs(s string) []refSpan {
var out []refSpan
claimed := func(start, end int) bool {
for _, r := range out {
if start < r.end && r.start < end {
return true
}
}
return false
}
// URLs first: they contain the other shapes.
for _, re := range []*regexp.Regexp{urlTok, hashTok, qhashTok, seqTok} {
for _, m := range re.FindAllStringSubmatchIndex(s, -1) {
start, end := m[2], m[3]
if claimed(start, end) {
continue
}
tok, ok := ParseRef(s[start:end])
if !ok {
continue
}
out = append(out, refSpan{start: start, end: end, tok: tok})
}
}
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j-1].start > out[j].start; j-- {
out[j-1], out[j] = out[j], out[j-1]
}
}
return out
}
// RefsIn lists the resolvable references in a body, canonicalized, in
// order and without duplicates. The ops layer uses it to hand API
// clients the tokens a message carries, and the backlink writer uses it
// to record what a chat message pointed at — both from the SAME grammar
// the renderer links, so a reference a reader can click is a reference
// the store knows about.
func RefsIn(text string, tr TokenResolver) []RefToken {
var out []RefToken
seen := map[string]bool{}
for _, sp := range scanRefs(text) {
if _, ok := tr.ResolveToken(sp.tok); !ok {
continue
}
c := sp.tok.Canonical()
if seen[c] {
continue
}
seen[c] = true
out = append(out, sp.tok)
}
return out
}