From 4a6519b1a79f7f02f72d29dadd15d577070dedbe Mon Sep 17 00:00:00 2001
From: Jonathan Bernard <jonathan@jdbernard.com>
Date: Wed, 5 Oct 2022 10:48:05 -0500
Subject: [PATCH] Refactors to support key transposition.

---
 src/pco_chordspkg/ast.nim  | 146 +++++++++++++++++++++++--------------
 src/pco_chordspkg/html.nim |  42 ++++++++---
 2 files changed, 122 insertions(+), 66 deletions(-)

diff --git a/src/pco_chordspkg/ast.nim b/src/pco_chordspkg/ast.nim
index b22446b..34d6a31 100644
--- a/src/pco_chordspkg/ast.nim
+++ b/src/pco_chordspkg/ast.nim
@@ -24,6 +24,11 @@ type
 
   ChordChartPitch* = enum Af, A, Bf, B, C, Df, D, Ef, E, F, Fs, G
 
+  ChordChartChord* = object
+    original*: string
+    rootPitch*: ChordChartPitch
+    flavor*: string
+
   ChordChartNode* = ref object
     case kind: ChordChartNodeKind
     of ccnkSection:
@@ -34,7 +39,7 @@ type
     of ccnkWord:
       spaceBefore*: bool
       spaceAfter*: bool
-      chord*: string
+      chord*: ChordChartChord
       word*: string
     of ccnkNote:
       note*: string
@@ -81,6 +86,11 @@ func `$`*(pitch: ChordChartPitch): string =
   of Fs: "F#"
   of G: "G"
 
+func `+`*(pitch: ChordChartPitch, steps: int): ChordChartPitch =
+  cast[ChordChartPitch]((ord(pitch) + steps) mod 12)
+
+func `-`*(a, b: ChordChartPitch): int = ord(a) - ord(b)
+
 func dump*(m: ChordChartMetadata, indent = ""): string =
   return indent & "Metadata\p" & join(m.pairs --> map(indent & "  " & it.key & ": " & it.value), "\p")
 
@@ -111,7 +121,10 @@ func dump*(ccn: ChordChartNode, indent = ""): string =
   of ccnkWord:
     result = ""
     if ccn.spaceBefore: result &= " "
-    if ccn.chord.len > 0: result &= "[" & ccn.chord & "]"
+    if ccn.chord.flavor.len > 0:
+      result &= "[" & $ccn.chord.rootPitch & "_" & ccn.chord.flavor & "]"
+    elif ccn.chord.original.len > 0: result &= "[" & ccn.chord.original & "]"
+    if ccn.chord.original.len > 0: result &= "[" & ccn.chord.original & "]"
     result &= ccn.word
     if ccn.spaceAfter: result &= " "
 
@@ -154,20 +167,46 @@ template addToCurSection(n: ChordChartNode): untyped =
 
 func parsePitch*(ctx: ParserContext, keyValue: string): ChordChartPitch =
   case keyValue.strip.toLower
-  of "gs", "gis", "g#", "ab", "af", "aes": return ChordChartPitch.Af
-  of "a": return ChordChartPitch.A
-  of "as", "ais", "a#", "bf", "bb", "bes": return ChordChartPitch.Bf
-  of "b", "ces", "cf": return ChordChartPitch.B
-  of "bs", "bis", "b#", "c": return ChordChartPitch.C
-  of "cs", "cis", "c#", "df", "db", "des": return ChordChartPitch.Df
-  of "d": return ChordChartPitch.D
-  of "ds", "dis", "d#", "ef", "eb", "ees": return ChordChartPitch.Ef
-  of "e", "fes", "ff": return ChordChartPitch.E
-  of "es", "eis", "e#", "f": return ChordChartPitch.F
-  of "fs", "fis", "f#", "gf", "gb", "ges": return ChordChartPitch.Fs
-  of "g": return ChordChartPitch.G
+  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", "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
   else: raise ctx.makeError(keyValue.strip & " is not a recognized key.")
 
+let CHORD_REGEX =
+  "([[:upper:]][b#♭♮𝄫𝄪]?)" &                #      chord root
+  "([mM1-9#b♭♮𝄫𝄪Δ+oøoø]|min|maj|aug|dim|sus|6\\/9)*" &  # chord flavor/type
+  "(\\/[[:upper:]])?"                               # optional bass
+let CHORD_PAT = re(CHORD_REGEX)
+
+proc parseChord*(ctx: ParserContext, chordValue: string): ChordChartChord =
+  let m = chordValue.match(CHORD_PAT)
+  if m.isNone:
+    return ChordChartChord(
+      original: chordValue,
+      flavor: "")
+  else:
+    let flavor =
+      if m.get.captures.contains(1): m.get.captures[1]
+      else: ""
+
+    let bass =
+      if m.get.captures.contains(2): m.get.captures[2]
+      else: ""
+
+    return ChordChartChord(
+      original: chordValue,
+      rootPitch: ctx.parsePitch(m.get.captures[0]),
+      flavor: flavor & bass)
+
 let METADATA_LINE_PAT = re"^([^:]+):(.*)$"
 let METADATA_END_PAT = re"^-+$"
 proc parseMetadata(ctx: var ParserContext): ChordChartMetadata =
@@ -203,11 +242,6 @@ proc parseMetadata(ctx: var ParserContext): ChordChartMetadata =
     key: ctx.parsePitch(songKey),
     optionalProps: optProps)
 
-let NAKED_CHORD_REGEX =
-  "[[:upper:]][b#♭♮𝄫𝄪]?" &                #      chord root
-  "([mM1-9#b♭♮𝄫𝄪Δ+oøoø]|min|maj|aug|dim|sus|6\\/9)*" &  # chord flavor/type
-  "(\\/[[:upper:]])?"                               # optional bass
-
 const KNOWN_SECTION_NAMES = [
   "chorus", "verse", "bridge", "breakdown", "vamp", "intstrumental",
   "interlude", "intro", "outtro", "ending", "end", "tag"
@@ -233,19 +267,19 @@ 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("^(" & NAKED_CHORD_REGEX & "\\s*\\|*\\s*)+$")
+let NAKED_CHORDS_ONLY_PAT = re("^(" & CHORD_REGEX & "\\s*\\|*\\s*)+$")
 
-proc parseLineParts(parts: varargs[string]): seq[ChordChartNode] =
+proc parseLineParts(ctx: ParserContext, parts: varargs[string]): seq[ChordChartNode] =
   result = @[]
   for p in parts:
     var m = p.match(SPACE_PAT)
     if m.isSome:
-      result &= parseLineParts(p.splitWhitespace)
+      result &= ctx.parseLineParts(p.splitWhitespace)
       continue
 
     m = p.match(CHORD_IN_LYRICS_PAT)
     if m.isSome:
-      result &= parseLineParts(m.get.captures[0], m.get.captures[1])
+      result &= ctx.parseLineParts(m.get.captures[0], m.get.captures[1])
       continue
 
     m = p.match(CHORD_AND_LYRICS_PAT)
@@ -254,9 +288,9 @@ proc parseLineParts(parts: varargs[string]): seq[ChordChartNode] =
         kind: ccnkWord,
         spaceAfter: true, #FIXME
         spaceBefore: true, #FIXME
-        chord: m.get.captures[0],
+        chord: ctx.parseChord(m.get.captures[0]),
         word: m.get.captures[1]))
-      result &= parseLineParts(m.get.captures[2])
+      result &= ctx.parseLineParts(m.get.captures[2])
       continue
 
     m = p.match(BRACED_CHORD_PAT)
@@ -265,7 +299,7 @@ proc parseLineParts(parts: varargs[string]): seq[ChordChartNode] =
         kind: ccnkWord,
         spaceAfter: false, #FIXME
         spaceBefore: false, #FIXME
-        chord: m.get.captures[0],
+        chord: ctx.parseChord(m.get.captures[0]),
         word: ""))
       continue
 
@@ -274,10 +308,10 @@ proc parseLineParts(parts: varargs[string]): seq[ChordChartNode] =
         kind: ccnkWord,
           spaceAfter: true, #FIXME
           spaceBefore: true, #FIXME
-          chord: "",
+          chord: ChordChartChord(original: "", flavor: ""),
           word: p))
 
-proc parseLine(line: string): ChordChartNode =
+proc parseLine(ctx: ParserContext, line: string): ChordChartNode =
   result = ChordChartNode(kind: ccnkLine)
 
   let m = line.match(NAKED_CHORDS_ONLY_PAT)
@@ -287,10 +321,10 @@ proc parseLine(line: string): ChordChartNode =
         kind: ccnkWord,
         spaceAfter: false, #FIXME
         spaceBefore: false, #FIXME
-        chord: it.strip,
+        chord: ctx.parseChord(it.strip),
         word: ""))
 
-  else: result.line = parseLineParts(line.splitWhitespace)
+  else: result.line = ctx.parseLineParts(line.splitWhitespace)
 
 proc readNote(ctx: var ParserContext, endPat: Regex): string =
   let startLineNum = ctx.curLineNum
@@ -331,31 +365,6 @@ proc parseBody(ctx: var ParserContext): seq[ChordChartNode] =
           inclInLyrics: m.get.match .len < 2,
           note: ctx.readNote(NOTE_END_PAT)))
 
-    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),
-        sectionContents: @[])
-      result.add(ctx.curSection)
-      if captures[3].isSome:
-        ctx.curSection.sectionContents &= parseLineParts(captures[3].get)
-      continue
-
-    # FIXME: as implemented, this will not allow column breaks within a section
-    m = line.match(COL_BREAK_PAT)
-    if m.isSome:
-      result.add(ChordChartNode(kind: ccnkColBreak))
-      continue
-
-    # FIXME: as implemented, this will not allow page breaks within a section
-    m = line.match(PAGE_BREAK_PAT)
-    if m.isSome:
-      result.add(ChordChartNode(kind: ccnkPageBreak))
-      continue
-
     m = line.match(TRANSPOSE_PAT)
     if m.isSome:
       addToCurSection(ChordChartNode(
@@ -375,8 +384,33 @@ proc parseBody(ctx: var ParserContext): seq[ChordChartNode] =
         newKey: cast[ChordChartPitch](newKeyInt)))
       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),
+        sectionContents: @[])
+      result.add(ctx.curSection)
+      if captures[3].isSome:
+        ctx.curSection.sectionContents &= ctx.parseLineParts(captures[3].get)
+      continue
+
+    # FIXME: as implemented, this will not allow column breaks within a section
+    m = line.match(COL_BREAK_PAT)
+    if m.isSome:
+      result.add(ChordChartNode(kind: ccnkColBreak))
+      continue
+
+    # FIXME: as implemented, this will not allow page breaks within a section
+    m = line.match(PAGE_BREAK_PAT)
+    if m.isSome:
+      result.add(ChordChartNode(kind: ccnkPageBreak))
+      continue
+
     else:
-      addToCurSection(parseLine(line))
+      addToCurSection(ctx.parseLine(line))
       continue
 
 
diff --git a/src/pco_chordspkg/html.nim b/src/pco_chordspkg/html.nim
index 9af4ed5..6a95fdf 100644
--- a/src/pco_chordspkg/html.nim
+++ b/src/pco_chordspkg/html.nim
@@ -3,6 +3,12 @@ import zero_functional
 
 import ./ast
 
+type
+  FormatContext = ref object
+    chart: ChordChart
+    currentKey: ChordChartPitch
+    sourceKey: ChordChartPitch
+
 const DEFAULT_STYLESHEET* = """
 <style>
 * {
@@ -51,22 +57,28 @@ h3 {
 </style>
 """
 
-proc toHtml(node: ChordChartNode, indent: string): string =
+proc transpose(ctx: FormatContext, chord: ChordChartChord): string =
+  let distance = ctx.currentKey - ctx.sourceKey
+  if distance != 0: return $(chord.rootPitch + distance) & chord.flavor
+  else: return chord.original
+
+proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): string =
   result = ""
   case node.kind
   of ccnkSection:
     result &= indent & "<section>\p" &
       indent & "  " & "<h3>" & node.sectionName & "</h3>\p"
 
-    result &= join(
-      node.sectionContents --> map(it.toHtml(indent & "  ")),
-      "\p")
+    var contents = newSeq[string]()
+    for contentNode in node.sectionContents:
+      contents.add(ctx.toHtml(contentNode, indent & "  "))
+    result &= contents.join("\p")
 
     result &= indent & "</section>"
 
   of ccnkLine:
     result &= indent & "<div class=line>\p"
-    result &= join(node.line --> map(it.toHtml(indent & "  ")), "")
+    for linePart in node.line: result &= ctx.toHtml(linePart, indent & "  ")
     result &= indent & "</div>"
 
   of ccnkWord:
@@ -75,8 +87,8 @@ proc toHtml(node: ChordChartNode, indent: string): string =
     if node.spaceAfter: result&=" space-after"
     result &= "'>"
 
-    if node.chord.len > 0:
-      result &= "<span class=chord>" & node.chord & "</span>"
+    if node.chord.original.len > 0:
+      result &= "<span class=chord>" & ctx.transpose(node.chord) & "</span>"
 
     result &= "<span class=lyric>"
     if node.word.len > 0: result &= node.word
@@ -90,14 +102,24 @@ proc toHtml(node: ChordChartNode, indent: string): string =
 
   of ccnkColBreak: discard #FIXME
   of ccnkPageBreak: discard #FIXME
-  of ccnkTransposeKey: discard #FIXME
-  of ccnkRedefineKey: discard #FIXME
+
+  of ccnkTransposeKey:
+    ctx.currentKey = ctx.currentKey + node.transposeSteps
+
+  of ccnkRedefineKey:
+    ctx.sourceKey = ctx.currentKey + node.transposeSteps
+    ctx.currentKey = ctx.sourceKey
 
 proc toHtml*(
     cc: ChordChart,
     stylesheets = @[DEFAULT_STYLESHEET],
     scripts: seq[string] = @[]): string =
 
+  var ctx = FormatContext(
+    chart: cc,
+    currentKey: cc.metadata.key,
+    sourceKey: cc.metadata.key)
+
   result = """<!doctype html>
 <html>
   <head>
@@ -124,6 +146,6 @@ proc toHtml*(
   if cc.metadata.contains("bpm"): result &= " " & cc.metadata["bpm"] & "bpm"
   result &= "</h2>\p"
 
-  result &= join(cc.nodes --> map(it.toHtml(indent & "  ")), "\p")
+  result &= join(cc.nodes --> map(ctx.toHtml(it, indent & "  ")), "\p")
 
   result &= "  </body>\p</html>"