diff --git a/package.json b/package.json index 0372888..f7064cb 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ ], "devDependencies": { "@types/jest": "^29.5.10", + "@types/node": "^20.11.6", "jest": "^29.7.0", "ts-jest": "^29.1.1", "typescript": "^5.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0087520..285005f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,12 @@ devDependencies: '@types/jest': specifier: ^29.5.10 version: 29.5.10 + '@types/node': + specifier: ^20.11.6 + version: 20.11.6 jest: specifier: ^29.7.0 - version: 29.7.0 + version: 29.7.0(@types/node@20.11.6) ts-jest: specifier: ^29.1.1 version: 29.1.1(@babel/core@7.23.5)(jest@29.7.0)(typescript@5.3.2) @@ -389,7 +392,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.3 - '@types/node': 20.10.3 + '@types/node': 20.11.6 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -410,14 +413,14 @@ packages: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.10.3 + '@types/node': 20.11.6 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.10.3) + jest-config: 29.7.0(@types/node@20.11.6) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -445,7 +448,7 @@ packages: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.10.3 + '@types/node': 20.11.6 jest-mock: 29.7.0 dev: true @@ -472,7 +475,7 @@ packages: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.10.3 + '@types/node': 20.11.6 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -505,7 +508,7 @@ packages: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.20 - '@types/node': 20.10.3 + '@types/node': 20.11.6 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -593,7 +596,7 @@ packages: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.10.3 + '@types/node': 20.11.6 '@types/yargs': 17.0.32 chalk: 4.1.2 dev: true @@ -701,7 +704,7 @@ packages: /@types/graceful-fs@4.1.9: resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} dependencies: - '@types/node': 20.10.3 + '@types/node': 20.11.6 dev: true /@types/istanbul-lib-coverage@2.0.6: @@ -731,8 +734,8 @@ packages: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true - /@types/node@20.10.3: - resolution: {integrity: sha512-XJavIpZqiXID5Yxnxv3RUDKTN5b81ddNC3ecsA0SoFXz/QU8OGBwZGMomiq0zw+uuqbL/krztv/DINAQ/EV4gg==} + /@types/node@20.11.6: + resolution: {integrity: sha512-+EOokTnksGVgip2PbYbr3xnR7kZigh4LbybAfBAw5BpnQ+FqBYUsvCEjYd70IXKlbohQ64mzEYmMtlWUY8q//Q==} dependencies: undici-types: 5.26.5 dev: true @@ -1220,7 +1223,7 @@ packages: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true - /create-jest@29.7.0: + /create-jest@29.7.0(@types/node@20.11.6): resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -1229,7 +1232,7 @@ packages: chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.10.3) + jest-config: 29.7.0(@types/node@20.11.6) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -1680,7 +1683,7 @@ packages: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.10.3 + '@types/node': 20.11.6 chalk: 4.1.2 co: 4.6.0 dedent: 1.5.1 @@ -1701,7 +1704,7 @@ packages: - supports-color dev: true - /jest-cli@29.7.0: + /jest-cli@29.7.0(@types/node@20.11.6): resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -1715,10 +1718,10 @@ packages: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0 + create-jest: 29.7.0(@types/node@20.11.6) exit: 0.1.2 import-local: 3.1.0 - jest-config: 29.7.0(@types/node@20.10.3) + jest-config: 29.7.0(@types/node@20.11.6) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -1729,7 +1732,7 @@ packages: - ts-node dev: true - /jest-config@29.7.0(@types/node@20.10.3): + /jest-config@29.7.0(@types/node@20.11.6): resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: @@ -1744,7 +1747,7 @@ packages: '@babel/core': 7.23.5 '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.10.3 + '@types/node': 20.11.6 babel-jest: 29.7.0(@babel/core@7.23.5) chalk: 4.1.2 ci-info: 3.9.0 @@ -1804,7 +1807,7 @@ packages: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.10.3 + '@types/node': 20.11.6 jest-mock: 29.7.0 jest-util: 29.7.0 dev: true @@ -1820,7 +1823,7 @@ packages: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 20.10.3 + '@types/node': 20.11.6 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -1871,7 +1874,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.3 - '@types/node': 20.10.3 + '@types/node': 20.11.6 jest-util: 29.7.0 dev: true @@ -1926,7 +1929,7 @@ packages: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.10.3 + '@types/node': 20.11.6 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -1957,7 +1960,7 @@ packages: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.10.3 + '@types/node': 20.11.6 chalk: 4.1.2 cjs-module-lexer: 1.2.3 collect-v8-coverage: 1.0.2 @@ -2009,7 +2012,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.3 - '@types/node': 20.10.3 + '@types/node': 20.11.6 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -2034,7 +2037,7 @@ packages: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.10.3 + '@types/node': 20.11.6 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -2046,7 +2049,7 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} dependencies: - '@types/node': 20.10.3 + '@types/node': 20.11.6 merge-stream: 2.0.0 supports-color: 8.1.1 dev: true @@ -2055,13 +2058,13 @@ packages: resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@types/node': 20.10.3 + '@types/node': 20.11.6 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 dev: true - /jest@29.7.0: + /jest@29.7.0(@types/node@20.11.6): resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -2074,7 +2077,7 @@ packages: '@jest/core': 29.7.0 '@jest/types': 29.6.3 import-local: 3.1.0 - jest-cli: 29.7.0 + jest-cli: 29.7.0(@types/node@20.11.6) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -2657,7 +2660,7 @@ packages: '@babel/core': 7.23.5 bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0 + jest: 29.7.0(@types/node@20.11.6) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 diff --git a/src/parser/binding-power.ts b/src/parser/binding-power.ts new file mode 100644 index 0000000..3f4eb64 --- /dev/null +++ b/src/parser/binding-power.ts @@ -0,0 +1,45 @@ +export type BindingPower = number; + +export type BindingPowerEntry = { left: BindingPower, right: BindingPower }; +export type BindingPowers = { [key: string]: BindingPowerEntry }; + +export const bindingPowers: BindingPowers = { + lowest: { left: 0, right: 1 }, + assignment: { left: 31, right: 30 }, + comparison: { left: 41, right: 40 }, + summative: { left: 50, right: 51 }, + productive: { left: 60, right: 61 }, + prefix: { left: 70, right: 71 }, + call: { left: 80, right: 81 }, +}; + +export const getInfixBindingPower = (infix: string): BindingPowerEntry => { + switch (infix) { + case "=": + return bindingPowers.assignment; + + case "==": + case "!=": + case ">": + case "<": + case ">=": + case "<=": + return bindingPowers.comparison; + + case "+": + case "-": + return bindingPowers.summative; + + case "*": + case "/": + return bindingPowers.productive; + + // for function call, it behaves an infix operator which lies between + // function expression and parameter list, e.g, print("hello") + case "(": + return bindingPowers.call; + + default: + return bindingPowers.lowest; + } +}; diff --git a/src/parser/index.ts b/src/parser/index.ts index 78ea614..97fba0f 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -67,6 +67,7 @@ const getBindingPower = (infix: string): BindingPower => { } }; +/** @deprecated */ export default class Parser { private buffer: TokenReader; diff --git a/src/parser/syntax-node/base/index.ts b/src/parser/syntax-node/base/index.ts index 68e77c5..dc1fa6e 100644 --- a/src/parser/syntax-node/base/index.ts +++ b/src/parser/syntax-node/base/index.ts @@ -1,25 +1,37 @@ import type Position from "../../../util/position"; +export interface Range { + readonly begin: Position, + readonly end: Position, +}; + export interface SyntaxNodeBase { - type: T, - range: { - begin: Position, - end: Position, - }, - fields: F, + readonly type: T, + readonly range: Range, + readonly fields: F, }; export function createNodeCreator(type: N["type"]) { - function createNode(fields: N["fields"], rangeBegin: Position, rangeEnd: Position) { - const range = { - begin: rangeBegin, - end: rangeEnd, - }; + type Node = { type: N["type"], range: N["range"], fields: N["fields"] }; + + function createNode(fields: N["fields"], range: Range): Node; + function createNode(fields: N["fields"], rangeBegin: Position, rangeEnd: Position): Node; + function createNode(fields: N["fields"], arg1: Range | Position, rangeEnd?: Position): Node { + if (rangeEnd !== undefined) { + const range = { + begin: arg1 as Position, + end: rangeEnd, + }; + + return { type, range, fields }; + } - return { type, range, fields }; + return { type, range: arg1 as Range, fields }; }; return createNode; }; -export type CreateNode = (fields: N["fields"], rangeBegin: Position, rangeEnd: Position) => N; +declare function createNode(fields: N["fields"], range: Range): N; +declare function createNode(fields: N["fields"], rangeBegin: Position, rangeEnd: Position): N; +export type CreateNode = typeof createNode; diff --git a/src/parser/syntax-node/expression/index.ts b/src/parser/syntax-node/expression/index.ts index 6250ee7..8f7fc9a 100644 --- a/src/parser/syntax-node/expression/index.ts +++ b/src/parser/syntax-node/expression/index.ts @@ -27,6 +27,7 @@ export interface AssignmentNode extends SyntaxNodeBase<"assignment", { left: Ide export const createIdentifierNode: CreateNode = createNodeCreator("identifier"); export const createNumberNode: CreateNode = createNodeCreator("number"); +export const createBooleanNode: CreateNode = createNodeCreator("boolean"); export const createStringNode: CreateNode = createNodeCreator("string"); export const createPrefixNode: CreateNode = createNodeCreator("prefix"); export const createInfixNode: CreateNode = createNodeCreator("infix"); diff --git a/src/parser/syntax-node/group/index.ts b/src/parser/syntax-node/group/index.ts index 77a24ef..5a2d20b 100644 --- a/src/parser/syntax-node/group/index.ts +++ b/src/parser/syntax-node/group/index.ts @@ -1,12 +1,13 @@ import type { SyntaxNodeBase, CreateNode } from "../base"; import { createNodeCreator } from "../base"; +import type { StatementNode } from "../statement"; export type GroupNode = ProgramNode | BlockNode; /** a root node for a syntax tree of a program */ -export interface ProgramNode extends SyntaxNodeBase<"program", { statements: any[] }> {}; +export interface ProgramNode extends SyntaxNodeBase<"program", { statements: StatementNode[] }> {}; /** a group of statements */ -export interface BlockNode extends SyntaxNodeBase<"block", { statements: any[] }> {}; +export interface BlockNode extends SyntaxNodeBase<"block", { statements: StatementNode[] }> {}; export const createProgramNode: CreateNode = createNodeCreator("program"); export const createBlockNode: CreateNode = createNodeCreator("block"); diff --git a/src/parser/v2.test.ts b/src/parser/v2.test.ts new file mode 100644 index 0000000..3bb902f --- /dev/null +++ b/src/parser/v2.test.ts @@ -0,0 +1,1374 @@ +import Lexer from "../lexer"; +import Parser from "./v2"; +import { + ParserError, + BadPrefixError, + BadExpressionError, +} from "./v2"; +import type { + ProgramNode, + AssignmentNode, + IdentifierNode, + ExpressionStatementNode, +} from "./syntax-node"; + +type SuccessTestCase = { name: string, input: string, expected: E }; +type FailureTestCase = { name: string, input: string, expected: E }; + +describe("parseSource()", () => { + const createParser = (input: string) => { + const lexer = new Lexer(input); + const parser = new Parser(lexer); + + return parser; + }; + + const testSuccess = ({ input, expected }: { input: string, expected: ProgramNode }) => { + const parser = createParser(input); + + const node = parser.parseSource(); + + expect(node).toMatchObject(expected); + }; + + const testFailure = ({ input, expected }: { input: string, expected: typeof ParserError }) => { + const parser = createParser(input); + + expect(() => parser.parseSource()).toThrow(expected); + }; + + describe("creating nodes", () => { + describe("literal expressions", () => { + const cases: SuccessTestCase[] = [ + { + name: "a number literal", + input: "42", + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "number", + fields: { + value: 42, + }, + } + }, + }, + ], + }, + }, + }, + { + name: "a boolean literal", + input: "참", + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "boolean", + fields: { + value: true, + }, + } + }, + }, + ], + }, + }, + }, + { + name: "a string literal", + input: "'foo bar'", + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "string", + fields: { + value: "foo bar", + }, + } + }, + }, + ], + }, + }, + }, + { + name: "a identifer literal", + input: "foo", + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "identifier", + fields: { + value: "foo", + }, + } + }, + }, + ], + }, + }, + }, + ]; + + it.each(cases)("$name", testSuccess); + }); + + describe("arithmetic expressions", () => { + describe("single number", () => { + const cases: SuccessTestCase[] = [ + { + name: "positive number", + input: "+42", + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "prefix", + fields: { + prefix: "+", + right: { + type: "number", + fields: { + value: 42, + }, + }, + }, + } + }, + }, + ], + }, + }, + }, + { + name: "negative number", + input: "-42", + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "prefix", + fields: { + prefix: "-", + right: { + type: "number", + fields: { + value: 42, + }, + }, + }, + } + }, + }, + ], + }, + }, + }, + { + name: "doubly negative number", + input: "--42", + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "prefix", + fields: { + prefix: "-", + right: { + type: "prefix", + fields: { + prefix: "-", + right: { + type: "number", + fields: { + value: 42, + }, + }, + }, + }, + }, + } + }, + }, + ], + }, + }, + }, + ]; + + it.each(cases)("$name", testSuccess); + }); + + describe("left associativity", () => { + const leftAssocCases = [ + { infix: "+", name: "left associative addition" }, + { infix: "-", name: "left associative subtraction" }, + { infix: "*", name: "left associative multiplication" }, + { infix: "/", name: "left associative division" }, + ]; + const leftAssocTestCases: SuccessTestCase[] = leftAssocCases.map(({ infix, name }) => ({ + name, + input: `11 ${infix} 22 ${infix} 33`, + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "infix", + fields: { + infix, + left: { + type: "infix", + fields: { + infix, + left: { + type: "number", + fields: { + value: 11, + }, + }, + right: { + type: "number", + fields: { + value: 22, + }, + }, + }, + }, + right: { + type: "number", + fields: { + value: 33, + }, + }, + }, + } + }, + }, + ], + }, + }, + })); + + it.each(leftAssocTestCases)("$name", testSuccess); + }); + + describe("associativity among different operations", () => { + const cases: SuccessTestCase[] = [ + { + name: "four operations", + input: "11+22*33/44-55", + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "infix", + fields: { + infix: "-", + left: { + type: "infix", + fields: { + infix: "+", + left: { + type: "number", + fields: { value: 11 }, + }, + right: { + type: "infix", + fields: { + infix: "/", + left: { + type: "infix", + fields: { + infix: "*", + left: { + type: "number", + fields: { + value: 22 + }, + }, + right: { + type: "number", + fields: { + value: 33 + }, + }, + }, + }, + right: { + type: "number", + fields: { + value: 44 + }, + }, + }, + }, + }, + }, + right: { + type: "number", + fields: { + value: 55, + }, + }, + }, + } + }, + }, + ], + }, + }, + }, + { + name: "with grouped", + input: "11+(22+33)", + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "infix", + fields: { + infix: "+", + left: { + type: "number", + fields: { + value: 11, + }, + }, + right: { + type: "infix", + fields: { + infix: "+", + left: { + type: "number", + fields: { + value: 22, + }, + }, + right: { + type: "number", + fields: { + value: 33, + }, + }, + }, + }, + }, + } + }, + }, + ], + }, + }, + }, + ]; + + it.each(cases)("$name", testSuccess); + }); + + }); + + describe("logical expressions", () => { + describe("unary operation", () => { + const cases: SuccessTestCase[] = [ + { + name: "negation expression", + input: "!foo", + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "prefix", + fields: { + prefix: "!", + right: { + type: "identifier", + fields: { + value: "foo", + }, + }, + }, + } + }, + }, + ], + }, + }, + }, + { + name: "double negation expression", + input: "!!foo", + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "prefix", + fields: { + prefix: "!", + right: { + type: "prefix", + fields: { + prefix: "!", + right: { + type: "identifier", + fields: { + value: "foo", + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + }, + ]; + + it.each(cases)("$name", testSuccess); + }); + + describe("binary operation", () => { + const infixCases = [ + { name: "equal-to expression", infix: "==" }, + { name: "not-equal-to expression", infix: "!=" }, + { name: "greater-than expression", infix: ">" }, + { name: "less-than expression", infix: "<" }, + { name: "greater-than-or-equal-to expression", infix: ">=" }, + { name: "less-than-or-equal-to expression", infix: "<=" }, + ]; + const infixTestCases: SuccessTestCase[] = infixCases.map(({ name, infix }) => ({ + name, + input: `foo ${infix} bar`, + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "infix", + fields: { + infix, + left: { + type: "identifier", + fields: { + value: "foo", + }, + }, + right: { + type: "identifier", + fields: { + value: "bar", + }, + }, + }, + }, + }, + }, + ], + }, + }, + })); + + it.each(infixTestCases)("$name", testSuccess); + }); + + describe("right associativity", () => { + const infixCases = [ + { name: "right associative equal-to expression", infix: "==" }, + { name: "right associative not-equal-to expression", infix: "!=" }, + ]; + const infixTestCases: SuccessTestCase[] = infixCases.map(({ name, infix }) => ({ + name, + input: `foo ${infix} bar ${infix} baz`, + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "infix", + fields: { + infix, + left: { + type: "identifier", + fields: { + value: "foo", + }, + }, + right: { + type: "infix", + fields: { + infix, + left: { + type: "identifier", + fields: { + value: "bar", + }, + }, + right: { + type: "identifier", + fields: { + value: "baz", + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + })); + + it.each(infixTestCases)("$name", testSuccess); + }); + + describe("grouped expression", () => { + const cases: SuccessTestCase[] = [ + { + name: "equal-to and not-equal-to", + input: "(foo == bar) != baz", + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "infix", + fields: { + infix: "!=", + left: { + type: "infix", + fields: { + infix: "==", + left: { + type: "identifier", + fields: { + value: "foo", + }, + }, + right: { + type: "identifier", + fields: { + value: "bar", + }, + }, + }, + }, + right: { + type: "identifier", + fields: { + value: "baz" + }, + }, + }, + }, + }, + }, + ], + }, + }, + }, + ]; + + it.each(cases)("$name", testSuccess); + }); + }); + + describe("assignment", () => { + const cases: SuccessTestCase[] = [ + { + name: "a single assignment statement", + input: "x = 42", + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "assignment", + fields: { + left: { + type: "identifier", + fields: { + value: "x", + }, + }, + right: { + type: "number", + fields: { + value: 42, + }, + }, + }, + }, + }, + }, + ], + }, + }, + }, + { + name: "right associative assignment", + input: "x = y = 42", + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "assignment", + fields: { + left: { + type: "identifier", + fields: { + value: "x", + }, + }, + right: { + type: "assignment", + fields: { + left: { + type: "identifier", + fields: { + value: "y", + }, + }, + right: { + type: "number", + fields: { + value: 42, + }, + }, + }, + }, + }, + } + }, + }, + ], + }, + }, + }, + ]; + + it.each(cases)("$name", testSuccess); + }); + + describe("call expressions", () => { + const cases: SuccessTestCase[] = [ + { + name: "call function with identifier", + input: "foo(bar, 42)", + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "call", + fields: { + func: { + type: "identifier", + fields: { + value: "foo", + }, + }, + args: [ + { + type: "identifier", + fields: { + value: "bar", + }, + }, + { + type: "number", + fields: { + value: 42, + }, + }, + ], + }, + }, + }, + }, + ], + }, + }, + }, + { + name: "call function with function literal", + input: "함수(foo){ foo }(42)", + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "call", + fields: { + func: { + type: "function", + fields: { + parameters: {}, // omit + body: {}, // omit + }, + }, + args: [ + { + type: "number", + fields: { + value: 42, + }, + }, + ], + }, + }, + }, + }, + ], + }, + }, + }, + ]; + + it.each(cases)("$name", testSuccess); + }); + + describe("function expressions", () => { + const cases: SuccessTestCase[] = [ + { + name: "function expression with parameters", + input: "함수 (foo, bar) { foo + bar }", + expected: { + type: "program", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "function", + fields: { + parameters: [ + { + type: "identifier", + fields: { + value: "foo", + }, + }, + { + type: "identifier", + fields: { + value: "bar", + }, + }, + ], + body: { + type: "block", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "infix", + fields: {}, // omit + }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + ], + }, + }, + }, + ]; + + it.each(cases)("$name", testSuccess); + }); + describe("return statement", () => { + const cases: SuccessTestCase[] = [ + { + name: "return number literal", + input: "결과 42", + expected: { + type: "program", + fields: { + statements: [ + { + type: "return", + fields: { + expression: { + type: "number", + fields: { + value: 42, + }, + }, + }, + }, + ], + }, + }, + }, + ]; + + it.each(cases)("$name", testSuccess); + }); + + describe("branch statement", () => { + const cases: SuccessTestCase[] = [ + { + name: "predicate and consequence", + input: "만약 foo { bar }", + expected: { + type: "program", + fields: { + statements: [ + { + type: "branch", + fields: { + predicate: { + type: "identifier", + fields: { + value: "foo", + }, + }, + consequence: { + type: "block", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "identifier", + fields: { + value: "bar", + }, + }, + }, + }, + ], + }, + }, + }, + }, + ], + }, + }, + }, + { + name: "predicate and consequence with alternative", + input: "만약 foo { bar } 아니면 { baz }", + expected: { + type: "program", + fields: { + statements: [ + { + type: "branch", + fields: { + predicate: { + type: "identifier", + fields: { + value: "foo", + }, + }, + consequence: { + type: "block", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "identifier", + fields: { + value: "bar", + }, + }, + }, + }, + ], + }, + }, + alternative: { + type: "block", + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "identifier", + fields: { + value: "baz", + }, + }, + }, + }, + ], + }, + }, + }, + }, + ], + }, + }, + }, + ]; + + it.each(cases)("$name", testSuccess); + }); + }); + + describe("marking positions", () => { + describe("single statements", () => { + describe("literal expressions", () => { + const literalCases = [ + { + name: "number literal", + type: "number", + input: "12345", + range: { + begin: { row: 0, col: 0 }, + end: { row: 0, col: 4 }, + }, + }, + { + name: "string literal", + type: "string", + input: "'foo bar'", + range: { + begin: { row: 0, col: 0 }, + end: { row: 0, col: 8 }, + }, + }, + { + name: "boolean literal", + type: "boolean", + input: "거짓", + range: { + begin: { row: 0, col: 0 }, + end: { row: 0, col: 1 }, + }, + }, + { + name: "identifier literal", + type: "identifier", + input: "foo", + range: { + begin: { row: 0, col: 0 }, + end: { row: 0, col: 2 }, + }, + }, + ]; + const cases: SuccessTestCase[] = literalCases.map(({ name, input, range, type }) => ({ + name, + input, + expected: { + type: "program", + range, + fields: { + statements: [ + { + type: "expression statement", + range, + fields: { + expression: { + type, + range, + } + }, + }, + ], + }, + }, + })); + + it.each(cases)("$name", testSuccess); + }); + + describe("single expressions", () => { + const cases: SuccessTestCase[] = [ + { + name: "assignment", + input: "x = 42", + expected: { + type: "program", + range: { + begin: { row: 0, col: 0 }, + end: { row: 0, col: 5 }, + }, + fields: { + statements: [ + { + type: "expression statement", + range: { + begin: { row: 0, col: 0 }, + end: { row: 0, col: 5 }, + }, + fields: { + expression: { + type: "assignment", + range: { + begin: { row: 0, col: 0 }, + end: { row: 0, col: 5 }, + }, + fields: { + left: { + type: "identifier", + range: { + begin: { row: 0, col: 0 }, + end: { row: 0, col: 0 }, + }, + }, + right: { + type: "number", + range: { + begin: { row: 0, col: 4 }, + end: { row: 0, col: 5 }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + }, + { + name: "arithmetic expression", + input: "11 + 22", + expected: { + type: "program", + range: { + begin: { row: 0, col: 0 }, + end: { row: 0, col: 6 }, + }, + fields: { + statements: [ + { + type: "expression statement", + range: { + begin: { row: 0, col: 0 }, + end: { row: 0, col: 6 }, + }, + fields: { + expression: { + type: "infix", + range: { + begin: { row: 0, col: 0 }, + end: { row: 0, col: 6 }, + }, + fields: { + left: { + type: "number", + range: { + begin: { row: 0, col: 0 }, + end: { row: 0, col: 1 }, + }, + }, + right: { + type: "number", + range: { + begin: { row: 0, col: 5 }, + end: { row: 0, col: 6 }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + }, + { + name: "grouped expression", + input: "(11 + 22)", + expected: { + type: "program", + range: { + }, + fields: { + statements: [ + { + type: "expression statement", + range: { + }, + fields: { + expression: { + type: "infix", + range: { + begin: { row: 0, col: 0 }, + end: { row: 0, col: 8 }, + }, + fields: { + left: { + type: "number", + range: { + begin: { row: 0, col: 1 }, + end: { row: 0, col: 2 }, + }, + }, + right: { + type: "number", + range: { + begin: { row: 0, col: 6 }, + end: { row: 0, col: 7 }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + }, + { + name: "function expression", + input: "함수(foo) {\n foo\n}", + expected: { + type: "program", + range: { + begin: { + row: 0, + col: 0, + }, + end: { + row: 2, + col: 0, + }, + }, + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "function", + range: { + begin: { + row: 0, + col: 0, + }, + end: { + row: 2, + col: 0, + }, + }, + fields: { + body: { + type: "block", + range: { + begin: { + row: 0, + col: 8, + }, + end: { + row: 2, + col: 0, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + }, + { + name: "call expression", + input: "foo(bar, baz)", + expected: { + type: "program", + range: { + }, + fields: { + statements: [ + { + type: "expression statement", + fields: { + expression: { + type: "call", + range: { + begin: { + row: 0, + col: 0, + }, + end: { + row: 0, + col: 12, + }, + }, + }, + }, + }, + ], + }, + }, + }, + ]; + + it.each(cases.slice(4))("$name", testSuccess); + }); + + describe("single statements", () => { + const cases: SuccessTestCase[] = [ + { + name: "branch statement", + input: "만약 foo {\n 11\n} 아니면 {\n 22\n}", + expected: { + type: "program", + range: { + }, + fields: { + statements: [ + { + type: "branch", + range: { + }, + fields: { + predicate: { + range: { + begin: { + row: 0, + col: 3, + }, + end: { + row: 0, + col: 5, + }, + }, + }, + consequence: { + type: "block", + range: { + begin: { + row: 0, + col: 7, + }, + end: { + row: 2, + col: 0, + }, + }, + }, + alternative: { + type: "block", + range: { + begin: { + row: 2, + col: 6, + }, + end: { + row: 4, + col: 0, + }, + }, + }, + }, + }, + ], + }, + }, + }, + ]; + + it.each(cases)("$name", testSuccess); + }); + }); + }); + + describe("error handling", () => { + const cases: FailureTestCase[] = [ + { + name: "not parsable expression start", + input: "*3", + expected: BadExpressionError, + } + ]; + + it.each(cases)("$name", testFailure); + }); +}); diff --git a/src/parser/v2.ts b/src/parser/v2.ts new file mode 100644 index 0000000..4df21f6 --- /dev/null +++ b/src/parser/v2.ts @@ -0,0 +1,397 @@ +import type * as Node from "./syntax-node"; +import * as node from "./syntax-node"; +import { getInfixBindingPower, bindingPowers, type BindingPowerEntry } from "./binding-power"; + +import { copyRange, type Range } from "../util/position"; + +export class ParserError extends Error { + public received: string; + public expected: string; + public range: { begin: { col: number, row: number }, end: { col: number, row: number }}; + + constructor(received: string, expected: string, range: Range) { + super(); + this.received = received; + this.expected = expected; + this.range = range; + } +}; + +export class BadNumberLiteralError extends ParserError {}; +export class BadBooleanLiteralError extends ParserError {}; +export class BadPrefixError extends ParserError {}; +export class BadInfixError extends ParserError {}; +export class BadExpressionError extends ParserError {}; +export class BadGroupDelimiterError extends ParserError {}; +export class BadBlockDelimiterError extends ParserError {}; +export class BadAssignmentError extends ParserError {}; +export class BadFunctionKeywordError extends ParserError {}; +export class BadIdentifierError extends ParserError {}; +export class BadSeparatorError extends ParserError {}; + +import Lexer from "../lexer"; +import SourceTokenReader from "./source-token-reader"; + +type PrefixOperator = "+" | "-" | "!"; +type InfixOperator = "+" | "-" | "*" | "/" | "!=" | "==" | ">" | "<" | ">=" | "<="; + +export default class Parser { + private static readonly PREFIX_OPERATORS = ["+", "-", "!"] as const; + private static readonly INFIX_OPERATORS = ["+", "-", "*", "/", "!=", "==", ">", "<", ">=", "<="] as const; + + private reader: SourceTokenReader; + + constructor(lexer: Lexer) { + this.reader = new SourceTokenReader(lexer); + } + + parseSource(): Node.ProgramNode { + const statements: Node.StatementNode[] = []; + + while (!this.reader.isEnd()) { + statements.push(this.parseStatement()); + } + + const firstPos = { row: 0, col: 0 }; + const posBegin = statements.length > 0 ? statements[0].range.begin : firstPos; + const posEnd = statements.length > 0 ? statements[statements.length-1].range.end : firstPos; + + const program = node.createProgramNode({ statements }, posBegin, posEnd); + + return program; + } + + private parseBlock(): Node.BlockNode { + const firstToken = this.reader.read(); + this.advanceOrThrow("block delimiter", "{", BadBlockDelimiterError); + + const statements: Node.StatementNode[] = []; + while (true) { + const token = this.reader.read(); + if (token.type === "block delimiter" && token.value === "}") { + this.reader.advance(); + + const range = copyRange(firstToken.range.begin, token.range.end); + return node.createBlockNode({ statements }, range); + } + + const statement = this.parseStatement(); + statements.push(statement); + } + } + + private parseStatement(): Node.StatementNode { + const token = this.reader.read(); + const { type, value } = token; + + if (type === "keyword" && value === "만약") { + return this.parseBranchStatement(); + } + + if (type === "keyword" && value === "결과") { + return this.parseReturnStatement(); + } + + return this.parseExpressionStatement(); + } + + private parseBranchStatement(): Node.BranchNode { + const { range } = this.reader.read(); + this.reader.advance(); + + const predicate = this.parseExpression(bindingPowers.lowest); + const consequence = this.parseBlock(); + + const maybeElseToken = this.reader.read(); + if (maybeElseToken.type !== "keyword" || maybeElseToken.value !== "아니면") { + return node.createBranchNode({ predicate, consequence }, range); + } + this.reader.advance(); + + const alternative = this.parseBlock(); + return node.createBranchNode({ predicate, consequence, alternative }, range); + } + + private parseReturnStatement(): Node.ReturnNode { + const { range } = this.reader.read(); + this.reader.advance(); + + const expression = this.parseExpression(bindingPowers.lowest); + return node.createReturnNode({ expression }, range); + } + + /** return expression statement node, which is just a statement wrapper for an expression */ + private parseExpressionStatement(): Node.ExpressionStatementNode { + const expression = this.parseExpression(bindingPowers.lowest); + + const range = expression.range; + return node.createExpressionStatementNode({ expression }, range); + } + + private parseExpression(threshold: BindingPowerEntry): Node.ExpressionNode { + let topNode = this.parseExpressionStart(); + + while (true) { + const nextBindingPower = getInfixBindingPower(this.reader.read().value); + if (nextBindingPower.left <= threshold.right) { + break; + } + + const infixExpression = this.parseExpressionMiddle(topNode); + if (infixExpression === null) { + break; + } + + topNode = infixExpression; + } + + return topNode; + } + + private parseExpressionStart(): Node.ExpressionNode { + const { type, value, range } = this.reader.read(); + + if (type === "number literal") { + return this.parseNumberLiteral(); + } + if (type === "boolean literal") { + return this.parseBooleanLiteral(); + } + if (type === "string literal") { + return this.parseStringLiteral(); + } + if (type === "identifier") { + return this.parseIdentifier(); + } + if (type === "operator" && this.isPrefixOperator(value)) { + return this.parsePrefix(); + } + if (type === "keyword" && value === "함수") { + return this.parseFunction(); + } + if (type === "group delimiter" && value === "(") { + return this.parseGroupedExpression(); + } + + throw new BadExpressionError(type, "expression", range); + } + + /** return node if parsable; null otherwise **/ + private parseExpressionMiddle(left: Node.ExpressionNode): Node.ExpressionNode | null { + const { type, value } = this.reader.read(); + + if (type === "group delimiter" && value === "(") { + if (left.type !== "function" && left.type !== "identifier") { + return null; + } + + return this.parseCall(left); + } + + if (type === "operator" && this.isInfixOperator(value)) { + return this.parseInfix(left); + } + + if (type === "operator" && value === "=" && left.type === "identifier") { + return this.parseAssignment(left); + } + + return null; + } + + private parseCall(left: Node.FunctionNode | Node.IdentifierNode): Node.CallNode { + this.advanceOrThrow("group delimiter", "(", BadGroupDelimiterError); + + const secondToken = this.reader.read(); + if (secondToken.type === "group delimiter" && secondToken.value === ")") { + this.reader.advance(); // eat delimiter + + const range = copyRange(left.range.begin, secondToken.range.end); + return node.createCallNode({ func: left, args: [] }, range); + } + + const args = [this.parseExpression(bindingPowers.lowest)]; + while (true) { + const token = this.reader.read(); + if (token.type !== "separator") { + break; + } + this.reader.advance(); // eat comma + + args.push(this.parseExpression(bindingPowers.lowest)); + } + + const lastToken = this.reader.read(); + this.advanceOrThrow("group delimiter", ")", BadGroupDelimiterError); + + const range = copyRange(left.range.begin, lastToken.range.end); + + return node.createCallNode({ func: left, args }, range); + } + + private parseAssignment(left: Node.IdentifierNode): Node.ExpressionNode { + const { value, range } = this.reader.read(); + this.reader.advance(); + + if (value !== "=") { + throw new BadAssignmentError(value, "=", range); + } + const infix = value; + + const infixBindingPower = getInfixBindingPower(infix); + const right = this.parseExpression(infixBindingPower); + const assignmentRange = { begin: left.range.begin, end: right.range.end }; + + return node.createAssignmentNode({ left, right }, assignmentRange); + } + + private parseNumberLiteral(): Node.NumberNode { + const { value, range } = this.reader.read(); + this.reader.advance(); + + const parsed = Number(value); + if (Number.isNaN(parsed)) { + throw new BadNumberLiteralError(value, "non NaN", range); + } + + return node.createNumberNode({ value: parsed }, range); + } + + private parseBooleanLiteral(): Node.BooleanNode { + const { value, range } = this.reader.read(); + this.reader.advance(); + + let parsed: boolean; + if (value === "참") { + parsed = true; + } else if (value === "거짓") { + parsed = false; + } else { + throw new BadBooleanLiteralError(value, "참, 거짓", range); + } + + return node.createBooleanNode({ value: parsed }, range); + } + + private parseStringLiteral(): Node.StringNode { + const { value, range } = this.reader.read(); + this.reader.advance(); + + return node.createStringNode({ value }, range); + } + + private parseIdentifier(): Node.IdentifierNode { + const { type, value, range } = this.reader.read(); + this.reader.advance(); + + if (type !== "identifier") { + throw new BadIdentifierError(type, "identifier", range); + } + + return node.createIdentifierNode({ value }, range); + } + + private parsePrefix(): Node.PrefixNode { + const { value, range } = this.reader.read(); + this.reader.advance(); + + if (!this.isPrefixOperator(value)) { + throw new BadPrefixError(value, "prefix operator", range); + } + + const prefix = value; + const right = this.parseExpression(bindingPowers.prefix); + + return node.createPrefixNode({ prefix, right }, range); + } + + private parseInfix(left: Node.ExpressionNode): Node.InfixNode { + const { value, range } = this.reader.read(); + this.reader.advance(); + + if (!this.isInfixOperator(value)) { + throw new BadInfixError(value, "infix operator", range); + } + const infix = value; + + const infixBindingPower = getInfixBindingPower(infix); + const right = this.parseExpression(infixBindingPower); + const infixRange = copyRange(left.range.begin, right.range.end); + + return node.createInfixNode({ infix, left, right }, infixRange); + } + + private parseFunction(): Node.FunctionNode { + const firstToken = this.reader.read(); + + this.advanceOrThrow("keyword", "함수", BadFunctionKeywordError); + + const parameters = this.parseParameters(); + const body = this.parseBlock(); + + const range = copyRange(firstToken.range.begin, body.range.end); + return node.createFunctionNode({ parameters, body }, range); + } + + private parseParameters(): Node.IdentifierNode[] { + this.advanceOrThrow("group delimiter", "(", BadGroupDelimiterError); + + const groupEndOrIdentifier = this.reader.read(); + + // early return if empty parameter list + if (groupEndOrIdentifier.type === "group delimiter" && groupEndOrIdentifier.value === ")") { + this.reader.advance(); + return []; + } + + const parameters = [this.parseIdentifier()]; + + while (true) { + const commaOrGroupEnd = this.reader.read(); + this.reader.advance(); + + if (commaOrGroupEnd.type === "group delimiter" && commaOrGroupEnd.value === ")") { + return parameters; + } + + if (commaOrGroupEnd.type !== "separator") { + throw new BadSeparatorError(commaOrGroupEnd.type, ",", commaOrGroupEnd.range); + } + + parameters.push(this.parseIdentifier()); + } + } + + private parseGroupedExpression(): Node.ExpressionNode { + this.advanceOrThrow("group delimiter", "(", BadGroupDelimiterError); + + const expression = this.parseExpression(bindingPowers.lowest); + + this.advanceOrThrow("group delimiter", ")", BadGroupDelimiterError); + + // range change due to group delimiters + const offset = { begin: { row: 0, col: -1 }, end: { row: 0, col: 1 } }; + const range = copyRange(expression.range.begin, expression.range.end, offset); + + return { ...expression, range }; + } + + private advanceOrThrow(type: string, value: string, ErrorClass: typeof ParserError): void { + const token = this.reader.read(); + this.reader.advance(); + + if (token.type !== type || token.value !== value) { + throw new ErrorClass(token.value, value, token.range); + } + } + + private isPrefixOperator(operator: string): operator is PrefixOperator { + return Parser.PREFIX_OPERATORS.some(prefix => prefix === operator); + } + + private isInfixOperator(operator: string): operator is InfixOperator { + return Parser.INFIX_OPERATORS.some(infix => infix === operator); + } +}; + +export type * from "./syntax-node"; diff --git a/src/util/position/index.ts b/src/util/position/index.ts index c37a918..c2d6e4d 100644 --- a/src/util/position/index.ts +++ b/src/util/position/index.ts @@ -2,3 +2,22 @@ export default interface Position { readonly row: number, readonly col: number, } + +export interface Range { + readonly begin: Position, + readonly end: Position, +} + +export const copyPosition = (pos: Position, offset?: Position) => { + const row = pos.row + (offset?.row ?? 0); + const col = pos.col + (offset?.col ?? 0); + + return { row, col }; +} + +export const copyRange = (begin: Position, end: Position, offset?: Range) => { + const copiedBegin = copyPosition(begin, offset?.begin); + const copiedEnd = copyPosition(end, offset?.end); + + return { begin: copiedBegin, end: copiedEnd }; +} diff --git a/tsconfig.json b/tsconfig.json index 932814b..02de301 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "es2015", "module": "commonjs", + "lib": ["es2022"], "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true,