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.. 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..