diff --git a/docs/Makefile b/docs/Makefile
index bc0c07e..79d50cd 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -43,7 +43,7 @@ gh-pages: build
git add -A && \
git commit -m "Deploy to GitHub Pages" && \
git branch -M gh-pages && \
- git remote add origin git@panyam-github:panyam/notations.git && \
+ git remote add origin git@github.com:panyam/notations.git && \
git push -f origin gh-pages
@echo "Deployed! Enable GitHub Pages in repo settings to serve from gh-pages branch"
@rm -rf dist/docs/.git
diff --git a/docs/content/reference/syntax/index.html b/docs/content/reference/syntax/index.html
index 3a6ba16..f2a7877 100644
--- a/docs/content/reference/syntax/index.html
+++ b/docs/content/reference/syntax/index.html
@@ -54,6 +54,22 @@
Whitespace
Both lines produce identical output.
+Bar Separators
+
+A | or || inside a role line is ignored the same way whitespace is, so
+you can mark avartanam boundaries in the source if you find them easier to read. The renderer draws
+its own lines from the cycle either way, one at the end of each bar and a double line at the end of
+the avartanam, so a bar you type is never what you see:
+
+
+\cycle("|4|2|2|")
+Sw: S R G M | P D | N S. ||
+
+
+Because they are ignored rather than checked, a bar in the wrong place is not an error. The
+| characters inside \cycle("|4|2|2|") are a different thing entirely,
+since that argument is a quoted string and the bars in it define the cycle.
+
Spaces (Karvais)
Spaces represent empty durations where the previous note continues. Use commas:
@@ -69,7 +85,7 @@ Spaces (Karvais)
, | Space (Karvai) | 1 unit |
; | Double space | 2 units |
- _ | Silent space (not implemented yet) | 1 unit (not displayed) |
+ _ | Silent space | 1 unit (not displayed) |
@@ -77,7 +93,9 @@ Spaces (Karvais)
S , R ; G , , M
-Silent spaces are meant to consume duration without displaying the space symbol, to avoid clutter. They are not implemented yet, so use , for now.
+Silent spaces consume duration without displaying the space symbol, to avoid clutter. Only a
+standalone _ counts as one, so write _ _ _ for three. A run such as
+___ is an ordinary literal, as is an underscore inside a word.
Rests
diff --git a/docs/tests/playground-patterns.spec.ts b/docs/tests/playground-patterns.spec.ts
index 80de327..0b2da32 100644
--- a/docs/tests/playground-patterns.spec.ts
+++ b/docs/tests/playground-patterns.spec.ts
@@ -152,12 +152,10 @@ describe("Playground Patterns", () => {
expect(addAtomsCmd!.atoms.length).toBe(3);
});
- it("should throw on invalid commands", () => {
+ it("should report invalid commands as errors rather than throwing", () => {
const parser = new Parser();
- // Invalid commands throw an error
- expect(() => {
- parser.parse("S R G \\invalidCommand");
- }).toThrow("Invalid command");
+ parser.parse("S R G \\invalidCommand");
+ expect(parser.errors.map((e: any) => e.message)).toEqual(["Invalid command: invalidcommand"]);
});
});
});
diff --git a/src/parser.ts b/src/parser.ts
index 036aad4..ea1371d 100644
--- a/src/parser.ts
+++ b/src/parser.ts
@@ -67,11 +67,13 @@ const [parser, tokenFunc /*itemGraph*/] = G.newParser(
%token DOTS_IDENT /(\.+)({IdentChar}+)/ { toOctavedNote }
%token IDENT_DOTS /({IdentChar}+)(\.+)/ { toOctavedNote }
%token IDENT_COLON /{IdentChar}+:/ { toRoleSelector }
+ %token UNDER_SCORE /_(?!{IdentChar})/
%token IDENT /{IdentChar}+/
%token BSLASH_IDENT /\\{IDENT}/ { toCommandName }
%token BSLASH_NUMBER /\\{NUMBER}/
%token HYPHEN /-/
%skip /[ \t\n\f\r]+/
+ %skip /\|\|?/
%skip /\/\/[^\n]*/
%skip /\/\*.*?\*\//
@@ -477,6 +479,9 @@ export class Parser {
const blockCommands = children[2].value as Command[] | null;
const innerCommand = this.createCommand(name, params);
+ // The block's commands were already spliced out of this.commands by endBlock, so an unknown
+ // command takes its block down with it rather than leaving those commands at the top level.
+ if (innerCommand === null) return null;
if (blockCommands !== null) {
// Wrap in BlockCommand
@@ -503,8 +508,8 @@ export class Parser {
return children[1].value;
},
appendCommand: (rule: G.Rule, parent: G.PTNode, ...children: G.PTNode[]) => {
- const command = children[1].value as Command;
- this.addCommand(command);
+ const command = children[1].value as Command | null;
+ if (command !== null) this.addCommand(command);
const atoms = children[2].value as Atom[];
if (atoms.length > 0) {
@@ -540,7 +545,16 @@ export class Parser {
// config = config || {};
}
- createCommand(name: string, params: CmdParam[]): Command {
+ /**
+ * Builds the command a `\\name(...)` directive names.
+ *
+ * Returns null for a name with no command behind it, after recording a ParseError on this
+ * parser, rather than throwing. Rule handlers run inside galore's parse loop, so a throw
+ * escapes `parse` and `load` entirely and reaches the caller as an exception while every other
+ * parse failure arrives in the errors array. Callers get one failure shape by being handed a
+ * null here and reading `errors`.
+ */
+ createCommand(name: string, params: CmdParam[]): Command | null {
const lName = name.trim().toLowerCase();
params = params || [];
if (lName == "line") {
@@ -564,7 +578,8 @@ export class Parser {
return new Repeat(params);
} else {
// Try to set this as the current role
- throw new Error("Invalid command: " + lName);
+ this.errors.push(new G.ParseError("Invalid command: " + lName, "InvalidCommand", lName));
+ return null;
}
}
diff --git a/src/tests/parser.spec.ts b/src/tests/parser.spec.ts
index 4a7e146..68741b2 100644
--- a/src/tests/parser.spec.ts
+++ b/src/tests/parser.spec.ts
@@ -977,3 +977,98 @@ describe("Comment Tests", () => {
]);
});
});
+
+describe("Silent Space Tests", () => {
+ function atomsOf(input: string): any[] {
+ const parser = new Parser();
+ parser.parse(input);
+ expect(parser.errors).toEqual([]);
+ const addAtoms = parser.commands.map((c: any) => c.debugValue()).find((c: any) => c.atoms);
+ return addAtoms.atoms;
+ }
+
+ test("A lone underscore is a silent space", () => {
+ expect(atomsOf(`Sw: S _ R`)).toEqual([
+ { type: "Literal", value: "S" },
+ { type: "Space", isSilent: true },
+ { type: "Literal", value: "R" },
+ ]);
+ });
+
+ test("Separated underscores are one silent space each", () => {
+ expect(atomsOf(`Sw: _ _ _`)).toEqual([
+ { type: "Space", isSilent: true },
+ { type: "Space", isSilent: true },
+ { type: "Space", isSilent: true },
+ ]);
+ });
+
+ test("A comma is a space that is not silent", () => {
+ expect(atomsOf(`Sw: S , R`)).toEqual([
+ { type: "Literal", value: "S" },
+ { type: "Space", isSilent: false },
+ { type: "Literal", value: "R" },
+ ]);
+ });
+
+ // The token is guarded by a lookahead so that only a standalone underscore becomes a space.
+ // Runs and embedded underscores stay literals, which is what they were before the token existed.
+ test("An underscore run stays a literal", () => {
+ expect(atomsOf(`Sw: S ___ R`)).toEqual([
+ { type: "Literal", value: "S" },
+ { type: "Literal", value: "___" },
+ { type: "Literal", value: "R" },
+ ]);
+ });
+
+ test("An underscore inside a word stays part of the word", () => {
+ expect(atomsOf(`Sw: S_N_D _abc`)).toEqual([
+ { type: "Literal", value: "S_N_D" },
+ { type: "Literal", value: "_abc" },
+ ]);
+ });
+
+ test("Inside a group a silent space differs from a comma only by isSilent", () => {
+ const silent = atomsOf(`Sw: [S _ R]`);
+ const comma = atomsOf(`Sw: [S , R]`);
+ expect(silent[0].atoms[1]).toEqual({ type: "Space", isSilent: true });
+ expect(comma[0].atoms[1]).toEqual({ type: "Space", isSilent: false });
+ expect({ ...silent[0], atoms: null }).toEqual({ ...comma[0], atoms: null });
+ });
+});
+
+describe("Bar Separator Tests", () => {
+ function parseOf(input: string): Parser {
+ const parser = new Parser();
+ parser.parse(input);
+ return parser;
+ }
+
+ function expectSameAsUnbarred(barred: string, unbarred: string): void {
+ const withBars = parseOf(barred);
+ expect(withBars.errors).toEqual([]);
+ expect(withBars.commands.map((c: any) => c.debugValue())).toEqual(
+ parseOf(unbarred).commands.map((c: any) => c.debugValue()),
+ );
+ }
+
+ test("Single and double bars in a role line are ignored", () => {
+ expectSameAsUnbarred(`Sw: S R G M | P D | N S. ||`, `Sw: S R G M P D N S.`);
+ });
+
+ test("A bar with no surrounding whitespace is ignored", () => {
+ expectSameAsUnbarred(`Sw: S R|G M`, `Sw: S R G M`);
+ });
+
+ test("A bar between commands is ignored", () => {
+ expectSameAsUnbarred(`\\beatDuration(2) | Sw: S R`, `\\beatDuration(2) Sw: S R`);
+ });
+
+ // The skip rule must not reach inside a quoted string, which is where the cycle spec lives.
+ test("Bars inside the cycle spec still define the bars", () => {
+ const parser = parseOf(`\\cycle("|4|2|2|")\nSw: S R G M | P D | N S. ||`);
+ expect(parser.errors).toEqual([]);
+ const cycle = parser.commands[0].debugValue().params[0].value;
+ expect(cycle).toEqual("|4|2|2|");
+ });
+});
diff --git a/src/tests/regressions.spec.ts b/src/tests/regressions.spec.ts
index b45eb3e..d2cb3f8 100644
--- a/src/tests/regressions.spec.ts
+++ b/src/tests/regressions.spec.ts
@@ -68,3 +68,31 @@ describe("Parser State Isolation", () => {
]);
});
});
+
+describe("Invalid Commands", () => {
+ const BAD_COMMAND = `\\cycle("|4|")\nSw: S R G M\n\\nosuchcommand\n`;
+
+ test("An unknown command is reported rather than thrown", () => {
+ const [, , errors] = load(BAD_COMMAND);
+ expect(errors.map((e: any) => e.message)).toEqual(["Invalid command: nosuchcommand"]);
+ });
+
+ test("Parsing continues past an unknown command", () => {
+ const parser = new Parser();
+ parser.parse(BAD_COMMAND);
+ const names = parser.commands.map((c: any) => c.debugValue().name);
+ expect(names).toEqual(["SetCycle", "ActivateRole", "AddAtoms"]);
+ });
+
+ test("An unknown command carrying a block drops the block without crashing", () => {
+ const parser = new Parser();
+ parser.parse(`\\cycle("|2|")\n\\nosuchcommand {\n mrid: tham ,\n}\nmrid: thi ,\n`);
+ expect(parser.errors.map((e: any) => e.message)).toEqual(["Invalid command: nosuchcommand"]);
+ expect(parser.commands.map((c: any) => c.debugValue().name)).toEqual(["SetCycle", "ActivateRole", "AddAtoms"]);
+ });
+
+ test("A later load is unaffected by an earlier invalid command", () => {
+ expect(load(BAD_COMMAND)[2].length).toBeGreaterThan(0);
+ expect(load(GOOD)[2]).toEqual([]);
+ });
+});