469 lines
14 KiB
Nim

import std/[nre, strtabs, strutils]
import zero_functional
type
ChordChartMetadata* = object
title*: string
key*: ChordChartChord
optionalProps: StringTableRef
ChordChart* = ref object
rawContent*: string
metadata*: ChordChartMetadata
nodes*: seq[ChordChartNode]
ChordChartNodeKind* = enum
ccnkSection,
ccnkLine,
ccnkWord,
ccnkNote,
ccnkColBreak,
ccnkPageBreak,
ccnkTransposeKey,
ccnkRedefineKey,
ccnkNone
ChordChartPitch* = enum Af, A, Bf, B, C, Df, D, Ef, E, F, Fs, G
ChordChartChord* = object
original*: Option[string]
rootPitch*: ChordChartPitch
flavor*: Option[string]
bassPitch*: Option[ChordChartPitch]
ChordChartNode* = ref object
case kind: ChordChartNodeKind
of ccnkSection:
sectionName*: string
remainingSectionLine*: Option[string]
sectionContents*: seq[ChordChartNode]
of ccnkLine:
line*: seq[ChordChartNode]
of ccnkWord:
spaceBefore*: bool
spaceAfter*: bool
chord*: Option[ChordChartChord]
word*: Option[string]
of ccnkNote:
note*: string
inclInLyrics*: bool
of ccnkColBreak: discard
of ccnkPageBreak: discard
of ccnkTransposeKey:
transposeSteps*: int
of ccnkRedefineKey:
newKey*: ChordChartPitch
of ccnkNone: discard
ParserContext = ref object
lines: seq[string]
curKeyCenter: ChordChartPitch
curLineNum: int
curSection: ChordChartNode
unparsedLineParts: seq[string]
let EMPTY_CHORD_CHART_NODE* = ChordChartNode(kind: ccnkNone)
func `[]`*(ccmd: ChordChartMetadata, key: string): string =
ccmd.optionalProps[key]
func contains*(ccmd: ChordChartMetadata, key: string): bool =
return key == "title" or key == "key" or ccmd.optionalProps.contains(key)
iterator pairs*(ccmd: ChordChartMetadata): tuple[key, value: string] =
yield ("title", ccmd.title)
yield ("key", $ccmd.key)
for p in ccmd.optionalProps.pairs: yield p
func kind*(ccn: ChordChartNode): ChordChartNodeKind = ccn.kind
func `$`*(pitch: ChordChartPitch): string =
case pitch
of Af: "Ab"
of A: "A"
of Bf: "Bb"
of B: "B"
of C: "C"
of Df: "Db"
of D: "D"
of Ef: "Eb"
of E: "E"
of F: "F"
of Fs: "F#"
of G: "G"
func `+`*(pitch: ChordChartPitch, steps: int): ChordChartPitch =
cast[ChordChartPitch]((ord(pitch) + steps) mod 12)
func `-`*(a, b: ChordChartPitch): int =
result = ord(a) - ord(b)
if result < 0: result += 12
func dump*(m: ChordChartMetadata, indent = ""): string =
return indent & "Metadata\p" & join(m.pairs --> map(indent & " " & it.key & ": " & it.value), "\p")
func dump*(ccn: ChordChartNode, indent = ""): string =
case ccn.kind
of ccnkSection:
let formattedChildren = ccn.sectionContents --> map(dump(it, indent & " "))
result = indent & "Section -- " & ccn.sectionName & "\p" &
formattedChildren.join("\p")
of ccnkColBreak: result = indent & "Column Break"
of ccnkPageBreak: result = indent & "Page Break"
of ccnkTransposeKey:
result = indent & "Transpose key by " & $ccn.transposeSteps
of ccnkRedefineKey:
result = indent & "Redefine key to " & $ccn.newKey
of ccnkLine:
result = indent & "Line "
for child in ccn.line:
let formattedChild = dump(child, indent)
if child.kind == ccnkWord: result &= formattedChild
else: result &= formattedChild & "\p"
of ccnkWord:
result = ""
if ccn.spaceBefore: result &= " "
if ccn.chord.isSome:
let chord = ccn.chord.get
result &= "["
if chord.original.isSome and chord.flavor.isNone and
chord.bassPitch.isNone:
result &= chord.original.get
else:
result &= $chord.rootPitch
if chord.flavor.isSome:
result &= "_" & chord.flavor.get
if chord.bassPitch.isSome:
result &= "/" & $chord.bassPitch.get
result &= "]"
if ccn.word.isSome: result &= ccn.word.get
if ccn.spaceAfter: result &= " "
of ccnkNote:
result = indent & "Note "
if not ccn.inclInLyrics: result &= "(chords only) "
result &= ccn.note
of ccnkNone:
result &= indent & "NONE"
func `$`*(cc: ChordChart): string =
result = "ChordChart\p" &
dump(cc.metadata, " ") & "\p" &
join(cc.nodes --> map(dump(it, " ")), "\p")
# -----------------------------------------------------------------------------
# PARSER
# -----------------------------------------------------------------------------
func hasNextLine(ctx: ParserContext): bool =
ctx.unparsedLineParts.len > 1 or
ctx.curLineNum + 1 < ctx.lines.len
func nextLine(ctx: var ParserContext): string =
if ctx.unparsedLineParts.len > 0:
result = ctx.unparsedLineParts[0]
ctx.unparsedLineParts.delete(0)
else:
ctx.curLineNum += 1
result = ctx.lines[ctx.curLineNum]
func pushPartialsToParse(ctx: var ParserContext, parts: varargs[string]): void =
ctx.unparsedLineParts &= parts
template makeError(ctx: ParserContext, msg: string): untyped =
newException(ValueError,
"error parsing input at line " & $ctx.curLineNum & ":\p\t" & msg)
template addToCurSection(n: ChordChartNode): untyped =
if ctx.curSection.kind == ccnkSection: ctx.curSection.sectionContents.add(n)
else: result.add(n)
proc parsePitch*(ctx: ParserContext, keyValue: string): ChordChartPitch =
let normK = keyValue.strip.toLower
case normK
of "gs", "gis", "g#", "ab", "a♭", "af", "aes": return ChordChartPitch.Af
of "g𝄪", "a", "a♮", "b𝄫": return ChordChartPitch.A
of "as", "ais", "a#", "bf", "bb", "b♭", "bes", "c𝄫": return ChordChartPitch.Bf
of "a𝄪", "b", "cb", "ces", "cf": return ChordChartPitch.B
of "bs", "bis", "b#", "c", "d𝄫": return ChordChartPitch.C
of "b𝄪", "cs", "cis", "c#", "df", "db", "des": return ChordChartPitch.Df
of "c𝄪", "d", "e𝄫": return ChordChartPitch.D
of "ds", "dis", "d#", "ef", "eb", "ees", "f𝄫": return ChordChartPitch.Ef
of "d𝄪", "e", "fes", "ff": return ChordChartPitch.E
of "es", "eis", "e#", "f", "g𝄫": return ChordChartPitch.F
of "e𝄪", "fs", "fis", "f#", "gf", "gb", "ges": return ChordChartPitch.Fs
of "f𝄪", "g", "a𝄫": return ChordChartPitch.G
of "1": return ctx.curKeyCenter
of "2": return ctx.curKeyCenter + 2
of "3": return ctx.curKeyCenter + 4
of "4": return ctx.curKeyCenter + 5
of "5": return ctx.curKeyCenter + 7
of "6": return ctx.curKeyCenter + 9
of "7": return ctx.curKeyCenter + 11
else: raise ctx.makeError(keyValue.strip & " is not a recognized key.")
# see regexr.com/70nv1
let CHORD_REGEX =
"([A-G1-7][b#♭♮𝄫𝄪]?)" & # chord root
"(([mM1-9#b♭♮𝄫𝄪Δ+oøoø][0-9]?|min|maj|aug|dim|sus|6\\/9|\\([1-9#b♭]+\\))*)" & # chord flavor/type
"(\\/([A-G1-7][b#♭♮𝄫𝄪]?))?" # optional bass
let CHORD_PAT = re(CHORD_REGEX)
proc parseChord*(
ctx: ParserContext, chordValue: string
): Option[ChordChartChord] =
let m = chordValue.match(CHORD_PAT)
if m.isNone: none[ChordChartChord]()
else:
let flavor =
if m.get.captures.contains(1) and m.get.captures[1].len > 0:
some(m.get.captures[1])
else: none[string]()
let bassPitch =
if m.get.captures.contains(4) and m.get.captures[4].len > 0:
some(ctx.parsePitch(m.get.captures[4]))
else: none[ChordChartPitch]()
return some(ChordChartChord(
original: some(chordValue),
rootPitch: ctx.parsePitch(m.get.captures[0]),
flavor: flavor,
bassPitch: bassPitch))
let METADATA_LINE_PAT = re"^([^:]+):(.*)$"
let METADATA_END_PAT = re"^-+$"
proc parseMetadata(ctx: var ParserContext): ChordChartMetadata =
var title = "MISSING"
var optProps = newStringTable()
var songKey: Option[ChordChartChord]
while ctx.curLineNum < ctx.lines.len:
let line = ctx.nextLine
if line.match(METADATA_END_PAT).isSome:
break
let m = line.match(METADATA_LINE_PAT)
if m.isNone:
raise ctx.makeError("expected metadata property or ending marker")
let key = m.get.captures[0].strip.tolower
let value = m.get.captures[1].strip
if key == "title": title = value
elif key == "key":
songKey = ctx.parseChord(value)
if songKey.isNone:
raise ctx.makeError("unrecognized key: " & value)
else: optProps[key] = value
if title == "MISSING":
raise ctx.makeError("metadata is missing the 'title' property")
if songKey.isNone:
raise ctx.makeError("metadata is missing the 'key' property")
result = ChordChartMetadata(
title: title,
key: songKey.get,
optionalProps: optProps)
const KNOWN_SECTION_NAMES = [
"chorus", "verse", "bridge", "breakdown", "vamp", "intstrumental",
"interlude", "intro", "outtro", "ending", "end", "tag", "prechorus",
"pre-chorus", "pre chorus"
]
let SECTION_LINE_PAT = re(
"^((" & "((?i)" & # case insensitive
KNOWN_SECTION_NAMES.join("|") & ")" & # known names
"|[[:upper:]]{3,}" & # all upper-case words
") ?\\d*)" & # numeric suffix (Verse 2)
# Allow notes or other text to follow on the same line
"(.*)$"
)
let COL_BREAK_PAT = re"\s*COLUMN_BREAK\s*$"
let PAGE_BREAK_PAT = re"\s*PAGE_BREAK\s*$"
let TRANSPOSE_PAT = re"\s*TRANSPOSE KEY ([+-]\d+)\s*$"
let REDEFINE_KEY_PAT = re"\s*REDEFINE KEY ([+-]\d+)\s*$"
#let NOTE_PAT = re"^(.*)({{[^}]+}}|{[^}]+})(.*)$"
let NOTE_START_PAT = re"{{"
let NOTE_END_PAT = re"}}"
let SPACE_PAT = re"\s"
let CHORD_IN_LYRICS_PAT = re"(\w+)(\[.+)"
let CHORD_AND_LYRICS_PAT = re"^\[([^\]]+)\]([^\s\[]+)(.*)$"
let BRACED_CHORD_PAT = re"^\[([^\]]+)\]$"
let NAKED_CHORDS_ONLY_PAT = re("^(" & CHORD_REGEX & "\\s*\\|*\\s*)+$")
proc parseLineParts(ctx: ParserContext, parts: varargs[string]): seq[ChordChartNode] =
result = @[]
for p in parts:
var m = p.match(SPACE_PAT)
if m.isSome:
result &= ctx.parseLineParts(p.splitWhitespace)
continue
m = p.match(CHORD_IN_LYRICS_PAT)
if m.isSome:
result &= ctx.parseLineParts(m.get.captures[0], m.get.captures[1])
continue
m = p.match(CHORD_AND_LYRICS_PAT)
if m.isSome:
result.add(ChordChartNode(
kind: ccnkWord,
spaceAfter: true, #FIXME
spaceBefore: true, #FIXME
chord: ctx.parseChord(m.get.captures[0]),
word: some(m.get.captures[1])))
result &= ctx.parseLineParts(m.get.captures[2])
continue
m = p.match(BRACED_CHORD_PAT)
if m.isSome:
result.add(ChordChartNode(
kind: ccnkWord,
spaceAfter: false, #FIXME
spaceBefore: false, #FIXME
chord: ctx.parseChord(m.get.captures[0]),
word: none[string]()))
continue
if p.len > 0:
result.add(ChordChartNode(
kind: ccnkWord,
spaceAfter: true, #FIXME
spaceBefore: true, #FIXME
chord: none[ChordChartChord](),
word: some(p)))
proc parseLine(ctx: ParserContext, line: string): ChordChartNode =
result = ChordChartNode(kind: ccnkLine)
let m = line.match(NAKED_CHORDS_ONLY_PAT)
if m.isSome:
result.line = line.splitWhitespace --> map(
ChordChartNode(
kind: ccnkWord,
spaceAfter: false, #FIXME
spaceBefore: false, #FIXME
chord: ctx.parseChord(it.strip),
word: none[string]()))
else: result.line = ctx.parseLineParts(line.splitWhitespace)
proc readNote(ctx: var ParserContext, endPat: Regex): string =
let startLineNum = ctx.curLineNum
result = ""
while ctx.lines.len > ctx.curLineNum and ctx.hasNextLine:
let line = ctx.nextLine
let m = line.find(endPat)
if m.isSome:
result &= line[0..<m.get.matchBounds.a]
ctx.pushPartialsToParse(line[m.get.matchBounds.b..^1])
break
else: result &= line
if not ctx.hasNextLine:
raise ctx.makeError("a note section was started on line " &
$startLineNum & " and never closed")
proc parseBody(ctx: var ParserContext): seq[ChordChartNode] =
result = @[]
while ctx.hasNextLine:
var line = ctx.nextLine
var m = line.find(NOTE_START_PAT)
if m.isSome:
if m.get.matchBounds.a > 0:
# if this is not the first character of the line then let's split the
# line and continue to parse
ctx.pushPartialsToParse(
line[0..<m.get.matchBounds.a],
line[m.get.matchBounds.a..^1])
continue
else:
# if this is the first character of the line, then let's parse the note
result.add(ChordChartNode(
kind: ccnkNote,
inclInLyrics: m.get.match.len < 2,
note: ctx.readNote(NOTE_END_PAT)))
m = line.match(TRANSPOSE_PAT)
if m.isSome:
addToCurSection(ChordChartNode(
kind: ccnkTransposeKey,
transposeSteps: parseInt(m.get.captures[0])))
continue
m = line.match(REDEFINE_KEY_PAT)
if m.isSome:
let newKeyInt = (
cast[int](ctx.curKeyCenter) +
parseInt(m.get.captures[0])
) mod 12
addToCurSection(ChordChartNode(
kind: ccnkRedefineKey,
newKey: cast[ChordChartPitch](newKeyInt)))
continue
m = line.match(COL_BREAK_PAT)
if m.isSome:
result.add(ChordChartNode(kind: ccnkColBreak))
continue
m = line.match(PAGE_BREAK_PAT)
if m.isSome:
result.add(ChordChartNode(kind: ccnkPageBreak))
continue
m = line.match(SECTION_LINE_PAT)
if m.isSome:
let captures = m.get.captures.toSeq
ctx.curSection = ChordChartNode(
kind: ccnkSection,
sectionName:
if captures[0].isSome: captures[0].get.strip
else: raise ctx.makeError("unknown error parsing section header: " & line),
remainingSectionLine:
if captures[3].isSome: some(captures[3].get.strip)
else: none[string](),
sectionContents: @[])
result.add(ctx.curSection)
continue
else:
addToCurSection(ctx.parseLine(line))
continue
proc parseChordChart*(s: string): ChordChart =
var parserCtx = ParserContext(
lines: s.splitLines,
curLineNum: -1,
curSection: EMPTY_CHORD_CHART_NODE,
unparsedLineParts: @[])
let metadata = parseMetadata(parserCtx)
parserCtx.curKeyCenter = metadata.key.rootPitch
result = ChordChart(
rawContent: s,
metadata: metadata,
nodes: parseBody(parserCtx))