From fa6045d4a327b6f027a899f2ce74acb9bab44514 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Thu, 17 Feb 2022 15:46:55 -0500 Subject: [PATCH 01/17] Start grammar for OnSong parser --- .eslintignore | 1 + .gitignore | 1 + package.json | 4 +- src/parser/on_song_grammar.pegjs | 110 +++++++++++++++ test/parser/on_song_grammar.test.js | 207 ++++++++++++++++++++++++++++ yarn.lock | 8 +- 6 files changed, 325 insertions(+), 6 deletions(-) create mode 100644 src/parser/on_song_grammar.pegjs create mode 100644 test/parser/on_song_grammar.test.js diff --git a/.eslintignore b/.eslintignore index e2ba0919..fd70fa19 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,3 +2,4 @@ lib node_modules src/formatter/templates/*.js src/parser/chord_pro_peg_parser.js +src/parser/on_song_grammar.js diff --git a/.gitignore b/.gitignore index e71b3eed..fef72012 100755 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ node_modules lib src/formatter/templates/*.js src/parser/chord_pro_peg_parser.js +src/parser/on_song_grammar.js .tool-versions diff --git a/package.json b/package.json index 4cb93e93..24a56b12 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "eslint-plugin-import": "^2.21.2", "jest": "^27.0.1", "jsdoc-to-markdown": "^7.1.0", - "pegjs": "^0.10.0", + "peggy": "^1.2.0", "print": "^1.2.0" }, "scripts": { @@ -35,7 +35,7 @@ "build:template": "handlebars \"src/formatter/templates/$TEMPLATE.handlebars\" -f \"src/formatter/templates/$TEMPLATE.js\" --known each --known if --known with --known paragraphClasses --known isChordLyricsPair --known isTag --known isComment --known shouldRenderLine --known hasChordContents --known lineHasContents --known lineClasses --known toUpperCase --known paragraphClasses --commonjs handlebars", "build:templates": "TEMPLATE=html_div_formatter yarn build:template && TEMPLATE=html_table_formatter yarn build:template", "build:sources": "rm -rf lib && babel src --out-dir lib", - "build:pegjs": "pegjs -o src/parser/chord_pro_peg_parser.js src/parser/chord_pro_grammar.pegjs", + "build:pegjs": "peggy -o src/parser/chord_pro_peg_parser.js src/parser/chord_pro_grammar.pegjs && peggy src/parser/on_song_grammar.pegjs", "build": "yarn build:templates && yarn build:pegjs && yarn build:sources", "readme": "jsdoc2md -f src/**/*.js -f src/*.js --template doc/README.hbs > README.md", "prepublishOnly": "yarn install && yarn test && yarn build", diff --git a/src/parser/on_song_grammar.pegjs b/src/parser/on_song_grammar.pegjs new file mode 100644 index 00000000..e0ece0ce --- /dev/null +++ b/src/parser/on_song_grammar.pegjs @@ -0,0 +1,110 @@ +// https://www.onsongapp.com/docs/features/formats/onsong/ + +ChordSheet + = metadata:Metadata __ sections:Section* { + return { type: "chordsheet", metadata, sections } + } + +// https://www.onsongapp.com/docs/features/formats/onsong/metadata/ +Metadata + // First line is implicitly title + = first:(!Metatag value:MetaValue EOL { + return { type: "metatag", name: "title", value } + })? + // Second line is implicitly artist + second:(!Metatag value:MetaValue EOL { + return { type: "metatag", name: "artist", value } + })? + rest:(@Metatag __)* + { + return [ first, second, ...rest ].filter(Boolean) + } + +Metatag "metatag" + = name:MetaName _ ":" _ value:MetaValue EOL { + return { type: "metatag", name: name.toLowerCase().trim(), value } + } + +MetaName + = $[^\r\n:]+ + +MetaValue + = $[^\r\n]+ + +// https://www.onsongapp.com/docs/features/formats/onsong/section/ +Section + // Section with explcit name and maybe a body + = name:SectionName __ items:SectionBody* { return { type: 'section', name, items } } + // Section without explicit name + / items:SectionBody+ { return { type: 'section', name: null, items } } + +SectionName + = name:MetaName _ ":" EOL { + return name.trim() + } + +SectionBody + = @Stanza __ + +Stanza + = lines:Line+ { + return { type: 'stanza', lines } + } + +Line + = parts:(ChordLyricsPair / JustChords / JustLyrics)+ EOL { + return {type: 'line', parts } + } + +JustChords + = chords:Chord { + return { type: "ChordLyricsPair", chords, lyrics: "" } + } + +JustLyrics + = lyrics:Lyrics { + return { type: "ChordLyricsPair", lyrics, chords: "" } + } + +ChordLyricsPair + = chords:Chord lyrics:Lyrics { + return { type: "ChordLyricsPair", chords: chords || '', lyrics } + } + +Chord "chord" + = !Escape "[" chords:$(ChordChar*) "]" { + return chords; + } + +ChordChar + = [^\]] + +Lyrics "lyrics" + = $(Char+) + +// MusicalInstruction // e.g. (Repeat 8x) +// = "(" [^)]+ ")" + +Char + = [^\|\[\]\\#\r\n] + +Escape + = "\\" + +Space + = "\t" / " " + +NewLine "new line" + = "\r\n" / "\r" / "\n" + +EOL "end of line" // Strict linebreak or end of file + = _ (NewLine / EOF) + +EOF + = !. + +_ // Insignificant space + = Space* + +__ // Insignificant whitespace + = (Space / NewLine)* diff --git a/test/parser/on_song_grammar.test.js b/test/parser/on_song_grammar.test.js new file mode 100644 index 00000000..62ca3528 --- /dev/null +++ b/test/parser/on_song_grammar.test.js @@ -0,0 +1,207 @@ +import * as peggy from 'peggy'; +import '../matchers'; +import { readFileSync } from 'fs'; + +describe('OnSongGrammar', () => { + const examples = { + Metadata: { + // First and second lines are assumed to be title / artist + 'Song Title\nArtist Name': [ + { type: 'metatag', name: 'title', value: 'Song Title' }, + { type: 'metatag', name: 'artist', value: 'Artist Name' }, + ], + 'Song Title\nArtist:Artist Name': [ + { type: 'metatag', name: 'title', value: 'Song Title' }, + { type: 'metatag', name: 'artist', value: 'Artist Name' }, + ], + 'Song Title\nTime:3/4': [ + { type: 'metatag', name: 'title', value: 'Song Title' }, + { type: 'metatag', name: 'time', value: '3/4' }, + ], + 'title: Song Title\nArtist: Artist Name': [ + { type: 'metatag', name: 'title', value: 'Song Title' }, + { type: 'metatag', name: 'artist', value: 'Artist Name' }, + ], + // "before the first blank line or until no more metatags are encountered" + 'A: 1\n\nB:2\n\n': [ + { type: 'metatag', name: 'a', value: '1' }, + { type: 'metatag', name: 'b', value: '2' }, + ], + // TODO: + // "{title: ChordPro}": [ + // { type: 'metatag', name: 'title', value: 'ChordPro' }, + // ] + // "Notes:" : { // Known metatag without a value + // { type: 'metatag', name: 'Notes', value: '' }, + // } + 'Unknown Tag:': Error, // Unknown metatag without a value + }, + + // Inline Tags - https://www.onsongapp.com/docs/features/formats/onsong/metadata/?#inline-tags + + SectionName: { + 'Chorus:': 'Chorus', + 'Verse 1:\n': 'Verse 1', + 'Intro :': 'Intro', + 'Intro: ': 'Intro', + }, + + Section: { + 'Chorus:\nThis is a stanza\n\nThis is another stanza': { + type: 'section', + name: 'Chorus', + items: [ + { + type: 'stanza', + lines: [ + { type: 'line', parts: [{ type: 'ChordLyricsPair', chords: '', lyrics: 'This is a stanza' }] }, + ], + }, + { + type: 'stanza', + lines: [ + { type: 'line', parts: [{ type: 'ChordLyricsPair', chords: '', lyrics: 'This is another stanza' }] }, + ], + }, + ], + }, + + 'Intro:\n\n': { + type: 'section', + name: 'Intro', + items: [], + }, + + 'Intro:\n\n\n[G]': { + type: 'section', + name: 'Intro', + items: [ + { + type: 'stanza', + lines: [{ type: 'line', parts: [{ type: 'ChordLyricsPair', chords: 'G', lyrics: '' }] }], + }, + ], + }, + + 'Chord and lyrics': { + type: 'section', + name: null, + items: [ + { + type: 'stanza', + lines: [ + { type: 'line', parts: [{ type: 'ChordLyricsPair', chords: '', lyrics: 'Chord and lyrics' }] }, + ], + }, + ], + }, + }, + + Line: { + 'This [D]is a s[G]ong,': { + type: 'line', + parts: [ + { type: 'ChordLyricsPair', chords: '', lyrics: 'This ' }, + { type: 'ChordLyricsPair', chords: 'D', lyrics: 'is a s' }, + { type: 'ChordLyricsPair', chords: 'G', lyrics: 'ong,' }, + ], + }, + 'Ends with a chord [D]': { + type: 'line', + parts: [ + { type: 'ChordLyricsPair', chords: '', lyrics: 'Ends with a chord ' }, + { type: 'ChordLyricsPair', chords: 'D', lyrics: '' }, + ], + }, + '[D]Starts with a chord': { + type: 'line', + parts: [ + { type: 'ChordLyricsPair', chords: 'D', lyrics: 'Starts with a chord' }, + ], + }, + 'Just lyrics': { + type: 'line', + parts: [ + { type: 'ChordLyricsPair', chords: '', lyrics: 'Just lyrics' }, + ], + }, + + '[G]': { + type: 'line', + parts: [{ type: 'ChordLyricsPair', chords: 'G', lyrics: '' }], + }, + }, + + Chord: { + '[G]': 'G', + '[D/F#]': 'D/F#', + '[Bsus2]': 'Bsus2', + '\\[notachord]': Error, + }, + + ChordSheet: { + 'Title\n\nChord and lyrics': { + type: 'chordsheet', + metadata: [{ type: 'metatag', name: 'title', value: 'Title' }], + sections: [ + { + type: 'section', + items: [ + { + lines: [ + { parts: [{ chords: '', lyrics: 'Chord and lyrics', type: 'ChordLyricsPair' }], type: 'line' }, + ], + type: 'stanza', + }, + ], + name: null, + }, + ], + }, + 'Title\n\nIntro:\n': { + type: 'chordsheet', + metadata: [{ type: 'metatag', name: 'title', value: 'Title' }], + sections: [ + { + type: 'section', + name: 'Intro', + items: [], + }, + ], + }, + 'Tempo: 73\nUnknown(s): Value:with@various:characters1-5\n\nChorus:': { + type: 'chordsheet', + metadata: [ + { name: 'tempo', type: 'metatag', value: '73' }, + { name: 'unknown(s)', type: 'metatag', value: 'Value:with@various:characters1-5' }, + ], + sections: [ + { items: [], name: 'Chorus', type: 'section' }, + ], + }, + }, + }; + + const grammar = readFileSync('src/parser/on_song_grammar.pegjs', { encoding: 'utf-8' }); + const { parse } = peggy.generate(grammar, { + // Allow starting with these in tests + allowedStartRules: Object.keys(examples), + }); + + Object.entries(examples).forEach(([startRule, ruleExamples]) => { + describe(startRule, () => { + Object.entries(ruleExamples).forEach(([input, expected]) => { + test(JSON.stringify(input), () => { + try { + const actual = parse(input, { startRule }); + expect(actual).toEqual(expected); + } catch (e) { + if (expected !== Error) { + throw e; + } + } + }); + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 46acc147..ea084cfe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3931,10 +3931,10 @@ path-parse@^1.0.6: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -pegjs@^0.10.0: - version "0.10.0" - resolved "https://registry.yarnpkg.com/pegjs/-/pegjs-0.10.0.tgz#cf8bafae6eddff4b5a7efb185269eaaf4610ddbd" - integrity sha1-z4uvrm7d/0tafvsYUmnqr0YQ3b0= +peggy@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/peggy/-/peggy-1.2.0.tgz#657ba45900cbef1dc9f52356704bdbb193c2021c" + integrity sha512-PQ+NKpAobImfMprYQtc4Egmyi29bidRGEX0kKjCU5uuW09s0Cthwqhfy7mLkwcB4VcgacE5L/ZjruD/kOPCUUw== picocolors@^1.0.0: version "1.0.0" From a9773b838fd26778c47dbefbfb2629f64642f90b Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Thu, 17 Feb 2022 20:39:58 -0500 Subject: [PATCH 02/17] Annotate errors for syntax errors --- package.json | 1 + test/parser/on_song_grammar.test.js | 17 ++++++++++++++--- yarn.lock | 5 +++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 24a56b12..0ad7775c 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@babel/eslint-parser": "^7.16.3", "@babel/plugin-transform-runtime": "^7.10.1", "@babel/preset-env": "^7.10.2", + "annotate-code": "^2.0.1", "babel-plugin-handlebars-inline-precompile": "^2.1.1", "eslint": "^8.3.0", "eslint-config-airbnb-base": "^15.0.0", diff --git a/test/parser/on_song_grammar.test.js b/test/parser/on_song_grammar.test.js index 62ca3528..1eef8ebb 100644 --- a/test/parser/on_song_grammar.test.js +++ b/test/parser/on_song_grammar.test.js @@ -1,6 +1,7 @@ import * as peggy from 'peggy'; import '../matchers'; import { readFileSync } from 'fs'; +import { annotate } from 'annotate-code'; describe('OnSongGrammar', () => { const examples = { @@ -183,7 +184,7 @@ describe('OnSongGrammar', () => { }; const grammar = readFileSync('src/parser/on_song_grammar.pegjs', { encoding: 'utf-8' }); - const { parse } = peggy.generate(grammar, { + const { parse, SyntaxError } = peggy.generate(grammar, { // Allow starting with these in tests allowedStartRules: Object.keys(examples), }); @@ -191,12 +192,22 @@ describe('OnSongGrammar', () => { Object.entries(examples).forEach(([startRule, ruleExamples]) => { describe(startRule, () => { Object.entries(ruleExamples).forEach(([input, expected]) => { - test(JSON.stringify(input), () => { + test(input, () => { try { const actual = parse(input, { startRule }); expect(actual).toEqual(expected); } catch (e) { - if (expected !== Error) { + if (expected === Error) { + // expected, do nothing + } else if (e instanceof SyntaxError) { + const opts = { + message: e.message, + index: e.location.start.offset, + size: e.location.end.offset - e.location.start.offset, + input, + }; + throw new Error(annotate(opts).message); + } else { throw e; } } diff --git a/yarn.lock b/yarn.lock index ea084cfe..f1fd9bf9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1420,6 +1420,11 @@ ajv@^6.10.0, ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +annotate-code@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/annotate-code/-/annotate-code-2.0.1.tgz#42828010861e8c2c476d0a8ee5d849b60195dd97" + integrity sha512-5YgsWQu2aVLsBqEdOF8AeA/aXcWpo6sx8y3zdXRF14oR2xwxeTC3PqIQFCWCFeIvZlFBY8vgT9gDxFu/JEBt8A== + ansi-escape-sequences@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/ansi-escape-sequences/-/ansi-escape-sequences-4.1.0.tgz#2483c8773f50dd9174dd9557e92b1718f1816097" From 3055b11111fa659eb57e8c5709b972e9e27cbe60 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Thu, 17 Feb 2022 20:40:18 -0500 Subject: [PATCH 03/17] Parse tabs --- src/parser/on_song_grammar.pegjs | 21 ++++++++++++++++++--- test/parser/on_song_grammar.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/parser/on_song_grammar.pegjs b/src/parser/on_song_grammar.pegjs index e0ece0ce..926a8d80 100644 --- a/src/parser/on_song_grammar.pegjs +++ b/src/parser/on_song_grammar.pegjs @@ -1,7 +1,7 @@ // https://www.onsongapp.com/docs/features/formats/onsong/ ChordSheet - = metadata:Metadata __ sections:Section* { + = metadata:Metadata sections:Section* { return { type: "chordsheet", metadata, sections } } @@ -16,6 +16,7 @@ Metadata return { type: "metatag", name: "artist", value } })? rest:(@Metatag __)* + __ { return [ first, second, ...rest ].filter(Boolean) } @@ -44,7 +45,7 @@ SectionName } SectionBody - = @Stanza __ + = @(Tab / Stanza) __ Stanza = lines:Line+ { @@ -82,6 +83,20 @@ ChordChar Lyrics "lyrics" = $(Char+) +Tab + = SotDirective NewLine content:$(!EotDirective TabLine __)+ EotDirective { + return { type: 'tab', content } + } + / StartOfTabDirective NewLine content:$(!EndOfTabDirective TabLine __)+ EndOfTabDirective { + return { type: 'tab', content } + } + +SotDirective = "{sot}" +StartOfTabDirective = "{start_of_tab}" +EotDirective = "{eot}" +EndOfTabDirective = "{end_of_tab}" +TabLine = [^\r\n]+ NewLine + // MusicalInstruction // e.g. (Repeat 8x) // = "(" [^)]+ ")" @@ -92,7 +107,7 @@ Escape = "\\" Space - = "\t" / " " + = [ \t] NewLine "new line" = "\r\n" / "\r" / "\n" diff --git a/test/parser/on_song_grammar.test.js b/test/parser/on_song_grammar.test.js index 1eef8ebb..74e06286 100644 --- a/test/parser/on_song_grammar.test.js +++ b/test/parser/on_song_grammar.test.js @@ -84,6 +84,14 @@ describe('OnSongGrammar', () => { ], }, + 'Intro:\n{sot}\ntab\n{eot}': { + type: 'section', + name: 'Intro', + items: [ + { type: 'tab', content: 'tab\n' }, + ], + }, + 'Chord and lyrics': { type: 'section', name: null, @@ -140,6 +148,23 @@ describe('OnSongGrammar', () => { '\\[notachord]': Error, }, + Tab: { + '{sot}\nthe tab\nis here\n{eot}': { + type: 'tab', + content: 'the tab\nis here\n', + }, + '{start_of_tab}\ntab here\n{end_of_tab}': { + type: 'tab', + content: 'tab here\n', + }, + '{sot}\npart1\n\npart2\n{eot}': { + type: 'tab', + content: 'part1\n\npart2\n', + }, + '{sot}\ntab\n{end_of_tab}': Error, + '{start_of_tab}\ntab\n{eot}': Error, + }, + ChordSheet: { 'Title\n\nChord and lyrics': { type: 'chordsheet', From 6ecb036e971085a5168e495cbf4c803741fbdb66 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Fri, 18 Feb 2022 14:20:51 -0500 Subject: [PATCH 04/17] Add pegjs-tracer in test for more helpful error messages --- package.json | 1 + test/parser/on_song_grammar.test.js | 8 ++++++-- yarn.lock | 5 +++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0ad7775c..582e5187 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "jest": "^27.0.1", "jsdoc-to-markdown": "^7.1.0", "peggy": "^1.2.0", + "pegjs-backtrace": "^0.2.1", "print": "^1.2.0" }, "scripts": { diff --git a/test/parser/on_song_grammar.test.js b/test/parser/on_song_grammar.test.js index 74e06286..e61c4a1c 100644 --- a/test/parser/on_song_grammar.test.js +++ b/test/parser/on_song_grammar.test.js @@ -2,6 +2,7 @@ import * as peggy from 'peggy'; import '../matchers'; import { readFileSync } from 'fs'; import { annotate } from 'annotate-code'; +import Tracer from 'pegjs-backtrace'; describe('OnSongGrammar', () => { const examples = { @@ -212,14 +213,17 @@ describe('OnSongGrammar', () => { const { parse, SyntaxError } = peggy.generate(grammar, { // Allow starting with these in tests allowedStartRules: Object.keys(examples), + trace: true, }); Object.entries(examples).forEach(([startRule, ruleExamples]) => { describe(startRule, () => { Object.entries(ruleExamples).forEach(([input, expected]) => { test(input, () => { + const tracer = new Tracer(input); + try { - const actual = parse(input, { startRule }); + const actual = parse(input, { startRule, tracer }); expect(actual).toEqual(expected); } catch (e) { if (expected === Error) { @@ -231,7 +235,7 @@ describe('OnSongGrammar', () => { size: e.location.end.offset - e.location.start.offset, input, }; - throw new Error(annotate(opts).message); + throw new Error([annotate(opts).message, tracer.getBacktraceString()].join("\n\n")); } else { throw e; } diff --git a/yarn.lock b/yarn.lock index f1fd9bf9..9709da4e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3941,6 +3941,11 @@ peggy@^1.2.0: resolved "https://registry.yarnpkg.com/peggy/-/peggy-1.2.0.tgz#657ba45900cbef1dc9f52356704bdbb193c2021c" integrity sha512-PQ+NKpAobImfMprYQtc4Egmyi29bidRGEX0kKjCU5uuW09s0Cthwqhfy7mLkwcB4VcgacE5L/ZjruD/kOPCUUw== +pegjs-backtrace@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/pegjs-backtrace/-/pegjs-backtrace-0.2.1.tgz#1e09c1fafff37877f92aae5433a7026e03385932" + integrity sha512-rnVQiHyTE1wZG14Vl3Xk33ecrF7ZJ7ZW7jSgSlw4LdzBuhbyGVQ+oVApQ6tRi4QsII/xHgByHb6Ax68K6SPLhw== + picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" From bdb9fbc9a0e197b072e8102fb1fc90c8552340b4 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Fri, 18 Feb 2022 15:42:00 -0500 Subject: [PATCH 05/17] Refactor implicit metatag parsing --- src/parser/on_song_grammar.pegjs | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/src/parser/on_song_grammar.pegjs b/src/parser/on_song_grammar.pegjs index 926a8d80..14c81fa0 100644 --- a/src/parser/on_song_grammar.pegjs +++ b/src/parser/on_song_grammar.pegjs @@ -1,5 +1,4 @@ // https://www.onsongapp.com/docs/features/formats/onsong/ - ChordSheet = metadata:Metadata sections:Section* { return { type: "chordsheet", metadata, sections } @@ -7,28 +6,24 @@ ChordSheet // https://www.onsongapp.com/docs/features/formats/onsong/metadata/ Metadata - // First line is implicitly title - = first:(!Metatag value:MetaValue EOL { - return { type: "metatag", name: "title", value } - })? - // Second line is implicitly artist - second:(!Metatag value:MetaValue EOL { - return { type: "metatag", name: "artist", value } - })? - rest:(@Metatag __)* - __ - { - return [ first, second, ...rest ].filter(Boolean) - } + = (@Metatag __)* Metatag "metatag" - = name:MetaName _ ":" _ value:MetaValue EOL { - return { type: "metatag", name: name.toLowerCase().trim(), value } - } + = name:((@MetaName _ ":") / ImplicitMetaName) _ value:MetaValue EOL { + return { type: "metatag", name: name.toLowerCase().trim(), value } + } MetaName = $[^\r\n:]+ +ImplicitMetaName + = & { return location().start.line <= 2 } { + switch(location().start.line) { + case 1: return 'title'; // First line is implicitly title + case 2: return 'artist'; // Second line is implicitly artist + } + } + MetaValue = $[^\r\n]+ From 1e1677d5afc0a6d0e647c068cd9dc015d37d765e Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sat, 19 Feb 2022 08:45:13 -0500 Subject: [PATCH 06/17] Support for chordpro metadata directives --- src/parser/on_song_grammar.pegjs | 9 +++++---- test/parser/on_song_grammar.test.js | 12 ++++-------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/parser/on_song_grammar.pegjs b/src/parser/on_song_grammar.pegjs index 14c81fa0..7f127e6d 100644 --- a/src/parser/on_song_grammar.pegjs +++ b/src/parser/on_song_grammar.pegjs @@ -6,15 +6,16 @@ ChordSheet // https://www.onsongapp.com/docs/features/formats/onsong/metadata/ Metadata - = (@Metatag __)* + = (@Metatag EOL __)* Metatag "metatag" - = name:((@MetaName _ ":") / ImplicitMetaName) _ value:MetaValue EOL { + = "{" _ @Metatag _ "}" + / name:((@MetaName _ ":") / ImplicitMetaName) _ value:MetaValue { return { type: "metatag", name: name.toLowerCase().trim(), value } } MetaName - = $[^\r\n:]+ + = $[^\r\n:{]+ ImplicitMetaName = & { return location().start.line <= 2 } { @@ -25,7 +26,7 @@ ImplicitMetaName } MetaValue - = $[^\r\n]+ + = $[^\r\n}]+ // https://www.onsongapp.com/docs/features/formats/onsong/section/ Section diff --git a/test/parser/on_song_grammar.test.js b/test/parser/on_song_grammar.test.js index e61c4a1c..0edde54d 100644 --- a/test/parser/on_song_grammar.test.js +++ b/test/parser/on_song_grammar.test.js @@ -29,13 +29,9 @@ describe('OnSongGrammar', () => { { type: 'metatag', name: 'a', value: '1' }, { type: 'metatag', name: 'b', value: '2' }, ], - // TODO: - // "{title: ChordPro}": [ - // { type: 'metatag', name: 'title', value: 'ChordPro' }, - // ] - // "Notes:" : { // Known metatag without a value - // { type: 'metatag', name: 'Notes', value: '' }, - // } + '{title: ChordPro}': [ + { type: 'metatag', name: 'title', value: 'ChordPro' }, + ], 'Unknown Tag:': Error, // Unknown metatag without a value }, @@ -235,7 +231,7 @@ describe('OnSongGrammar', () => { size: e.location.end.offset - e.location.start.offset, input, }; - throw new Error([annotate(opts).message, tracer.getBacktraceString()].join("\n\n")); + throw new Error([annotate(opts).message, tracer.getBacktraceString()].join('\n\n')); } else { throw e; } From 2dd11581bd236300ecce0abb5c9a01c029555932 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 20 Feb 2022 08:20:25 -0500 Subject: [PATCH 07/17] Add some TODOs in tests --- test/parser/on_song_grammar.test.js | 59 ++++++++++++++++++----------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/test/parser/on_song_grammar.test.js b/test/parser/on_song_grammar.test.js index 0edde54d..643ce858 100644 --- a/test/parser/on_song_grammar.test.js +++ b/test/parser/on_song_grammar.test.js @@ -88,7 +88,10 @@ describe('OnSongGrammar', () => { { type: 'tab', content: 'tab\n' }, ], }, - + '{start_of_verse}\nLyrics\n{end_of_verse}': 'todo', + '{start_of_verse: Verse 1}\nLyrics\n{end_of_verse}': 'todo', + '{start_of_chorus}\nLyrics\n{end_of_chorus}': 'todo', + '{start_of_verse}\nLyrics\n{start_of_chorus}': 'todo', 'Chord and lyrics': { type: 'section', name: null, @@ -131,11 +134,17 @@ describe('OnSongGrammar', () => { { type: 'ChordLyricsPair', chords: '', lyrics: 'Just lyrics' }, ], }, - '[G]': { type: 'line', parts: [{ type: 'ChordLyricsPair', chords: 'G', lyrics: '' }], }, + '[G]Line (2x)': { + type: 'line', + parts: [ + { type: 'ChordLyricsPair', chords: 'G', lyrics: 'Line ' }, + { type: 'instruction', content: '2x' }, + ], + } }, Chord: { @@ -202,6 +211,8 @@ describe('OnSongGrammar', () => { { items: [], name: 'Chorus', type: 'section' }, ], }, + 'Title\nFlow: V1 C v1\n\nVerse 1:\nVerse\n\nChorus:\nChorus': 'todo', + 'Title\n\nVerse:\n[G]Hello\n\n{transpose: 2}\n\nVerse': 'todo', }, }; @@ -215,28 +226,32 @@ describe('OnSongGrammar', () => { Object.entries(examples).forEach(([startRule, ruleExamples]) => { describe(startRule, () => { Object.entries(ruleExamples).forEach(([input, expected]) => { - test(input, () => { - const tracer = new Tracer(input); + if (expected === 'todo') { + test.todo(input); + } else { + test(input, () => { + const tracer = new Tracer(input); - try { - const actual = parse(input, { startRule, tracer }); - expect(actual).toEqual(expected); - } catch (e) { - if (expected === Error) { - // expected, do nothing - } else if (e instanceof SyntaxError) { - const opts = { - message: e.message, - index: e.location.start.offset, - size: e.location.end.offset - e.location.start.offset, - input, - }; - throw new Error([annotate(opts).message, tracer.getBacktraceString()].join('\n\n')); - } else { - throw e; + try { + const actual = parse(input, { startRule, tracer }); + expect(actual).toEqual(expected); + } catch (e) { + if (expected === Error) { + // expected, do nothing + } else if (e instanceof SyntaxError) { + const opts = { + message: e.message, + index: e.location.start.offset, + size: e.location.end.offset - e.location.start.offset, + input, + }; + throw new Error([annotate(opts).message, tracer.getBacktraceString()].join('\n\n')); + } else { + throw e; + } } - } - }); + }); + } }); }); }); From 40fb83521e1c646af00fbfda035ac5341b023c91 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 20 Feb 2022 08:20:39 -0500 Subject: [PATCH 08/17] Musical instruction --- src/parser/on_song_grammar.pegjs | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/parser/on_song_grammar.pegjs b/src/parser/on_song_grammar.pegjs index 7f127e6d..10e88262 100644 --- a/src/parser/on_song_grammar.pegjs +++ b/src/parser/on_song_grammar.pegjs @@ -49,23 +49,19 @@ Stanza } Line - = parts:(ChordLyricsPair / JustChords / JustLyrics)+ EOL { + = parts:(MusicalInstruction / ChordLyricsPair)+ EOL { return {type: 'line', parts } } -JustChords - = chords:Chord { - return { type: "ChordLyricsPair", chords, lyrics: "" } - } - -JustLyrics - = lyrics:Lyrics { - return { type: "ChordLyricsPair", lyrics, chords: "" } - } - ChordLyricsPair = chords:Chord lyrics:Lyrics { - return { type: "ChordLyricsPair", chords: chords || '', lyrics } + return { type: "ChordLyricsPair", chords: chords || '', lyrics } + } + / chords:Chord { + return { type: "ChordLyricsPair", chords, lyrics: "" } + } + / lyrics:Lyrics { + return { type: "ChordLyricsPair", lyrics, chords: "" } } Chord "chord" @@ -77,7 +73,7 @@ ChordChar = [^\]] Lyrics "lyrics" - = $(Char+) + = $Char+ Tab = SotDirective NewLine content:$(!EotDirective TabLine __)+ EotDirective { @@ -93,11 +89,13 @@ EotDirective = "{eot}" EndOfTabDirective = "{end_of_tab}" TabLine = [^\r\n]+ NewLine -// MusicalInstruction // e.g. (Repeat 8x) -// = "(" [^)]+ ")" +MusicalInstruction // e.g. (Repeat 8x) + = "(" content:$[^)]+ ")" { + return { type: 'instruction', content } + } Char - = [^\|\[\]\\#\r\n] + = [^\|\[\]\\#\(\r\n] Escape = "\\" From d422dfe1f9786811febde87265c662a76a409b15 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 20 Feb 2022 08:54:11 -0500 Subject: [PATCH 09/17] Examples of poor formatting in the wild --- test/parser/on_song_grammar.test.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/parser/on_song_grammar.test.js b/test/parser/on_song_grammar.test.js index 643ce858..476d91bc 100644 --- a/test/parser/on_song_grammar.test.js +++ b/test/parser/on_song_grammar.test.js @@ -88,6 +88,9 @@ describe('OnSongGrammar', () => { { type: 'tab', content: 'tab\n' }, ], }, + + 'Intro:\n| [B] / / / | / / [C#m7] / | [E] / / / | / / [F#sus] / / |': 'todo', + 'Intro:\n[| [D] /// | //// | [F#m] /// | [E] //// |]¬': 'todo', '{start_of_verse}\nLyrics\n{end_of_verse}': 'todo', '{start_of_verse: Verse 1}\nLyrics\n{end_of_verse}': 'todo', '{start_of_chorus}\nLyrics\n{end_of_chorus}': 'todo', @@ -144,7 +147,15 @@ describe('OnSongGrammar', () => { { type: 'ChordLyricsPair', chords: 'G', lyrics: 'Line ' }, { type: 'instruction', content: '2x' }, ], - } + }, + // Poor formatting found in the wild. These should produce warnings but not raise errors + 'Rogue [C#m]#pound sign': 'todo', + 'Rogue C] square bracket': { + type: 'line', + parts: [ { "chords": "", "lyrics": "Rogue C] square bracket", "type": "ChordLyricsPair" } ] + }, + 'Empty []chord': 'todo', + 'F#m Whoops forgot the brackets': 'todo', }, Chord: { From 7b1c2b3be263df84525d5df7bac434916d2828fb Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 20 Feb 2022 08:55:23 -0500 Subject: [PATCH 10/17] Handle rogue closing bracket --- src/parser/on_song_grammar.pegjs | 6 +++--- test/parser/on_song_grammar.test.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/parser/on_song_grammar.pegjs b/src/parser/on_song_grammar.pegjs index 10e88262..7c6b3c4e 100644 --- a/src/parser/on_song_grammar.pegjs +++ b/src/parser/on_song_grammar.pegjs @@ -95,12 +95,12 @@ MusicalInstruction // e.g. (Repeat 8x) } Char - = [^\|\[\]\\#\(\r\n] + = [^\|\[\\#\(\r\n] Escape = "\\" -Space +Space "space" = [ \t] NewLine "new line" @@ -109,7 +109,7 @@ NewLine "new line" EOL "end of line" // Strict linebreak or end of file = _ (NewLine / EOF) -EOF +EOF "end of file" = !. _ // Insignificant space diff --git a/test/parser/on_song_grammar.test.js b/test/parser/on_song_grammar.test.js index 476d91bc..00a80c43 100644 --- a/test/parser/on_song_grammar.test.js +++ b/test/parser/on_song_grammar.test.js @@ -152,7 +152,7 @@ describe('OnSongGrammar', () => { 'Rogue [C#m]#pound sign': 'todo', 'Rogue C] square bracket': { type: 'line', - parts: [ { "chords": "", "lyrics": "Rogue C] square bracket", "type": "ChordLyricsPair" } ] + parts: [{ chords: '', lyrics: 'Rogue C] square bracket', type: 'ChordLyricsPair' }], }, 'Empty []chord': 'todo', 'F#m Whoops forgot the brackets': 'todo', From da8f58c42a35c233888144061aee74ac46225f79 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 20 Feb 2022 21:10:18 -0500 Subject: [PATCH 11/17] Add {start_of_*} sections --- src/parser/on_song_grammar.pegjs | 32 +++++++++----- test/parser/on_song_grammar.test.js | 65 +++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/src/parser/on_song_grammar.pegjs b/src/parser/on_song_grammar.pegjs index 7c6b3c4e..1041ff6f 100644 --- a/src/parser/on_song_grammar.pegjs +++ b/src/parser/on_song_grammar.pegjs @@ -30,18 +30,34 @@ MetaValue // https://www.onsongapp.com/docs/features/formats/onsong/section/ Section + // {start_of_*} + = DirectiveSection // Section with explcit name and maybe a body - = name:SectionName __ items:SectionBody* { return { type: 'section', name, items } } + / name:SectionName __ items:SectionBody* { return { type: 'section', name, items } } // Section without explicit name / items:SectionBody+ { return { type: 'section', name: null, items } } +DirectiveSection + = "{" _ ("start_of_" / "so") type:DirectiveSectionTypes _ name:(":" _ @MetaValue)? "}" EOL + items:SectionBody* + "{" _ ("end_of_" / "eo") DirectiveSectionTypes _ "}" EOL // FIXME: must match start tag + { + return { type: 'section', name: name || type, items } + } + +DirectiveSectionTypes + = "b" "ridge"? { return "bridge" } + / "c" "horus"? { return "chorus" } + / "p" "art"? { return "part" } + / "v" "erse"? { return "verse" } + SectionName = name:MetaName _ ":" EOL { return name.trim() } SectionBody - = @(Tab / Stanza) __ + = !SectionName @(Tab / Stanza) __ Stanza = lines:Line+ { @@ -76,18 +92,14 @@ Lyrics "lyrics" = $Char+ Tab - = SotDirective NewLine content:$(!EotDirective TabLine __)+ EotDirective { + = "{" _ "sot" _ "}" NewLine content:$(TabLine __)+ "{" _ "eot" _ "}" { return { type: 'tab', content } } - / StartOfTabDirective NewLine content:$(!EndOfTabDirective TabLine __)+ EndOfTabDirective { + / "{start_of_tab}" NewLine content:$(TabLine __)+ "{end_of_tab}" { return { type: 'tab', content } } -SotDirective = "{sot}" -StartOfTabDirective = "{start_of_tab}" -EotDirective = "{eot}" -EndOfTabDirective = "{end_of_tab}" -TabLine = [^\r\n]+ NewLine +TabLine = [^\r\n{]+ NewLine MusicalInstruction // e.g. (Repeat 8x) = "(" content:$[^)]+ ")" { @@ -95,7 +107,7 @@ MusicalInstruction // e.g. (Repeat 8x) } Char - = [^\|\[\\#\(\r\n] + = [^\|\[\\{#\(\r\n] Escape = "\\" diff --git a/test/parser/on_song_grammar.test.js b/test/parser/on_song_grammar.test.js index 00a80c43..a9b8d35f 100644 --- a/test/parser/on_song_grammar.test.js +++ b/test/parser/on_song_grammar.test.js @@ -91,9 +91,28 @@ describe('OnSongGrammar', () => { 'Intro:\n| [B] / / / | / / [C#m7] / | [E] / / / | / / [F#sus] / / |': 'todo', 'Intro:\n[| [D] /// | //// | [F#m] /// | [E] //// |]¬': 'todo', - '{start_of_verse}\nLyrics\n{end_of_verse}': 'todo', - '{start_of_verse: Verse 1}\nLyrics\n{end_of_verse}': 'todo', - '{start_of_chorus}\nLyrics\n{end_of_chorus}': 'todo', + '{start_of_verse}\nLyrics\n{end_of_verse}': { + type: 'section', + name: 'verse', + items: [ + { + type: 'stanza', + lines: [{ type: 'line', parts: [{type: 'ChordLyricsPair', chords: '', lyrics: 'Lyrics'}]}] + }, + ], + }, + '{start_of_verse: Verse 1}\n{end_of_verse}': { type: 'section', name: 'Verse 1', items: [] }, + '{sov}\n{eov}': { type: 'section', name: 'verse', items: [] }, + '{start_of_chorus}\nLyrics\n{end_of_chorus}': { + type: 'section', + name: 'chorus', + items: [ + { + type: 'stanza', + lines: [{ type: 'line', parts: [{type: 'ChordLyricsPair', chords: '', lyrics: 'Lyrics'}]}] + }, + ], + }, '{start_of_verse}\nLyrics\n{start_of_chorus}': 'todo', 'Chord and lyrics': { type: 'section', @@ -107,6 +126,23 @@ describe('OnSongGrammar', () => { }, ], }, + 'Verse 1:\n D G D\nAmazing Grace, how sweet the sound': { + // type: 'section', + // name: 'Verse 1', + // items: [ + // { + // type: 'stanza', + // lines: [ + // { type: 'line', parts: [ + // { type: 'ChordLyricsPair', chords: '', lyrics: 'Amazing ' }, + // { type: 'ChordLyricsPair', chords: 'D', lyrics: 'Grace, how s' }, + // { type: 'ChordLyricsPair', chords: 'G', lyrics: 'weet the ' }, + // { type: 'ChordLyricsPair', chords: 'D', lyrics: 'sound' }, + // ]} + // ] + // } + // ] + }, }, Line: { @@ -148,6 +184,29 @@ describe('OnSongGrammar', () => { { type: 'instruction', content: '2x' }, ], }, + + // {define: ...} is used to define custom chord diagrams. See Defining Chords for more information. + // {comment: ...} or {c: ...} Defines a comment and appears as a musical instruction. + // {comment_bold: ...} or {cb: ...} Defines text to appear in bold. + // {comment_italic: ...} or {ci: ...} Defines text to appear as italic. + // {guitar_comment: ...} or {gc: ...} Defines a comment that appears as a musical instruction. + // {new_page} or {np} This is used to declare a new page. + // {new_physical_page} or {npp} This is used to declare a new page. + + // Formatting Tags + // {textsize: ...} Defines the size of the lyrics as a numeric value in points. + // {textfont: ...} Defines the name of the font to use for lyrics. Must be supported on the platform. + // {chordsize: ...} Defines the size of the chords as a numeric value in points. + // {chordfont: ...} Defines the name of the font to use for chords. Must be supported on the platform. + + // *This line will be bold + // /This line will be italicized + // !This line will be bold and italicized + // _This line will eventually be underlined + // &red:This text will be red + // 𞉀:This text will be a custom color using HTML color codes + // >yellow:This line will be highlighted in yellow + // Poor formatting found in the wild. These should produce warnings but not raise errors 'Rogue [C#m]#pound sign': 'todo', 'Rogue C] square bracket': { From d3ef0be9c647195aef8f9d45728ab62e37d0c150 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 20 Feb 2022 23:47:22 -0500 Subject: [PATCH 12/17] Add support for chords over lyrics --- src/parser/on_song_grammar.pegjs | 80 ++++++++++++++++++++--- test/parser/on_song_grammar.test.js | 99 +++++++++++++++++++++++------ 2 files changed, 150 insertions(+), 29 deletions(-) diff --git a/src/parser/on_song_grammar.pegjs b/src/parser/on_song_grammar.pegjs index 1041ff6f..050918f9 100644 --- a/src/parser/on_song_grammar.pegjs +++ b/src/parser/on_song_grammar.pegjs @@ -1,7 +1,17 @@ +// This is a gramar for the OnSong format as documented here: // https://www.onsongapp.com/docs/features/formats/onsong/ + +{ + const warnings = options.warnings || [] + + function warn(message, location) { + warnings.push({message, location}) + } +} + ChordSheet = metadata:Metadata sections:Section* { - return { type: "chordsheet", metadata, sections } + return { type: "chordsheet", metadata, sections, warnings } } // https://www.onsongapp.com/docs/features/formats/onsong/metadata/ @@ -65,28 +75,78 @@ Stanza } Line - = parts:(MusicalInstruction / ChordLyricsPair)+ EOL { + = parts:(@ChordsOverLyrics / @(MusicalInstruction / ChordLyricsPair)+ EOL) { return {type: 'line', parts } } +// The other way to express chords in lyrics is to place the chords on a line above the lyrics and +// use space characters to align the chords with lyrics. This is supported since most music found +// in other formats use this technique. Here is an example of chords over lyrics: +// +// Verse 1: +// D G D +// Amazing Grace, how sweet the sound, +// A7 +// That saved a wretch like me. +// D G D +// I once was lost, but now am found, +// A7 D +// Was blind, but now I see. +ChordsOverLyrics + = chords:(_ @(chord:Chord { return { chord, column: location().start.column } }))+ EOL + lyrics:(@Lyrics EOL)? { + // FIXME: + // Is there a better way to do this in PEG? + // Or, is there a more idiomatic way to merge chords and lyrics into pairs? + + // First chord does not start at beginning of line, add an empty chord + if(chords[0]?.column > 1) chords.unshift({chord: '', column: 1}) + + // Ensure lyrics are a string + if(!lyrics) lyrics = '' + + const items = []; + + for (let index = 0; index < chords.length; index++) { + const { chord, column } = chords[index]; + const startColumn = column - 1; + const endColumn = chords[index + 1]?.column - 1 || lyrics.length; + const l = lyrics.padEnd(endColumn, ' ').slice(startColumn, endColumn) + + items.push({type: 'ChordLyricsPair', chords: chord, lyrics: l}) + } + + return items; + } + ChordLyricsPair - = chords:Chord lyrics:Lyrics { + = chords:BracketedChord lyrics:Lyrics { return { type: "ChordLyricsPair", chords: chords || '', lyrics } } - / chords:Chord { + / chords:BracketedChord { return { type: "ChordLyricsPair", chords, lyrics: "" } } / lyrics:Lyrics { return { type: "ChordLyricsPair", lyrics, chords: "" } } -Chord "chord" - = !Escape "[" chords:$(ChordChar*) "]" { - return chords; - } +BracketedChord "chord" + = !Escape "[" @Chord "]" + / !Escape "[" chord:$[^\]]+ "]" { + warn(`Unknown chord: ${chord}`, location()) + } + + +// OnSong recognizes chords using the following set of rules: +// https://www.onsongapp.com/docs/features/formats/onsong/chords/#chords-over-lyrics +Chord + = $(ChordLetter SharpOrFlat? ChordModifier? ChordNumericPosition? ("/" ChordLetter SharpOrFlat?)?) -ChordChar - = [^\]] +// It must start with a capital A, B, C, D, E, F, G or H (used in some languages) +ChordLetter = [A-H] +SharpOrFlat = [#♯b♭] +ChordModifier = "add" / "sus" / "m" / "min" / "man" / "aug" / "dim" +ChordNumericPosition = $([0234579] / "11" / "13") SharpOrFlat? Lyrics "lyrics" = $Char+ diff --git a/test/parser/on_song_grammar.test.js b/test/parser/on_song_grammar.test.js index a9b8d35f..1685e11c 100644 --- a/test/parser/on_song_grammar.test.js +++ b/test/parser/on_song_grammar.test.js @@ -97,7 +97,7 @@ describe('OnSongGrammar', () => { items: [ { type: 'stanza', - lines: [{ type: 'line', parts: [{type: 'ChordLyricsPair', chords: '', lyrics: 'Lyrics'}]}] + lines: [{ type: 'line', parts: [{ type: 'ChordLyricsPair', chords: '', lyrics: 'Lyrics' }] }], }, ], }, @@ -109,7 +109,7 @@ describe('OnSongGrammar', () => { items: [ { type: 'stanza', - lines: [{ type: 'line', parts: [{type: 'ChordLyricsPair', chords: '', lyrics: 'Lyrics'}]}] + lines: [{ type: 'line', parts: [{ type: 'ChordLyricsPair', chords: '', lyrics: 'Lyrics' }] }], }, ], }, @@ -126,22 +126,63 @@ describe('OnSongGrammar', () => { }, ], }, - 'Verse 1:\n D G D\nAmazing Grace, how sweet the sound': { - // type: 'section', - // name: 'Verse 1', - // items: [ - // { - // type: 'stanza', - // lines: [ - // { type: 'line', parts: [ - // { type: 'ChordLyricsPair', chords: '', lyrics: 'Amazing ' }, - // { type: 'ChordLyricsPair', chords: 'D', lyrics: 'Grace, how s' }, - // { type: 'ChordLyricsPair', chords: 'G', lyrics: 'weet the ' }, - // { type: 'ChordLyricsPair', chords: 'D', lyrics: 'sound' }, - // ]} - // ] - // } - // ] + }, + + SectionBody: { + [[ + ' D G D', + 'Amazing Grace, how sweet the sound', + ' A7', + 'That saved a wretch like me.', + ].join('\n')]: { + type: 'stanza', + lines: [ + { + type: 'line', + parts: [ + { type: 'ChordLyricsPair', chords: '', lyrics: 'Amazing ' }, + { type: 'ChordLyricsPair', chords: 'D', lyrics: 'Grace, how s' }, + { type: 'ChordLyricsPair', chords: 'G', lyrics: 'weet the ' }, + { type: 'ChordLyricsPair', chords: 'D', lyrics: 'sound' }, + ], + }, + { + type: 'line', + parts: [ + { type: 'ChordLyricsPair', chords: '', lyrics: 'That saved a wretch like ' }, + { type: 'ChordLyricsPair', chords: 'A7', lyrics: 'me.' }, + ], + }, + ], + }, + + 'Am F': { + type: 'stanza', + lines: [ + { + type: 'line', + parts: [ + { type: 'ChordLyricsPair', chords: 'Am', lyrics: ' ' }, + { type: 'ChordLyricsPair', chords: 'F', lyrics: '' }, + ], + }, + ], + }, + + [[ + 'G D', + 'Lyric' + ].join('\n')]: { + type: 'stanza', + lines: [ + { + type: 'line', + parts: [ + { type: 'ChordLyricsPair', chords: 'G', lyrics: 'Lyric ' }, + { type: 'ChordLyricsPair', chords: 'D', lyrics: '' }, + ], + }, + ], }, }, @@ -218,10 +259,25 @@ describe('OnSongGrammar', () => { }, Chord: { + A: 'A', + 'C/G': 'C/G', + 'F#m': 'F#m', + 'C♯': 'C♯', + Asus4: 'Asus4', + E7: 'E7', + 'B♭': 'B♭', + 'Eb/Bb': 'Eb/Bb', + 'Abm7/Eb': 'Abm7/Eb', + AMaj: Error, + X: Error, + }, + + BracketedChord: { '[G]': 'G', '[D/F#]': 'D/F#', '[Bsus2]': 'Bsus2', '\\[notachord]': Error, + // '[unknown]': ExpectWarning, // FIXME }, Tab: { @@ -259,6 +315,7 @@ describe('OnSongGrammar', () => { name: null, }, ], + warnings: [], }, 'Title\n\nIntro:\n': { type: 'chordsheet', @@ -270,6 +327,7 @@ describe('OnSongGrammar', () => { items: [], }, ], + warnings: [], }, 'Tempo: 73\nUnknown(s): Value:with@various:characters1-5\n\nChorus:': { type: 'chordsheet', @@ -280,6 +338,7 @@ describe('OnSongGrammar', () => { sections: [ { items: [], name: 'Chorus', type: 'section' }, ], + warnings: [], }, 'Title\nFlow: V1 C v1\n\nVerse 1:\nVerse\n\nChorus:\nChorus': 'todo', 'Title\n\nVerse:\n[G]Hello\n\n{transpose: 2}\n\nVerse': 'todo', @@ -301,10 +360,12 @@ describe('OnSongGrammar', () => { } else { test(input, () => { const tracer = new Tracer(input); + const warnings = []; try { - const actual = parse(input, { startRule, tracer }); + const actual = parse(input, { startRule, tracer, warnings }); expect(actual).toEqual(expected); + expect(warnings).toEqual([]); } catch (e) { if (expected === Error) { // expected, do nothing From c78d23557b541880026ac040e1bad6dec539294b Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 21 Feb 2022 09:06:20 -0500 Subject: [PATCH 13/17] Fix rule names for consistency with chord.js --- src/parser/on_song_grammar.pegjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/parser/on_song_grammar.pegjs b/src/parser/on_song_grammar.pegjs index 050918f9..af12e6c8 100644 --- a/src/parser/on_song_grammar.pegjs +++ b/src/parser/on_song_grammar.pegjs @@ -140,13 +140,13 @@ BracketedChord "chord" // OnSong recognizes chords using the following set of rules: // https://www.onsongapp.com/docs/features/formats/onsong/chords/#chords-over-lyrics Chord - = $(ChordLetter SharpOrFlat? ChordModifier? ChordNumericPosition? ("/" ChordLetter SharpOrFlat?)?) + = $(ChordLetter ChordModifier? ChordSuffix? ChordNumericPosition? ("/" ChordLetter ChordModifier?)?) // It must start with a capital A, B, C, D, E, F, G or H (used in some languages) ChordLetter = [A-H] -SharpOrFlat = [#♯b♭] -ChordModifier = "add" / "sus" / "m" / "min" / "man" / "aug" / "dim" -ChordNumericPosition = $([0234579] / "11" / "13") SharpOrFlat? +ChordModifier = [#♯b♭] +ChordSuffix = "add" / "sus" / "m" / "min" / "man" / "aug" / "dim" +ChordNumericPosition = $([0234579] / "11" / "13") ChordModifier? Lyrics "lyrics" = $Char+ From da592e0c9bf1a61e538e5c0e199cefa87dfd779b Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 21 Feb 2022 15:58:14 -0500 Subject: [PATCH 14/17] Update chord parsing to handle more cases found in the wild --- src/parser/on_song_grammar.pegjs | 8 +++-- test/parser/on_song_grammar.test.js | 51 ++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/parser/on_song_grammar.pegjs b/src/parser/on_song_grammar.pegjs index af12e6c8..0aeaafc0 100644 --- a/src/parser/on_song_grammar.pegjs +++ b/src/parser/on_song_grammar.pegjs @@ -140,13 +140,15 @@ BracketedChord "chord" // OnSong recognizes chords using the following set of rules: // https://www.onsongapp.com/docs/features/formats/onsong/chords/#chords-over-lyrics Chord - = $(ChordLetter ChordModifier? ChordSuffix? ChordNumericPosition? ("/" ChordLetter ChordModifier?)?) + = $(ChordLetter (ChordModifier / ChordSuffix / ChordNumericPosition)* (Space? "/" Space? ChordLetter ChordModifier?)?) // It must start with a capital A, B, C, D, E, F, G or H (used in some languages) ChordLetter = [A-H] ChordModifier = [#♯b♭] -ChordSuffix = "add" / "sus" / "m" / "min" / "man" / "aug" / "dim" -ChordNumericPosition = $([0234579] / "11" / "13") ChordModifier? +ChordSuffix = "sus" / "maj"i / "min" / "man" / "m"i / "aug" / "dim" +ChordNumericPosition + = (ChordModifier / "add")? ([02345679] / "11" / "13") + / "(" ChordNumericPosition ")" Lyrics "lyrics" = $Char+ diff --git a/test/parser/on_song_grammar.test.js b/test/parser/on_song_grammar.test.js index 1685e11c..2586ce66 100644 --- a/test/parser/on_song_grammar.test.js +++ b/test/parser/on_song_grammar.test.js @@ -171,7 +171,7 @@ describe('OnSongGrammar', () => { [[ 'G D', - 'Lyric' + 'Lyric', ].join('\n')]: { type: 'stanza', lines: [ @@ -184,6 +184,44 @@ describe('OnSongGrammar', () => { }, ], }, + + 'G (strum once)\nLyrics': { + type: 'stanza', + lines: [ + { + type: 'line', + parts: [ + { type: 'ChordLyricsPair', chords: 'G', lyrics: 'Ly' }, + { type: 'ChordLyricsPair', chords: { type: 'instruction', content: 'strum once' }, lyrics: 'rics' }, + ], + }, + ], + }, + + // TODO: "You can also start the line with a period or a back tick character + // to force the line to be detected as chords" + // '.I am chords\nI am lyrics': { + // type: 'stanza', + // lines: [ + // { + // type: 'line', + // parts: [ + // { type: 'ChordLyricsPair', chords: 'I am chords', lyrics: 'I am lyrics' }, + // ], + // }, + // ], + // }, + // '`I am chords\nI am lyrics': { + // type: 'stanza', + // lines: [ + // { + // type: 'line', + // parts: [ + // { type: 'ChordLyricsPair', chords: 'I am chords', lyrics: 'I am lyrics' }, + // ], + // }, + // ], + // }, }, Line: { @@ -268,6 +306,17 @@ describe('OnSongGrammar', () => { 'B♭': 'B♭', 'Eb/Bb': 'Eb/Bb', 'Abm7/Eb': 'Abm7/Eb', + 'F / A': 'F / A', + 'Dm7(b5)': 'Dm7(b5)', + E7b13: 'E7b13', + B7b5: 'B7b5', + CM7: 'CM7', + Cmaj7: 'Cmaj7', + AbMaj7: 'AbMaj7', + 'C9(11)': 'C9(11)', + 'Dm7(9)': 'Dm7(9)', + D6: 'D6', + 'B(add4)': 'B(add4)', AMaj: Error, X: Error, }, From 63a5bf25796795c20a5988279fbf859528215e0f Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 6 Mar 2022 07:38:18 -0500 Subject: [PATCH 15/17] Add support for musical instructions over and inline with lyrics --- src/parser/on_song_grammar.pegjs | 35 ++++---- test/parser/on_song_grammar.test.js | 130 ++++++++++++++++------------ 2 files changed, 93 insertions(+), 72 deletions(-) diff --git a/src/parser/on_song_grammar.pegjs b/src/parser/on_song_grammar.pegjs index 0aeaafc0..bd331995 100644 --- a/src/parser/on_song_grammar.pegjs +++ b/src/parser/on_song_grammar.pegjs @@ -75,7 +75,7 @@ Stanza } Line - = parts:(@ChordsOverLyrics / @(MusicalInstruction / ChordLyricsPair)+ EOL) { + = parts:(@ChordsOverLyrics / @InlineAnnotation+ EOL) { return {type: 'line', parts } } @@ -93,42 +93,42 @@ Line // A7 D // Was blind, but now I see. ChordsOverLyrics - = chords:(_ @(chord:Chord { return { chord, column: location().start.column } }))+ EOL + = annotations:(_ @(annotation:(Chord / MusicalInstruction) { return { annotation, column: location().start.column } }))+ EOL lyrics:(@Lyrics EOL)? { // FIXME: // Is there a better way to do this in PEG? // Or, is there a more idiomatic way to merge chords and lyrics into pairs? - // First chord does not start at beginning of line, add an empty chord - if(chords[0]?.column > 1) chords.unshift({chord: '', column: 1}) + // First annotation does not start at beginning of line, add an empty one + if(annotations[0]?.column > 1) annotations.unshift({annotation: null, column: 1}) // Ensure lyrics are a string if(!lyrics) lyrics = '' const items = []; - for (let index = 0; index < chords.length; index++) { - const { chord, column } = chords[index]; + for (let index = 0; index < annotations.length; index++) { + const { annotation, column } = annotations[index]; const startColumn = column - 1; - const endColumn = chords[index + 1]?.column - 1 || lyrics.length; + const endColumn = annotations[index + 1]?.column - 1 || lyrics.length; const l = lyrics.padEnd(endColumn, ' ').slice(startColumn, endColumn) - items.push({type: 'ChordLyricsPair', chords: chord, lyrics: l}) + items.push({type: 'annotation', annotation, lyrics: l}) } return items; } -ChordLyricsPair - = chords:BracketedChord lyrics:Lyrics { - return { type: "ChordLyricsPair", chords: chords || '', lyrics } +InlineAnnotation + = annotation:(BracketedChord / MusicalInstruction) lyrics:Lyrics { + return { type: 'annotation', annotation, lyrics } } - / chords:BracketedChord { - return { type: "ChordLyricsPair", chords, lyrics: "" } + / annotation:(BracketedChord / MusicalInstruction) { + return { type: 'annotation', annotation, lyrics: null } } / lyrics:Lyrics { - return { type: "ChordLyricsPair", lyrics, chords: "" } - } + return { type: 'annotation', lyrics, annotation: null } + } BracketedChord "chord" = !Escape "[" @Chord "]" @@ -136,11 +136,12 @@ BracketedChord "chord" warn(`Unknown chord: ${chord}`, location()) } - // OnSong recognizes chords using the following set of rules: // https://www.onsongapp.com/docs/features/formats/onsong/chords/#chords-over-lyrics Chord - = $(ChordLetter (ChordModifier / ChordSuffix / ChordNumericPosition)* (Space? "/" Space? ChordLetter ChordModifier?)?) + = value:$(ChordLetter (ChordModifier / ChordSuffix / ChordNumericPosition)* (Space? "/" Space? ChordLetter ChordModifier?)?) { + return { type: 'chord', value } + } // It must start with a capital A, B, C, D, E, F, G or H (used in some languages) ChordLetter = [A-H] diff --git a/test/parser/on_song_grammar.test.js b/test/parser/on_song_grammar.test.js index 2586ce66..fc4c31f7 100644 --- a/test/parser/on_song_grammar.test.js +++ b/test/parser/on_song_grammar.test.js @@ -52,13 +52,13 @@ describe('OnSongGrammar', () => { { type: 'stanza', lines: [ - { type: 'line', parts: [{ type: 'ChordLyricsPair', chords: '', lyrics: 'This is a stanza' }] }, + { type: 'line', parts: [{ type: 'annotation', annotation: null, lyrics: 'This is a stanza' }] }, ], }, { type: 'stanza', lines: [ - { type: 'line', parts: [{ type: 'ChordLyricsPair', chords: '', lyrics: 'This is another stanza' }] }, + { type: 'line', parts: [{ type: 'annotation', annotation: null, lyrics: 'This is another stanza' }] }, ], }, ], @@ -76,7 +76,14 @@ describe('OnSongGrammar', () => { items: [ { type: 'stanza', - lines: [{ type: 'line', parts: [{ type: 'ChordLyricsPair', chords: 'G', lyrics: '' }] }], + lines: [ + { + type: 'line', + parts: [ + { type: 'annotation', annotation: { type: 'chord', value: 'G' } }, + ], + }, + ], }, ], }, @@ -97,7 +104,7 @@ describe('OnSongGrammar', () => { items: [ { type: 'stanza', - lines: [{ type: 'line', parts: [{ type: 'ChordLyricsPair', chords: '', lyrics: 'Lyrics' }] }], + lines: [{ type: 'line', parts: [{ type: 'annotation', annotation: null, lyrics: 'Lyrics' }] }], }, ], }, @@ -109,7 +116,7 @@ describe('OnSongGrammar', () => { items: [ { type: 'stanza', - lines: [{ type: 'line', parts: [{ type: 'ChordLyricsPair', chords: '', lyrics: 'Lyrics' }] }], + lines: [{ type: 'line', parts: [{ type: 'annotation', annotation: null, lyrics: 'Lyrics' }] }], }, ], }, @@ -121,7 +128,7 @@ describe('OnSongGrammar', () => { { type: 'stanza', lines: [ - { type: 'line', parts: [{ type: 'ChordLyricsPair', chords: '', lyrics: 'Chord and lyrics' }] }, + { type: 'line', parts: [{ type: 'annotation', annotation: null, lyrics: 'Chord and lyrics' }] }, ], }, ], @@ -140,17 +147,17 @@ describe('OnSongGrammar', () => { { type: 'line', parts: [ - { type: 'ChordLyricsPair', chords: '', lyrics: 'Amazing ' }, - { type: 'ChordLyricsPair', chords: 'D', lyrics: 'Grace, how s' }, - { type: 'ChordLyricsPair', chords: 'G', lyrics: 'weet the ' }, - { type: 'ChordLyricsPair', chords: 'D', lyrics: 'sound' }, + { type: 'annotation', annotation: null, lyrics: 'Amazing ' }, + { type: 'annotation', annotation: { type: 'chord', value: 'D' }, lyrics: 'Grace, how s' }, + { type: 'annotation', annotation: { type: 'chord', value: 'G' }, lyrics: 'weet the ' }, + { type: 'annotation', annotation: { type: 'chord', value: 'D' }, lyrics: 'sound' }, ], }, { type: 'line', parts: [ - { type: 'ChordLyricsPair', chords: '', lyrics: 'That saved a wretch like ' }, - { type: 'ChordLyricsPair', chords: 'A7', lyrics: 'me.' }, + { type: 'annotation', annotation: null, lyrics: 'That saved a wretch like ' }, + { type: 'annotation', annotation: {type: 'chord', value: 'A7'}, lyrics: 'me.' }, ], }, ], @@ -162,8 +169,8 @@ describe('OnSongGrammar', () => { { type: 'line', parts: [ - { type: 'ChordLyricsPair', chords: 'Am', lyrics: ' ' }, - { type: 'ChordLyricsPair', chords: 'F', lyrics: '' }, + { type: 'annotation', annotation: {type: 'chord', value: 'Am'}, lyrics: ' ' }, + { type: 'annotation', annotation: {type: 'chord', value: 'F'}, lyrics: '' }, ], }, ], @@ -178,8 +185,8 @@ describe('OnSongGrammar', () => { { type: 'line', parts: [ - { type: 'ChordLyricsPair', chords: 'G', lyrics: 'Lyric ' }, - { type: 'ChordLyricsPair', chords: 'D', lyrics: '' }, + { type: 'annotation', annotation: {type: 'chord', value: 'G'}, lyrics: 'Lyric ' }, + { type: 'annotation', annotation: {type: 'chord', value: 'D'}, lyrics: '' }, ], }, ], @@ -191,8 +198,21 @@ describe('OnSongGrammar', () => { { type: 'line', parts: [ - { type: 'ChordLyricsPair', chords: 'G', lyrics: 'Ly' }, - { type: 'ChordLyricsPair', chords: { type: 'instruction', content: 'strum once' }, lyrics: 'rics' }, + { type: 'annotation', annotation: { type: 'chord', value: 'G' }, lyrics: 'Ly' }, + { type: 'annotation', annotation: { type: 'instruction', content: 'strum once' }, lyrics: 'rics' }, + ], + }, + ], + }, + + '[G] (strum once) Lyrics': { + type: 'stanza', + lines: [ + { + type: 'line', + parts: [ + { type: 'annotation', annotation: { type: 'chord', value: 'G' }, lyrics: ' ' }, + { type: 'annotation', annotation: { type: 'instruction', content: 'strum once' }, lyrics: ' Lyrics' }, ], }, ], @@ -206,7 +226,7 @@ describe('OnSongGrammar', () => { // { // type: 'line', // parts: [ - // { type: 'ChordLyricsPair', chords: 'I am chords', lyrics: 'I am lyrics' }, + // { type: 'annotation', chords: 'I am chords', lyrics: 'I am lyrics' }, // ], // }, // ], @@ -217,7 +237,7 @@ describe('OnSongGrammar', () => { // { // type: 'line', // parts: [ - // { type: 'ChordLyricsPair', chords: 'I am chords', lyrics: 'I am lyrics' }, + // { type: 'annotation', chords: 'I am chords', lyrics: 'I am lyrics' }, // ], // }, // ], @@ -228,39 +248,39 @@ describe('OnSongGrammar', () => { 'This [D]is a s[G]ong,': { type: 'line', parts: [ - { type: 'ChordLyricsPair', chords: '', lyrics: 'This ' }, - { type: 'ChordLyricsPair', chords: 'D', lyrics: 'is a s' }, - { type: 'ChordLyricsPair', chords: 'G', lyrics: 'ong,' }, + { type: 'annotation', annotation: null, lyrics: 'This ' }, + { type: 'annotation', annotation: {type: 'chord', value: 'D'}, lyrics: 'is a s' }, + { type: 'annotation', annotation: {type: 'chord', value: 'G'}, lyrics: 'ong,' }, ], }, 'Ends with a chord [D]': { type: 'line', parts: [ - { type: 'ChordLyricsPair', chords: '', lyrics: 'Ends with a chord ' }, - { type: 'ChordLyricsPair', chords: 'D', lyrics: '' }, + { type: 'annotation', annotation: null, lyrics: 'Ends with a chord ' }, + { type: 'annotation', annotation: {type: 'chord', value: 'D'} }, ], }, '[D]Starts with a chord': { type: 'line', parts: [ - { type: 'ChordLyricsPair', chords: 'D', lyrics: 'Starts with a chord' }, + { type: 'annotation', annotation: {type: 'chord', value: 'D'}, lyrics: 'Starts with a chord' }, ], }, 'Just lyrics': { type: 'line', parts: [ - { type: 'ChordLyricsPair', chords: '', lyrics: 'Just lyrics' }, + { type: 'annotation', annotation: null, lyrics: 'Just lyrics' }, ], }, '[G]': { type: 'line', - parts: [{ type: 'ChordLyricsPair', chords: 'G', lyrics: '' }], + parts: [{ type: 'annotation', annotation: { type: 'chord', value: 'G' } }], }, '[G]Line (2x)': { type: 'line', parts: [ - { type: 'ChordLyricsPair', chords: 'G', lyrics: 'Line ' }, - { type: 'instruction', content: '2x' }, + { type: 'annotation', annotation: { type: 'chord', value: 'G' }, lyrics: 'Line ' }, + { type: 'annotation', annotation: { type: 'instruction', content: '2x' } }, ], }, @@ -290,41 +310,41 @@ describe('OnSongGrammar', () => { 'Rogue [C#m]#pound sign': 'todo', 'Rogue C] square bracket': { type: 'line', - parts: [{ chords: '', lyrics: 'Rogue C] square bracket', type: 'ChordLyricsPair' }], + parts: [{ annotation: null, lyrics: 'Rogue C] square bracket', type: 'annotation' }], }, 'Empty []chord': 'todo', 'F#m Whoops forgot the brackets': 'todo', }, Chord: { - A: 'A', - 'C/G': 'C/G', - 'F#m': 'F#m', - 'C♯': 'C♯', - Asus4: 'Asus4', - E7: 'E7', - 'B♭': 'B♭', - 'Eb/Bb': 'Eb/Bb', - 'Abm7/Eb': 'Abm7/Eb', - 'F / A': 'F / A', - 'Dm7(b5)': 'Dm7(b5)', - E7b13: 'E7b13', - B7b5: 'B7b5', - CM7: 'CM7', - Cmaj7: 'Cmaj7', - AbMaj7: 'AbMaj7', - 'C9(11)': 'C9(11)', - 'Dm7(9)': 'Dm7(9)', - D6: 'D6', - 'B(add4)': 'B(add4)', + A: { type: 'chord', value: 'A' }, + 'C/G': { type: 'chord', value: 'C/G' }, + 'F#m': { type: 'chord', value: 'F#m' }, + 'C♯': { type: 'chord', value: 'C♯' }, + Asus4: { type: 'chord', value: 'Asus4' }, + E7: { type: 'chord', value: 'E7' }, + 'B♭': { type: 'chord', value: 'B♭' }, + 'Eb/Bb': { type: 'chord', value: 'Eb/Bb' }, + 'Abm7/Eb': { type: 'chord', value: 'Abm7/Eb' }, + 'F / A': { type: 'chord', value: 'F / A' }, + 'Dm7(b5)': { type: 'chord', value: 'Dm7(b5)' }, + E7b13: { type: 'chord', value: 'E7b13' }, + B7b5: { type: 'chord', value: 'B7b5' }, + CM7: { type: 'chord', value: 'CM7' }, + Cmaj7: { type: 'chord', value: 'Cmaj7' }, + AbMaj7: { type: 'chord', value: 'AbMaj7' }, + 'C9(11)': { type: 'chord', value: 'C9(11)' }, + 'Dm7(9)': { type: 'chord', value: 'Dm7(9)' }, + D6: { type: 'chord', value: 'D6' }, + 'B(add4)': { type: 'chord', value: 'B(add4)' }, AMaj: Error, X: Error, }, BracketedChord: { - '[G]': 'G', - '[D/F#]': 'D/F#', - '[Bsus2]': 'Bsus2', + '[G]': { type: 'chord', value: 'G' }, + '[D/F#]': { type: 'chord', value: 'D/F#' }, + '[Bsus2]': { type: 'chord', value: 'Bsus2' }, '\\[notachord]': Error, // '[unknown]': ExpectWarning, // FIXME }, @@ -356,7 +376,7 @@ describe('OnSongGrammar', () => { items: [ { lines: [ - { parts: [{ chords: '', lyrics: 'Chord and lyrics', type: 'ChordLyricsPair' }], type: 'line' }, + { parts: [{ annotation: null, lyrics: 'Chord and lyrics', type: 'annotation' }], type: 'line' }, ], type: 'stanza', }, From 60638abc67c17e5f089f0a4fe5c212afe0e27602 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 6 Mar 2022 08:36:41 -0500 Subject: [PATCH 16/17] Always include blank lyrics --- src/parser/on_song_grammar.pegjs | 2 +- test/parser/on_song_grammar.test.js | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/parser/on_song_grammar.pegjs b/src/parser/on_song_grammar.pegjs index bd331995..04d1b337 100644 --- a/src/parser/on_song_grammar.pegjs +++ b/src/parser/on_song_grammar.pegjs @@ -124,7 +124,7 @@ InlineAnnotation return { type: 'annotation', annotation, lyrics } } / annotation:(BracketedChord / MusicalInstruction) { - return { type: 'annotation', annotation, lyrics: null } + return { type: 'annotation', annotation, lyrics: '' } } / lyrics:Lyrics { return { type: 'annotation', lyrics, annotation: null } diff --git a/test/parser/on_song_grammar.test.js b/test/parser/on_song_grammar.test.js index fc4c31f7..5abffd2c 100644 --- a/test/parser/on_song_grammar.test.js +++ b/test/parser/on_song_grammar.test.js @@ -80,7 +80,7 @@ describe('OnSongGrammar', () => { { type: 'line', parts: [ - { type: 'annotation', annotation: { type: 'chord', value: 'G' } }, + { type: 'annotation', annotation: { type: 'chord', value: 'G' }, lyrics: '' }, ], }, ], @@ -157,7 +157,7 @@ describe('OnSongGrammar', () => { type: 'line', parts: [ { type: 'annotation', annotation: null, lyrics: 'That saved a wretch like ' }, - { type: 'annotation', annotation: {type: 'chord', value: 'A7'}, lyrics: 'me.' }, + { type: 'annotation', annotation: { type: 'chord', value: 'A7' }, lyrics: 'me.' }, ], }, ], @@ -169,8 +169,8 @@ describe('OnSongGrammar', () => { { type: 'line', parts: [ - { type: 'annotation', annotation: {type: 'chord', value: 'Am'}, lyrics: ' ' }, - { type: 'annotation', annotation: {type: 'chord', value: 'F'}, lyrics: '' }, + { type: 'annotation', annotation: { type: 'chord', value: 'Am' }, lyrics: ' ' }, + { type: 'annotation', annotation: { type: 'chord', value: 'F' }, lyrics: '' }, ], }, ], @@ -185,8 +185,8 @@ describe('OnSongGrammar', () => { { type: 'line', parts: [ - { type: 'annotation', annotation: {type: 'chord', value: 'G'}, lyrics: 'Lyric ' }, - { type: 'annotation', annotation: {type: 'chord', value: 'D'}, lyrics: '' }, + { type: 'annotation', annotation: { type: 'chord', value: 'G' }, lyrics: 'Lyric ' }, + { type: 'annotation', annotation: { type: 'chord', value: 'D' }, lyrics: '' }, ], }, ], @@ -226,7 +226,7 @@ describe('OnSongGrammar', () => { // { // type: 'line', // parts: [ - // { type: 'annotation', chords: 'I am chords', lyrics: 'I am lyrics' }, + // { type: 'annotation', annotation: { type: 'text', value: 'I am chords' }, lyrics: 'I am lyrics' }, // ], // }, // ], @@ -249,21 +249,21 @@ describe('OnSongGrammar', () => { type: 'line', parts: [ { type: 'annotation', annotation: null, lyrics: 'This ' }, - { type: 'annotation', annotation: {type: 'chord', value: 'D'}, lyrics: 'is a s' }, - { type: 'annotation', annotation: {type: 'chord', value: 'G'}, lyrics: 'ong,' }, + { type: 'annotation', annotation: { type: 'chord', value: 'D' }, lyrics: 'is a s' }, + { type: 'annotation', annotation: { type: 'chord', value: 'G' }, lyrics: 'ong,' }, ], }, 'Ends with a chord [D]': { type: 'line', parts: [ { type: 'annotation', annotation: null, lyrics: 'Ends with a chord ' }, - { type: 'annotation', annotation: {type: 'chord', value: 'D'} }, + { type: 'annotation', annotation: { type: 'chord', value: 'D' }, lyrics: '' }, ], }, '[D]Starts with a chord': { type: 'line', parts: [ - { type: 'annotation', annotation: {type: 'chord', value: 'D'}, lyrics: 'Starts with a chord' }, + { type: 'annotation', annotation: { type: 'chord', value: 'D' }, lyrics: 'Starts with a chord' }, ], }, 'Just lyrics': { @@ -274,13 +274,13 @@ describe('OnSongGrammar', () => { }, '[G]': { type: 'line', - parts: [{ type: 'annotation', annotation: { type: 'chord', value: 'G' } }], + parts: [{ type: 'annotation', annotation: { type: 'chord', value: 'G' }, lyrics: '' }], }, '[G]Line (2x)': { type: 'line', parts: [ { type: 'annotation', annotation: { type: 'chord', value: 'G' }, lyrics: 'Line ' }, - { type: 'annotation', annotation: { type: 'instruction', content: '2x' } }, + { type: 'annotation', annotation: { type: 'instruction', content: '2x' }, lyrics: '' }, ], }, From 8ceb5a85d121a1c6c2687677f4f556f9a477401f Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 7 Mar 2022 21:50:33 -0500 Subject: [PATCH 17/17] Add support for Flow metatag --- src/parser/on_song_grammar.pegjs | 63 +++++++++--- test/parser/on_song_grammar.test.js | 142 +++++++++++++++++++++++++++- 2 files changed, 188 insertions(+), 17 deletions(-) diff --git a/src/parser/on_song_grammar.pegjs b/src/parser/on_song_grammar.pegjs index 04d1b337..c4a865bf 100644 --- a/src/parser/on_song_grammar.pegjs +++ b/src/parser/on_song_grammar.pegjs @@ -1,4 +1,4 @@ -// This is a gramar for the OnSong format as documented here: +// This is a grammar for the OnSong format as documented here: // https://www.onsongapp.com/docs/features/formats/onsong/ { @@ -7,11 +7,35 @@ function warn(message, location) { warnings.push({message, location}) } + + // Use the first character of each word in the section name, e.g. "Verse 3" => "V3" + function sectionAbbr(name) { + return name?.match(/\b(\w)/g)?.join('').toUpperCase(); + } + + // Rearrange sections according to the `Flow` metatag + function reflow(metadata, sections) { + const flow = metadata.find(m => m.name === 'flow')?.value; + + if(flow) { + return flow.map(name => { + if(typeof name === 'string') { + return sections.find(section => { + return section.name === name || sectionAbbr(section.name) === name + }) + } else { + return name; + } + }); + } else { + return sections; + } + } } ChordSheet = metadata:Metadata sections:Section* { - return { type: "chordsheet", metadata, sections, warnings } + return { type: "chordsheet", metadata, sections: reflow(metadata, sections), warnings } } // https://www.onsongapp.com/docs/features/formats/onsong/metadata/ @@ -20,12 +44,15 @@ Metadata Metatag "metatag" = "{" _ @Metatag _ "}" - / name:((@MetaName _ ":") / ImplicitMetaName) _ value:MetaValue { + / _ "flow"i _ ":" _ value:Flow { + return { type: "metatag", name: 'flow', value } + } + / _ name:((@MetaName _ ":") / ImplicitMetaName) _ value:MetaValue { return { type: "metatag", name: name.toLowerCase().trim(), value } } MetaName - = $[^\r\n:{]+ + = $[^\r\n:{,]+ ImplicitMetaName = & { return location().start.line <= 2 } { @@ -38,12 +65,22 @@ ImplicitMetaName MetaValue = $[^\r\n}]+ +Flow + = head:FlowKeyword tail:(_ "," _ @FlowKeyword)+ { return [head, ...tail] } + / (@FlowAbbr _)+ + +FlowKeyword + = MusicalInstruction / SectionName + +FlowAbbr + = abbr:$([A-Z]i / [0-9])+ { return abbr.toUpperCase() } + // https://www.onsongapp.com/docs/features/formats/onsong/section/ Section // {start_of_*} = DirectiveSection // Section with explcit name and maybe a body - / name:SectionName __ items:SectionBody* { return { type: 'section', name, items } } + / name:SectionHeader __ items:SectionBody* { return { type: 'section', name, items } } // Section without explicit name / items:SectionBody+ { return { type: 'section', name: null, items } } @@ -62,12 +99,15 @@ DirectiveSectionTypes / "v" "erse"? { return "verse" } SectionName - = name:MetaName _ ":" EOL { + = MetaName + +SectionHeader + = name:SectionName _ ":" EOL { return name.trim() } SectionBody - = !SectionName @(Tab / Stanza) __ + = !SectionHeader @(Tab / Stanza) __ Stanza = lines:Line+ { @@ -86,17 +126,10 @@ Line // Verse 1: // D G D // Amazing Grace, how sweet the sound, -// A7 -// That saved a wretch like me. -// D G D -// I once was lost, but now am found, -// A7 D -// Was blind, but now I see. ChordsOverLyrics = annotations:(_ @(annotation:(Chord / MusicalInstruction) { return { annotation, column: location().start.column } }))+ EOL lyrics:(@Lyrics EOL)? { - // FIXME: - // Is there a better way to do this in PEG? + // FIXME: Is there a better way to do this in PEG? // Or, is there a more idiomatic way to merge chords and lyrics into pairs? // First annotation does not start at beginning of line, add an empty one diff --git a/test/parser/on_song_grammar.test.js b/test/parser/on_song_grammar.test.js index 5abffd2c..722258f2 100644 --- a/test/parser/on_song_grammar.test.js +++ b/test/parser/on_song_grammar.test.js @@ -37,7 +37,7 @@ describe('OnSongGrammar', () => { // Inline Tags - https://www.onsongapp.com/docs/features/formats/onsong/metadata/?#inline-tags - SectionName: { + SectionHeader: { 'Chorus:': 'Chorus', 'Verse 1:\n': 'Verse 1', 'Intro :': 'Intro', @@ -409,7 +409,145 @@ describe('OnSongGrammar', () => { ], warnings: [], }, - 'Title\nFlow: V1 C v1\n\nVerse 1:\nVerse\n\nChorus:\nChorus': 'todo', + 'Title\nFlow: V1 C v1\n\nVerse 1:\nVerse\n\nChorus:\nChorus': { + metadata: [ + { name: 'title', type: 'metatag', value: 'Title' }, + { name: 'flow', type: 'metatag', value: ['V1', 'C', 'V1'] }, + ], + sections: [ + { + type: 'section', + name: 'Verse 1', + items: [ + { + type: 'stanza', + lines: [ + { + type: 'line', + parts: [{ annotation: null, lyrics: 'Verse', type: 'annotation' }], + }, + ], + }, + ], + }, + { + type: 'section', + name: 'Chorus', + items: [ + { + type: 'stanza', + lines: [ + { + type: 'line', + parts: [{ annotation: null, lyrics: 'Chorus', type: 'annotation' }], + }, + ], + }, + ], + }, + { + type: 'section', + name: 'Verse 1', + items: [ + { + type: 'stanza', + lines: [ + { + type: 'line', + parts: [{ annotation: null, lyrics: 'Verse', type: 'annotation' }], + }, + ], + }, + ], + }, + ], + type: 'chordsheet', + warnings: [], + }, + 'Title\nFlow: Verse 1, Chorus, Verse 1\n\nVerse 1:\nVerse\n\nChorus:\nChorus': { + metadata: [ + { name: 'title', type: 'metatag', value: 'Title' }, + { name: 'flow', type: 'metatag', value: ['Verse 1', 'Chorus', 'Verse 1'] }, + ], + sections: [ + { + type: 'section', + name: 'Verse 1', + items: [ + { + type: 'stanza', + lines: [ + { + type: 'line', + parts: [{ annotation: null, lyrics: 'Verse', type: 'annotation' }], + }, + ], + }, + ], + }, + { + type: 'section', + name: 'Chorus', + items: [ + { + type: 'stanza', + lines: [ + { + type: 'line', + parts: [{ annotation: null, lyrics: 'Chorus', type: 'annotation' }], + }, + ], + }, + ], + }, + { + type: 'section', + name: 'Verse 1', + items: [ + { + type: 'stanza', + lines: [ + { + type: 'line', + parts: [{ annotation: null, lyrics: 'Verse', type: 'annotation' }], + }, + ], + }, + ], + }, + ], + type: 'chordsheet', + warnings: [], + }, + 'Title\nFlow: Chorus, (Repeat 2x)\n\nChorus:\nLyrics': { + metadata: [ + { name: 'title', type: 'metatag', value: 'Title' }, + { name: 'flow', type: 'metatag', value: ['Chorus', { type: 'instruction', content: 'Repeat 2x' }] }, + ], + sections: [ + { + type: 'section', + name: 'Chorus', + items: [ + { + type: 'stanza', + lines: [ + { + type: 'line', + parts: [{ annotation: null, lyrics: 'Lyrics', type: 'annotation' }], + }, + ], + }, + ], + }, + { + type: 'instruction', + content: 'Repeat 2x', + }, + ], + type: 'chordsheet', + warnings: [], + }, 'Title\n\nVerse:\n[G]Hello\n\n{transpose: 2}\n\nVerse': 'todo', }, };