Skip to content
quarry Artifact [33535cfed5]
Login

Artifact [33535cfed5]

Artifact 33535cfed51b7a2cfbce429b4ae122acf993fcdfc6a20cc1f1e1c6456bd4b20b:


// 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 bare number, optionally repo-qualified: a ticket by its number
	// (#309), and a chat message only in chat posted before ^N.
	seqTok = regexp.MustCompile(`(?:^|[^\w/#:-])((?:` + repoPat + `:)?#[1-9][0-9]{0,18})\b`)
	// "^1601": a chat message by its sequence (#309). Never after a
	// word, a caret or a bracket, so "2^10", "^^4" and a footnote's
	// "[^4]" stay text.
	caretTok = 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 was a message until ^N (54c5175f, #309).
	ticketNumTok = regexp.MustCompile(`(?i)(?:^|[^\w/-])(ticket\s+(?:` + repoPat + `:)?#[1-9][0-9]{0,18})\b`)
	// "!12": a landing by its number (d60c0c76), in its own sequence
	// apart from the tickets'. Never after a word, a bang or a bracket,
	// so "wow!12", "!!12" and image syntax stay text.
	landingNumTok = regexp.MustCompile(`(?:^|[^\w/!\[-])((?:` + 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})$`)
	oneCaret = regexp.MustCompile(`^(?:(` + repoPat + `):)?\^([1-9][0-9]{0,18})$`)
	oneLNum  = 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 := oneLNum.FindStringSubmatch(s); m != nil {
		return RefToken{Repo: m[1], Kind: RefLanding, ID: m[2]}, true
	}
	if m := oneSeq.FindStringSubmatch(s); m != nil {
		return RefToken{Repo: m[1], Kind: RefChat, ID: m[2]}, true
	}
	if m := oneCaret.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
	// sigil is '#' or '^' for a number written bare, whose kind the
	// surface decides (forSurface); 0 for every other spelling.
	sigil byte
}

// 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, landingNumTok, hashTok, qhashTok, seqTok, caretTok, brHashTok)
}

// ChatReader is an OPTIONAL upgrade on a TokenResolver: how this
// surface reads the two bare numbers (#309). #N is a ticket by its
// number and ^N a chat message — on chat. A chat message posted before
// ^N existed wrote #N for a message, and SeqIsChat keeps it meaning
// what its author meant. A resolver without the method reads #N as a
// ticket and leaves ^N text, as every surface but chat does.
type ChatReader interface {
	CaretIsChat() bool
	SeqIsChat() bool
}

// forSurface reads a bare number the way this resolver's surface does;
// false is a token this surface leaves as text.
func forSurface(sp refSpan, tr TokenResolver) (RefToken, bool) {
	t := sp.tok
	if t.Kind != RefChat || sp.sigil == 0 {
		return t, true
	}
	cr, _ := tr.(ChatReader)
	switch {
	case sp.sigil == '^':
		return t, cr != nil && cr.CaretIsChat()
	case cr != nil && cr.SeqIsChat():
		return t, true
	}
	return RefToken{Repo: t.Repo, Kind: RefTicket, ID: t.ID}, true
}

// 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, landingNumTok, hashTok, qhashTok, seqTok, caretTok, 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
			}
			sp := refSpan{start: start, end: end, tok: tok}
			switch {
			case oneSeq.MatchString(s[start:end]):
				sp.sigil = '#'
			case oneCaret.MatchString(s[start:end]):
				sp.sigil = '^'
			}
			out = append(out, sp)
		}
	}
	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) {
		var ok bool
		if sp.tok, ok = forSurface(sp, tr); !ok {
			continue
		}
		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
}