Skip to content

Commit

Permalink
Parse conditional directives
Browse files Browse the repository at this point in the history
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 #983
  • Loading branch information
martijnversluis committed Dec 9, 2024
1 parent f882bb0 commit b4ef4aa
Show file tree
Hide file tree
Showing 12 changed files with 139 additions and 27 deletions.
30 changes: 18 additions & 12 deletions script/build_chord_pro_section_grammar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/chord_sheet/line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
23 changes: 18 additions & 5 deletions src/chord_sheet/paragraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/chord_sheet/tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"}`
Expand Down
2 changes: 2 additions & 0 deletions src/chord_sheet_serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 13 additions & 4 deletions src/parser/chord_pro/grammar.pegjs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ WordChar
}

ChordDefinition
= "{" _ name:("chord" / "define") _ ":" _ value:ChordDefinitionValue _ "}" {
= "{" _ name:("chord" / "define") selector:TagSelector? _ ":" _ value:ChordDefinitionValue _ "}" {
const { text, ...chordDefinition } = value;

return {
Expand All @@ -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;
Expand Down Expand Up @@ -209,7 +218,7 @@ TagAttribute
}

TagName
= [a-zA-Z-_]+
= [a-zA-Z_]+

TagSimpleValue
= _ chars:TagValueChar* {
Expand Down
2 changes: 2 additions & 0 deletions src/parser/chord_pro/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function buildSection(startTag: SerializedTag, endTag: SerializedTag, con
export function buildTag(
name: string,
value: Partial<{ value: string | null, attributes: Record<string, string>}> | null,
selector: string | null,
location: FileRange,
): SerializedTag {
return {
Expand All @@ -40,6 +41,7 @@ export function buildTag(
location: location.start,
value: value?.value || '',
attributes: value?.attributes || {},
selector,
};
}

Expand Down
1 change: 1 addition & 0 deletions src/serialized_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type SerializedTag = SerializedTraceInfo & {
value: string,
chordDefinition?: SerializedChordDefinition,
attributes?: Record<string, string>,
selector?: string | null,
};

export interface SerializedComment {
Expand Down
11 changes: 8 additions & 3 deletions src/song_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ class SongBuilder {

sectionType: string = NONE;

selector: string | null = null;

song: Song;

transposeKey: string | null = null;
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion test/jest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions test/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
70 changes: 70 additions & 0 deletions test/parser/chord_pro_parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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: [],
});
});
});
});

0 comments on commit b4ef4aa

Please sign in to comment.