Skip to content
quarry Artifact [4944056267]
Login

Artifact [4944056267]

Artifact 494405626781a228bc543327e0d1022480b232b9ed08dcff79a57ba336857105:


// 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`)
	// The UNQUALIFIED bracketed form at a length the older hashRef rule
	// cannot reach — its floor is ten characters, so `[a91f3c2e]`
	// renders as literal text today. Claiming only the SHORT ones adds
	// a reference where there was none and leaves every hash the older
	// rule already draws — comment on the link, excerpt beside it —
	// exactly as it draws it.
	brHashTok = regexp.MustCompile(`(?:^|[^\w/:\[-])(\[[0-9a-fA-F]{6,9}\])`)
	// A BARE unqualified hash. The older hashRef rule owns rendering
	// these — it has linked check-ins since 0002 and draws them its own
	// way, with the comment riding the link — so this pattern is read
	// only by RefsIn, which needs to know what a body pointed at.
	bareHashTok = regexp.MustCompile(`(?:^|[^\w/:-])([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<>"'()\[\]]+)`)
	// "ticket #12": a ticket by its number, spelled so it cannot be read
	// as a chat message — the one way to name a numbered ticket from
	// chat, where a bare #12 stays a message (54c5175f).
	ticketNumTok = regexp.MustCompile(`(?i)(?:^|[^\w/-])(ticket\s+(?:` + repoPat + `:)?#[1-9][0-9]{0,18})\b`)
	// 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})$`)
	oneTNum = regexp.MustCompile(`(?i)^ticket\s+(?:(` + 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})$`)
	// The web serves a repository at /r/{repo} and at /r/{owner}/{repo};
	// both spellings name the same repository, keyed by its bare name.
	oneURL = regexp.MustCompile(`^(?:https?://[^\s/]+)?/r/(?:` + repoPat + `/)?(` + 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 := oneTNum.FindStringSubmatch(s); m != nil {
		return RefToken{Repo: m[1], Kind: RefTicket, ID: m[2]}, 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 {
	return scanWith(s, urlTok, ticketNumTok, hashTok, qhashTok, seqTok, brHashTok)
}

// SeqReader is an OPTIONAL upgrade on a TokenResolver: whether a bare
// #N on this surface names a ticket by its number. Everywhere but chat
// it does; in chat #N stays a message, and "ticket #N" names the ticket
// (54c5175f). A resolver without the method keeps #N a chat message.
type SeqReader interface {
	SeqIsTicket() bool
}

// forSurface reads a bare #N the way this resolver's surface does.
func forSurface(t RefToken, tr TokenResolver) RefToken {
	if t.Kind != RefChat {
		return t
	}
	if sr, ok := tr.(SeqReader); ok && sr.SeqIsTicket() {
		return RefToken{Repo: t.Repo, Kind: RefTicket, ID: t.ID}
	}
	return t
}

// scanRefsAll is scanRefs plus the shapes the OLDER rules render: what
// a body pointed at, whoever draws the link. The backlink writer reads
// this one, so a bare check-in hash in chat still puts a row on the
// check-in's page.
func scanRefsAll(s string) []refSpan {
	return scanWith(s, urlTok, ticketNumTok, hashTok, qhashTok, seqTok, brHashTok, bareHashTok)
}

func scanWith(s string, res ...*regexp.Regexp) []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 res {
		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 scanRefsAll(text) {
		sp.tok = forSurface(sp.tok, tr)
		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
}