jira-cli/pkg/md/jirawiki/parser.go

603 lines
12 KiB
Go

package jirawiki
import (
"fmt"
"strings"
)
// Supported Jira wiki tags.
const (
TagHeading1 = "h1."
TagHeading2 = "h2."
TagHeading3 = "h3."
TagHeading4 = "h4."
TagHeading5 = "h5."
TagHeading6 = "h6."
TagBlockQuote = "bq."
TagQuote = "{quote}"
TagPanel = "{panel}"
TagCodeBlock = "{code}"
TagNoFormat = "{noformat}"
TagLink = "["
TagOrderedList = "#"
TagUnorderedList = "*" // '*' can be either be bold or an unordered list 🤦.
TagBold = "*"
TagTable = "||"
// Let's group tags based on their behavior.
typeTagTextEffect = "text-effect"
typeTagList = "list"
typeTagHeading = "heading"
typeTagInlineQuote = "inline-quote"
typeTagReferenceLink = "link"
typeTagFencedCode = "code"
typeTagTable = "table"
typeTagOther = "other"
// Supported attributes.
attrTitle = "title"
// Line feeds.
newLine = '\n'
carriageReturn = '\r'
)
var validTags = []string{
TagHeading1,
TagHeading2,
TagHeading3,
TagHeading4,
TagHeading5,
TagHeading6,
TagBlockQuote,
TagQuote,
TagPanel,
TagCodeBlock,
TagNoFormat,
TagLink,
TagOrderedList,
TagUnorderedList,
TagBold,
TagTable,
}
var replacements = map[string]string{
TagHeading1: "#", // '#' can be either be a h1 tag or an ordered list 🤷.
TagHeading2: "##", // '##' could mean a h2 tag or indentation for an ordered list 😑.
TagHeading3: "###",
TagHeading4: "####",
TagHeading5: "#####",
TagHeading6: "######",
TagQuote: "> ",
TagPanel: "---",
TagBlockQuote: ">",
TagCodeBlock: "```",
TagNoFormat: "```",
TagOrderedList: "-",
TagBold: "**",
TagTable: "|",
}
// Parse converts input string to Jira markdown.
func Parse(input string) string {
return secondPass(firstPass(input))
}
/*
First pass:
- Fetch all lines from the input while skipping unnecessary line feeds.
*/
func firstPass(input string) []string {
var (
beg = 0
size = len(input)
lines = make([]string, 0)
)
for beg < size {
end := beg
for end < size && input[end] != carriageReturn && input[end] != newLine {
end++
}
lines = append(lines, input[beg:end])
for end < size && input[end] == carriageReturn {
end++
}
beg = end + 1
}
return lines
}
/*
Second pass: actual rendering.
- Process each line to search and mark tags.
- Use replacements to prepare markdown.
*/
func secondPass(lines []string) string {
var (
out strings.Builder
lineNum int
)
for lineNum < len(lines) {
line := lines[lineNum]
tokens := tokenize(line)
if len(tokens) == 0 {
out.WriteString(line)
lineNum++
if lineNum < len(lines)-1 {
out.WriteRune(newLine)
}
continue
}
var beg int
out:
for beg < len(line) {
end := beg
if token, ok := tokenStarts(beg, tokens); ok {
switch token.family {
case typeTagTextEffect:
end = token.handleTextEffects(line, &out)
case typeTagHeading:
end = token.handleHeadings(line, &out)
case typeTagInlineQuote:
end = token.handleInlineBlockQuote(line, &out)
case typeTagList:
end = token.handleList(line, &out)
case typeTagFencedCode:
lineNum = token.handleFencedCodeBlock(lineNum, lines, &out)
break out
case typeTagReferenceLink:
end = token.handleReferenceLink(line, &out)
case typeTagTable:
end = token.handleTable(line, &out)
case typeTagOther:
if token.tag == TagQuote {
// If end is same as size of the input, it implies that
// we've found a closing token, and we will ignore it.
if token.endIdx != len(line)-1 {
out.WriteString(fmt.Sprintf("\n%s", replacements[token.tag]))
}
} else {
out.WriteString(fmt.Sprintf("\n%s", replacements[token.tag]))
}
if token.tag == TagPanel {
if t, ok := token.attrs[attrTitle]; ok {
out.WriteString(fmt.Sprintf("\n**%s**\n", t))
}
if token.endIdx != len(line)-1 {
out.WriteRune(newLine)
}
}
end = token.endIdx
}
} else {
out.WriteRune(rune(line[beg]))
}
end++
beg = end
}
lineNum++
out.WriteRune(newLine)
}
return out.String()
}
// Mark tokens in a given string.
func tokenize(line string) []*Token { //nolint:gocyclo
line = strings.TrimSpace(line)
var (
tokens []*Token
beg = 0
size = len(line)
)
out:
for beg < size-1 {
var (
end int
tagType = getTagType(line, beg)
)
switch tagType {
case typeTagTextEffect:
end = beg + 1
for end < len(line) && line[end] != line[beg] {
end++
}
var word string
if end < size-1 {
word = line[beg : end+1]
} else {
word = line[beg:end]
}
tokens = append(tokens, &Token{
tag: word,
family: typeTagTextEffect,
startIdx: beg,
endIdx: end,
})
end++
case typeTagHeading:
fallthrough
case typeTagInlineQuote:
end = beg + 1
for end < len(line) && line[end] != '.' {
end++
}
word := line[beg : end+1]
tokens = append(tokens, &Token{
tag: word,
family: tagType,
startIdx: beg,
endIdx: end,
})
break out
case typeTagList:
end = beg + 1
for end < len(line) && line[end] == line[beg] {
end++
}
word := line[beg:end]
tokens = append(tokens, &Token{
tag: word,
family: typeTagList,
startIdx: beg,
endIdx: end,
})
end++
case typeTagReferenceLink:
end = beg + 1
for end < len(line) && line[end] != ']' {
end++
}
word := line[beg : end+1]
tokens = append(tokens, &Token{
tag: word,
family: typeTagReferenceLink,
startIdx: beg,
endIdx: end,
})
case typeTagTable:
end = len(line) - 1
tokens = append(tokens, &Token{
tag: line,
family: typeTagTable,
startIdx: beg,
endIdx: end,
})
default:
end = beg + 1
for end < size && line[end] != '*' && line[end] != '{' && line[end] != '}' && line[end] != '[' && line[end] != ']' {
end++
}
if end != size && line[end] != '*' && line[end] != '{' && line[end] != '[' {
end++
}
word := line[beg:end]
word, attrs := extractAttributes(word)
if isToken(word) {
fam := typeTagOther
if word == TagCodeBlock || word == TagNoFormat {
fam = typeTagFencedCode
}
tokens = append(tokens, &Token{
tag: word,
family: fam,
attrs: attrs,
startIdx: beg,
endIdx: end - 1,
})
}
}
beg = end
}
return tokens
}
func extractAttributes(token string) (string, map[string]string) {
attrs := make(map[string]string)
if token[0] != '{' || !strings.Contains(token, ":") {
return token, attrs
}
pieces := strings.Split(token, ":")
if len(pieces) != 2 || pieces[1] == "" {
return token, attrs
}
tag := pieces[0] + "}"
meta := pieces[1][0 : len(pieces[1])-1]
// We only support title attribute at the moment.
validAttr := attrTitle
pieces = strings.Split(meta, "|")
for _, m := range pieces {
props := strings.Split(m, "=")
if len(props) == 1 {
attrs[validAttr] = props[0]
} else {
key, val := props[0], props[1]
if key == validAttr {
attrs[key] = val
}
}
}
return tag, attrs
}
// Token represents jira tags in a given string.
type Token struct {
tag string
family string
attrs map[string]string
startIdx int
endIdx int
}
func (t *Token) handleTextEffects(line string, out *strings.Builder) int {
word := line[t.startIdx+1 : t.endIdx]
out.WriteString(replacements[string(line[t.startIdx])])
out.WriteString(word)
out.WriteString(replacements[string(line[t.startIdx])])
if t.endIdx == len(line)-1 {
out.WriteRune(newLine)
}
return t.endIdx
}
func (t *Token) handleHeadings(line string, out *strings.Builder) int {
word := line[t.endIdx+1:]
out.WriteString(replacements[t.tag])
out.WriteString(word)
return t.endIdx + len(word)
}
func (t *Token) handleInlineBlockQuote(line string, out *strings.Builder) int {
word := line[t.endIdx+1:]
out.WriteString(fmt.Sprintf("\n%s", replacements[t.tag]))
out.WriteString(word)
return t.endIdx + len(word)
}
func (t *Token) handleList(line string, out *strings.Builder) int {
end := t.endIdx + 1
for i := t.startIdx; i < t.endIdx-1; i++ {
out.WriteRune('\t')
}
if end >= len(line) {
out.WriteString("-")
return t.endIdx
}
rem := strings.TrimSpace(line[end:])
out.WriteString(fmt.Sprintf("- %s", rem))
end += len(rem) + 1
return end
}
func (t *Token) handleFencedCodeBlock(idx int, lines []string, out *strings.Builder) int {
if idx == len(lines)-1 {
return t.endIdx
}
out.WriteString(fmt.Sprintf("\n%s", replacements[t.tag]))
if t, ok := t.attrs[attrTitle]; ok {
pieces := strings.Split(t, ".")
if len(pieces) == 2 {
out.WriteString(pieces[1])
} else {
out.WriteString(t)
}
}
out.WriteRune(newLine)
i := idx + 1
for ; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
if line == TagCodeBlock || line == TagNoFormat {
break
}
if x := checkForInlineClose(line); x > 0 {
out.WriteString(line[:x])
out.WriteRune(newLine)
break
} else {
// Write everything as is.
out.WriteString(lines[i])
}
out.WriteRune(newLine)
}
out.WriteString(replacements[t.tag])
return i
}
func (t *Token) handleReferenceLink(line string, out *strings.Builder) int {
if len(line) < 2 {
return t.endIdx
}
body := line[t.startIdx+1 : t.endIdx]
pieces := strings.Split(body, "|")
var link string
if len(pieces) == 2 {
link = fmt.Sprintf("[%s](%s)", pieces[0], pieces[1])
} else {
link = fmt.Sprintf("[](%s)", pieces[0])
}
out.WriteString(link)
return t.endIdx
}
func (t *Token) handleTable(line string, out *strings.Builder) int {
if line[1] != '|' {
out.WriteString(line)
return t.endIdx
}
headers := strings.ReplaceAll(line, TagTable, replacements[TagTable])
cols := strings.Split(headers, "|")
var sep strings.Builder
for i := 0; i < len(cols)-2; i++ {
sep.WriteString("|---")
}
row := fmt.Sprintf("%s\n%s|", headers, sep.String())
out.WriteString(row)
return t.endIdx
}
func isToken(inp string) bool {
for _, tag := range validTags {
if inp == tag {
return true
}
}
return false
}
func tokenStarts(idx int, tokens []*Token) (*Token, bool) {
for _, token := range tokens {
if idx == token.startIdx {
return token, true
}
}
return nil, false
}
func getTagType(line string, beg int) string {
if isTextEffect(line[beg], line[beg+1]) {
return typeTagTextEffect
}
if isListTag(line[beg], line[beg+1]) {
return typeTagList
}
if isHeadingsTag(beg, line) {
return typeTagHeading
}
if isInlineBlockQuote(beg, line) {
return typeTagInlineQuote
}
if isReferenceLink(beg, line) {
return typeTagReferenceLink
}
if isTable(beg, line) {
return typeTagTable
}
return typeTagOther
}
func isTextEffect(beg, next uint8) bool {
s := string(beg)
return s == TagBold && (next != ' ' && next != beg)
}
func isListTag(beg, next uint8) bool {
s := string(beg)
return (s == TagOrderedList || s == TagUnorderedList) && (next == ' ' || next == beg)
}
func isHeadingsTag(beg int, line string) bool {
size := len(line)
if size < 3 {
return false
}
return line[beg] == 'h' && line[2] == '.'
}
func isInlineBlockQuote(beg int, line string) bool {
size := len(line)
if size < 3 {
return false
}
return line[beg] == 'b' && line[2] == '.'
}
func isReferenceLink(beg int, line string) bool {
if line[beg] != '[' {
return false
}
var end int
for beg < len(line) {
end = beg + 1
for end < len(line) && line[end] != ']' {
end++
}
break
}
return end < len(line) && line[end] == ']'
}
func isTable(beg int, line string) bool {
end := len(line) - 1
return end != beg && line[beg] == '|' && line[end] == '|'
}
func checkForInlineClose(line string) int {
n := len(line)
if n > len(TagCodeBlock) && line[n-len(TagCodeBlock):] == TagCodeBlock {
return n - len(TagCodeBlock)
}
if n > len(TagNoFormat) && line[n-len(TagNoFormat):] == TagNoFormat {
return n - len(TagNoFormat)
}
return 0
}