From b4ef4aa9cf64153d6d5152790f0bf7a5bac0b5e2 Mon Sep 17 00:00:00 2001 From: Martijn Versluis Date: Mon, 9 Dec 2024 20:50:06 +0100 Subject: [PATCH] Parse conditional directives The tag selector is stored on the tag, the current line and evaluated for a paragraph. See https://www.chordpro.org/chordpro/chordpro-directives/#conditional-directives Related to https://github.com/martijnversluis/ChordSheetJS/issues/983 --- script/build_chord_pro_section_grammar.ts | 30 ++++++---- src/chord_sheet/line.ts | 2 + src/chord_sheet/paragraph.ts | 23 ++++++-- src/chord_sheet/tag.ts | 2 + src/chord_sheet_serializer.ts | 2 + src/parser/chord_pro/grammar.pegjs | 17 ++++-- src/parser/chord_pro/helpers.ts | 2 + src/serialized_types.ts | 1 + src/song_builder.ts | 11 +++- test/jest.d.ts | 2 +- test/matchers.ts | 4 +- test/parser/chord_pro_parser.test.ts | 70 +++++++++++++++++++++++ 12 files changed, 139 insertions(+), 27 deletions(-) diff --git a/script/build_chord_pro_section_grammar.ts b/script/build_chord_pro_section_grammar.ts index 884fd621f..21f3e6001 100644 --- a/script/build_chord_pro_section_grammar.ts +++ b/script/build_chord_pro_section_grammar.ts @@ -24,26 +24,32 @@ function sectionTags(sectionName: string, shortTag: boolean, type: 'start' | 'en } export default function buildChordProSectionGrammar(_: BuildOptions, _data: string): string { - const sectionsGrammars = sections.map(([name, shortTags]) => ` -${capitalize(name)}Section - = startTag:${capitalize(name)}StartTag + const sectionsGrammars = sections.map(([name, shortTags]) => { + const sectionName = capitalize(name); + const startTag = sectionTags(name, shortTags, 'start'); + const endTag = sectionTags(name, shortTags, 'end'); + + return ` +${sectionName}Section + = startTag:${sectionName}StartTag NewLine - content:$(!${capitalize(name)}EndTag SectionCharacter)* - endTag:${capitalize(name)}EndTag + content:$(!${sectionName}EndTag SectionCharacter)* + endTag:${sectionName}EndTag { return helpers.buildSection(startTag, endTag, content); } -${capitalize(name)}StartTag - = "{" _ tagName:(${sectionTags(name, shortTags, 'start')}) _ tagColonWithValue: TagColonWithValue? _ "}" { - return helpers.buildTag(tagName, tagColonWithValue, location()); +${sectionName}StartTag + = "{" _ tagName:(${startTag}) selector:TagSelector? _ tagColonWithValue:TagColonWithValue? _ "}" { + return helpers.buildTag(tagName, tagColonWithValue, selector, location()); } -${capitalize(name)}EndTag - = "{" _ tagName:(${sectionTags(name, shortTags, 'end')}) _ "}" { - return helpers.buildTag(tagName, null, location()); +${sectionName}EndTag + = "{" _ tagName:(${endTag}) _ "}" { + return helpers.buildTag(tagName, null, null, location()); } -`); +`; + }); return ` Section diff --git a/src/chord_sheet/line.ts b/src/chord_sheet/line.ts index ff565f5c9..0e48e0e69 100644 --- a/src/chord_sheet/line.ts +++ b/src/chord_sheet/line.ts @@ -54,6 +54,8 @@ class Line { lineNumber: number | null = null; + selector: string | null = null; + /** * The text font that applies to this line. Is derived from the directives: * `textfont`, `textsize` and `textcolour` diff --git a/src/chord_sheet/paragraph.ts b/src/chord_sheet/paragraph.ts index 418a3ab59..3056cfdf9 100644 --- a/src/chord_sheet/paragraph.ts +++ b/src/chord_sheet/paragraph.ts @@ -4,6 +4,16 @@ import Literal from './chord_pro/literal'; import Tag from './tag'; import Item from './item'; +function getCommonValue(values: string[], fallback: string | null): string | null { + const uniqueValues = [...new Set(values)]; + + if (uniqueValues.length === 1) { + return uniqueValues[0]; + } + + return fallback; +} + /** * Represents a paragraph of lines in a chord sheet */ @@ -87,13 +97,16 @@ class Paragraph { */ get type(): string { const types = this.lines.map((line) => line.type); - const uniqueTypes = [...new Set(types)]; + return getCommonValue(types, INDETERMINATE) as string; + } - if (uniqueTypes.length === 1) { - return uniqueTypes[0]; - } + get selector(): string | null { + const selectors = + this.lines + .map((line) => line.selector) + .filter((selector) => selector !== null); - return INDETERMINATE; + return getCommonValue(selectors, null); } /** diff --git a/src/chord_sheet/tag.ts b/src/chord_sheet/tag.ts index f8100542e..031444837 100644 --- a/src/chord_sheet/tag.ts +++ b/src/chord_sheet/tag.ts @@ -411,6 +411,8 @@ class Tag extends AstComponent { chordDefinition?: ChordDefinition; + selector: string | null = null; + /** * The tag attributes. For example, section related tags can have a label: * `{start_of_verse: label="Verse 1"}` diff --git a/src/chord_sheet_serializer.ts b/src/chord_sheet_serializer.ts index 0199cc3c7..f0959a79d 100644 --- a/src/chord_sheet_serializer.ts +++ b/src/chord_sheet_serializer.ts @@ -218,8 +218,10 @@ class ChordSheetSerializer { location: { offset = null, line = null, column = null } = {}, chordDefinition, attributes, + selector, } = astComponent; const tag = new Tag(name, value, { line, column, offset }, attributes); + tag.selector = selector || null; if (chordDefinition) { tag.chordDefinition = new ChordDefinition( diff --git a/src/parser/chord_pro/grammar.pegjs b/src/parser/chord_pro/grammar.pegjs index 8b0671554..70343ff2d 100644 --- a/src/parser/chord_pro/grammar.pegjs +++ b/src/parser/chord_pro/grammar.pegjs @@ -157,7 +157,7 @@ WordChar } ChordDefinition - = "{" _ name:("chord" / "define") _ ":" _ value:ChordDefinitionValue _ "}" { + = "{" _ name:("chord" / "define") selector:TagSelector? _ ":" _ value:ChordDefinitionValue _ "}" { const { text, ...chordDefinition } = value; return { @@ -166,14 +166,23 @@ ChordDefinition value: text, chordDefinition, location: location().start, + selector, }; } Tag - = "{" _ tagName:$(TagName) _ tagColonWithValue:TagColonWithValue? "}" { - return helpers.buildTag(tagName, tagColonWithValue, location()); + = "{" _ tagName:$(TagName) selector:TagSelector? _ tagColonWithValue:TagColonWithValue? "}" { + return helpers.buildTag(tagName, tagColonWithValue, selector, location()); } +TagSelector + = "-" value:TagSelectorValue { + return value; + } + +TagSelectorValue + = $([a-zA-Z0-9-_]+) + TagColonWithValue = ":" tagValue:TagValue { return tagValue; @@ -209,7 +218,7 @@ TagAttribute } TagName - = [a-zA-Z-_]+ + = [a-zA-Z_]+ TagSimpleValue = _ chars:TagValueChar* { diff --git a/src/parser/chord_pro/helpers.ts b/src/parser/chord_pro/helpers.ts index 7357ffa38..d3bff01d5 100644 --- a/src/parser/chord_pro/helpers.ts +++ b/src/parser/chord_pro/helpers.ts @@ -32,6 +32,7 @@ export function buildSection(startTag: SerializedTag, endTag: SerializedTag, con export function buildTag( name: string, value: Partial<{ value: string | null, attributes: Record}> | null, + selector: string | null, location: FileRange, ): SerializedTag { return { @@ -40,6 +41,7 @@ export function buildTag( location: location.start, value: value?.value || '', attributes: value?.attributes || {}, + selector, }; } diff --git a/src/serialized_types.ts b/src/serialized_types.ts index efe0080d1..25484bca0 100644 --- a/src/serialized_types.ts +++ b/src/serialized_types.ts @@ -39,6 +39,7 @@ export type SerializedTag = SerializedTraceInfo & { value: string, chordDefinition?: SerializedChordDefinition, attributes?: Record, + selector?: string | null, }; export interface SerializedComment { diff --git a/src/song_builder.ts b/src/song_builder.ts index 0be197d1d..19de62056 100644 --- a/src/song_builder.ts +++ b/src/song_builder.ts @@ -30,6 +30,8 @@ class SongBuilder { sectionType: string = NONE; + selector: string | null = null; + song: Song; transposeKey: string | null = null; @@ -61,17 +63,18 @@ class SongBuilder { this.lines.push(this.currentLine); } - this.setCurrentProperties(this.sectionType); + this.setCurrentProperties(this.sectionType, this.selector); this.currentLine.transposeKey = this.transposeKey ?? this.currentKey; this.currentLine.key = this.currentKey || this.metadata.getSingle(KEY); this.currentLine.lineNumber = this.lines.length - 1; return this.currentLine; } - setCurrentProperties(sectionType: string): void { + setCurrentProperties(sectionType: string, selector: string | null = null): void { if (!this.currentLine) throw new Error('Expected this.currentLine to be present'); this.currentLine.type = sectionType as LineType; + this.currentLine.selector = selector; this.currentLine.textFont = this.fontStack.textFont.clone(); this.currentLine.chordFont = this.fontStack.chordFont.clone(); } @@ -151,12 +154,14 @@ class SongBuilder { startSection(sectionType: string, tag: Tag): void { this.checkCurrentSectionType(NONE, tag); this.sectionType = sectionType; - this.setCurrentProperties(sectionType); + this.selector = tag.selector; + this.setCurrentProperties(sectionType, tag.selector); } endSection(sectionType: string, tag: Tag): void { this.checkCurrentSectionType(sectionType, tag); this.sectionType = NONE; + this.selector = null; } checkCurrentSectionType(sectionType: string, tag: Tag): void { diff --git a/test/jest.d.ts b/test/jest.d.ts index d6c0122b8..99ae2faf7 100644 --- a/test/jest.d.ts +++ b/test/jest.d.ts @@ -17,7 +17,7 @@ declare global { toBeComment(_contents: string): jest.CustomMatcherResult; - toBeTag(_name: string, _value?: string): jest.CustomMatcherResult; + toBeTag(_name: string, _value?: string, _selector?: string): jest.CustomMatcherResult; toBeSoftLineBreak(): jest.CustomMatcherResult; } diff --git a/test/matchers.ts b/test/matchers.ts index 9005cb15a..a185e1446 100644 --- a/test/matchers.ts +++ b/test/matchers.ts @@ -119,8 +119,8 @@ function toBeChordLyricsPair(received, chords, lyrics, annotation = '') { return toBeClassInstanceWithProperties(received, ChordLyricsPair, { chords, lyrics, annotation }); } -function toBeTag(received, name, value = '') { - return toBeClassInstanceWithProperties(received, Tag, { name, value }); +function toBeTag(received, name, value = '', selector = null) { + return toBeClassInstanceWithProperties(received, Tag, { name, value, selector }); } function toBeComment(received, content) { diff --git a/test/parser/chord_pro_parser.test.ts b/test/parser/chord_pro_parser.test.ts index 3302d3515..4aed62e75 100644 --- a/test/parser/chord_pro_parser.test.ts +++ b/test/parser/chord_pro_parser.test.ts @@ -337,6 +337,15 @@ This part is [G]key expect(song.title).toEqual('my {title}'); }); + it('allows conditional directives', () => { + const chordSheet = '{title-guitar: Guitar song}'; + const song = new ChordProParser().parse(chordSheet); + + const tag = song.lines[0].items[0] as Tag; + + expect(tag).toBeTag('title', 'Guitar song', 'guitar'); + }); + it('parses annotation', () => { const chordSheet = '[*Full band!]Let it be'; const song = new ChordProParser().parse(chordSheet); @@ -670,6 +679,32 @@ Let it [Am]be expect(lines[2].items[0]).toBeLiteral('LY line 2'); }); + it('parses conditional sections', () => { + const chordSheet = heredoc` + {start_of_ly-guitar: Intro} + LY line 1 + LY line 2 + {end_of_ly} + `; + + const parser = new ChordProParser(); + const song = parser.parse(chordSheet); + const { paragraphs } = song; + const paragraph = paragraphs[0]; + const { lines } = paragraph; + + expect(paragraphs).toHaveLength(1); + expect(paragraph.type).toEqual(LILYPOND); + expect(paragraph.selector).toEqual('guitar'); + expect(lines).toHaveLength(3); + + expect(lines[0].items[0]).toBeTag('start_of_ly', 'Intro', 'guitar'); + expect(lines[1].items[0]).toBeLiteral('LY line 1'); + expect(lines[2].items[0]).toBeLiteral('LY line 2'); + + expect(lines.every((line) => line.selector === 'guitar')).toBe(true); + }); + it('parses soft line breaks when enabled', () => { const chordSheet = heredoc` [Am]Let it be,\\ let it [C/G]be @@ -748,6 +783,23 @@ Let it [Am]be fingers: [], }); }); + + it('parses conditional chord definitions', () => { + const chordSheet = '{define-guitar: Am base-fret 1 frets 0 2 2 1 0 0}'; + const parser = new ChordProParser(); + const song = parser.parse(chordSheet); + const tag = song.lines[0].items[0]; + const { chordDefinition } = (tag as Tag); + + expect(tag).toBeTag('define', 'Am base-fret 1 frets 0 2 2 1 0 0', 'guitar'); + + expect(chordDefinition).toEqual({ + name: 'Am', + baseFret: 1, + frets: [0, 2, 2, 1, 0, 0], + fingers: [], + }); + }); }); describe('{chord} chord definitions', () => { @@ -786,5 +838,23 @@ Let it [Am]be fingers: [], }); }); + + it('parses conditional chord definitions', () => { + const chordSheet = '{chord-ukulele: D7 base-fret 3 frets x 3 2 3 1 x }'; + + const parser = new ChordProParser(); + const song = parser.parse(chordSheet); + const tag = song.lines[0].items[0]; + const { chordDefinition } = (tag as Tag); + + expect(tag).toBeTag('chord', 'D7 base-fret 3 frets x 3 2 3 1 x', 'ukulele'); + + expect(chordDefinition).toEqual({ + name: 'D7', + baseFret: 3, + frets: ['x', 3, 2, 3, 1, 'x'], + fingers: [], + }); + }); }); });