From 608728ade9848bc5b8339d3ec64c07e75bc8aa9c Mon Sep 17 00:00:00 2001 From: Sam Vervaeck Date: Sun, 7 Jun 2020 00:21:15 +0200 Subject: [PATCH] Add a minimally working test infrastructure --- package-lock.json | 137 ++++++++-- package.json | 5 +- src/bin/bolt-test.ts | 370 ++++++++++++++++++++++++++ src/scanner.ts | 93 ++++++- src/text.ts | 37 ++- src/util.ts | 60 ++++- test/scan/000-bolt-identifier.md | 53 ++++ test/scan/001-bolt-string-literal.md | 20 ++ test/scan/002-bolt-integer-literal.md | 57 ++++ webpack.dev.js | 7 +- 10 files changed, 802 insertions(+), 37 deletions(-) create mode 100644 src/bin/bolt-test.ts create mode 100644 test/scan/000-bolt-identifier.md create mode 100644 test/scan/001-bolt-string-literal.md create mode 100644 test/scan/002-bolt-integer-literal.md diff --git a/package-lock.json b/package-lock.json index 9375928a2..248f0a6c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -403,6 +403,14 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.11.tgz", "integrity": "sha512-lCvvI24L21ZVeIiyIUHZ5Oflv1hhHQ5E1S25IRlKIXaRkVgmXpJMI3wUJkmym2bTbCe+WoIibQnMVAU3FguaOg==" }, + "@types/ora": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/ora/-/ora-3.2.0.tgz", + "integrity": "sha512-jll99xUKpiFbIFZSQcxm4numfsLaOWBzWNaRk3PvTSE7BPqTzzOCFmS0mQ7m8qkTfmYhuYbehTGsxkvRLPC++w==", + "requires": { + "ora": "*" + } + }, "@types/semver": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.2.0.tgz", @@ -656,7 +664,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "requires": { "color-convert": "^1.9.0" } @@ -1211,6 +1218,19 @@ } } }, + "cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "requires": { + "restore-cursor": "^3.1.0" + } + }, + "cli-spinners": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.3.0.tgz", + "integrity": "sha512-Xs2Hf2nzrvJMFKimOR7YR0QwZ8fc0u98kdtwN1eNAZzNQgH3vK2pXzff6GJtKh7S5hoJ87ECiAiZFS2fb5Ii2w==" + }, "cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -1221,6 +1241,11 @@ "wrap-ansi": "^6.2.0" } }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=" + }, "collection-visit": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", @@ -1235,7 +1260,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "requires": { "color-name": "1.1.3" } @@ -1243,8 +1267,7 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, "commander": { "version": "2.20.3", @@ -1576,6 +1599,14 @@ "type-detect": "^4.0.0" } }, + "defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "requires": { + "clone": "^1.0.2" + } + }, "define-properties": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", @@ -1802,8 +1833,7 @@ "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, "eslint-scope": { "version": "4.0.3", @@ -2273,8 +2303,7 @@ "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" }, "has-symbols": { "version": "1.0.1", @@ -2615,6 +2644,11 @@ "is-extglob": "^2.1.1" } }, + "is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==" + }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2798,7 +2832,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", - "dev": true, "requires": { "chalk": "^2.4.2" }, @@ -2807,7 +2840,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "requires": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -2818,7 +2850,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "requires": { "has-flag": "^3.0.0" } @@ -3055,8 +3086,7 @@ "mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" }, "minimalistic-assert": { "version": "1.0.1", @@ -3320,6 +3350,11 @@ "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", "dev": true }, + "mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" + }, "nanomatch": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", @@ -3556,6 +3591,62 @@ "wrappy": "1" } }, + "onetime": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.0.tgz", + "integrity": "sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==", + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "ora": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/ora/-/ora-4.0.4.tgz", + "integrity": "sha512-77iGeVU1cIdRhgFzCK8aw1fbtT1B/iZAvWjS+l/o1x0RShMgxHUZaD2yDpWsNCPwXg9z1ZA78Kbdvr8kBmG/Ww==", + "requires": { + "chalk": "^3.0.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.2.0", + "is-interactive": "^1.0.0", + "log-symbols": "^3.0.0", + "mute-stream": "0.0.8", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "requires": { + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + } + } + }, "os-browserify": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", @@ -3992,6 +4083,15 @@ "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", "dev": true }, + "restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "requires": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + } + }, "ret": { "version": "0.1.15", "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", @@ -4134,8 +4234,7 @@ "signal-exit": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", - "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", - "dev": true + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" }, "snapdragon": { "version": "0.8.2", @@ -5135,6 +5234,14 @@ } } }, + "wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", + "requires": { + "defaults": "^1.0.3" + } + }, "webpack": { "version": "4.43.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.43.0.tgz", diff --git a/package.json b/package.json index 09eb4e3d1..5ee02beac 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "scripts": { "watch": "webpack --watch --config webpack.dev.js", "prepare": "webpack --config webpack.dev.js", - "test": "mocha lib/test" + "test": "node build/bin/bolt-test.js", + "update-lkg": "node build/bin/bolt-test.js snapshot lkg" }, "author": "Sam Vervaeck ", "license": "GPL-3.0", @@ -21,6 +22,7 @@ "@types/microtime": "^2.1.0", "@types/minimist": "^1.2.0", "@types/node": "^14.0.11", + "@types/ora": "^3.2.0", "@types/semver": "^7.2.0", "@types/yargs": "^15.0.5", "chalk": "^4.0.0", @@ -30,6 +32,7 @@ "microtime": "^3.0.0", "minimist": "^1.2.5", "moment": "^2.26.0", + "ora": "^4.0.4", "pegjs": "^0.11.0-master.b7b87ea", "reflect-metadata": "^0.1.13", "semver": "^7.3.2", diff --git a/src/bin/bolt-test.ts b/src/bin/bolt-test.ts new file mode 100644 index 000000000..a46d05274 --- /dev/null +++ b/src/bin/bolt-test.ts @@ -0,0 +1,370 @@ + +import "source-map-support/register" + +import * as fs from "fs-extra" +import * as path from "path" +import * as crypto from "crypto" + +import yargs from "yargs" +import yaml from "js-yaml" +import { sync as globSync } from "glob" +import ora from "ora" + +import { Parser } from "../parser" +import { Scanner } from "../scanner" +import { SyntaxKind, Syntax } from "../ast" +import { Json, serialize, JsonObject, MapLike, upsearchSync, deepEqual } from "../util" +import { DiagnosticIndex } from "../diagnostics" +import { TextFile, TextPos, TextSpan } from "../text" + +const PACKAGE_ROOT = path.dirname(upsearchSync('package.json')!); +const STORAGE_DIR = path.join(PACKAGE_ROOT, '.test-storage'); + +const spinner = ora(`Initializing test session ...`).start(); + +function toArray(value: T | T[]): T[] { + if (Array.isArray(value)) { + return value; + } + return value === undefined || value === null ? [] : [ value ] +} + +class Test { + + public key: string; + + public result?: Json; + public error: Error | null = null; + + constructor( + public readonly span: TextSpan, + public readonly type: string, + public readonly text: string, + public readonly data: JsonObject, + ) { + this.key = hash([text, data]); + } + +} + +interface LoadTestsOptions { + include: string[]; + exclude: string[]; +} + +class TestSession { + + private failCount = 0; + + public key: string; + + constructor(private tests: Test[] = []) { + this.key = `${Date.now().toString()}-${Math.random()}`; + } + + public getAllTests() { + return this.tests[Symbol.iterator]();; + } + + public scanForTests(options?: LoadTestsOptions) { + const includes = options?.include ?? ['test/**/*.md']; + const excludes = options?.exclude ?? []; + spinner.text = 'Scanning for tests [0 found]'; + for (const include of includes) { + for (const filepath of globSync(include, { ignore: excludes })) { + spinner.info(`Found file ${filepath}`) + for (const test of loadTests(filepath)) { + this.tests.push(test); + spinner.text = `Scanning for tests [${this.tests.length} found]`; + } + } + } + } + + public run() { + let i = 1; + //let failed = []; + for (const test of this.tests) { + spinner.text = `Running tests [${i}/${this.tests.length}]` + const runner = TEST_RUNNERS[test.type] + if (runner === undefined) { + spinner.warn(`Test runner '${test.type}' not found.`) + continue; + } + let result; + try { + test.result = runner(test); + } catch (e) { + test.error = e; + this.failCount++; + //failed.push(test); + spinner.warn(`The following test from ${path.relative(process.cwd(), test.span.file.fullPath)} failed with "${e.message}":\n\n${test.text}\n`) + } + i++; + } + if (this.failCount > 0) { + spinner.fail(`${this.failCount} tests failed.`) + } + } + + public save() { + fs.mkdirpSync(path.join(STORAGE_DIR, 'snapshots', this.key)) + for (const test of this.tests) { + fs.writeFileSync(path.join(STORAGE_DIR, 'snapshots', this.key, test.key), JSON.stringify(test.result), 'utf8'); + } + } + + public hasFailedTests() { + return this.failCount > 0; + } + +} + +function compare(actual: string, expected: string) { + for (const testKey of fs.readdirSync(path.join(STORAGE_DIR, 'snapshots', actual))) { + const actualTestResult = readJson(path.join(STORAGE_DIR, 'snapshots', actual, testKey))!; + const expectedTestResult = readJson(path.join(STORAGE_DIR, 'snapshots', expected, testKey)); + if (!deepEqual(actualTestResult, expectedTestResult)) { + spinner.warn(`Test ${testKey} does not compare to its expected value.`) + } + } +} + +function isWhiteSpace(ch: string): boolean { + return /[\n\r\t ]/.test(ch); +} + +interface TestFileMetadata { + type: string; + expect: string; +} + +function* loadTests(filepath: string): IterableIterator { + + const file = new TextFile(filepath); + const contents = file.getText('utf8'); + + let i = 0; + let column = 1 + let line = 1; + let atNewLine = true; + + assertText('---'); + let yamlStr = ''; + i += 3; + while (!lookaheadEquals('---')) { + yamlStr += contents[i++]; + } + i += 3; + const metadata = yaml.safeLoad(yamlStr); + + while (i < contents.length) { + skipWhiteSpace(); + if (atNewLine && column >= 5) { + const startPos = new TextPos(i, line, column); + const text = scanCodeBlock(); + const endPos = new TextPos(i, line, column); + if (metadata['split-lines']) { + for (const line of text.split('\n')) { + if (line.trim() !== '') { + yield new Test(new TextSpan(file, startPos.clone(), endPos), metadata.type, line, metadata); + startPos.advance(line); + } + } + } else { + yield new Test(new TextSpan(file, startPos, endPos), metadata.type, text, metadata); + } + } else { + getChar(); + } + } + + function getChar() { + const ch = contents[i++]; + if (ch === '\n') { + column = 1; + line++; + atNewLine = true; + } else { + if (!isEmpty(ch)) { + atNewLine = false; + } + column++; + } + return ch; + } + + function assertText(str: string) { + for (let k = 0; k < str.length; k++) { + if (contents[i+k] !== str[k]) { + throw new Error(`Expected '${str}' but got ${contents.substr(i, i+str.length)}`) + } + } + } + + function isEmpty(ch: string): boolean { + return /[\t ]/.test(ch); + } + + function lookaheadEquals(str: string): boolean { + for (let k = 0; k < str.length; k++) { + if (contents[i+k] !== str[k]) { + return false; + } + } + return true; + } + + function scanCodeBlock() { + let out = '' + while (i < contents.length) { + const ch = getChar(); + if (ch === '\n') { + out += ch; + skipWhiteSpace(); + continue; + } + if (column < 5) { + break; + } + out += ch; + } + return out; + } + + function skipWhiteSpace() { + takeWhile(isWhiteSpace) + } + + function takeWhile(pred: (ch: string) => boolean) { + let out = ''; + while (true) { + const c0 = contents[i]; + if (!pred(c0)) { + break + } + out += getChar(); + } + return out; + } + +} + +function readJson(filename: string): Json | null { + try { + return JSON.parse(fs.readFileSync(filename, 'utf8')); + } catch (e) { + if (e.code === 'ENOENT') { + return null + } + throw e; + } +} + +function hash(value: Json) { + const hasher = crypto.createHash('sha256'); + hasher.update(JSON.stringify(value)); + return hasher.digest('hex'); +} + +type TestRunner = (test: Test) => Json; + +const TEST_RUNNERS: MapLike = { + + scan(test: Test): Json { + const diagnostics = new DiagnosticIndex; + const scanner = new Scanner(test.span.file, test.text, test.span.start); + const tokens = [] + while (true) { + const token = scanner.scan(); + if (token.kind === SyntaxKind.EndOfFile) { + break; + } + tokens.push(token); + } + return serialize({ + diagnostics: [...diagnostics.getAllDiagnostics()], + tokens, + }); + }, + + parse(test: Test): Json { + const kind = test.data.expect ?? 'SourceFile'; + const diagnostics = new DiagnosticIndex; + const parser = new Parser(); + const tokens = new Scanner(test.span.file, test.text); + let results: Syntax[]; + switch (kind) { + case 'SourceFile': + results = [ parser.parseSourceFile(tokens) ]; + break; + case 'SourceElements': + results = parser.parseSourceElements(tokens); + break; + default: + throw new Error(`I did not know how to parse ${kind}`) + } + return serialize({ + diagnostics: [...diagnostics.getAllDiagnostics()], + results, + }) + }, + +} + +yargs + .command(['$0 [pattern..]', 'run [pattern..]'], 'Run all tests on the current version of the compiler', + yargs => yargs + .array('pattern') + .describe('pattern', 'Only run the tests matching the given pattern') + .array('include') + .describe('include', 'Files to scan for tests') + .default('include', ['test/**/*.md']) + .array('exclude') + .describe('exclude', 'Files to never scan for tests') + .default('exclude', []) + , args => { + + const session = new TestSession(); + session.scanForTests(args as LoadTestsOptions); + session.run(); + session.save(); + + if (session.hasFailedTests()) { + return; + } + + const expectedKey = fs.readFileSync(path.join(STORAGE_DIR, 'aliases', 'lkg'), 'utf8') + compare(session.key, expectedKey) + + } + ) + .command(['snapshot [name]'], 'Create a new snapshot from the output of the current compiler', + yargs => yargs + .array('include') + .describe('include', 'Files to scan for tests') + .default('include', ['test/**/*.md']) + .array('exclude') + .describe('exclude', 'Files to never scan for tests') + .default('exclude', []) + , args => { + + // Load and run all tests, saving the results to disk + const session = new TestSession(); + session.scanForTests(args as LoadTestsOptions); + session.run(); + session.save(); + + // Set the tests that we have run as being the new 'lkg' + fs.mkdirpSync(path.join(STORAGE_DIR, 'aliases')); + fs.writeFileSync(path.join(STORAGE_DIR, 'aliases', 'lkg'), session.key, 'utf8') + } + ) + .command('inspect ', 'Inspect the ouput of a given test', + yargs => yargs + , args => { + } + ) + .version() + .help() + .argv; diff --git a/src/scanner.ts b/src/scanner.ts index f267c3490..e00864ea1 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -52,6 +52,7 @@ import { createBoltExMark, createBoltWhereKeyword, } from "./ast" +import { outputFile } from "fs-extra"; export enum PunctType { Paren, @@ -111,11 +112,11 @@ function isNewLine(ch: string) { } function isIdentStart(ch: string) { - return /[_\p{L}]/u.test(ch) + return /[_\p{ID_Start}]/u.test(ch) } function isIdentPart(ch: string) { - return /[_\p{L}\p{Nd}]/u.test(ch) + return /[_\p{ID_Continue}]/u.test(ch) } function isSymbol(ch: string) { @@ -181,6 +182,86 @@ export class Scanner { return text; } + private scanHexDigit(): number { + const c0 = this.peekChar(); + let out; + switch (c0) { + case '0': out = 0; break; + case '1': out = 1; break; + case '2': out = 2; break; + case '3': out = 3; break; + case '4': out = 4; break; + case '5': out = 5; break; + case '6': out = 6; break; + case '7': out = 7; break; + case '8': out = 8; break; + case '9': out = 9; break; + case 'a': out = 10; break; + case 'b': out = 11; break; + case 'c': out = 12; break; + case 'd': out = 13; break; + case 'e': out = 14; break; + case 'f': out = 15; break; + case 'A': out = 10; break; + case 'B': out = 11; break; + case 'C': out = 12; break; + case 'D': out = 13; break; + case 'E': out = 14; break; + case 'F': out = 15; break; + default: + throw new ScanError(this.file, this.currPos.clone(), c0); + } + this.getChar(); + return out; + } + + private scanEscapeSequence(): string { + this.assertChar('\\') + const c0 = this.peekChar(); + switch (c0) { + case 'a': + this.getChar(); + return '\n' + case 'b': + this.getChar(); + return '\b' + case 'f': + this.getChar(); + return '\f' + case 'n': + this.getChar(); + return '\n' + case 'r': + this.getChar(); + return '\r' + case 't': + this.getChar(); + return '\t' + case 'v': + this.getChar(); + return '\v' + case '0': + this.getChar(); + return '\0' + case 'u': + { + const d0 = this.scanHexDigit(); + const d1 = this.scanHexDigit(); + const d2 = this.scanHexDigit(); + const d3 = this.scanHexDigit(); + return String.fromCharCode(d0 * (16 ** 3) + d1 * (16 ** 2) + d2 * 16 + d3); + } + case 'x': + { + const d0 = this.scanHexDigit(); + const d1 = this.scanHexDigit(); + return String.fromCharCode(d0 * 16 + d1); + } + default: + throw new ScanError(this.file, this.currPos.clone(), c0); + } + } + public scan(): BoltToken { while (true) { @@ -227,15 +308,17 @@ export class Scanner { let text = '' while (true) { - const c1 = this.getChar(); + const c1 = this.peekChar(); if (c1 === EOF) { throw new ScanError(this.file, this.currPos.clone(), EOF); } if (c1 === '"') { + this.getChar(); break; } else if (c1 === '\\') { - this.scanEscapeSequence() + text += this.scanEscapeSequence() } else { + this.getChar(); text += c1 } } @@ -246,7 +329,7 @@ export class Scanner { } else if (isDigit(c0)) { - const digits = this.takeWhile(isDigit) + const digits = this.takeWhile(isDigit); const endPos = this.currPos.clone(); return createBoltIntegerLiteral(BigInt(digits), new TextSpan(this.file, startPos, endPos)); diff --git a/src/text.ts b/src/text.ts index 81be23f5f..0e3605896 100644 --- a/src/text.ts +++ b/src/text.ts @@ -1,6 +1,7 @@ import * as path from "path" import * as fs from "fs" +import { serializeTag, serialize } from "./util"; export class TextFile { @@ -14,11 +15,15 @@ export class TextFile { return path.resolve(this.origPath) } - public getText(): string { + [serializeTag]() { + return this.origPath; + } + + public getText(encoding: BufferEncoding = 'utf8'): string { if (this.cachedText !== null) { return this.cachedText; } - const text = fs.readFileSync(this.fullPath, 'utf8'); + const text = fs.readFileSync(this.fullPath, encoding); this.cachedText = text; return text } @@ -39,6 +44,26 @@ export class TextPos { return new TextPos(this.offset, this.line, this.column) } + [serializeTag]() { + return { + offset: this.offset, + line: this.line, + column: this.column, + } + } + + public advance(str: string) { + for (const ch of str) { + if (ch === '\n') { + this.line++; + this.column = 1; + } else { + this.column++; + } + this.offset++; + } + } + } export class TextSpan { @@ -55,5 +80,13 @@ export class TextSpan { return new TextSpan(this.file, this.start.clone(), this.end.clone()); } + [serializeTag]() { + return { + file: serialize(this.file), + start: serialize(this.start), + end: serialize(this.end), + } + } + } diff --git a/src/util.ts b/src/util.ts index 734361626..cf44aa90c 100644 --- a/src/util.ts +++ b/src/util.ts @@ -6,14 +6,12 @@ import * as os from "os" import moment from "moment" import chalk from "chalk" import { LOG_DATETIME_FORMAT } from "./constants" -import { E_CANDIDATE_FUNCTION_REQUIRES_THIS_PARAMETER } from "./diagnostics" -import { isPrimitive } from "util" + export function isPowerOf(x: number, n: number):boolean { const a = Math.log(x) / Math.log(n); return Math.pow(a, n) == x; } - export function some(iterator: Iterator, pred: (value: T) => boolean): boolean { while (true) { const { value, done } = iterator.next(); @@ -194,15 +192,31 @@ export type Newable = { new(...args: any): T; } +export function isPrimitive(value: any): boolean { + return (typeof(value) !== 'function' && typeof(value) !== 'object') || value === null; +} + export const serializeTag = Symbol('serializer tag'); const serializableClasses = new Map>(); export function serialize(value: any): Json { if (isPrimitive(value)) { + if (typeof(value) === 'bigint') { + return { + type: 'bigint', + value: value.toString(), + } + } else { + return { + type: 'primitive', + value, + } + } + } else if (Array.isArray(value)) { return { - type: 'primitive', - value, + type: 'array', + elements: value.map(serialize) } } else if (isObjectLike(value)) { if (isPlainObject(value)) { @@ -219,13 +233,9 @@ export function serialize(value: any): Json { data: value[serializeTag](), } } else { + console.log(value); throw new Error(`Could not serialize ${value}: it was a non-primitive object and has no serializer tag.`) } - } else if (Array.isArray(value)) { - return { - type: 'array', - elements: value.map(serialize) - } } else { throw new Error(`Could not serialize ${value}: is was not recognised as a primitive type, an object, a class instance, or an array.`) } @@ -503,8 +513,8 @@ export function upsearchSync(filename: string, startDir = '.') { if (fs.existsSync(filePath)) { return filePath } - const { root, dir } = path.parse(currDir); - if (dir === root) { + const { root, dir } = path.parse(currDir); + if (currDir === root) { return null; } currDir = dir; @@ -638,3 +648,29 @@ export function format(message: string, data: MapLike) { } +export function deepEqual(a: any, b: any): boolean { + if (isPrimitive(a) && isPrimitive(b)) { + return a === b; + } else if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) { + return false; + } + } + return true; + } else if (isPlainObject(a) && isPlainObject(b)) { + const unmarked = new Set(Object.keys(b)); + for (const key of Object.keys(a)) { + if (!deepEqual(a[key], b[key])) { + return false; + } + unmarked.delete(key); + } + return unmarked.size === 0; + } + return false; +} + diff --git a/test/scan/000-bolt-identifier.md b/test/scan/000-bolt-identifier.md new file mode 100644 index 000000000..de9e373db --- /dev/null +++ b/test/scan/000-bolt-identifier.md @@ -0,0 +1,53 @@ +--- +type: scan +expect: BoltIdentifier +split-lines: true +--- + +The most simple identifiers are those made out of ASCII letters: + + Foo + Bar + Baz + +However, they may also contain digits as long as they do not begin with a +digit: + + Var1 + Var2 + Var10029384 + +Identifiers may be as long as you want: + + ThisIsALongAndValidIdentifier + ThisIsAnEvenLongButStilCompletelyValidIdentifier + +Moreover, they may have arbitrary underscores (`_`) in their names. + + a_valid_identifier + another__0000__valid_identfier + _1 + __2 + ___3 + +They may even be nothing more than underscores: + + _ + __ + ___ + +All identifiers starting with a `ID_Start` character are valid identifiers, +including `Other_ID_Start`: + + ℘rototype + ℮llipsis + +Likewise, the following code points using `Other_ID_Continue` are also valid: + + α·β + ano·teleia + +And, of course, the combination of `ID_Start` and `ID_Continue`: + + alfa·beta + diff --git a/test/scan/001-bolt-string-literal.md b/test/scan/001-bolt-string-literal.md new file mode 100644 index 000000000..3ca25cf34 --- /dev/null +++ b/test/scan/001-bolt-string-literal.md @@ -0,0 +1,20 @@ +--- +type: scan +expect: BoltStringLiteral +split-lines: true +--- + +A string may hold arbirary ASCII characters, including spaces: + + "Foo!" + "Once upon a time ..." + +Special ASCII characters have no effect, other than that they are appended to +the contents of the string: + + "S+me w3!rd @SCII ch@r$" + +Some special escape sequences: + + "\n\r" + "\n" diff --git a/test/scan/002-bolt-integer-literal.md b/test/scan/002-bolt-integer-literal.md new file mode 100644 index 000000000..7e0aa0f2a --- /dev/null +++ b/test/scan/002-bolt-integer-literal.md @@ -0,0 +1,57 @@ +--- +type: scan +expect: BoltIntegerLiteral +split-lines: true +--- + +All decimal digits are valid integers. + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 0 + +Any combination of decimal digits are valid integers, including integers +prefixed with an abirary amount of zeroes. + + 12345 + 99 + 10 + 01 + 000 + 0010 + +In binary mode, integers are read in base-2. + + 0b0 + 0b1 + 0b10010 + 0b00100 + 0b00000 + +This means the following expressions are invalid in binary mode: + + 0b20001 + 0b12345 + 0b00003 + +In octal mode, integers are read in base-8. + + 0o0 + 0o00000 + 0o007 + 0o706 + 0o12345 + +This means the following expressions are invalid in octal mode: + + 0o8 + 0o9 + 0o123456789 + diff --git a/webpack.dev.js b/webpack.dev.js index d17fa6548..fba935da7 100644 --- a/webpack.dev.js +++ b/webpack.dev.js @@ -5,9 +5,12 @@ const path = require("path"); module.exports = { target: 'node', mode: 'development', - entry: './src/bin/bolt.ts', + entry: { + 'bolt': './src/bin/bolt.ts', + 'bolt-test': './src/bin/bolt-test.ts', + }, output: { - filename: 'bin/bolt.js', + filename: 'bin/[name].js', path: path.resolve(__dirname, 'build'), }, resolve: {