From c0842ac1d6c86fc08dde2c2650e2c2ca72934fe6 Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Mon, 27 Nov 2023 13:04:56 +0200 Subject: [PATCH] Formalize grammar Replace the parser with a PEG.js AST parser and an AST evaluator. ref #21 --- .eslintignore | 1 + .gitignore | 4 + package-lock.json | 51 +++ package.json | 3 + src/__tests__/parser.spec.ts | 14 +- src/__tests__/sw2-ast.spec.ts | 96 ++++++ src/monzo.ts | 3 + src/parser.ts | 566 ++++++++++++---------------------- src/sw2.pegjs | 144 +++++++++ tsconfig.json | 3 +- 10 files changed, 517 insertions(+), 368 deletions(-) create mode 100644 src/__tests__/sw2-ast.spec.ts create mode 100644 src/sw2.pegjs diff --git a/.eslintignore b/.eslintignore index 5c53e32..8e333a1 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ dist/ docs/ +src/sw2-ast.js diff --git a/.gitignore b/.gitignore index bdfcf80..78ccc85 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,7 @@ dist # Typedoc docs/ + +# Generated grammars + +src/sw2-ast.js diff --git a/package-lock.json b/package-lock.json index 51e0a19..f9d336a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.6", "license": "MIT", "dependencies": { + "peggy": "^3.0.2", "xen-dev-utils": "^0.1.4" }, "devDependencies": { @@ -731,6 +732,14 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "engines": { + "node": ">=14" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2677,6 +2686,21 @@ "node": "*" } }, + "node_modules/peggy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/peggy/-/peggy-3.0.2.tgz", + "integrity": "sha512-n7chtCbEoGYRwZZ0i/O3t1cPr6o+d9Xx4Zwy2LYfzv0vjchMBU0tO+qYYyvZloBPcgRgzYvALzGWHe609JjEpg==", + "dependencies": { + "commander": "^10.0.0", + "source-map-generator": "0.8.0" + }, + "bin": { + "peggy": "bin/peggy.js" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -3121,6 +3145,14 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/source-map-generator": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map-generator/-/source-map-generator-0.8.0.tgz", + "integrity": "sha512-psgxdGMwl5MZM9S3FWee4EgsEaIjahYV5AzGnwUvPhWeITz/j6rKpysQHlQ4USdxvINlb8lKfWGIXwfkrgtqkA==", + "engines": { + "node": ">= 10" + } + }, "node_modules/source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", @@ -4196,6 +4228,11 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==" + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5539,6 +5576,15 @@ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", "dev": true }, + "peggy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/peggy/-/peggy-3.0.2.tgz", + "integrity": "sha512-n7chtCbEoGYRwZZ0i/O3t1cPr6o+d9Xx4Zwy2LYfzv0vjchMBU0tO+qYYyvZloBPcgRgzYvALzGWHe609JjEpg==", + "requires": { + "commander": "^10.0.0", + "source-map-generator": "0.8.0" + } + }, "picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -5831,6 +5877,11 @@ "is-fullwidth-code-point": "^3.0.0" } }, + "source-map-generator": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map-generator/-/source-map-generator-0.8.0.tgz", + "integrity": "sha512-psgxdGMwl5MZM9S3FWee4EgsEaIjahYV5AzGnwUvPhWeITz/j6rKpysQHlQ4USdxvINlb8lKfWGIXwfkrgtqkA==" + }, "source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", diff --git a/package.json b/package.json index 716cf47..d0df641 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "scripts": { "lint": "gts lint", "clean": "gts clean", + "compile-parser": "peggy src/sw2.pegjs -o src/sw2-ast.js", + "precompile": "npm run compile-parser", "compile": "tsc", "fix": "gts fix", "prepare": "npm run compile", @@ -46,6 +48,7 @@ "doc": "typedoc --entryPointStrategy packages . --name scale-workshop-core" }, "dependencies": { + "peggy": "^3.0.2", "xen-dev-utils": "^0.1.4" } } diff --git a/src/__tests__/parser.spec.ts b/src/__tests__/parser.spec.ts index 12a41cd..9419b54 100644 --- a/src/__tests__/parser.spec.ts +++ b/src/__tests__/parser.spec.ts @@ -44,6 +44,10 @@ describe('Line parser', () => { ).toThrow(); }); + it('rejects fractions without a numerator', () => { + expect(() => parseLine('/5')).toThrow(); + }); + it('parses N-of-EDO (negative)', () => { const result = parseLine('-2\\5'); expect( @@ -114,6 +118,10 @@ describe('Line parser', () => { expect(result.type).toBe('equal temperament'); }); + it('rejects N-of-EDO without a numerator', () => { + expect(() => parseLine('\\8')).toThrow(); + }); + it('parses monzos', () => { const result = parseLine('[-1, 2, 3/2, 0>'); const components = [new Fraction(-1), new Fraction(2), new Fraction(3, 2)]; @@ -127,7 +135,8 @@ describe('Line parser', () => { expect(result.type).toBe('monzo'); }); - it('parses composites (positive offset)', () => { + // Skipped because disappearing cents have mostly caused confusion + it.skip('parses composites (positive offset)', () => { const result = parseLine('3\\5 + 5.'); expect(result.monzo.cents).toBeCloseTo(5); expect(result.name).toBe('3\\5'); @@ -144,7 +153,8 @@ describe('Line parser', () => { expect(result.equals(expected)).toBeTruthy(); }); - it('parses composites (negative offset)', () => { + // See above for why this is skipped + it.skip('parses composites (negative offset)', () => { const result = parseLine('3/2 - 1.955'); expect(result.monzo.cents).toBeCloseTo(-1.955); expect(result.name).toBe('3/2'); diff --git a/src/__tests__/sw2-ast.spec.ts b/src/__tests__/sw2-ast.spec.ts new file mode 100644 index 0000000..2f77dbd --- /dev/null +++ b/src/__tests__/sw2-ast.spec.ts @@ -0,0 +1,96 @@ +import {describe, it, expect} from 'vitest'; +import {parse} from '../sw2-ast'; + +describe('Scale Workshop 2 Abstract Syntax Tree Parser', () => { + it('parses plain numbers as plain literals ', () => { + const ast = parse('81'); + expect(ast.type).toBe('PlainLiteral'); + expect(ast.value).toBe(81); + }); + + it('parses dot-separated numbers as cents literals', () => { + const ast = parse('81.80'); + expect(ast.type).toBe('CentsLiteral'); + expect(ast.whole).toBe(81); + expect(ast.fractional).toBe('80'); + }); + + it('parses comma-separated numbers as numeric literals', () => { + const ast = parse('81,80'); + expect(ast.type).toBe('NumericLiteral'); + expect(ast.whole).toBe(81); + expect(ast.fractional).toBe('80'); + }); + + it('parses leading zeroes in the fractional part of cents literals', () => { + const ast = parse('.00123'); + expect(ast.type).toBe('CentsLiteral'); + expect(ast.whole).toBe(null); + expect(ast.fractional).toBe('00123'); + }); + + it('parses leading zeroes in the fractional part of numeric literals', () => { + const ast = parse(',00123'); + expect(ast.type).toBe('NumericLiteral'); + expect(ast.whole).toBe(null); + expect(ast.fractional).toBe('00123'); + }); + + it('parses slash-separated numbers as fraction literals', () => { + const ast = parse('81/80'); + expect(ast.type).toBe('FractionLiteral'); + expect(ast.numerator).toBe(81); + expect(ast.denominator).toBe(80); + }); + + it('parses backslash-separated numbers as EDJI fractions', () => { + const ast = parse('5\\7'); + expect(ast.type).toBe('EdjiFraction'); + expect(ast.numerator).toBe(5); + expect(ast.denominator).toBe(7); + expect(ast.equave).toBe(null); + }); + + it('parses EDJI fractions with explicit equaves', () => { + const ast = parse('6\\13<3>'); + expect(ast.type).toBe('EdjiFraction'); + expect(ast.numerator).toBe(6); + expect(ast.denominator).toBe(13); + expect(ast.equave.type).toBe('PlainLiteral'); + expect(ast.equave.value).toBe(3); + }); + + it('parses space-separated numbers between a square and an angle bracket as monzos', () => { + const ast = parse('[-4 4 -1>'); + expect(ast.type).toBe('Monzo'); + expect(ast.components).toEqual(['-4', '4', '-1']); + }); + + it('parses comma-separated numbers between a square and an angle bracket as monzos', () => { + const ast = parse('[-4, 4, -1>'); + expect(ast.type).toBe('Monzo'); + expect(ast.components).toEqual(['-4', '4', '-1']); + }); + + it('parses unary negated EDJI', () => { + const ast = parse('-1\\12'); + expect(ast.type).toBe('UnaryExpression'); + expect(ast.operator).toBe('-'); + expect(ast.operand.type).toBe('EdjiFraction'); + expect(ast.operand.numerator).toBe(1); + expect(ast.operand.denominator).toBe(12); + expect(ast.operand.equave).toBe(null); + }); + + it('parses binary added numbers and cents', () => { + const ast = parse('2 + 1.23'); + expect(ast.type).toBe('BinaryExpression'); + expect(ast.operator).toBe('+'); + }); + + it('parses binary subtracted numbers and cents', () => { + const ast = parse('2 - 1.23'); + expect(ast.type).toBe('BinaryExpression'); + expect(ast.operator).toBe('-'); + }); +}); diff --git a/src/monzo.ts b/src/monzo.ts index 5f6b3c9..c570997 100644 --- a/src/monzo.ts +++ b/src/monzo.ts @@ -145,6 +145,9 @@ export class ExtendedMonzo { while (vector.length < numberOfComponents) { vector.push(new Fraction(0)); } + if (value === 0) { + return new ExtendedMonzo(vector, new Fraction(0), 0); + } return new ExtendedMonzo(vector, undefined, valueToCents(value)); } diff --git a/src/parser.ts b/src/parser.ts index beacfcd..6967990 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,8 +1,8 @@ import {ExtendedMonzo} from './monzo'; -import {stringToNumeratorDenominator} from './utils'; import {Interval, type IntervalOptions} from './interval'; -import {Fraction} from 'xen-dev-utils'; +import {Fraction, PRIMES, PRIME_CENTS} from 'xen-dev-utils'; import {Scale} from './scale'; +import {parse} from './sw2-ast'; /** * The types of intervals strings can represent. @@ -19,90 +19,67 @@ export enum LINE_TYPE { INVALID = 'invalid', } -// `true`, when the input is a string of digits -// for example: '19' -function isNumber(input: string): boolean { - return /^\d+$/.test(input.trim()); -} +// Abstract Syntax Tree hierarchy +type PlainLiteral = { + type: 'PlainLiteral'; + value: number; +}; -// `true`, when the input has digits at the beginning, followed by a dot, ending with any number of digits -// for example: '700.00', '-700.' -function isCent(input: string): boolean { - return /^-?\d*\.\d*$/.test(input.trim()); -} +type CentsLiteral = { + type: 'CentsLiteral'; + whole: number | null; + fractional: string | null; +}; -// `true`, when the input has numbers at the beginning, followed by a comma, ending with any number of digits -// for example: '1,25' -function isCommaDecimal(input: string): boolean { - return /^\d*,\d*$/.test(input.trim()); -} +type NumericLiteral = { + type: 'NumericLiteral'; + whole: number | null; + fractional: string | null; +}; -// `true`, when the input has digits at the beginning and the end, separated by a single slash -// for example: '3/2' -function isRatio(input: string) { - return /^\d+\/\d+$/.test(input.trim()); -} +type FractionLiteral = { + type: 'FractionLiteral'; + numerator: number; + denominator: number; +}; -// `true`, when the input has digits at the beginning and the end, separated by a single backslash -// for example: '7\12', '-7\12' -function isNOfEdo(input: string) { - return /^-?\d+\\-?\d+$/.test(input.trim()); -} +type EdjiFraction = { + type: 'EdjiFraction'; + numerator: number; + denominator: number; + equave: null | PlainLiteral | FractionLiteral; +}; -// `true`, when input looks like N-of-EDO followed by a fraction or a number in angle brackets -// for example: '7\11<3/2>', '-7\13<5>' -function isNOfEdji(input: string) { - return /^-?\d+\\-?\d+<\d+(\/\d+)?>$/.test(input.trim()); -} +type Monzo = { + type: 'Monzo'; + components: string[]; +}; -// `true`, when input has a square bracket followed by a comma/space separated list of numbers or fractions followed by and angle bracket -// for example: '[-4, 4, -1>' -function isMonzo(input: string) { - return /^\[(-?\d+(\/-?\d+)?[\s,]*)*>$/.test(input.trim()); -} +type UnaryExpression = { + type: 'UnaryExpression'; + operator: '-'; + operand: Expression; +}; -// `true`, when input is not a combination of simpler line types. -function isNonComposite(input: string) { - return ( - isCent(input) || - isCommaDecimal(input) || - isNOfEdo(input) || - isRatio(input) || - isNOfEdji(input) || - isMonzo(input) - ); -} +type BinaryExpression = { + type: 'BinaryExpression'; + operator: '+' | '-'; + left: Expression; + right: Expression; +}; -function isSubtractive(input: string) { - let prefix: string | undefined; - const parts = input.split('-'); - for (let i = 0; i < parts.length; ++i) { - if (prefix === undefined) { - prefix = parts[i]; - } else { - prefix += '-' + parts[i]; - } - if (isNonComposite(prefix.trim())) { - prefix = undefined; - } - } - return !prefix?.length; -} +type Expression = + | PlainLiteral + | CentsLiteral + | NumericLiteral + | FractionLiteral + | EdjiFraction + | Monzo + | UnaryExpression + | BinaryExpression; -// `true`, when input is a combination of simpler line types. -// for example: '3/2 - 1.955' -function isComposite(input: string) { - if (!input.includes('-') && !input.includes('+')) { - return false; - } - const parts = input.split('+'); - for (let i = 0; i < parts.length; ++i) { - const part = parts[i].trim(); - if (!isSubtractive(part)) { - return false; - } - } - return true; +function parseAst(input: string): Expression { + return parse(input); } /** @@ -111,266 +88,36 @@ function isComposite(input: string) { * @returns The type of interval the string represents. */ export function getLineType(input: string) { - if (isCent(input)) { - return LINE_TYPE.CENTS; - } - if (isCommaDecimal(input)) { - return LINE_TYPE.DECIMAL; - } - if (isNOfEdo(input)) { - return LINE_TYPE.N_OF_EDO; - } - if (isRatio(input)) { - return LINE_TYPE.RATIO; - } - if (isNOfEdji(input)) { - return LINE_TYPE.N_OF_EDJI; - } - if (isMonzo(input)) { - return LINE_TYPE.MONZO; - } - if (isComposite(input)) { - return LINE_TYPE.COMPOSITE; - } - if (isNumber(input)) { - return LINE_TYPE.NUMBER; - } - - return LINE_TYPE.INVALID; -} - -function parseNumber( - input: string, - numberOfComponents: number, - options?: IntervalOptions -) { - const number = parseInt(input); - return new Interval( - ExtendedMonzo.fromFraction(number, numberOfComponents), - 'ratio', - input, - options - ); -} - -function parseCents( - input: string, - numberOfComponents: number, - options?: IntervalOptions -) { - if (input.trim() === '.') { - return new Interval( - ExtendedMonzo.fromCents(0, numberOfComponents), - 'cents', - input, - options - ); - } - const cents = parseFloat(input); - if (isNaN(cents)) { - throw new Error(`Failed to parse ${input} to cents`); - } - return new Interval( - ExtendedMonzo.fromCents(cents, numberOfComponents), - 'cents', - input, - options - ); -} - -function parseDecimal( - input: string, - numberOfComponents: number, - options?: IntervalOptions -) { - if (input.trim() === ',') { - return new Interval( - ExtendedMonzo.fromValue(0, numberOfComponents), - 'decimal', - input, - options - ); - } - const value = parseFloat(input.replace(',', '.')); - if (isNaN(value)) { - throw new Error(`Failed to parse ${input} to decimal`); - } - return new Interval( - ExtendedMonzo.fromValue(value, numberOfComponents), - 'decimal', - input, - options - ); -} - -function parseNOfEdo( - input: string, - numberOfComponents: number, - options?: IntervalOptions -) { - const [numerator, denominator] = stringToNumeratorDenominator( - input.replace('\\', '/') - ); - const octave = new Fraction(2); - if (options === undefined) { - options = { - preferredEtDenominator: denominator, - preferredEtEquave: octave, - }; - } - return new Interval( - ExtendedMonzo.fromEqualTemperament( - new Fraction(numerator, denominator), - octave, - numberOfComponents - ), - 'equal temperament', - input, - options - ); -} - -function parseNOfEdji( - input: string, - numberOfComponents: number, - options?: IntervalOptions -) { - const [nOfEdo, equavePart] = input.split('<'); - const [numerator, denominator] = stringToNumeratorDenominator( - nOfEdo.replace('\\', '/') - ); - const equave = new Fraction(equavePart.slice(0, -1)); - if (options === undefined) { - options = { - preferredEtDenominator: denominator, - preferredEtEquave: equave, - }; + try { + return getAstType(parseAst(input)); + } catch { + return LINE_TYPE.INVALID; } - return new Interval( - ExtendedMonzo.fromEqualTemperament( - new Fraction(numerator, denominator), - equave, - numberOfComponents - ), - 'equal temperament', - input, - options - ); } -function parseMonzo( - input: string, - numberOfComponents: number, - options?: IntervalOptions -) { - const components: Fraction[] = []; - input - .slice(1, -1) - .replace(/,/g, ' ') - .split(/\s/) - .forEach(token => { - token = token.trim(); - if (token.length) { - const [numerator, denominator] = stringToNumeratorDenominator(token); - components.push(new Fraction(numerator, denominator)); - } - }); - if (components.length > numberOfComponents) { - throw new Error('Not enough components to represent monzo'); +function getAstType(ast: Expression): LINE_TYPE { + switch (ast.type) { + case 'PlainLiteral': + return LINE_TYPE.NUMBER; + case 'CentsLiteral': + return LINE_TYPE.CENTS; + case 'NumericLiteral': + return LINE_TYPE.DECIMAL; + case 'FractionLiteral': + return LINE_TYPE.RATIO; + case 'EdjiFraction': + return ast.equave ? LINE_TYPE.N_OF_EDJI : LINE_TYPE.N_OF_EDO; + case 'Monzo': + return LINE_TYPE.MONZO; + case 'UnaryExpression': + return getAstType(ast.operand); + case 'BinaryExpression': + return LINE_TYPE.COMPOSITE; } - while (components.length < numberOfComponents) { - components.push(new Fraction(0)); - } - return new Interval(new ExtendedMonzo(components), 'monzo', input, options); } -function parseRatio( - input: string, - numberOfComponents: number, - options?: IntervalOptions, - inferPreferences = false -) { - if (inferPreferences && options === undefined) { - const [numerator, denominator] = stringToNumeratorDenominator(input); - options = { - preferredNumerator: numerator, - preferredDenominator: denominator, - }; - } - return new Interval( - ExtendedMonzo.fromFraction(new Fraction(input), numberOfComponents), - 'ratio', - input, - options - ); -} - -function parseSubtractive( - input: string, - numberOfComponents: number, - options?: IntervalOptions -): [Interval, boolean] { - let centCount = 0; - let prefix: string | undefined; - const parts = input.split('-'); - const results: Interval[] = []; - for (let i = 0; i < parts.length; ++i) { - if (prefix === undefined) { - prefix = parts[i]; - } else { - prefix += '-' + parts[i]; - } - if (isNonComposite(prefix.trim())) { - if (isCent(prefix.trim())) { - centCount++; - } - results.push(parseLine(prefix.trim(), numberOfComponents, options)); - prefix = undefined; - } - } - if (prefix?.length || !results.length) { - throw new Error(`Failed to parse composite part ${input}`); - } - if (results.length === 1) { - return [results[0], false]; - } - return [ - results[0].sub(results.slice(1).reduce((a, b) => a.add(b))), - results.length === 2 && centCount > 0, - ]; -} - -function parseComposite( - input: string, - numberOfComponents: number, - options?: IntervalOptions -) { - const parts = input.split('+'); - // Special handling for cent offsets: Use name of the primary interval - if (parts.length === 1) { - const [result, hasOffset] = parseSubtractive( - parts[0], - numberOfComponents, - options - ); - if (hasOffset) { - return result; - } - } - if ( - parts.length === 2 && - (isCent(parts[0].trim()) || isCent(parts[1].trim())) - ) { - return parseLine(parts[0].trim(), numberOfComponents).add( - parseLine(parts[1].trim(), numberOfComponents) - ); - } - - const result = parts - .map(part => parseSubtractive(part, numberOfComponents, options)[0]) - .reduce((a, b) => a.add(b)); - result.name = input; - return result; +function parseDegenerateFloat(whole: number | null, fractional: string | null) { + return parseFloat(`${whole ?? 0}.${fractional ?? ''}`); } /** @@ -390,37 +137,130 @@ export function parseLine( admitBareNumbers = false, universalMinus = true ): Interval { - if (universalMinus && input.startsWith('-')) { - return parseLine( - input.slice(1), - numberOfComponents, - options, - admitBareNumbers, - universalMinus - ).neg(); + const ast = parseAst(input); + if (!universalMinus && ast.type !== 'CentsLiteral') { + throw new Error('Univeral minus violation'); + } + if ( + !admitBareNumbers && + (ast.type === 'PlainLiteral' || + (ast.type === 'UnaryExpression' && ast.operand.type === 'PlainLiteral')) + ) { + throw new Error('Bare numbers not allowed'); + } + return evaluateAst(ast, numberOfComponents, input, options); +} + +function evaluateAst( + ast: Expression, + numberOfComponents: number, + name?: string, + options?: IntervalOptions +): Interval { + switch (ast.type) { + case 'PlainLiteral': + return new Interval( + ExtendedMonzo.fromFraction(ast.value, numberOfComponents), + 'ratio', + name, + options + ); + case 'CentsLiteral': + return new Interval( + ExtendedMonzo.fromCents( + parseDegenerateFloat(ast.whole, ast.fractional), + numberOfComponents + ), + 'cents', + name, + options + ); + case 'NumericLiteral': + return new Interval( + ExtendedMonzo.fromValue( + parseDegenerateFloat(ast.whole, ast.fractional), + numberOfComponents + ), + 'decimal', + name, + options + ); + case 'FractionLiteral': + return new Interval( + ExtendedMonzo.fromFraction( + new Fraction([ast.numerator, ast.denominator]), + numberOfComponents + ), + 'ratio', + name, + options + ); } - const lineType = getLineType(input); - switch (lineType) { - case LINE_TYPE.CENTS: - return parseCents(input, numberOfComponents, options); - case LINE_TYPE.DECIMAL: - return parseDecimal(input, numberOfComponents, options); - case LINE_TYPE.N_OF_EDO: - return parseNOfEdo(input, numberOfComponents, options); - case LINE_TYPE.RATIO: - return parseRatio(input, numberOfComponents, options); - case LINE_TYPE.N_OF_EDJI: - return parseNOfEdji(input, numberOfComponents, options); - case LINE_TYPE.MONZO: - return parseMonzo(input, numberOfComponents, options); - case LINE_TYPE.COMPOSITE: - return parseComposite(input, numberOfComponents, options); - default: - if (admitBareNumbers && lineType === LINE_TYPE.NUMBER) { - return parseNumber(input, numberOfComponents, options); + if (ast.type === 'EdjiFraction') { + const fractionOfEquave = new Fraction([ast.numerator, ast.denominator]); + let equave: Fraction | undefined; + if (ast.equave?.type === 'PlainLiteral') { + equave = new Fraction(ast.equave.value); + } else if (ast.equave?.type === 'FractionLiteral') { + equave = new Fraction([ast.equave.numerator, ast.equave.denominator]); + } + if (options === undefined) { + options = { + preferredEtDenominator: ast.denominator, + preferredEtEquave: equave ?? new Fraction(2), + }; + } + return new Interval( + ExtendedMonzo.fromEqualTemperament( + fractionOfEquave, + equave, + numberOfComponents + ), + 'equal temperament', + name, + options + ); + } else if (ast.type === 'Monzo') { + const components = ast.components.map(c => new Fraction(c)); + while (components.length < numberOfComponents) { + components.push(new Fraction(0)); + } + let residual = new Fraction(1); + let cents = 0; + while (components.length > numberOfComponents) { + const exponent = new Fraction(components.pop()!); + const factor = new Fraction(PRIMES[components.length]).pow(exponent); + if (factor === null) { + cents += exponent.valueOf() * PRIME_CENTS[components.length]; + } else { + residual = residual.mul(factor); } - throw new Error(`Failed to parse ${input}`); + } + return new Interval( + new ExtendedMonzo(components, residual, cents), + 'monzo', + name, + options + ); + } else if (ast.type === 'UnaryExpression') { + const operand = evaluateAst(ast.operand, numberOfComponents, name, options); + operand.monzo = operand.monzo.neg(); + return operand; + } + const left = evaluateAst(ast.left, numberOfComponents, undefined, options); + const right = evaluateAst(ast.right, numberOfComponents, undefined, options); + if (ast.operator === '+') { + const result = left.add(right); + if (name !== undefined) { + result.name = name; + } + return result; + } + const result = left.sub(right); + if (name !== undefined) { + result.name = name; } + return result; } /** @@ -444,15 +284,11 @@ export function parseChord( const chord: Interval[] = []; input.split(separator).forEach(line => { // Restore commas (coalescing whitespace is fine) - line = line.trim().replace(/¤/g, ','); + line = line.trim().replace(/¤+/g, ','); if (!line.length) { return; } - if (isNumber(line)) { - chord.push(parseNumber(line, numberOfComponents, options)); - } else { - chord.push(parseLine(line, numberOfComponents, options)); - } + chord.push(parseLine(line, numberOfComponents, options, true)); }); return chord; } diff --git a/src/sw2.pegjs b/src/sw2.pegjs new file mode 100644 index 0000000..e13af9a --- /dev/null +++ b/src/sw2.pegjs @@ -0,0 +1,144 @@ +{{ + function PlainLiteral(value) { + return { + type: 'PlainLiteral', + value + } + } + + function CentsLiteral(whole, fractional) { + return { + type: 'CentsLiteral', + whole, + fractional + } + } + + function NumericLiteral(whole, fractional) { + return { + type: 'NumericLiteral', + whole, + fractional + } + } + + function FractionLiteral(numerator, denominator) { + return { + type: 'FractionLiteral', + numerator, + denominator + } + } + + function EdjiFraction(numerator, denominator, equave) { + return { + type: 'EdjiFraction', + numerator, + denominator, + equave + } + } + + function Monzo(components) { + return { + type: 'Monzo', + components + } + } + + function BinaryExpression(operator, left, right) { + return { + type: 'BinaryExpression', + operator, + left, + right + } + } + + function UnaryExpression(operator, operand) { + return { + type: 'UnaryExpression', + operator, + operand + } + } + + function operatorReducer (result, element) { + const left = result; + const [op, right] = element; + + return BinaryExpression(op, left, right); + } +}} + +Start + = Expression + +SourceCharacter + = . + +Whitespace "whitespace" + = "\t" + / "\v" + / "\f" + / " " + / "\u00A0" + / "\uFEFF" + / Zs + / LineTerminator + +// Separator, Space +Zs = [\u0020\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000] + +LineTerminator + = [\n\r\u2028\u2029] + +_ = Whitespace* + +Expression + = head:Term tail:(_ @('+' / '-') _ @Term)* { + return tail.reduce(operatorReducer, head); + } + +Term + = _ @(UnaryExpression / Primary) _ + +Primary + = DotDecimal + / CommaDecimal + / SlashFraction + / BackslashFraction + / Monzo + / PlainNumber + +Integer = num:$[0-9]+ { return parseInt(num, 10) } + +SignedInteger + = sign:'-'? value:Integer { return sign ? -value : value } + +DotDecimal + = whole:Integer? '.' fractional:$[0-9]* { return CentsLiteral(whole, fractional) } + +CommaDecimal + = whole:Integer? ',' fractional:$[0-9]* { return NumericLiteral(whole, fractional) } + +SlashFraction + = numerator:Integer '/' denominator:Integer { return FractionLiteral(numerator, denominator) } + +PlainNumber + = value:Integer { return PlainLiteral(value) } + +EquaveExpression + = '<' _ @(SlashFraction / PlainNumber) _ '>' + +BackslashFraction + = numerator:Integer '\\' denominator:SignedInteger equave:EquaveExpression? { return EdjiFraction(numerator, denominator, equave) } + +Component + = $([+-]? (SlashFraction / PlainNumber)) + +Monzo + = '[' components:Component|.., _ ','? _| '>' { return Monzo(components) } + +UnaryExpression + = operator:'-' operand:Primary { return UnaryExpression(operator, operand) } diff --git a/tsconfig.json b/tsconfig.json index bb51c39..c05331a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,8 @@ "extends": "./node_modules/gts/tsconfig-google.json", "compilerOptions": { "rootDir": "./src", - "outDir": "dist" + "outDir": "dist", + "allowJs": true }, "include": [ "src/index.ts"