From cbb11627891a2f26d230ca83068c889e18305294 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sun, 5 Jan 2025 12:57:31 +0900 Subject: [PATCH 1/8] feat: AiScript Object Notation --- etc/aiscript.api.md | 8 +++- src/index.ts | 2 + src/interpreter/index.ts | 20 +--------- src/parser/aison.ts | 17 ++++++++ src/parser/syntaxes/aison.ts | 44 +++++++++++++++++++++ src/utils/node-to-js.ts | 21 ++++++++++ test/aison.ts | 76 ++++++++++++++++++++++++++++++++++++ 7 files changed, 168 insertions(+), 20 deletions(-) create mode 100644 src/parser/aison.ts create mode 100644 src/parser/syntaxes/aison.ts create mode 100644 src/utils/node-to-js.ts create mode 100644 test/aison.ts diff --git a/etc/aiscript.api.md b/etc/aiscript.api.md index ed176ac7..fa32b079 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): any; +} + // @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..1dc056ba --- /dev/null +++ b/src/parser/aison.ts @@ -0,0 +1,17 @@ +/** + * AiSON: AiScript Object Notation + */ +import { nodeToJs } from '../utils/node-to-js.js'; +import { Scanner } from './scanner.js'; +import { parseAiSonTopLevel } from './syntaxes/aison.js'; + +export class AiSON { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public static parse(input: string): any { + const scanner = new Scanner(input); + const ast = parseAiSonTopLevel(scanner); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return nodeToJs(ast) as any; + } +} 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..55db1b07 --- /dev/null +++ b/test/aison.ts @@ -0,0 +1,76 @@ +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 (object)', () => { + expect(() => AiSON.parse(`{key: "value"} + +{foo: "bar"}`)).toThrow(); + }); +}); From 1c6825d7c0a9cdd1c282b36008c6bc563ff5878f Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sun, 5 Jan 2025 13:03:26 +0900 Subject: [PATCH 2/8] Update Changelog --- unreleased/aison.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 unreleased/aison.md diff --git a/unreleased/aison.md b/unreleased/aison.md new file mode 100644 index 00000000..2a40d230 --- /dev/null +++ b/unreleased/aison.md @@ -0,0 +1,6 @@ +- AiScriptのオブジェクトの表記法を利用したデータ交換用フォーマット「AiScript Object Notation (AiSON)」およびそのパーサーを追加しました。 + - 現在、`AiSON.parse()`(パースしてJavaScriptオブジェクトに変換する)が使用できます。 + - 通常のAiScriptと異なるのは以下の点です: + - トップレベルのオブジェクトはひとつしか許可されません。 + - 動的な式(関数・オブジェクトのvalueにたいする動的なバインディングなど)は許可されません。 + - 名前空間・メタデータはサポートされていません。 From 797ad7deec8aa17dfde88bcfb2cadfacd8a748b5 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sun, 5 Jan 2025 13:30:27 +0900 Subject: [PATCH 3/8] Update Changelog --- unreleased/aison.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/unreleased/aison.md b/unreleased/aison.md index 2a40d230..35bead26 100644 --- a/unreleased/aison.md +++ b/unreleased/aison.md @@ -1,6 +1,6 @@ - AiScriptのオブジェクトの表記法を利用したデータ交換用フォーマット「AiScript Object Notation (AiSON)」およびそのパーサーを追加しました。 - 現在、`AiSON.parse()`(パースしてJavaScriptオブジェクトに変換する)が使用できます。 - 通常のAiScriptと異なるのは以下の点です: - - トップレベルのオブジェクトはひとつしか許可されません。 - - 動的な式(関数・オブジェクトのvalueにたいする動的なバインディングなど)は許可されません。 - - 名前空間・メタデータはサポートされていません。 + - リテラルはトップレベルにひとつだけしか許可されません。 + - 動的な式(関数・オブジェクトのvalueに対する動的なバインディングなど)は許可されません。 + - 名前空間・メタデータなど、リテラルとコメント以外をトップレベルに書くことは許可されていません。 From 656db10ee73c848fe9cd0243c654eaeb450cd62c Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sun, 5 Jan 2025 13:31:59 +0900 Subject: [PATCH 4/8] update test --- test/aison.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/aison.ts b/test/aison.ts index 55db1b07..0ffb4d26 100644 --- a/test/aison.ts +++ b/test/aison.ts @@ -68,6 +68,10 @@ greet()`)).toThrow(); "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"} From 67bd1154470d44c518b79689dca99cb9cf219dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Tue, 7 Jan 2025 19:24:44 +0900 Subject: [PATCH 5/8] Update unreleased/aison.md Co-authored-by: FineArchs <133759614+FineArchs@users.noreply.github.com> --- unreleased/aison.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unreleased/aison.md b/unreleased/aison.md index 35bead26..c92f17d3 100644 --- a/unreleased/aison.md +++ b/unreleased/aison.md @@ -1,4 +1,4 @@ -- AiScriptのオブジェクトの表記法を利用したデータ交換用フォーマット「AiScript Object Notation (AiSON)」およびそのパーサーを追加しました。 +- For Hosts: AiScriptのオブジェクトの表記法を利用したデータ交換用フォーマット「AiScript Object Notation (AiSON)」およびそのパーサーを追加しました。 - 現在、`AiSON.parse()`(パースしてJavaScriptオブジェクトに変換する)が使用できます。 - 通常のAiScriptと異なるのは以下の点です: - リテラルはトップレベルにひとつだけしか許可されません。 From bae8bdb93e07a0f380f0add99e2918701f2a350a Mon Sep 17 00:00:00 2001 From: FineArchs <133759614+FineArchs@users.noreply.github.com> Date: Sun, 12 Jan 2025 14:22:15 +0900 Subject: [PATCH 6/8] add Aison.parse type Co-authored-by: uzmoi --- src/parser/aison.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/parser/aison.ts b/src/parser/aison.ts index 1dc056ba..7ca9a368 100644 --- a/src/parser/aison.ts +++ b/src/parser/aison.ts @@ -1,17 +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 { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public static parse(input: string): any { + public static parse(input: string): JsValue { const scanner = new Scanner(input); const ast = parseAiSonTopLevel(scanner); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return nodeToJs(ast) as any; + return nodeToJs(ast); } } From 32e1add239b5295e61bcbc64a302587c6d605174 Mon Sep 17 00:00:00 2001 From: FineArchs <133759614+FineArchs@users.noreply.github.com> Date: Sun, 12 Jan 2025 14:24:44 +0900 Subject: [PATCH 7/8] Update aiscript.api.md --- etc/aiscript.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etc/aiscript.api.md b/etc/aiscript.api.md index fa32b079..32feb6b9 100644 --- a/etc/aiscript.api.md +++ b/etc/aiscript.api.md @@ -95,7 +95,7 @@ class AiScriptUserError extends AiScriptRuntimeError { // @public (undocumented) export class AiSON { // (undocumented) - static parse(input: string): any; + static parse(input: string): JsValue; } // @public (undocumented) From 586cd636154a4f5602f56a2735f2a79d1905c4bb Mon Sep 17 00:00:00 2001 From: FineArchs Date: Tue, 14 Jan 2025 17:44:56 +0900 Subject: [PATCH 8/8] indent --- test/aison.ts | 104 +++++++++++++++++++++++++------------------------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/test/aison.ts b/test/aison.ts index 0ffb4d26..97337530 100644 --- a/test/aison.ts +++ b/test/aison.ts @@ -2,79 +2,79 @@ 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('str', () => { + expect(AiSON.parse('"Ai-chan kawaii"')).toEqual('Ai-chan kawaii'); + }); - test.concurrent('number', () => { - expect(AiSON.parse('42')).toEqual(42); - }); + test.concurrent('number', () => { + expect(AiSON.parse('42')).toEqual(42); + }); - test.concurrent('bool', () => { - expect(AiSON.parse('true')).toEqual(true); - }); + test.concurrent('bool', () => { + expect(AiSON.parse('true')).toEqual(true); + }); - test.concurrent('null', () => { - expect(AiSON.parse('null')).toEqual(null); - }); + test.concurrent('null', () => { + expect(AiSON.parse('null')).toEqual(null); + }); - test.concurrent('array', () => { - expect(AiSON.parse('[1, 2, 3]')).toEqual([1, 2, 3]); - }); + 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('object', () => { + expect(AiSON.parse('{key: "value"}')).toEqual({ key: 'value' }); + }); - test.concurrent('nested', () => { - 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 string', () => { + expect(() => AiSON.parse('"hello')).toThrow(); + }); - test.concurrent('invalid: unclosed array', () => { - expect(() => AiSON.parse('[1, 2, 3')).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: empty', () => { + expect(() => AiSON.parse('')).toThrow(); + }); - test.concurrent('not allowed: function', () => { - expect(() => AiSON.parse(`@greet() { return "hello" } + 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: variable assignment', () => { + expect(() => AiSON.parse('let x = 42')).toThrow(); + }); - test.concurrent('not allowed: namespace', () => { - expect(() => AiSON.parse(`:: Ai { - let x = 42 + 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: expression', () => { + expect(() => AiSON.parse('{key: (3 + 5)}')).toThrow(); + }); - test.concurrent('not allowed: multiple statements (string)', () => { - expect(() => AiSON.parse(`"hello" + 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 in the same line', () => { + expect(() => AiSON.parse('"hello" "hi"')).toThrow(); + }); - test.concurrent('not allowed: multiple statements (object)', () => { - expect(() => AiSON.parse(`{key: "value"} + test.concurrent('not allowed: multiple statements (object)', () => { + expect(() => AiSON.parse(`{key: "value"} {foo: "bar"}`)).toThrow(); - }); + }); });