Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: AiScript Object Notation #897

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion etc/aiscript.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,6 +19,7 @@ export { values };
export { Parser };
export { ParserPlugin };
export { PluginType };
export { AiSON };
export { errors };
export { Ast };
export { AISCRIPT_VERSION };
20 changes: 1 addition & 19 deletions src/interpreter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -146,25 +147,6 @@
public static collectMetadata(script?: Ast.Node[]): Map<string | null, JsValue> | 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<string | null, JsValue>();

for (const node of script) {
Expand Down Expand Up @@ -411,7 +393,7 @@

case 'loop': {
// eslint-disable-next-line no-constant-condition
while (true) {

Check warning on line 396 in src/interpreter/index.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy
const v = await this._run(node.statements, scope.createChildScope(), callStack);
if (v.type === 'break') {
break;
Expand Down Expand Up @@ -910,7 +892,7 @@
public pause(): void {
if (this.pausing) return;
let resolve: () => void;
const promise = new Promise<void>(r => { resolve = () => r(); });

Check warning on line 895 in src/interpreter/index.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function
this.pausing = { promise, resolve: resolve! };
for (const handler of this.pauseHandlers) {
handler();
Expand Down
16 changes: 16 additions & 0 deletions src/parser/aison.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
44 changes: 44 additions & 0 deletions src/parser/syntaxes/aison.ts
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 21 additions & 0 deletions src/utils/node-to-js.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
80 changes: 80 additions & 0 deletions test/aison.ts
FineArchs marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -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();
});
});
6 changes: 6 additions & 0 deletions unreleased/aison.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- For Hosts: AiScriptのオブジェクトの表記法を利用したデータ交換用フォーマット「AiScript Object Notation (AiSON)」およびそのパーサーを追加しました。
- 現在、`AiSON.parse()`(パースしてJavaScriptオブジェクトに変換する)が使用できます。
- 通常のAiScriptと異なるのは以下の点です:
- リテラルはトップレベルにひとつだけしか許可されません。
- 動的な式(関数・オブジェクトのvalueに対する動的なバインディングなど)は許可されません。
- 名前空間・メタデータなど、リテラルとコメント以外をトップレベルに書くことは許可されていません。
Loading