diff --git a/src/__snapshots__/notcl.test.ts.snap b/src/__snapshots__/notcl.test.ts.snap index 78822d7..d966ca8 100644 --- a/src/__snapshots__/notcl.test.ts.snap +++ b/src/__snapshots__/notcl.test.ts.snap @@ -9,10 +9,7 @@ exports[`Parsing Notcl Misc Big mess of markup 1`] = ` "enchanted": "h1", }, { - "text": ""Hello,", - }, - { - "text": "World!"", + "text": "Hello, World!", }, ], [ @@ -47,7 +44,7 @@ exports[`Parsing Notcl Misc Big mess of markup 1`] = ` "enchanted": "-red", }, { - "text": ""Beware!"", + "text": "Beware!", }, ], [ @@ -55,61 +52,7 @@ exports[`Parsing Notcl Misc Big mess of markup 1`] = ` "enchanted": "para", }, { - "text": ""All", - }, - { - "enchanted": "text", - }, - { - "enchanted": "should", - }, - { - "enchanted": "be", - }, - { - "enchanted": "quoted,", - }, - { - "enchanted": "it's", - }, - { - "enchanted": "clearer", - }, - { - "enchanted": "that", - }, - { - "enchanted": "way.", - }, - { - "enchanted": "&", - }, - { - "enchanted": "blockquotes", - }, - { - "enchanted": "already", - }, - { - "enchanted": "should", - }, - { - "enchanted": "contain", - }, - { - "enchanted": "paragraphs.", - }, - { - "enchanted": "(maybe", - }, - { - "enchanted": "normalize", - }, - { - "enchanted": "nested", - }, - { - "text": "paragraphs)"", + "text": "All text should be quoted, it's clearer that way. & blockquotes already should contain paragraphs. (maybe normalize nested paragraphs)", }, ], [ diff --git a/src/notcl.test.ts b/src/notcl.test.ts index b956aef..7912a0f 100644 --- a/src/notcl.test.ts +++ b/src/notcl.test.ts @@ -33,12 +33,12 @@ b`) expect( parse(String.raw`a\\ b`) - ).toEqual([true, [[{ text: "a\\\\" }], [{ enchanted: "b" }]]])); + ).toEqual([true, [[{ text: "a\\" }], [{ enchanted: "b" }]]])); it("does not split commands on folded newlines with escaped backslashes", () => expect( parse(String.raw`a\\\ b`) - ).toEqual([true, [[{ text: "a\\\\" }, { enchanted: "b" }]]])); + ).toEqual([true, [[{ text: "a\\" }, { enchanted: "b" }]]])); it("accepts semicolons as command separators", () => expect(parse("a;b")).toEqual([ @@ -100,7 +100,37 @@ b`) // no before folded newline that gives us backslashes }); - describe("interpolated words", () => {}); + describe("interpolated words", () => { + it("accepts empty quotes", () => + expect(parse('""')).toEqual([true, [[{ text: "" }]]])); + it("accepts quoted words", () => + expect(parse('"a"')).toEqual([true, [[{ text: "a" }]]])); + it("accepts quoted words with spaces", () => + expect(parse('"a b"')).toEqual([true, [[{ text: "a b" }]]])); + it("allows escaped quotes inside a quote", () => + expect(parse('"a\\"b"')).toEqual([true, [[{ text: 'a"b' }]]])); + + it("does not allow trailing characters after a closing quote", () => + expect(parse('""a')).toMatchObject([false, {}])); + + it("accepts escaped spaces", () => + expect(parse("a\\ b")).toEqual([true, [[{ text: "a b" }]]])); + + it("treats a non-leading quote as a plain character", () => + expect(parse('a"')).toEqual([true, [[{ text: 'a"' }]]])); + it("treats a non-leading brace as a plain character", () => + expect(parse("a{")).toEqual([true, [[{ text: "a{" }]]])); + it("treats an escaped quote as a plain character", () => + expect(parse('\\"')).toEqual([true, [[{ text: '"' }]]])); + it("treats an escaped brace as a plain character", () => + expect(parse("\\{")).toEqual([true, [[{ text: "{" }]]])); + it("treats a quoted brace as a plain character", () => + expect(parse('"{"')).toEqual([true, [[{ text: "{" }]]])); + + // Interpolated words + // variables- bare, brace + // command subst + }); describe("brace words", () => { it("can parse empty braces", () => @@ -144,15 +174,6 @@ b`) ).toEqual([true, [[{ text: "\\\\ " }]]])); }); - // "Quotes" - // no trailing chars - - // Interpolated words - // bare - // quotes - // variables- bare, brace - // command subst - describe("Misc", () => { test("Big mess of markup", () => { expect( diff --git a/src/notcl.ts b/src/notcl.ts index abf92a7..dcb10e5 100644 --- a/src/notcl.ts +++ b/src/notcl.ts @@ -4,6 +4,7 @@ import { Word as WordType, TextWord, EnchantedWord as EnchantedWordType, + SimplifyWord, } from "./words"; export type Command = WordType[]; @@ -19,9 +20,35 @@ const EnchantedWord = Regex(/[^\]\[\}\{$\\";\s]+(?=[\s;]|$)/y) .map(([enchanted]) => ({ enchanted } as EnchantedWordType)) .expects("ENCHANTED_WORD"); -const BasicWord = Regex(/(?!\{)[^\s;]+/y) - .map(([text]) => ({ text } as TextWord)) - .expects("BASIC_WORD"); +const BackslashEscape = Regex(/\\(.)/y) + .expects("\\") + .map(([, char]) => ({ text: char })); + +const BareWord = Sequence( + AtLeast( + 1, + Choose( + BackslashEscape, + Regex(/[^\s\\;]+/y) + .expects("CHAR") + .map(([text]) => ({ text })) + ) + ) +).map(([pieces]) => SimplifyWord(pieces)); + +const QuotedWord = Sequence( + Regex(/"/y).expects('"'), + AtLeast( + 0, + Choose( + BackslashEscape, + Regex(/[^"\\]+/y) + .expects("CHAR") + .map(([text]) => ({ text })) + ) + ), + Regex(/"/y).expects('"') +).map(([, pieces]) => SimplifyWord(pieces)); const Brace: Pattern = Sequence( Regex(/\{/y).expects("{"), @@ -40,12 +67,13 @@ const Brace: Pattern = Sequence( ) ), Regex(/\}/y).expects("}") -).map(([_left, fragments, _right]) => fragments.join("")); +).map(([, fragments]) => fragments.join("")); const Word = Choose( EnchantedWord, - BasicWord, - Brace.map((text) => ({ text } as TextWord)) + Brace.map((text) => ({ text } as TextWord)), + QuotedWord, + BareWord ); const CommandTerminator = Regex(/[\n;]/y) diff --git a/src/words.ts b/src/words.ts index cc441da..894a826 100644 --- a/src/words.ts +++ b/src/words.ts @@ -29,12 +29,48 @@ export type HtmlWord = { html: string; }; +export type TextPiece = TextWord; +export type VariablePiece = { variable: string }; +export type CommandPiece = { command: unknown }; +export type InterpolatedPiece = TextPiece | VariablePiece | CommandPiece; + /** * A word whose value needs to be determined by evaluating some combination of variable and command * substitutions, and concatenating the results with any literal spans. */ export type InterpolatedWord = { - pieces: []; + pieces: InterpolatedPiece[]; }; +function IsTextPiece(piece: InterpolatedPiece | undefined): piece is TextPiece { + return piece ? "text" in piece : false; +} + +// safely concatenate text pieces, converting as needed +export function Concat(left: TextPiece, right: TextPiece) { + return { text: left.text + right.text }; +} + +export function SimplifyWord( + pieces: InterpolatedPiece[] +): InterpolatedWord | TextWord { + const consolidated: InterpolatedPiece[] = []; + for (const piece of pieces) { + const top = consolidated[consolidated.length - 1]; + if (IsTextPiece(top) && IsTextPiece(piece)) { + consolidated[consolidated.length - 1] = Concat(top, piece); + } else { + consolidated.push(piece); + } + } + + if (consolidated.length == 0) { + return { text: "" }; + } else if (consolidated.length == 1 && IsTextPiece(consolidated[0])) { + return consolidated[0]; + } else { + return { pieces: consolidated }; + } +} + export type Word = EnchantedWord | TextWord | HtmlWord | InterpolatedWord;