diff --git a/etc/aiscript.api.md b/etc/aiscript.api.md index ed176ac7..32feb6b9 100644 --- a/etc/aiscript.api.md +++ b/etc/aiscript.api.md @@ -92,6 +92,12 @@ class AiScriptUserError extends AiScriptRuntimeError { name: string; } +// @public (undocumented) +export class AiSON { + // (undocumented) + static parse(input: string): JsValue; +} + // @public (undocumented) type And = NodeBase & { type: 'and'; @@ -880,7 +886,7 @@ type VUserFn = VFnBase & { // Warnings were encountered during analysis: // -// src/interpreter/index.ts:48:4 - (ae-forgotten-export) The symbol "LogObject" needs to be exported by the entry point index.d.ts +// src/interpreter/index.ts:49:4 - (ae-forgotten-export) The symbol "LogObject" needs to be exported by the entry point index.d.ts // src/interpreter/value.ts:47:2 - (ae-forgotten-export) The symbol "Type" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/index.ts b/src/index.ts index a73251a2..ee600c18 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { Scope } from './interpreter/scope.js'; import * as utils from './interpreter/util.js'; import * as values from './interpreter/value.js'; import { Parser } from './parser/index.js'; +import { AiSON } from './parser/aison.js'; import * as errors from './error.js'; import * as Ast from './node.js'; import { AISCRIPT_VERSION } from './constants.js'; @@ -18,6 +19,7 @@ export { values }; export { Parser }; export { ParserPlugin }; export { PluginType }; +export { AiSON }; export { errors }; export { Ast }; export { AISCRIPT_VERSION }; diff --git a/src/interpreter/index.ts b/src/interpreter/index.ts index acc12d21..af63970e 100644 --- a/src/interpreter/index.ts +++ b/src/interpreter/index.ts @@ -5,6 +5,7 @@ import { autobind } from '../utils/mini-autobind.js'; import { AiScriptError, NonAiScriptError, AiScriptNamespaceError, AiScriptIndexOutOfRangeError, AiScriptRuntimeError, AiScriptHostsideError } from '../error.js'; import * as Ast from '../node.js'; +import { nodeToJs } from '../utils/node-to-js.js'; import { Scope } from './scope.js'; import { std } from './lib/std.js'; import { RETURN, unWrapRet, BREAK, CONTINUE, assertValue, isControl, type Control } from './control.js'; @@ -146,25 +147,6 @@ export class Interpreter { public static collectMetadata(script?: Ast.Node[]): Map | undefined { if (script == null || script.length === 0) return; - function nodeToJs(node: Ast.Node): JsValue { - switch (node.type) { - case 'arr': return node.value.map(item => nodeToJs(item)); - case 'bool': return node.value; - case 'null': return null; - case 'num': return node.value; - case 'obj': { - const obj: { [keys: string]: JsValue } = {}; - for (const [k, v] of node.value.entries()) { - // TODO: keyが__proto__とかじゃないかチェック - obj[k] = nodeToJs(v); - } - return obj; - } - case 'str': return node.value; - default: return undefined; - } - } - const meta = new Map(); for (const node of script) { diff --git a/src/parser/aison.ts b/src/parser/aison.ts new file mode 100644 index 00000000..7ca9a368 --- /dev/null +++ b/src/parser/aison.ts @@ -0,0 +1,16 @@ +/** + * AiSON: AiScript Object Notation + */ +import type { JsValue } from '../interpreter/util.js'; +import { nodeToJs } from '../utils/node-to-js.js'; +import { Scanner } from './scanner.js'; +import { parseAiSonTopLevel } from './syntaxes/aison.js'; + +export class AiSON { + public static parse(input: string): JsValue { + const scanner = new Scanner(input); + const ast = parseAiSonTopLevel(scanner); + + return nodeToJs(ast); + } +} diff --git a/src/parser/syntaxes/aison.ts b/src/parser/syntaxes/aison.ts new file mode 100644 index 00000000..8fa462e2 --- /dev/null +++ b/src/parser/syntaxes/aison.ts @@ -0,0 +1,44 @@ +import { TokenKind } from '../token.js'; +import { AiScriptSyntaxError } from '../../error.js'; +import { parseExpr } from './expressions.js'; +import type * as Ast from '../../node.js'; +import type { ITokenStream } from '../streams/token-stream.js'; + +export function parseAiSonTopLevel(s: ITokenStream): Ast.Node { + let node: Ast.Node | null = null; + + while (s.is(TokenKind.NewLine)) { + s.next(); + } + + while (!s.is(TokenKind.EOF)) { + if (node == null) { + node = parseExpr(s, true); + } else { + throw new AiScriptSyntaxError('AiSON only supports one top-level expression.', s.getPos()); + } + + // terminator + switch (s.getTokenKind()) { + case TokenKind.NewLine: + case TokenKind.SemiColon: { + while (s.is(TokenKind.NewLine) || s.is(TokenKind.SemiColon)) { + s.next(); + } + break; + } + case TokenKind.EOF: { + break; + } + default: { + throw new AiScriptSyntaxError('Multiple statements cannot be placed on a single line.', s.getPos()); + } + } + } + + if (node == null) { + throw new AiScriptSyntaxError('AiSON requires at least one top-level expression.', s.getPos()); + } + + return node; +} diff --git a/src/utils/node-to-js.ts b/src/utils/node-to-js.ts new file mode 100644 index 00000000..6ec5911f --- /dev/null +++ b/src/utils/node-to-js.ts @@ -0,0 +1,21 @@ +import type { JsValue } from '../interpreter/util.js'; +import type * as Ast from '../node.js'; + +export function nodeToJs(node: Ast.Node): JsValue { + switch (node.type) { + case 'arr': return node.value.map(item => nodeToJs(item)); + case 'bool': return node.value; + case 'null': return null; + case 'num': return node.value; + case 'obj': { + const obj: { [keys: string]: JsValue } = {}; + for (const [k, v] of node.value.entries()) { + // TODO: keyが__proto__とかじゃないかチェック + obj[k] = nodeToJs(v); + } + return obj; + } + case 'str': return node.value; + default: return undefined; + } +} diff --git a/test/aison.ts b/test/aison.ts new file mode 100644 index 00000000..97337530 --- /dev/null +++ b/test/aison.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'vitest'; +import { AiSON } from '../src/parser/aison'; + +describe('parse', () => { + test.concurrent('str', () => { + expect(AiSON.parse('"Ai-chan kawaii"')).toEqual('Ai-chan kawaii'); + }); + + test.concurrent('number', () => { + expect(AiSON.parse('42')).toEqual(42); + }); + + test.concurrent('bool', () => { + expect(AiSON.parse('true')).toEqual(true); + }); + + test.concurrent('null', () => { + expect(AiSON.parse('null')).toEqual(null); + }); + + test.concurrent('array', () => { + expect(AiSON.parse('[1, 2, 3]')).toEqual([1, 2, 3]); + }); + + test.concurrent('object', () => { + expect(AiSON.parse('{key: "value"}')).toEqual({ key: 'value' }); + }); + + test.concurrent('nested', () => { + expect(AiSON.parse('[{key: "value"}]')).toEqual([{ key: 'value' }]); + }); + + test.concurrent('invalid: unclosed string', () => { + expect(() => AiSON.parse('"hello')).toThrow(); + }); + + test.concurrent('invalid: unclosed array', () => { + expect(() => AiSON.parse('[1, 2, 3')).toThrow(); + }); + + test.concurrent('not allowed: empty', () => { + expect(() => AiSON.parse('')).toThrow(); + }); + + test.concurrent('not allowed: function', () => { + expect(() => AiSON.parse(`@greet() { return "hello" } + +greet()`)).toThrow(); + }); + + test.concurrent('not allowed: variable assignment', () => { + expect(() => AiSON.parse('let x = 42')).toThrow(); + }); + + test.concurrent('not allowed: namespace', () => { + expect(() => AiSON.parse(`:: Ai { + let x = 42 +}`)).toThrow(); + }); + + test.concurrent('not allowed: expression', () => { + expect(() => AiSON.parse('{key: (3 + 5)}')).toThrow(); + }); + + test.concurrent('not allowed: multiple statements (string)', () => { + expect(() => AiSON.parse(`"hello" + +"hi"`)).toThrow(); + }); + + test.concurrent('not allowed: multiple statements in the same line', () => { + expect(() => AiSON.parse('"hello" "hi"')).toThrow(); + }); + + test.concurrent('not allowed: multiple statements (object)', () => { + expect(() => AiSON.parse(`{key: "value"} + +{foo: "bar"}`)).toThrow(); + }); +}); diff --git a/unreleased/aison.md b/unreleased/aison.md new file mode 100644 index 00000000..c92f17d3 --- /dev/null +++ b/unreleased/aison.md @@ -0,0 +1,6 @@ +- For Hosts: AiScriptのオブジェクトの表記法を利用したデータ交換用フォーマット「AiScript Object Notation (AiSON)」およびそのパーサーを追加しました。 + - 現在、`AiSON.parse()`(パースしてJavaScriptオブジェクトに変換する)が使用できます。 + - 通常のAiScriptと異なるのは以下の点です: + - リテラルはトップレベルにひとつだけしか許可されません。 + - 動的な式(関数・オブジェクトのvalueに対する動的なバインディングなど)は許可されません。 + - 名前空間・メタデータなど、リテラルとコメント以外をトップレベルに書くことは許可されていません。