Skip to content

Commit e992fef

Browse files
Improve ChordPro soft line break support (#1312)
* Sort scripts and fix double code generate * Add support for parser tracing in test environment Trace parsing like below: ``` const song = trace(chordSheet, (tracer) => ( parser.parse(chordSheet, { softLineBreaks: true, tracer }) )); ``` * Fix parsing soft line break following a bracket Related to bettermusic#754 * Disable Jest's console to remove verbose logging * `debug:chordpro` debug task using peggyjs editor Running `yarn debug:chordpro` will open up `peggyjs.org/online.html` with the full chordpro grammar and JS compilation of the helpers included.
1 parent 5827020 commit e992fef

File tree

14 files changed

+915
-54
lines changed

14 files changed

+915
-54
lines changed

jest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
22
export default {
33
preset: 'ts-jest',
4+
setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
45
testEnvironment: 'node',
56
};

package.json

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,10 @@
5151
"parcel": "^2.4.1",
5252
"parcel-transformer-hbs": "^1.0.4",
5353
"peggy": "^4.0.2",
54+
"pegjs-backtrace": "^0.2.1",
5455
"pinst": "^3.0.0",
5556
"print": "^1.2.0",
57+
"puppeteer": "^23.1.0",
5658
"shx": "^0.3.4",
5759
"theredoc": "^1.0.0",
5860
"ts-jest": "^29.2.3",
@@ -64,38 +66,39 @@
6466
"typescript-eslint": "^8.0.1"
6567
},
6668
"scripts": {
67-
"build:suffix-normalize": "shx rm -rf src/normalize_mappings/suffix-normalize-mapping.ts && tsx src/normalize_mappings/generate-suffix-normalize-mapping.ts",
69+
"build": "yarn build:code-generate && yarn build:sources && yarn build:bundle && yarn build:check-types",
70+
"build:bundle": "yarn build:bundle:default && yarn build:bundle:min",
71+
"build:bundle:default": "esbuild lib/index.js --outfile=lib/bundle.js --bundle --global-name=ChordSheetJS",
72+
"build:bundle:min": "esbuild lib/index.js --outfile=lib/bundle.min.js --bundle --global-name=ChordSheetJS --minify-whitespace --minify-identifiers --minify-syntax",
73+
"build:check-types": "tsc lib/main.d.ts",
6874
"build:chord-suffix-grammar": "yarn tsx script/generate_chord_suffix_grammar.ts",
75+
"build:code-generate": "yarn build:suffix-normalize && yarn build:chord-suffix-grammar && yarn build:pegjs && yarn build:scales",
76+
"build:pegjs": "yarn build:pegjs:chord && yarn build:pegjs:chordpro && yarn build:pegjs:chords-over-words",
77+
"build:pegjs:chord": "tsx script/combine_files.ts src/parser/chord/base_grammar.pegjs src/parser/chord/suffix_grammar.pegjs src/parser/chord/combined_grammer.pegjs && peggy --plugin ts-pegjs -o src/parser/chord/peg_parser.ts src/parser/chord/combined_grammer.pegjs",
6978
"build:pegjs:chordpro": "tsx script/generate_parser.ts chord_pro --skip-chord-grammar",
7079
"build:pegjs:chords-over-words": "tsx script/generate_parser.ts chords_over_words",
71-
"build:pegjs:chord": "tsx script/combine_files.ts src/parser/chord/base_grammar.pegjs src/parser/chord/suffix_grammar.pegjs src/parser/chord/combined_grammer.pegjs && peggy --plugin ts-pegjs -o src/parser/chord/peg_parser.ts src/parser/chord/combined_grammer.pegjs",
72-
"build:pegjs": "yarn build:pegjs:chord && yarn build:pegjs:chordpro && yarn build:pegjs:chords-over-words",
7380
"build:scales": "tsx script/generate_scales.ts && yarn linter:fix src/scales.ts",
74-
"build:code-generate": "yarn build:suffix-normalize && yarn build:chord-suffix-grammar && yarn build:pegjs && yarn build:scales",
7581
"build:sources": "parcel build",
76-
"build:bundle": "yarn build:bundle:default && yarn build:bundle:min",
77-
"build:bundle:default": "esbuild lib/index.js --outfile=lib/bundle.js --bundle --global-name=ChordSheetJS",
78-
"build:bundle:min": "esbuild lib/index.js --outfile=lib/bundle.min.js --bundle --global-name=ChordSheetJS --minify-whitespace --minify-identifiers --minify-syntax",
79-
"build:check-types": "tsc lib/main.d.ts",
80-
"build": "yarn build:code-generate && yarn build:sources && yarn build:bundle && yarn build:check-types",
82+
"build:suffix-normalize": "shx rm -rf src/normalize_mappings/suffix-normalize-mapping.ts && tsx src/normalize_mappings/generate-suffix-normalize-mapping.ts",
83+
"ci": "yarn install && yarn lint && yarn test && yarn build && yarn readme",
84+
"clean": "shx rm -rf node_modules && shx rm -rf lib",
85+
"debug:chordpro": "tsx script/debug_parser.ts chord_pro --skip-chord-grammar",
8186
"dev": "parcel watch --no-cache",
82-
"test": "yarn pretest && yarn lint && yarn jest:run",
83-
"jest:watch": "jest --watch",
8487
"jest:debug": "bin/open_inspector && node --nolazy --inspect-brk ./node_modules/jest/bin/jest.js --runInBand --colors --verbose",
85-
"jest:run:exp": "node --experimental-vm-modules node_modules/.bin/jest",
8688
"jest:run": "jest",
87-
"linter:run": "yarn eslint",
88-
"linter:fix": "yarn linter:run --fix",
89-
"prelint": "yarn build:code-generate",
89+
"jest:run:exp": "node --experimental-vm-modules node_modules/.bin/jest",
90+
"jest:watch": "jest --watch",
9091
"lint": "yarn prelint && yarn linter:run .",
9192
"lint:fix": "yarn prelint && yarn linter:fix .",
92-
"clean": "shx rm -rf node_modules && shx rm -rf lib",
93-
"readme": "node_modules/.bin/jsdoc2md -f src/**/*.ts -f src/*.ts --configure ./jsdoc2md.json --template doc/README.hbs > README.md",
94-
"prepublish": "pinst --disable && yarn install && yarn test && yarn build",
93+
"linter:fix": "yarn linter:run --fix",
94+
"linter:run": "yarn eslint",
9595
"postpublish": "pinst --enable",
96-
"pretest": "yarn build:code-generate",
96+
"prelint": "yarn pretest",
9797
"prepare": "husky install",
98-
"ci": "yarn install && yarn lint && yarn test && yarn build && yarn readme"
98+
"prepublish": "pinst --disable && yarn install && yarn test && yarn build",
99+
"pretest": "NODE_ENV=test yarn build:code-generate",
100+
"readme": "node_modules/.bin/jsdoc2md -f src/**/*.ts -f src/*.ts --configure ./jsdoc2md.json --template doc/README.hbs > README.md",
101+
"test": "yarn pretest && yarn linter:run && yarn jest:run"
99102
},
100103
"dependencies": {
101104
"lodash.get": "^4.4.2"

script/debug_parser.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import fs from 'fs';
2+
import puppeteer from 'puppeteer';
3+
import esbuild from 'esbuild';
4+
5+
const parserName = process.argv[2];
6+
const args = process.argv.slice(3);
7+
const skipChordGrammar = args.includes('--skip-chord-grammar');
8+
9+
const parserFolder = `./src/parser/${parserName}`;
10+
const grammarFile = `${parserFolder}/grammar.pegjs`;
11+
const helpersFile = `${parserFolder}/helpers.ts`;
12+
const chordGrammarFile = './src/parser/chord/base_grammar.pegjs';
13+
const chordSuffixGrammarFile = './src/parser/chord/suffix_grammar.pegjs';
14+
15+
const parserGrammar = fs.readFileSync(grammarFile, 'utf8');
16+
const chordGrammar = skipChordGrammar ? '' : fs.readFileSync(chordGrammarFile);
17+
const chordSuffixGrammar = fs.readFileSync(chordSuffixGrammarFile);
18+
19+
const result = esbuild.buildSync({
20+
bundle: true,
21+
entryPoints: [helpersFile],
22+
globalName: 'helpers',
23+
write: false,
24+
});
25+
26+
const transpiledHelpers = result.outputFiles[0].text;
27+
28+
const parserSource = [
29+
`{\n${transpiledHelpers}\n}`,
30+
parserGrammar,
31+
chordGrammar,
32+
chordSuffixGrammar,
33+
].join('\n\n');
34+
35+
async function run() {
36+
const browser = await puppeteer.launch({
37+
args:['--start-maximized'],
38+
defaultViewport: null,
39+
headless: false,
40+
});
41+
42+
async function shutdownHandler() {
43+
await browser.close();
44+
}
45+
46+
for (const event of ['exit', 'SIGINT', 'SIGUSR1', 'SIGUSR2', 'uncaughtException', 'SIGTERM']) {
47+
process.on(event, shutdownHandler);
48+
}
49+
50+
const [page] = await browser.pages();
51+
await page.setViewport({ width: 0, height: 0 });
52+
await page.goto('https://peggyjs.org/online.html');
53+
54+
await page.evaluate((grammar) => {
55+
const textarea = document.getElementById('grammar');
56+
if (!textarea) return;
57+
58+
const editorNode = textarea.nextSibling;
59+
if (!editorNode) return;
60+
61+
// @ts-ignore
62+
const editor = editorNode.CodeMirror;
63+
editor.setValue(grammar);
64+
}, parserSource);
65+
66+
while (true) {
67+
// Loop forever to allow for interactive debugging with the online Peggy parser
68+
}
69+
}
70+
71+
run()
72+
.then(() => console.log('Done'))
73+
.catch(e => console.error(e));

script/generate_parser.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import fs from 'fs';
77
const parserName = process.argv[2];
88
const args = process.argv.slice(3);
99
const skipChordGrammar = args.includes('--skip-chord-grammar');
10+
const enableTracing = process.env.NODE_ENV === 'test';
11+
12+
console.warn('\x1b[34m', `👷 Building ${parserName} parser with${enableTracing ? '' : 'out'} tracing`);
1013

1114
const parserFolder = `./src/parser/${parserName}`;
1215
const grammarFile = `${parserFolder}/grammar.pegjs`;
@@ -24,6 +27,9 @@ const source = peggy.generate(input, {
2427
grammarSource: grammarFile,
2528
output: 'source',
2629
format: 'commonjs',
30+
trace: enableTracing,
2731
});
2832

2933
fs.writeFileSync(outputFile, `import * as helpers from './helpers';\n\n${source}`);
34+
35+
console.warn('\x1b[32m', `✨ Successfully built ${parserName} parser at ${outputFile}`);

src/parser/chord_pro/grammar.pegjs

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -99,18 +99,10 @@ Line
9999
Token
100100
= Tag
101101
/ AnnotationLyricsPair
102-
/ ChordLyricsPair
102+
/ chordLyricsPair:ChordLyricsPair
103103
/ MetaTernary
104104
/ lyrics:Lyrics {
105-
return lyrics.split('\xa0').flatMap((lyric, index) => ([
106-
index == 0 ? null : { type: 'softLineBreak' },
107-
{
108-
type: 'chordLyricsPair',
109-
chords: '',
110-
lyrics: lyric,
111-
},
112-
].filter(x => x)
113-
));
105+
return helpers.applySoftLineBreaks(lyrics);
114106
}
115107

116108
Comment
@@ -128,16 +120,13 @@ AnnotationLyricsPair
128120
}
129121

130122
ChordLyricsPair
131-
= chords:Chord lyrics:$(LyricsChar*) space:$(Space*) {
132-
return {
133-
type: 'chordLyricsPair',
134-
chords: chords || '',
135-
lyrics: lyrics + (space || ''),
136-
};
123+
= chords:Chord lyrics:LyricsChar* space:$(Space*) {
124+
const mergedLyrics = lyrics.map(c => c.char || c).join('') + (space || '');
125+
return helpers.breakChordLyricsPairOnSoftLineBreak(chords || '', mergedLyrics);
137126
}
138127

139128
Lyrics
140-
= lyrics: LyricsCharOrSpace+ {
129+
= lyrics:LyricsCharOrSpace+ {
141130
return lyrics.map(c => c.char || c).join('');
142131
}
143132

src/parser/chord_pro/helpers.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
2+
SerializedChordLyricsPair,
23
SerializedItem,
34
SerializedLine,
5+
SerializedSoftLineBreak,
46
SerializedTag,
57
} from '../../serialized_types';
68

@@ -35,3 +37,55 @@ export function buildTag(name: string, value: string | null, location: FileRange
3537
location: location.start,
3638
};
3739
}
40+
41+
export function stringSplitReplace(
42+
string: string,
43+
search: string,
44+
replaceMatch: (subString: string) => any,
45+
replaceRest: (subString: string) => any = (subString) => subString,
46+
): any[] {
47+
const regExp = new RegExp(search, 'g');
48+
const occurrences = Array.from(string.matchAll(regExp));
49+
const result: string[] = [];
50+
let index = 0;
51+
52+
occurrences.forEach((match) => {
53+
const before = string.slice(index, match.index);
54+
if (before !== '') result.push(replaceRest(before));
55+
result.push(replaceMatch(match[0]));
56+
index = match.index + match[0].length;
57+
});
58+
59+
const rest = string.slice(index);
60+
if (rest !== '') result.push(replaceRest(rest));
61+
62+
return result;
63+
}
64+
65+
export function applySoftLineBreaks(lyrics: string): SerializedChordLyricsPair[] {
66+
return stringSplitReplace(
67+
lyrics,
68+
'\xa0',
69+
() => ({ type: 'softLineBreak' }),
70+
(lyric) => ({ type: 'chordLyricsPair', chords: '', lyrics: lyric }),
71+
) as SerializedChordLyricsPair[];
72+
}
73+
74+
export function breakChordLyricsPairOnSoftLineBreak(
75+
chords: string,
76+
lyrics: string,
77+
): Array<SerializedChordLyricsPair | SerializedSoftLineBreak> {
78+
const pairs = applySoftLineBreaks(lyrics || '');
79+
let [first, ...rest] = pairs as Array<SerializedChordLyricsPair | SerializedSoftLineBreak>;
80+
let addedLeadingChord: SerializedChordLyricsPair | null = null;
81+
82+
if (chords !== '') {
83+
if (!first || first.type === 'softLineBreak') {
84+
addedLeadingChord = { type: 'chordLyricsPair', chords, lyrics: '' };
85+
} else {
86+
first = { ...first, chords };
87+
}
88+
}
89+
90+
return [addedLeadingChord, first || null, ...rest].filter((item) => item !== null);
91+
}

src/parser/chord_pro_parser.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Song from '../chord_sheet/song';
33
import ParserWarning from './parser_warning';
44
import { normalizeLineEndings } from '../utilities';
55
import ChordSheetSerializer from '../chord_sheet_serializer';
6+
import NullTracer from './null_tracer';
67

78
export type ChordProParserOptions = ParseOptions & {
89
softLineBreaks?: boolean;
@@ -33,7 +34,11 @@ class ChordProParser {
3334
* @returns {Song} The parsed song
3435
*/
3536
parse(chordSheet: string, options?: ChordProParserOptions): Song {
36-
const ast = parse(normalizeLineEndings(chordSheet), options);
37+
const ast = parse(
38+
normalizeLineEndings(chordSheet),
39+
{ tracer: new NullTracer(), ...options },
40+
);
41+
3742
this.song = new ChordSheetSerializer().deserialize(ast);
3843
return this.song;
3944
}

src/parser/chords_over_words_parser.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import ParserWarning from './parser_warning';
33
import { parse, ParseOptions } from './chords_over_words/peg_parser';
44
import { normalizeLineEndings } from '../utilities';
55
import ChordSheetSerializer from '../chord_sheet_serializer';
6+
import NullTracer from './null_tracer';
67

78
export type ChordsOverWordsParserOptions = ParseOptions & {
89
softLineBreaks?: boolean;
@@ -68,7 +69,11 @@ class ChordsOverWordsParser {
6869
* @returns {Song} The parsed song
6970
*/
7071
parse(chordSheet: string, options?: ChordsOverWordsParserOptions): Song {
71-
const ast = parse(normalizeLineEndings(chordSheet), options);
72+
const ast = parse(
73+
normalizeLineEndings(chordSheet),
74+
{ tracer: new NullTracer(), ...options },
75+
);
76+
7277
this.song = new ChordSheetSerializer().deserialize(ast);
7378
return this.song;
7479
}

src/parser/null_tracer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class NullTracer {
2+
trace() {}
3+
}
4+
5+
export default NullTracer;

0 commit comments

Comments
 (0)