diff --git a/package.json b/package.json index 67caf10..211a378 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "lint": "eslint src/", "format": "prettier -c src/", "format:fix": "prettier --write src/", - "prepush": "yarn test && yarn lint && yarn format" + "prepush": "yarn test --clear-cache && yarn test && yarn lint && yarn format" }, "devDependencies": { "@types/jest": "^25.2.1", diff --git a/src/language/error.ts b/src/language/error.ts index e0739aa..afe97e8 100644 --- a/src/language/error.ts +++ b/src/language/error.ts @@ -158,6 +158,24 @@ function generateErrorVisualization( }; } +export const enum SyntaxErrorCategory { + /** Lexer token error */ + LEXER, + /** Parser rule error */ + PARSER, + /** Jessie syntax error */ + JESSIE_SYNTAX, + /** Jessie forbidden construct error */ + JESSIE_FORBIDDEN_CONSTRUCT, +} + +export type ProtoError = { + readonly relativeSpan: Span; + readonly detail?: string; + readonly category: SyntaxErrorCategory; + readonly hint?: string; +}; + export class SyntaxError { /** Additional message attached to the error. */ readonly detail: string; @@ -169,7 +187,11 @@ export class SyntaxError { readonly location: Location, /** Span of the error. */ readonly span: Span, - detail?: string + /** Category of this error. */ + readonly category: SyntaxErrorCategory, + detail?: string, + /** Optional hint that is emitted to help with the resolution. */ + readonly hint?: string ) { this.detail = detail ?? 'Invalid or unexpected token'; } @@ -220,6 +242,7 @@ export class SyntaxError { source, location, span, + SyntaxErrorCategory.PARSER, `Expected ${expected} but found ${actual}` ); } @@ -236,7 +259,9 @@ export class SyntaxError { const locationLinePrefix = ' '.repeat(maxLineNumberLog) + '--> '; const locationLine = `${locationLinePrefix}${this.source.fileName}:${sourceLocation.line}:${sourceLocation.column}`; - return `${errorLine}\n${locationLine}\n${visualization}\n`; + const maybeHint = this.hint ? `Hint: ${this.hint}\n` : ''; + + return `${errorLine}\n${locationLine}\n${visualization}\n${maybeHint}`; } get message(): string { diff --git a/src/language/jessie/glue.ts b/src/language/jessie/glue.ts new file mode 100644 index 0000000..f4b50ea --- /dev/null +++ b/src/language/jessie/glue.ts @@ -0,0 +1,32 @@ +import { + JessieSyntaxProtoError, + transpileScript, +} from './transpiler/transpiler'; +import { + ForbiddenConstructProtoError, + validateScript, +} from './validator/validator'; + +type Success = { output: string; sourceMap: string }; + +/** + * Validates and transpiles Jessie script. + * + * This functions combines the transpiler and validator in a more efficient way + */ +export function validateAndTranspile( + input: string +): Success | JessieSyntaxProtoError | ForbiddenConstructProtoError { + const { output, sourceMap, syntaxProtoError } = transpileScript(input, true); + + if (syntaxProtoError) { + return syntaxProtoError; + } + + const subsetErrors = validateScript(input); + if (subsetErrors.length > 0) { + return subsetErrors[0]; // TODO + } + + return { output, sourceMap }; +} diff --git a/src/language/jessie/index.ts b/src/language/jessie/index.ts new file mode 100644 index 0000000..031cd15 --- /dev/null +++ b/src/language/jessie/index.ts @@ -0,0 +1 @@ +export { validateAndTranspile } from './glue'; diff --git a/src/language/jessie/transpiler/transpiler.test.ts b/src/language/jessie/transpiler/transpiler.test.ts new file mode 100644 index 0000000..b863b8d --- /dev/null +++ b/src/language/jessie/transpiler/transpiler.test.ts @@ -0,0 +1,37 @@ +import * as st from './transpiler'; + +test('transpiler basics', () => { + const { output, sourceMap } = st.transpileScript( + `let a = { hello: 1, world: 2 + "3" } +console.log(a)` + ); + + expect(output).toBe( + `var a = { hello: 1, world: 2 + "3" }; +console.log(a);` + ); + + expect(sourceMap).toBe( + 'AAAA,IAAI,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,GAAG,EAAE,CAAA;AACpC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAA' + ); +}); + +test('transpiler ES2020', () => { + const { output, sourceMap } = st.transpileScript( + `let nullishCoalescing = undefined ?? (false ?? "truthy") + const optionalChaining = console?.log?.(nullishCoalescing)` + ); + + expect(output).toMatch('var nullishCoalescing ='); + expect(output).toMatch('var optionalChaining ='); + expect(output).toMatch( + 'undefined !== null && undefined !== void 0 ? undefined' + ); + expect(output).toMatch( + 'console === null || console === void 0 ? void 0 : console.log' + ); + + expect(sourceMap).toMatch( + /[;,]?([a-zA-Z_+]|[a-zA-Z_+]{4}|[a-zA-Z_+]{5})([;,]([a-zA-Z_+]|[a-zA-Z_+]{4}|[a-zA-Z_+]{5}))*/ + ); +}); diff --git a/src/language/jessie/transpiler/transpiler.ts b/src/language/jessie/transpiler/transpiler.ts new file mode 100644 index 0000000..4ac9525 --- /dev/null +++ b/src/language/jessie/transpiler/transpiler.ts @@ -0,0 +1,81 @@ +import * as ts from 'typescript'; + +import { ProtoError, SyntaxErrorCategory } from '../../error'; + +const SCRIPT_OUTPUT_TARGET = ts.ScriptTarget.ES3; + +const AFTER_TRANSFORMERS: ts.TransformerFactory[] = []; + +export type JessieSyntaxProtoError = ProtoError & { + category: SyntaxErrorCategory.JESSIE_SYNTAX; +}; + +export function transpileScript( + input: string, + reportDiagnostics: true +): { + output: string; + sourceMap: string; + syntaxProtoError: JessieSyntaxProtoError; +}; +export function transpileScript( + input: string, + reportDiagnostics?: false +): { output: string; sourceMap: string }; + +export function transpileScript( + input: string, + reportDiagnostics?: boolean +): { + output: string; + sourceMap: string; + syntaxProtoError?: JessieSyntaxProtoError; +} { + // This will transpile the code, generate a source map and run transformers + const { outputText, diagnostics, sourceMapText } = ts.transpileModule(input, { + compilerOptions: { + allowJs: true, + target: SCRIPT_OUTPUT_TARGET, + sourceMap: true, + }, + transformers: { + after: AFTER_TRANSFORMERS, + }, + reportDiagnostics, + }); + + // Strip the source mapping comment from the end of the output + const outputTextStripped = outputText + .replace('//# sourceMappingURL=module.js.map', '') + .trimRight(); + + // `sourceMapText` will be here because we requested it by setting the compiler flag + if (!sourceMapText) { + throw 'Source map text is not present'; + } + const sourceMapJson: { mappings: string } = JSON.parse(sourceMapText); + + let syntaxProtoError: JessieSyntaxProtoError | undefined; + if (diagnostics && diagnostics.length > 0) { + const diag = diagnostics[0]; + let detail = diag.messageText; + if (typeof detail === 'object') { + detail = detail.messageText; + } + + syntaxProtoError = { + category: SyntaxErrorCategory.JESSIE_SYNTAX, + relativeSpan: { + start: diag.start ?? 0, + end: (diag.start ?? 0) + (diag.length ?? 1), + }, + detail, + }; + } + + return { + output: outputTextStripped, + syntaxProtoError, + sourceMap: sourceMapJson.mappings, + }; +} diff --git a/src/validator/README.md b/src/language/jessie/validator/README.md similarity index 100% rename from src/validator/README.md rename to src/language/jessie/validator/README.md diff --git a/src/validator/constructs.ts b/src/language/jessie/validator/constructs.ts similarity index 100% rename from src/validator/constructs.ts rename to src/language/jessie/validator/constructs.ts diff --git a/src/validator/ScriptValidator.test.ts b/src/language/jessie/validator/validator.test.ts similarity index 92% rename from src/validator/ScriptValidator.test.ts rename to src/language/jessie/validator/validator.test.ts index dba6171..6b6da81 100644 --- a/src/validator/ScriptValidator.test.ts +++ b/src/language/jessie/validator/validator.test.ts @@ -1,4 +1,4 @@ -import { validateScript, ValidationError } from './ScriptValidator'; +import { ForbiddenConstructProtoError, validateScript } from './validator'; // Declare custom matcher for sake of Typescript declare global { @@ -12,13 +12,13 @@ declare global { // Add the actual custom matcher expect.extend({ toBeValidScript(script: string, ...errors: string[]) { - function formatError(err: ValidationError): string { + function formatError(err: ForbiddenConstructProtoError): string { const hint = err.hint ?? 'not provided'; - return `${err.message} (hint: ${hint})`; + return `${err.detail} (hint: ${hint})`; } - const report = validateScript(script); + const protoErrors = validateScript(script); let pass = true; let message = ''; @@ -27,14 +27,14 @@ expect.extend({ // Expecting to fail pass = false; // Flip - if (report.isValid === true) { + if (protoErrors.length === 0) { pass = !pass; message = 'expected to fail'; } else { for (let i = 0; i < errors.length; i++) { - const err = report.errors[i]; + const err = protoErrors[i]; if ( - !err.message.includes(errors[i]) && + !err.detail.includes(errors[i]) && !err.hint?.includes(errors[i]) ) { pass = !pass; @@ -47,9 +47,9 @@ expect.extend({ } } else { // Expecting to pass - if (report.isValid === false) { + if (protoErrors.length > 0) { pass = !pass; - const messages = report.errors + const messages = protoErrors .map(err => `\n\t${formatError(err)}`) .join(''); message = `expected to pass, errors: ${messages}`; diff --git a/src/language/jessie/validator/validator.ts b/src/language/jessie/validator/validator.ts new file mode 100644 index 0000000..288e161 --- /dev/null +++ b/src/language/jessie/validator/validator.ts @@ -0,0 +1,97 @@ +import * as ts from 'typescript'; + +import { ProtoError, SyntaxErrorCategory } from '../../error'; +import { ALLOWED_SYNTAX, FORBIDDEN_CONSTRUCTS } from './constructs'; + +function constructDebugVisualTree(root: ts.Node): string { + let debugTree = ''; + let debugDepth = 0; + + function nodeVisitor(node: T): void { + const nodeCode = node.getText().replace('\r\n', ' ').trim(); + const treeIndent = ''.padStart(debugDepth, '\t'); + debugTree += `${treeIndent}NODE ${ts.SyntaxKind[node.kind]} "${nodeCode}"`; + + // Go over forbidden constructs and check if any of them applies + let anyRuleBroken = false; + const rules = FORBIDDEN_CONSTRUCTS[node.kind] ?? []; + for (const rule of rules) { + if (rule.predicate?.(node) ?? true) { + anyRuleBroken = true; + } + } + if (anyRuleBroken) { + debugTree += ' [R]'; + } + + // If none of the rules applied, but the syntax is not valid anyway, add an error without a hint + if (!anyRuleBroken && !ALLOWED_SYNTAX.includes(node.kind)) { + debugTree += ' [S]'; + } + + debugTree += '\n'; + + // Recurse into children + debugDepth += 1; + ts.forEachChild(node, nodeVisitor); + debugDepth -= 1; + } + nodeVisitor(root); + + return debugTree; +} + +export type ForbiddenConstructProtoError = ProtoError & { + detail: string; + category: SyntaxErrorCategory.JESSIE_FORBIDDEN_CONSTRUCT; +}; +export function validateScript(input: string): ForbiddenConstructProtoError[] { + const errors: ForbiddenConstructProtoError[] = []; + + const rootNode = ts.createSourceFile( + 'scripts.js', + input, + ts.ScriptTarget.ES2015, + true, + ts.ScriptKind.JS + ); + + function nodeVisitor(node: T): void { + // Go over forbidden constructs and check if any of them applies + let anyRuleBroken = false; + const rules = FORBIDDEN_CONSTRUCTS[node.kind] ?? []; + for (const rule of rules) { + if (rule.predicate?.(node) ?? true) { + anyRuleBroken = true; + + errors.push({ + detail: `${ts.SyntaxKind[node.kind]} construct is not supported`, + hint: rule.hint(input, node), + relativeSpan: { start: node.pos, end: node.end }, + category: SyntaxErrorCategory.JESSIE_FORBIDDEN_CONSTRUCT, + }); + } + } + + // If none of the rules applied, but the syntax is not valid anyway, add an error without a hint + if (!anyRuleBroken && !ALLOWED_SYNTAX.includes(node.kind)) { + errors.push({ + detail: `${ts.SyntaxKind[node.kind]} construct is not supported`, + relativeSpan: { start: node.pos, end: node.end }, + category: SyntaxErrorCategory.JESSIE_FORBIDDEN_CONSTRUCT, + }); + } + + // Recurse into children + ts.forEachChild(node, nodeVisitor); + } + nodeVisitor(rootNode); + + if (process.env.LOG_LEVEL === 'debug') { + if (errors.length > 0) { + console.debug(constructDebugVisualTree(rootNode)); + } + } + + return errors; +} diff --git a/src/language/lexer/index.ts b/src/language/lexer/index.ts index 38b078f..9d46e97 100644 --- a/src/language/lexer/index.ts +++ b/src/language/lexer/index.ts @@ -1,2 +1,2 @@ -export { Lexer } from './lexer'; +export { Lexer, LexerContext, LexerTokenKindFilter } from './lexer'; export { LexerToken, LexerTokenData, LexerTokenKind } from './token'; diff --git a/src/language/lexer/lexer.test.ts b/src/language/lexer/lexer.test.ts index 84988b2..eb91808 100644 --- a/src/language/lexer/lexer.test.ts +++ b/src/language/lexer/lexer.test.ts @@ -1,10 +1,11 @@ import { Source } from '../source'; -import { DEFAULT_TOKEN_KIND_FILER, Lexer } from './lexer'; +import { DEFAULT_TOKEN_KIND_FILER, Lexer, LexerContext } from './lexer'; import { CommentTokenData, formatTokenData, IdentifierTokenData, IdentifierValue, + JessieScriptTokenData, LexerToken, LexerTokenData, LexerTokenKind, @@ -30,13 +31,18 @@ declare global { // Add the actual custom matcher expect.extend({ toHaveTokenData(actual: LexerToken, data: LexerTokenData) { - function errorMessage(): string { - const fmt = formatTokenData(data); - - return `Expected (${fmt.kind} ${ - fmt.data - }) but found ${actual.toStringDebug()}`; - } + const errorMessage: () => string = () => { + const fmtExpected = formatTokenData(data); + const fmtActual = formatTokenData(actual.data); + + return this.utils.printDiffOrStringify( + `${fmtExpected.kind} ${fmtExpected.data}`, + `${fmtActual.kind} ${fmtActual.data}`, + 'Expected', + 'Received', + this.expand + ); + }; let pass = true; let message = `Expected something else than ${formatTokenData(data)}`; @@ -90,6 +96,13 @@ expect.extend({ message = errorMessage(); } break; + + case LexerTokenKind.JESSIE_SCRIPT: + if ((actual.data as JessieScriptTokenData).script !== data.script) { + pass = false; + message = errorMessage(); + } + break; } } @@ -370,7 +383,7 @@ describe('lexer', () => { Retrieves a map based on its URL (id) ''' - usecase GetMap @safe { + usecase GetMap safe { input { mapId } @@ -388,7 +401,7 @@ describe('lexer', () => { Creates new map from the map source and assigns a store URL to it ''' - usecase CreateMap @unsafe { + usecase CreateMap unsafe { input { source } @@ -401,7 +414,7 @@ describe('lexer', () => { Updates map source based on its URL ''' - usecase UpdateMap @idempotent { + usecase UpdateMap idempotent { input { mapId source @@ -415,7 +428,7 @@ describe('lexer', () => { Deletes map based on its URL ''' - usecase DeleteMap @unsafe { + usecase DeleteMap unsafe { input { mapId } @@ -436,13 +449,13 @@ describe('lexer', () => { # 'Id of the map in the store' - field mapId: String + field mapId String 'Source code of the map' - field source: String + field source String 'Direct "download" URL where the source code can be downloaded' - field sourceUrl: String` + field sourceUrl String` ) ); @@ -452,10 +465,73 @@ describe('lexer', () => { expect(token).toBeDefined(); } }); + + it('is valid map with scripts', () => { + const lexer = new Lexer( + new Source( + `map test { + foo = (() => { const foo = 1; return { foo: foo + 2, bar: Math.min(3, 4) }; })(); + bar = { x: 1, y: 2 }; + baz = true; + }` + ) + ); + + const expectedTokens: LexerTokenData[] = [ + { kind: LexerTokenKind.SEPARATOR, separator: 'SOF' }, + { kind: LexerTokenKind.IDENTIFIER, identifier: 'map' }, + { kind: LexerTokenKind.IDENTIFIER, identifier: 'test' }, + { kind: LexerTokenKind.SEPARATOR, separator: '{' }, + + { kind: LexerTokenKind.IDENTIFIER, identifier: 'foo' }, // 4 + { kind: LexerTokenKind.OPERATOR, operator: '=' }, + { + kind: LexerTokenKind.JESSIE_SCRIPT, + script: + '(function () { var foo = 1; return { foo: foo + 2, bar: Math.min(3, 4) }; })()', + sourceMap: 'not checked', + }, + { kind: LexerTokenKind.OPERATOR, operator: ';' }, + + { kind: LexerTokenKind.IDENTIFIER, identifier: 'bar' }, // 8 + { kind: LexerTokenKind.OPERATOR, operator: '=' }, + { + kind: LexerTokenKind.JESSIE_SCRIPT, + script: '{ x: 1, y: 2 }', + sourceMap: 'not checked', + }, + { kind: LexerTokenKind.OPERATOR, operator: ';' }, + + { kind: LexerTokenKind.IDENTIFIER, identifier: 'baz' }, // 12 + { kind: LexerTokenKind.OPERATOR, operator: '=' }, + { + kind: LexerTokenKind.JESSIE_SCRIPT, + script: 'true', + sourceMap: 'not checked', + }, + { kind: LexerTokenKind.OPERATOR, operator: ';' }, + + { kind: LexerTokenKind.SEPARATOR, separator: '}' }, + { kind: LexerTokenKind.SEPARATOR, separator: 'EOF' }, + ]; + const contexts: { [N in number]: LexerContext | undefined } = { + 6: LexerContext.JESSIE_SCRIPT_EXPRESSION, + 10: LexerContext.JESSIE_SCRIPT_EXPRESSION, + 14: LexerContext.JESSIE_SCRIPT_EXPRESSION, + }; + + for (let i = 0; i < expectedTokens.length; i++) { + const context = contexts[i]; + const actual = lexer.advance(context); + const expected = expectedTokens[i]; + + expect(actual).toHaveTokenData(expected); + } + }); }); describe('invalid', () => { - it('number literal', () => { + test('number literal', () => { const lexer = new Lexer(new Source('0xx')); lexer.advance(); // skip SOF @@ -464,28 +540,28 @@ describe('lexer', () => { ); }); - it('string literal', () => { + test('string literal', () => { const lexer = new Lexer(new Source('"asdf')); lexer.advance(); // skip SOF expect(() => lexer.advance()).toThrow('Unexpected EOF'); }); - it('block string literal', () => { + test('block string literal', () => { const lexer = new Lexer(new Source("'''asdf''")); lexer.advance(); // skip SOF expect(() => lexer.advance()).toThrow('Unexpected EOF'); }); - it('string escape sequence', () => { + test('string escape sequence', () => { const lexer = new Lexer(new Source('"asdf \\x"')); lexer.advance(); // skip SOF expect(() => lexer.advance()).toThrow('Invalid escape sequence'); }); - it('identifiers starting with a number', () => { + test('identifiers starting with a number', () => { const lexer = new Lexer(new Source('1ident')); lexer.advance(); // SOF expect(lexer.advance()).toHaveTokenData({ @@ -497,5 +573,25 @@ describe('lexer', () => { identifier: 'ident', }); }); + + test('Jessie non-expression with expression context', () => { + const lexer = new Lexer(new Source('var f = 1 ;')); + + lexer.advance(); // SOF + + expect(() => + lexer.advance(LexerContext.JESSIE_SCRIPT_EXPRESSION) + ).toThrowError('Expression expected.'); + }); + + test('non-Jessie construct in jessie context', () => { + const lexer = new Lexer(new Source('(function() {})() }')); + + lexer.advance(); // SOF + + expect(() => + lexer.advance(LexerContext.JESSIE_SCRIPT_EXPRESSION) + ).toThrowError('FunctionExpression construct is not supported'); + }); }); }); diff --git a/src/language/lexer/lexer.ts b/src/language/lexer/lexer.ts index 1edf24c..5a6442e 100644 --- a/src/language/lexer/lexer.ts +++ b/src/language/lexer/lexer.ts @@ -1,7 +1,15 @@ -import { SyntaxError } from '../error'; +import { SyntaxError, SyntaxErrorCategory } from '../error'; import { Source } from '../source'; -import * as rules from './rules'; -import { LexerToken, LexerTokenKind } from './token'; +import { tryParseDefault } from './sublexer/default'; +import { tryParseJessieScriptExpression } from './sublexer/jessie'; +import { ParseResult } from './sublexer/result'; +import { + DefaultSublexerTokenData, + JessieSublexerTokenData, + LexerToken, + LexerTokenData, + LexerTokenKind, +} from './token'; import * as util from './util'; export type LexerTokenKindFilter = { [K in LexerTokenKind]: boolean }; @@ -12,8 +20,30 @@ export const DEFAULT_TOKEN_KIND_FILER: LexerTokenKindFilter = { [LexerTokenKind.OPERATOR]: false, [LexerTokenKind.SEPARATOR]: false, [LexerTokenKind.STRING]: false, + [LexerTokenKind.JESSIE_SCRIPT]: false, }; +export const enum LexerContext { + /** + * Default lexer context for parsing the profile and map languages. + */ + DEFAULT, + /** + * Lexer context for parsing Jessie script expressions. + */ + JESSIE_SCRIPT_EXPRESSION, +} +export type Sublexer = ( + slice: string +) => ParseResult>; +export type SublexerReturnType< + C extends LexerContext +> = C extends LexerContext.DEFAULT + ? DefaultSublexerTokenData + : C extends LexerContext.JESSIE_SCRIPT_EXPRESSION + ? JessieSublexerTokenData + : never; + /** * Lexer tokenizes input string into tokens. * @@ -24,19 +54,33 @@ export const DEFAULT_TOKEN_KIND_FILER: LexerTokenKindFilter = { * * An optional `tokenKindFilter` parameter can be provided to filter * the tokens returned by `advance` and `lookahead`. By default, this filter skips comment nodes. + * + * The advance function also accepts an optional `context` parameter which can be used to control the lexer context + * for the next token. */ export class Lexer { + private readonly sublexers: { + [C in LexerContext]: Sublexer; + }; + + /** Last emitted token. */ private currentToken: LexerToken; + /** Next token after `currentToken`, stored when `lookahead` is called. */ private nextToken: LexerToken | undefined; /** Indexed from 1 */ private currentLine: number; - // Character offset in the source.body at which current line begins. + /** Character offset in the `source.body` at which current line begins. */ private currentLineStart: number; private readonly tokenKindFilter: LexerTokenKindFilter; constructor(readonly source: Source, tokenKindFilter?: LexerTokenKindFilter) { + this.sublexers = { + [LexerContext.DEFAULT]: tryParseDefault, + [LexerContext.JESSIE_SCRIPT_EXPRESSION]: tryParseJessieScriptExpression, + }; + this.currentToken = new LexerToken( { kind: LexerTokenKind.SEPARATOR, @@ -54,15 +98,15 @@ export class Lexer { } /** Advances the lexer returning the current token. */ - advance(): LexerToken { - this.currentToken = this.lookahead(); + advance(context?: LexerContext): LexerToken { + this.currentToken = this.lookahead(context); this.nextToken = undefined; return this.currentToken; } /** Returns the next token without advancing the lexer. */ - lookahead(): LexerToken { + lookahead(context?: LexerContext): LexerToken { // EOF forever if (this.currentToken.isEOF()) { return this.currentToken; @@ -70,11 +114,11 @@ export class Lexer { // read next token if not read already if (this.nextToken === undefined) { - this.nextToken = this.readNextToken(this.currentToken); + this.nextToken = this.readNextToken(this.currentToken, context); } // skip tokens if they are caught by the filter while (this.tokenKindFilter[this.nextToken.data.kind]) { - this.nextToken = this.readNextToken(this.nextToken); + this.nextToken = this.readNextToken(this.nextToken, context); } return this.nextToken; @@ -85,21 +129,29 @@ export class Lexer { * * The generator yields the result of `advance()` until `EOF` token is found, at which point it returns the `EOF` token. */ - [Symbol.iterator](): Generator { - // This rule is intended to catch assigning this to a variable when an arrow function would suffice + [Symbol.iterator](): Generator< + LexerToken, + undefined, + LexerContext | undefined + > { + // This rule is intended to catch assigning `this` to a variable when an arrow function would suffice // Generators cannot be defined using an arrow function and thus don't preserve `this` // eslint-disable-next-line @typescript-eslint/no-this-alias const lexer = this; - function* generatorClosure(): Generator { - let currentToken = lexer.advance(); + function* generatorClosure(): Generator< + LexerToken, + undefined, + LexerContext | undefined + > { + let currentToken = lexer.advance(); // No way to specify context for the first invocation. while (!currentToken.isEOF()) { - yield currentToken; - currentToken = lexer.advance(); + const context = yield currentToken; + currentToken = lexer.advance(context); } - // Yield EOF one last time + // Yield the EOF once yield currentToken; return undefined; @@ -109,7 +161,10 @@ export class Lexer { } /** Reads the next token following the `afterToken`. */ - private readNextToken(afterToken: LexerToken): LexerToken { + private readNextToken( + afterToken: LexerToken, + context?: LexerContext + ): LexerToken { // Compute the start of the next token by ignoring whitespace after last token. const start = afterToken.span.end + @@ -121,13 +176,19 @@ export class Lexer { const slice = this.source.body.slice(start); - const tokenParseResult = - rules.tryParseSeparator(slice) ?? - rules.tryParseOperator(slice) ?? - rules.tryParseLiteral(slice) ?? - rules.tryParseStringLiteral(slice) ?? - rules.tryParseIdentifier(slice) ?? - rules.tryParseComment(slice); + // Call one of the sublexers + let tokenParseResult: ParseResult; + switch (context ?? LexerContext.DEFAULT) { + case LexerContext.DEFAULT: + tokenParseResult = this.sublexers[LexerContext.DEFAULT](slice); + break; + + case LexerContext.JESSIE_SCRIPT_EXPRESSION: + tokenParseResult = this.sublexers[ + LexerContext.JESSIE_SCRIPT_EXPRESSION + ](slice); + break; + } // Didn't parse as any known token if (tokenParseResult === undefined) { @@ -135,36 +196,37 @@ export class Lexer { this.source, location, { start, end: start }, + SyntaxErrorCategory.LEXER, 'Could not match any token' ); } + const parsedTokenSpan = { + start: start + tokenParseResult.relativeSpan.start, + end: start + tokenParseResult.relativeSpan.end, + }; + // Parsing error - if (tokenParseResult instanceof rules.ParseError) { + if (tokenParseResult.isError) { throw new SyntaxError( this.source, location, - { - start: start + tokenParseResult.span.start, - end: start + tokenParseResult.span.end, - }, - tokenParseResult.detail + parsedTokenSpan, + tokenParseResult.category, + tokenParseResult.detail, + tokenParseResult.hint ); } // Go over the characters the token covers and count newlines, updating the state. this.countStartingWithNewlines( _ => true, - start, - start + tokenParseResult[1] + parsedTokenSpan.start, + parsedTokenSpan.end ); // All is well - return new LexerToken( - tokenParseResult[0], - { start, end: start + tokenParseResult[1] }, - location - ); + return new LexerToken(tokenParseResult.data, parsedTokenSpan, location); } /** diff --git a/src/language/lexer/sublexer/default/glue.ts b/src/language/lexer/sublexer/default/glue.ts new file mode 100644 index 0000000..bbc59d8 --- /dev/null +++ b/src/language/lexer/sublexer/default/glue.ts @@ -0,0 +1,23 @@ +import { DefaultSublexerTokenData } from '../../token'; +import { ParseResult } from '../result'; +import { + tryParseComment, + tryParseIdentifier, + tryParseLiteral, + tryParseOperator, + tryParseSeparator, +} from './rules'; +import { tryParseStringLiteral } from './string'; + +export function tryParseDefault( + slice: string +): ParseResult { + return ( + tryParseSeparator(slice) ?? + tryParseOperator(slice) ?? + tryParseLiteral(slice) ?? + tryParseStringLiteral(slice) ?? + tryParseIdentifier(slice) ?? + tryParseComment(slice) + ); +} diff --git a/src/language/lexer/rules/index.ts b/src/language/lexer/sublexer/default/index.ts similarity index 57% rename from src/language/lexer/rules/index.ts rename to src/language/lexer/sublexer/default/index.ts index 4de2caf..d36b4aa 100644 --- a/src/language/lexer/rules/index.ts +++ b/src/language/lexer/sublexer/default/index.ts @@ -4,6 +4,8 @@ export { tryParseLiteral, tryParseOperator, tryParseSeparator, - ParseError, } from './rules'; -export * from './string'; + +export { tryParseStringLiteral } from './string'; + +export { tryParseDefault } from './glue'; diff --git a/src/language/lexer/rules/rules.ts b/src/language/lexer/sublexer/default/rules.ts similarity index 80% rename from src/language/lexer/rules/rules.ts rename to src/language/lexer/sublexer/default/rules.ts index b4ba6b7..6246a5d 100644 --- a/src/language/lexer/rules/rules.ts +++ b/src/language/lexer/sublexer/default/rules.ts @@ -1,9 +1,8 @@ -import { Span } from '../../source'; +import { SyntaxErrorCategory } from '../../../error'; import { CommentTokenData, IdentifierTokenData, LexerScanRule, - LexerTokenData, LexerTokenKind, LITERALS_BOOL, LiteralTokenData, @@ -11,24 +10,9 @@ import { OperatorTokenData, SEPARATORS, SeparatorTokenData, -} from '../token'; -import * as util from '../util'; - -/** Error returned internally by the lexer `tryParse*` methods. */ -export class ParseError { - constructor( - /** Kind of the errored token. */ - readonly kind: LexerTokenKind, - /** Span of the errored token. */ - readonly span: Span, - /** Optional detail message. */ - readonly detail?: string - ) {} -} - -export type ParseResult = - | ([T, number] | undefined) - | ParseError; +} from '../../token'; +import * as util from '../../util'; +import { ParseResult } from '../result'; function tryParseScannerRules( slice: string, @@ -55,13 +39,14 @@ export function tryParseSeparator( ): ParseResult { // Handle EOF if (slice.length === 0) { - return [ - { + return { + isError: false, + data: { kind: LexerTokenKind.SEPARATOR, separator: 'EOF', }, - 0, - ]; + relativeSpan: { start: 0, end: 0 }, + }; } const parsed = tryParseScannerRules(slice, SEPARATORS); @@ -69,13 +54,14 @@ export function tryParseSeparator( return undefined; } - return [ - { + return { + isError: false, + data: { kind: LexerTokenKind.SEPARATOR, separator: parsed.value, }, - parsed.length, - ]; + relativeSpan: { start: 0, end: parsed.length }, + }; } /** @@ -91,13 +77,14 @@ export function tryParseOperator( return undefined; } - return [ - { + return { + isError: false, + data: { kind: LexerTokenKind.OPERATOR, operator: parsed.value, }, - parsed.length, - ]; + relativeSpan: { start: 0, end: parsed.length }, + }; } function tryParseLiteralBoolean(slice: string): ParseResult { @@ -106,13 +93,14 @@ function tryParseLiteralBoolean(slice: string): ParseResult { return undefined; } - return [ - { + return { + isError: false, + data: { kind: LexerTokenKind.LITERAL, literal: parsed.value, }, - parsed.length, - ]; + relativeSpan: { start: 0, end: parsed.length }, + }; } function tryParseLiteralNumber(slice: string): ParseResult { @@ -138,11 +126,13 @@ function tryParseLiteralNumber(slice: string): ParseResult { ); if (startingNumbers === 0) { if (keywordLiteralBase.value !== 10) { - return new ParseError( - LexerTokenKind.LITERAL, - { start: 0, end: keywordLiteralBase.length + 1 }, - 'Expected a number following integer base prefix' - ); + return { + isError: true, + kind: LexerTokenKind.LITERAL, + detail: 'Expected a number following integer base prefix', + category: SyntaxErrorCategory.LEXER, + relativeSpan: { start: 0, end: keywordLiteralBase.length + 1 }, + }; } else { return undefined; } @@ -177,13 +167,17 @@ function tryParseLiteralNumber(slice: string): ParseResult { throw 'Invalid lexer state. This in an error in the lexer.'; } - return [ - { + return { + isError: false, + data: { kind: LexerTokenKind.LITERAL, literal: numberValue, }, - keywordLiteralBase.length + numberLiteralLength, - ]; + relativeSpan: { + start: 0, + end: keywordLiteralBase.length + numberLiteralLength, + }, + }; } /** @@ -214,13 +208,14 @@ export function tryParseIdentifier( return undefined; } - return [ - { + return { + isError: false, + data: { kind: LexerTokenKind.IDENTIFIER, identifier: slice.slice(0, identLength), }, - identLength, - ]; + relativeSpan: { start: 0, end: identLength }, + }; } /** @@ -236,13 +231,14 @@ export function tryParseComment(slice: string): ParseResult { commentSlice ); - return [ - { + return { + isError: false, + data: { kind: LexerTokenKind.COMMENT, comment: commentSlice.slice(0, length), }, - length + 1, - ]; + relativeSpan: { start: 0, end: length + 1 }, + }; } else { return undefined; } diff --git a/src/language/lexer/rules/string.ts b/src/language/lexer/sublexer/default/string.ts similarity index 85% rename from src/language/lexer/rules/string.ts rename to src/language/lexer/sublexer/default/string.ts index 9983f0c..c87a49c 100644 --- a/src/language/lexer/rules/string.ts +++ b/src/language/lexer/sublexer/default/string.ts @@ -1,6 +1,7 @@ -import { LexerTokenKind, StringTokenData } from '../token'; -import * as util from '../util'; -import { ParseError, ParseResult } from './rules'; +import { SyntaxErrorCategory } from '../../../error'; +import { LexerTokenKind, StringTokenData } from '../../token'; +import * as util from '../../util'; +import { ParseResult } from '../result'; function resolveStringLiteralEscape( slice: string @@ -131,23 +132,25 @@ export function tryParseStringLiteral( // Special case where the string is empty ('' or "") if (startingQuoteChars === 2) { - return [ - { + return { + isError: false, + data: { kind: LexerTokenKind.STRING, string: '', }, - 2, - ]; + relativeSpan: { start: 0, end: 2 }, + }; } // Special case where a triple-quoted string is empty ('''''' or """""") if (startingQuoteChars >= 6) { - return [ - { + return { + isError: false, + data: { kind: LexerTokenKind.STRING, string: '', }, - 6, - ]; + relativeSpan: { start: 0, end: 6 }, + }; } // In case there are 4 or 5 quote chars in row, we treat the 4th and 5th as part of the string itself. @@ -192,22 +195,26 @@ export function tryParseStringLiteral( // * EOF const nextChar = restSlice.charCodeAt(0); if (isNaN(nextChar)) { - return new ParseError( - LexerTokenKind.STRING, - { start: 0, end: eatenChars }, - 'Unexpected EOF' - ); + return { + isError: true, + kind: LexerTokenKind.STRING, + relativeSpan: { start: 0, end: eatenChars }, + detail: 'Unexpected EOF', + category: SyntaxErrorCategory.LEXER, + }; } else if (util.isStringLiteralEscapeChar(nextChar)) { // Eat the backslash eatChars(1, false); const escapeResult = resolveStringLiteralEscape(restSlice); if (escapeResult === undefined) { - return new ParseError( - LexerTokenKind.STRING, - { start: 0, end: eatenChars + 1 }, - 'Invalid escape sequence' - ); + return { + isError: true, + kind: LexerTokenKind.STRING, + relativeSpan: { start: 0, end: eatenChars + 1 }, + detail: 'Invalid escape sequence', + category: SyntaxErrorCategory.LEXER, + }; } eatChars(escapeResult.length, escapeResult.value); @@ -236,11 +243,12 @@ export function tryParseStringLiteral( resultString = transformBlockStringValue(resultString); } - return [ - { + return { + isError: false, + data: { kind: LexerTokenKind.STRING, string: resultString, }, - eatenChars, - ]; + relativeSpan: { start: 0, end: eatenChars }, + }; } diff --git a/src/language/lexer/sublexer/jessie/expression.ts b/src/language/lexer/sublexer/jessie/expression.ts new file mode 100644 index 0000000..e99b733 --- /dev/null +++ b/src/language/lexer/sublexer/jessie/expression.ts @@ -0,0 +1,109 @@ +import * as ts from 'typescript'; + +import { SyntaxErrorCategory } from '../../../error'; +import { validateAndTranspile } from '../../../jessie'; +import { JessieSublexerTokenData, LexerTokenKind } from '../../token'; +import { ParseResult } from '../result'; + +// Static SCANNER to avoid reinitializing it, same thing is done inside TS library +const SCANNER = ts.createScanner( + ts.ScriptTarget.Latest, + true, + ts.LanguageVariant.Standard +); + +export function tryParseJessieScriptExpression( + slice: string +): ParseResult { + // Set the scanner text thus reusing the old scanner instance + SCANNER.setText(slice); + + // Counts the number of open (, [ and { pairs + let depthCounter = 0; + // Stores position after last valid token + let lastTokenEnd = 0; + for (;;) { + // Termination checks + const token = SCANNER.scan(); + + // Look ahead for a semicolon, } or ( + if ( + depthCounter === 0 && + (token === ts.SyntaxKind.SemicolonToken || + token === ts.SyntaxKind.CloseBraceToken || + token === ts.SyntaxKind.CloseParenToken) + ) { + break; + } + + // Unexpected EOF + if (token === ts.SyntaxKind.EndOfFileToken) { + return { + isError: true, + kind: LexerTokenKind.JESSIE_SCRIPT, + relativeSpan: { start: 0, end: lastTokenEnd }, + detail: 'Unexpected EOF', + category: SyntaxErrorCategory.JESSIE_SYNTAX, + }; + } + + lastTokenEnd = SCANNER.getTextPos(); + // Count bracket depth + switch (token) { + case ts.SyntaxKind.OpenBraceToken: // { + case ts.SyntaxKind.OpenBracketToken: // [ + case ts.SyntaxKind.OpenParenToken: // ( + depthCounter += 1; + break; + + case ts.SyntaxKind.CloseBraceToken: // } + case ts.SyntaxKind.CloseBracketToken: // ] + case ts.SyntaxKind.CloseParenToken: // ) + depthCounter -= 1; + break; + + // Ignore others + } + } + const scriptText = slice.slice(0, lastTokenEnd); + + // Diagnose the script text, but put it in a position where an expression would be required + const SCRIPT_WRAP = { + start: 'let x = ', + end: ';', + transpiled: { + start: 'var x = ', + end: ';', + }, + }; + + const transRes = validateAndTranspile( + SCRIPT_WRAP.start + scriptText + SCRIPT_WRAP.end + ); + if (!('category' in transRes)) { + return { + isError: false, + data: { + kind: LexerTokenKind.JESSIE_SCRIPT, + script: transRes.output.slice( + SCRIPT_WRAP.transpiled.start.length, + transRes.output.length - SCRIPT_WRAP.transpiled.end.length + ), + sourceMap: transRes.sourceMap, + }, + relativeSpan: { start: 0, end: scriptText.length }, + }; + } else { + return { + isError: true, + kind: LexerTokenKind.JESSIE_SCRIPT, + detail: transRes.detail, + hint: transRes.hint, + category: transRes.category, + relativeSpan: { + start: transRes.relativeSpan.start - SCRIPT_WRAP.start.length, + end: transRes.relativeSpan.end - SCRIPT_WRAP.start.length, + }, + }; + } +} diff --git a/src/language/lexer/sublexer/jessie/index.ts b/src/language/lexer/sublexer/jessie/index.ts new file mode 100644 index 0000000..0f7bb34 --- /dev/null +++ b/src/language/lexer/sublexer/jessie/index.ts @@ -0,0 +1 @@ +export { tryParseJessieScriptExpression } from './expression'; diff --git a/src/language/lexer/sublexer/result.ts b/src/language/lexer/sublexer/result.ts new file mode 100644 index 0000000..4650aa7 --- /dev/null +++ b/src/language/lexer/sublexer/result.ts @@ -0,0 +1,19 @@ +import { ProtoError } from '../../error'; +import { Span } from '../../source'; +import { LexerTokenData, LexerTokenKind } from '../token'; + +type ParseResultMatch = { + readonly isError: false; + readonly data: T; + readonly relativeSpan: Span; +}; +type ParseResultNomatch = undefined; +type ParseResultError = ProtoError & { + readonly isError: true; + readonly kind: LexerTokenKind; +}; + +export type ParseResult = + | ParseResultMatch + | ParseResultNomatch + | ParseResultError; diff --git a/src/language/lexer/token.ts b/src/language/lexer/token.ts index b4823ff..6bb2cd8 100644 --- a/src/language/lexer/token.ts +++ b/src/language/lexer/token.ts @@ -4,11 +4,12 @@ import * as util from './util'; /** Enum describing the different kinds of tokens that the lexer emits. */ export const enum LexerTokenKind { SEPARATOR, // SOF/EOF, (), [], {} - OPERATOR, // :, !, +, -, |, =, @, ,, \n + OPERATOR, // :, !, +, -, |, =, @, ,, ; LITERAL, // number or boolean STRING, // string literals - separate because it makes later stages easier IDENTIFIER, // a-z A-Z _ 0-9 COMMENT, // line comments (# foo) + JESSIE_SCRIPT, // Jessie script } export type LexerScanRule = [T, (_: number) => boolean]; @@ -35,7 +36,7 @@ export const SEPARATORS: { }; // Operators -export type OperatorValue = ':' | '+' | '-' | '!' | '|' | '=' | '@' | ','; +export type OperatorValue = ':' | '+' | '-' | '!' | '|' | '=' | '@' | ',' | ';'; export const OPERATORS: { [P in OperatorValue]: LexerScanRule

} = { ':': [':', util.isAny], '+': ['+', util.isAny], @@ -45,6 +46,7 @@ export const OPERATORS: { [P in OperatorValue]: LexerScanRule

} = { '=': ['=', util.isAny], '@': ['@', util.isAny], ',': [',', util.isAny], + ';': [';', util.isAny], }; // Literals @@ -58,6 +60,8 @@ export type StringValue = string; export type IdentifierValue = string; export type CommentValue = string; +export type JessieScriptValue = string; + // Token datas // export interface SeparatorTokenData { @@ -84,14 +88,22 @@ export interface CommentTokenData { kind: LexerTokenKind.COMMENT; comment: CommentValue; } +export interface JessieScriptTokenData { + kind: LexerTokenKind.JESSIE_SCRIPT; + script: JessieScriptValue; + sourceMap: string; +} -export type LexerTokenData = +export type DefaultSublexerTokenData = | SeparatorTokenData | OperatorTokenData | LiteralTokenData | StringTokenData | IdentifierTokenData | CommentTokenData; +export type JessieSublexerTokenData = JessieScriptTokenData; + +export type LexerTokenData = DefaultSublexerTokenData | JessieSublexerTokenData; export function formatTokenKind(kind: LexerTokenKind): string { switch (kind) { @@ -107,24 +119,29 @@ export function formatTokenKind(kind: LexerTokenKind): string { return 'identifier'; case LexerTokenKind.COMMENT: return 'comment'; + case LexerTokenKind.JESSIE_SCRIPT: + return 'jessie script'; } } export function formatTokenData( data: LexerTokenData ): { kind: string; data: string } { + const kind = formatTokenKind(data.kind); switch (data.kind) { case LexerTokenKind.SEPARATOR: - return { kind: 'separator', data: data.separator.toString() }; + return { kind, data: data.separator.toString() }; case LexerTokenKind.OPERATOR: - return { kind: 'operator', data: data.operator.toString() }; + return { kind, data: data.operator.toString() }; case LexerTokenKind.LITERAL: - return { kind: 'literal', data: data.literal.toString() }; + return { kind, data: data.literal.toString() }; case LexerTokenKind.STRING: - return { kind: 'string', data: data.string.toString() }; + return { kind, data: data.string.toString() }; case LexerTokenKind.IDENTIFIER: - return { kind: 'identifier', data: data.identifier.toString() }; + return { kind, data: data.identifier.toString() }; case LexerTokenKind.COMMENT: - return { kind: 'comment', data: data.comment.toString() }; + return { kind, data: data.comment.toString() }; + case LexerTokenKind.JESSIE_SCRIPT: + return { kind, data: data.script.toString() }; } } diff --git a/src/transpiler/ScriptTranspiler.test.ts b/src/transpiler/ScriptTranspiler.test.ts deleted file mode 100644 index 83fe0e9..0000000 --- a/src/transpiler/ScriptTranspiler.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as st from './ScriptTranspiler'; - -test('transpiler debug', () => { - const { output, sourceMap } = st.transpileScript( - `let a = { hello: 1, world: 2 + "3" } -console.log(a)` - ); - - expect(output).toBe( - `var a = { hello: 1, world: 2 + "3" }; -console.log(a);` - ); - - expect(sourceMap).toBe( - 'AAAA,IAAI,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,GAAG,EAAE,CAAA;AACpC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAA' - ); -}); - -test('transpiler ES2020', () => { - const { output, sourceMap } = st.transpileScript( - `let nullishCoalescing = undefined ?? (false ?? "truthy") - let optionalChaining = console?.log?.(nullishCoalescing)` - ); - - expect(output).toBe( - `var _a, _b; -var nullishCoalescing = undefined !== null && undefined !== void 0 ? undefined : ((_a = false) !== null && _a !== void 0 ? _a : "truthy"); -var optionalChaining = (_b = console === null || console === void 0 ? void 0 : console.log) === null || _b === void 0 ? void 0 : _b.call(console, nullishCoalescing);` - ); - - expect(sourceMap).toBe( - ';AAAA,IAAI,iBAAiB,GAAG,SAAS,aAAT,SAAS,cAAT,SAAS,GAAI,OAAC,KAAK,mCAAI,QAAQ,CAAC,CAAA;AACtD,IAAI,gBAAgB,SAAG,OAAO,aAAP,OAAO,uBAAP,OAAO,CAAE,GAAG,+CAAZ,OAAO,EAAQ,iBAAiB,CAAC,CAAA' - ); -}); diff --git a/src/transpiler/ScriptTranspiler.ts b/src/transpiler/ScriptTranspiler.ts deleted file mode 100644 index dcc9429..0000000 --- a/src/transpiler/ScriptTranspiler.ts +++ /dev/null @@ -1,65 +0,0 @@ -import * as ts from 'typescript'; - -const SCRIPT_OUTPUT_TARGET = ts.ScriptTarget.ES3; - -const AFTER_TRANSFORMERS: ts.TransformerFactory[] = [ - function dummyTransformerFactory(context: ts.TransformationContext) { - return (root: ts.SourceFile): ts.SourceFile => { - let stringTree = ''; - - function dummyVisitor(depth: number, node: ts.Node): ts.Node { - // Recursively log kind of each node - let depthIndent = ''; - for (let index = 0; index < depth; index++) { - depthIndent += '\t'; - } - stringTree += `${depthIndent}${ts.SyntaxKind[node.kind]}\n`; - - ts.visitEachChild(node, dummyVisitor.bind(null, depth + 1), context); - - // Return the unmodified node. - return node; - } - - // The transformer will visit the root node and the visitor will drive the recursion. - const result = ts.visitNode(root, dummyVisitor.bind(null, 0)); - if (process.env.LOG_LEVEL === 'debug') { - console.debug(stringTree); - } - - return result; - }; - }, -]; - -export function transpileScript( - input: string -): { output: string; sourceMap: string } { - // This will transpile the code, generate a source map and run transformers - const { outputText, sourceMapText } = ts.transpileModule(input, { - compilerOptions: { - allowJs: true, - target: SCRIPT_OUTPUT_TARGET, - sourceMap: true, - }, - transformers: { - after: AFTER_TRANSFORMERS, - }, - }); - - // Strip the source mapping comment from the end of the output - const outputTextStripped = outputText - .replace('//# sourceMappingURL=module.js.map', '') - .trimRight(); - - // `sourceMapText` will be here because we requested it by setting the compiler flag - if (!sourceMapText) { - throw 'Source map text is not present'; - } - const sourceMapJson: { mappings: string } = JSON.parse(sourceMapText); - - return { - output: outputTextStripped, - sourceMap: sourceMapJson.mappings, - }; -} diff --git a/src/validator/ScriptValidator.ts b/src/validator/ScriptValidator.ts deleted file mode 100644 index cb796c6..0000000 --- a/src/validator/ScriptValidator.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* eslint-disable no-fallthrough */ -import * as ts from 'typescript'; - -import { ALLOWED_SYNTAX, FORBIDDEN_CONSTRUCTS } from './constructs'; - -export enum ValidationErrorType { - ForbiddenConstruct, -} - -export interface ValidationError { - message: string; - hint?: string; - type: ValidationErrorType; - position: { - start: number; - end: number; - }; -} - -export class ScriptValidationReport { - errors: ValidationError[] = []; - - public get isValid(): boolean { - return this.errors.length === 0; - } - - public addError( - error: string, - errorType: ValidationErrorType, - node: ts.Node, - hint?: string - ): void { - this.errors.push({ - message: error, - type: errorType, - hint: hint, - position: { - start: node.pos, - end: node.end, - }, - }); - } -} - -export function validateScript(input: string): ScriptValidationReport { - const report = new ScriptValidationReport(); - - const rootNode = ts.createSourceFile( - 'scripts.js', - input, - ts.ScriptTarget.ES2015, - true, - ts.ScriptKind.JS - ); - - let debugTree = ''; - let debugDepth = 0; - function nodeVisitor(node: T): void { - const nodeCode = input - .substring(node.pos, node.end) - .replace('\r\n', ' ') - .trim(); - const treeIndent = ''.padStart(debugDepth, '\t'); - debugTree += `${treeIndent}NODE ${ts.SyntaxKind[node.kind]} "${nodeCode}"`; - - // Go over forbidden constructs and check if any of them applies - let anyRuleBroken = false; - const rules = FORBIDDEN_CONSTRUCTS[node.kind] ?? []; - for (const rule of rules) { - if (rule.predicate?.(node) ?? true) { - anyRuleBroken = true; - - report.addError( - `${ts.SyntaxKind[node.kind]} construct is not supported`, - ValidationErrorType.ForbiddenConstruct, - node, - rule.hint(input, node) - ); - } - } - if (anyRuleBroken) { - debugTree += ' [R]'; - } - - // If none of the rules applied, but the syntax is not valid anyway, add an error without a hint - if (!anyRuleBroken && !ALLOWED_SYNTAX.includes(node.kind)) { - debugTree += ' [S]'; - - report.addError( - `${ts.SyntaxKind[node.kind]} construct is not supported`, - ValidationErrorType.ForbiddenConstruct, - node - ); - } - - debugTree += '\n'; - - // Recurse into children - debugDepth += 1; - ts.forEachChild(node, nodeVisitor); - debugDepth -= 1; - } - nodeVisitor(rootNode); - if (process.env.LOG_LEVEL === 'debug') { - console.debug(debugTree); - } - - return report; -}