Repository: syuilo/aiscript
Pull request #348 (2023-09-13 09:17:44)

feat: Interpreterにエラーコールバックの機能を追加
## what
`Async:interval`や`Async:timeout`、加えて実装次第ではホストが用意するその他のネイティブ関数が失敗した場合にも呼び出されるエラーコールバック関数を設定できるように。
## why
#318
## description
- Interpreterのコンストラクタの第２引数の要素に`err`（エラーコールバック）を設定できる。**これは`Async:interval`や`Async:timeout`が失敗した場合にも呼び出される。** なお、これを設定した場合は例外throwは発生しなくなる。
- ネイティブ関数は`opts.call`の代わりに`opts.topCall`を用いることで上記２つのようにエラーコールバックが呼び出されるように。**必要な場合にのみ使うこと。従来エラーキャッチ出来ていたケースでは引き続き`opts.call`を使う。**
## remarks
本PRではエラーコールバックの引数の型をAiScriptErrorとし、それ以外の例外はNotAiScriptErrorとしてラッピングすることにしているが、
- 引数の型をErrorまで広げる（万一Error以外が投げられたらErrorでラッピング）
- 引数の型をanyまで広げる
- そもそもAiScriptError以外の例外は弾く、従来通りthrowする
というアプローチも考えられる。

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/syuilo/aiscript/pull/348?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=syuilo) Report
Patch coverage: **`97.33%`** and project coverage change: **`+0.39%`** :tada:
> Comparison is base [(`dac5aec`)](https://app.codecov.io/gh/syuilo/aiscript/commit/dac5aec3ea08003bdb340a7376fab02e158ddca4?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=syuilo) 88.12% compared to head [(`774e715`)](https://app.codecov.io/gh/syuilo/aiscript/pull/348?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=syuilo) 88.51%.
> Report is 2 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #348      +/-   ##
==========================================
+ Coverage   88.12%   88.51%   +0.39%     
==========================================
  Files          21       21              
  Lines        3369     3432      +63     
  Branches      547      561      +14     
==========================================
+ Hits         2969     3038      +69     
+ Misses        389      383       -6     
  Partials       11       11              
```


| [Files Changed](https://app.codecov.io/gh/syuilo/aiscript/pull/348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=syuilo) | Coverage Δ | |
|---|---|---|
| [src/interpreter/lib/std.ts](https://app.codecov.io/gh/syuilo/aiscript/pull/348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=syuilo#diff-c3JjL2ludGVycHJldGVyL2xpYi9zdGQudHM=) | `75.17% <0.00%> (ø)` | |
| [src/error.ts](https://app.codecov.io/gh/syuilo/aiscript/pull/348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=syuilo#diff-c3JjL2Vycm9yLnRz) | `95.74% <100.00%> (+1.00%)` | :arrow_up: |
| [src/interpreter/index.ts](https://app.codecov.io/gh/syuilo/aiscript/pull/348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=syuilo#diff-c3JjL2ludGVycHJldGVyL2luZGV4LnRz) | `93.14% <100.00%> (+1.63%)` | :arrow_up: |
| [src/interpreter/value.ts](https://app.codecov.io/gh/syuilo/aiscript/pull/348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=syuilo#diff-c3JjL2ludGVycHJldGVyL3ZhbHVlLnRz) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/syuilo/aiscript/pull/348?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=syuilo).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=syuilo).

FineArchs: 備考：
本PRではエラーコールバックの引数の型を`AiScriptError`とし、それ以外の例外は`NotAiScriptError`としてラッピングすることにしているが、
- 引数の型を`Error`まで広げる（万一`Error`以外が投げられたら`Error`でラッピング）
- 引数の型を`any`まで広げる
- そもそもAiScriptError以外の例外は弾く、従来通りthrowする
というアプローチも考えられる。

Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 3391116..ae5fffd 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -9,7 +9,15 @@ updates:
     directory: "/"
     schedule:
       interval: "daily"
+    groups:
+      all:
+        patterns:
+          - "*"
   - package-ecosystem: "npm" # See documentation for possible values
     directory: "/" # Location of package manifests
     schedule:
       interval: "daily"
+    groups:
+      all:
+        patterns:
+          - "*"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5222b43..5f9edf3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@
 - 「エラー型（`error`）」を導入
 - `Json:parse`がパース失敗時にエラー型の値を返すように
 - `let` で定義した変数が上書きできてしまうのを修正
+- For Host: Interpreterのコンストラクタの第２引数の要素に`err`（エラーコールバック）を設定できる。これは`Interpreter.exec`が失敗した時に加えて、**`Async:interval`や`Async:timeout`が失敗した場合にも呼び出される。** なお、これを設定した場合は例外throwは発生しなくなる。
+- For Host: ネイティブ関数は`opts.call`の代わりに`opts.topCall`を用いることで上記２つのようにエラーコールバックが呼び出されるように。**必要な場合にのみ使うこと。従来エラーキャッチ出来ていたケースでは引き続き`opts.call`を使う。**
 
 # 0.15.0
 - Mathを強化
diff --git a/etc/aiscript.api.md b/etc/aiscript.api.md
index 8078211..f6b0326 100644
--- a/etc/aiscript.api.md
+++ b/etc/aiscript.api.md
@@ -344,6 +344,7 @@ const ERROR: (name: string, info?: Value) => Value;
 declare namespace errors {
     export {
         AiScriptError,
+        NonAiScriptError,
         SyntaxError_2 as SyntaxError,
         TypeError_2 as TypeError,
         RuntimeError,
@@ -531,6 +532,7 @@ export class Interpreter {
     constructor(consts: Record<string, Value>, opts?: {
         in?(q: string): Promise<string>;
         out?(value: Value): void;
+        err?(e: AiScriptError): void;
         log?(type: string, params: Record<string, any>): void;
         maxStep?: number;
     });
@@ -540,8 +542,8 @@ export class Interpreter {
     static collectMetadata(script?: Ast.Node[]): Map<any, any> | undefined;
     // (undocumented)
     exec(script?: Ast.Node[]): Promise<void>;
-    // (undocumented)
     execFn(fn: VFn, args: Value[]): Promise<Value>;
+    execFnSimple(fn: VFn, args: Value[]): Promise<Value>;
     // (undocumented)
     registerAbortHandler(handler: () => void): void;
     // (undocumented)
@@ -673,6 +675,11 @@ type Node_2 = Namespace | Meta | Statement | Expression | TypeSource;
 // @public
 type Node_3 = Namespace_2 | Meta_2 | Statement_2 | Expression_2 | ChainMember | TypeSource_2;
 
+// @public
+class NonAiScriptError extends AiScriptError {
+    constructor(error: any);
+}
+
 // @public (undocumented)
 type Not = NodeBase & {
     type: 'not';
@@ -1003,13 +1010,14 @@ type VError = {
     info?: Value;
 };
 
-// @public (undocumented)
+// @public
 type VFn = {
     type: 'fn';
     args?: string[];
     statements?: Node_2[];
     native?: (args: (Value | undefined)[], opts: {
         call: (fn: VFn, args: Value[]) => Promise<Value>;
+        topCall: (fn: VFn, args: Value[]) => Promise<Value>;
         registerAbortHandler: (handler: () => void) => void;
         unregisterAbortHandler: (handler: () => void) => void;
     }) => Value | Promise<Value> | void;
diff --git a/playground/src/App.vue b/playground/src/App.vue
index ba01b99..fa2249f 100644
--- a/playground/src/App.vue
+++ b/playground/src/App.vue
@@ -99,6 +99,9 @@ const run = async () => {
 				print: true
 			});
 		},
+		err: (e) => {
+			window.alert('Error: ' + e.message);
+		},
 		log: (type, params) => {
 			switch (type) {
 				case 'end': logs.value.push({
@@ -115,7 +118,7 @@ const run = async () => {
 		await interpreter.exec(ast.value);
 	} catch (e) {
 		console.error(e);
-		window.alert('Error: ' + e);
+		window.alert('Internal Error: ' + e);
 	}
 }
 
diff --git a/run.js b/run.js
index f832fb6..8777f5c 100644
--- a/run.js
+++ b/run.js
@@ -1,8 +1,9 @@
 import fs from 'fs';
 import * as readline from 'readline';
 import chalk from 'chalk';
-import { Parser, Interpreter, utils } from '@syuilo/aiscript';
-const { valToString } = utils
+import { Parser, Interpreter, errors, utils } from '@syuilo/aiscript';
+const { AiScriptError } = errors;
+const { valToString } = utils;
 
 const i = readline.createInterface({
 	input: process.stdin,
@@ -18,6 +19,9 @@ const interpreter = new Interpreter({}, {
 	out(value) {
 		console.log(chalk.magenta(valToString(value, true)));
 	},
+	err(e) {
+		console.log(chalk.red(`${e}`));
+	},
 	log(type, params) {
 		/*
 		switch (type) {
@@ -36,6 +40,14 @@ const interpreter = new Interpreter({}, {
 });
 
 const script = fs.readFileSync('./test.is', 'utf8');
-const ast = Parser.parse(script);
-
-interpreter.exec(ast);
+try {
+	const ast = Parser.parse(script);
+	await interpreter.exec(ast);
+} catch (e) {
+	if (e instanceof AiScriptError) {
+		console.log(chalk.red(`${e}`));
+	} else {
+		throw e
+	}
+}
+i.close();
diff --git a/src/error.ts b/src/error.ts
index 73340ad..8c6be6d 100644
--- a/src/error.ts
+++ b/src/error.ts
@@ -13,6 +13,15 @@ export abstract class AiScriptError extends Error {
 	}
 }
 
+/**
+ * Wrapper for non-AiScript errors.
+ */
+export class NonAiScriptError extends AiScriptError {
+	constructor(error: any) {
+		super(`Internal Error. ${error}`, error);
+	}
+}
+
 export class SyntaxError extends AiScriptError {
 	constructor(message: string, info?: any) {
 		super(message, info);
diff --git a/src/interpreter/index.ts b/src/interpreter/index.ts
index 79395c5..65a7616 100644
--- a/src/interpreter/index.ts
+++ b/src/interpreter/index.ts
@@ -3,11 +3,11 @@
  */
 
 import { autobind } from '../utils/mini-autobind.js';
-import { IndexOutOfRangeError, RuntimeError } from '../error.js';
+import { AiScriptError, NonAiScriptError, IndexOutOfRangeError, RuntimeError } from '../error.js';
 import { Scope } from './scope.js';
 import { std } from './lib/std.js';
 import { assertNumber, assertString, assertFunction, assertBoolean, assertObject, assertArray, eq, isObject, isArray, expectAny, reprValue } from './util.js';
-import { NULL, RETURN, unWrapRet, FN_NATIVE, BOOL, NUM, STR, ARR, OBJ, FN, BREAK, CONTINUE } from './value.js';
+import { NULL, RETURN, unWrapRet, FN_NATIVE, BOOL, NUM, STR, ARR, OBJ, FN, BREAK, CONTINUE, ERROR } from './value.js';
 import { getPrimProp } from './primitive-props.js';
 import { Variable } from './variable.js';
 import type { Value, VFn } from './value.js';
@@ -28,6 +28,7 @@ export class Interpreter {
 		private opts: {
 			in?(q: string): Promise<string>;
 			out?(value: Value): void;
+			err?(e: AiScriptError): void;
 			log?(type: string, params: Record<string, any>): void;
 			maxStep?: number;
 		} = {},
@@ -66,16 +67,47 @@ export class Interpreter {
 	@autobind
 	public async exec(script?: Ast.Node[]): Promise<void> {
 		if (script == null || script.length === 0) return;
-
-		await this.collectNs(script);
-
-		const result = await this._run(script, this.scope);
-
-		this.log('end', { val: result });
+		try {
+			await this.collectNs(script);
+			const result = await this._run(script, this.scope);
+			this.log('end', { val: result });
+		} catch (e) {
+			this.handleError(e);
+		}
 	}
 
+	/**
+	 * Executes AiScript Function.
+	 * When it fails,
+	 * (i)If error callback is registered via constructor, this.abort is called and the callback executed, then returns ERROR('func_failed').
+	 * (ii)Otherwise, just throws a error.
+	 *
+	 * @remarks This is the same function as that passed to AiScript NATIVE functions as opts.topCall.
+	 *
+	 * @param fn - the function
+	 * @param args - arguments for the function
+	 * @returns Return value of the function, or ERROR('func_failed') when the (i) condition above is fulfilled.
+	 */
 	@autobind
 	public async execFn(fn: VFn, args: Value[]): Promise<Value> {
+		return await this._fn(fn, args)
+			.catch(e => {
+				this.handleError(e);
+				return ERROR('func_failed');
+			});
+	}
+	/**
+	 * Executes AiScript Function.
+	 * Almost same as execFn but when error occurs this always throws and never calls callback.
+	 *
+	 * @remarks This is the same function as that passed to AiScript NATIVE functions as opts.call.
+	 *
+	 * @param fn - the function
+	 * @param args - arguments for the function
+	 * @returns Return value of the function.
+	 */
+	@autobind
+	public async execFnSimple(fn: VFn, args: Value[]): Promise<Value> {
 		return this._fn(fn, args);
 	}
 
@@ -120,6 +152,23 @@ export class Interpreter {
 		return meta;
 	}
 
+	@autobind
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	private handleError(e: any): void {
+		if (this.opts.err) {
+			if (!this.stop) {
+				this.abort();
+				if (e instanceof AiScriptError) {
+					this.opts.err(e);
+				} else {
+					this.opts.err(new NonAiScriptError(e));
+				}
+			}
+		} else {
+			throw e;
+		}
+	}
+
 	@autobind
 	private log(type: string, params: Record<string, unknown>): void {
 		if (this.opts.log) this.opts.log(type, params);
@@ -173,7 +222,8 @@ export class Interpreter {
 	private async _fn(fn: VFn, args: Value[]): Promise<Value> {
 		if (fn.native) {
 			const result = fn.native(args, {
-				call: this._fn,
+				call: this.execFnSimple,
+				topCall: this.execFn,
 				registerAbortHandler: this.registerAbortHandler,
 				unregisterAbortHandler: this.unregisterAbortHandler,
 			});
diff --git a/src/interpreter/lib/std.ts b/src/interpreter/lib/std.ts
index f46e20b..91b69ae 100644
--- a/src/interpreter/lib/std.ts
+++ b/src/interpreter/lib/std.ts
@@ -536,7 +536,7 @@ export const std: Record<string, Value> = {
 		}
 
 		const id = setInterval(() => {
-			opts.call(callback, []);
+			opts.topCall(callback, []);
 		}, interval.value);
 
 		const abortHandler = (): void => {
@@ -557,7 +557,7 @@ export const std: Record<string, Value> = {
 		assertFunction(callback);
 
 		const id = setTimeout(() => {
-			opts.call(callback, []);
+			opts.topCall(callback, []);
 		}, delay.value);
 
 		const abortHandler = (): void => {
diff --git a/src/interpreter/value.ts b/src/interpreter/value.ts
index 420ccfb..0933bc1 100644
--- a/src/interpreter/value.ts
+++ b/src/interpreter/value.ts
@@ -30,12 +30,16 @@ export type VObj = {
 	value: Map<string, Value>;
 };
 
+/**
+ * When your AiScript NATIVE function passes VFn.call to other caller(s) whose error thrown outside the scope, use VFn.topCall instead to keep it under AiScript error control system.
+ */
 export type VFn = {
 	type: 'fn';
 	args?: string[];
 	statements?: Node[];
 	native?: (args: (Value | undefined)[], opts: {
 		call: (fn: VFn, args: Value[]) => Promise<Value>;
+		topCall: (fn: VFn, args: Value[]) => Promise<Value>;
 		registerAbortHandler: (handler: () => void) => void;
 		unregisterAbortHandler: (handler: () => void) => void;
 	}) => Value | Promise<Value> | void;


Test patch:
diff --git a/test/index.ts b/test/index.ts
index 2e23691..cd7db13 100644
--- a/test/index.ts
+++ b/test/index.ts
@@ -5,7 +5,7 @@
 
 import * as assert from 'assert';
 import { Parser, Interpreter, utils, errors, Ast } from '../src';
-import { NUM, STR, NULL, ARR, OBJ, BOOL, TRUE, FALSE, ERROR } from '../src/interpreter/value';
+import { NUM, STR, NULL, ARR, OBJ, BOOL, TRUE, FALSE, ERROR ,FN_NATIVE } from '../src/interpreter/value';
 import { RuntimeError } from '../src/error';
 
 const exe = (program: string): Promise<any> => new Promise((ok, err) => {
@@ -71,6 +71,41 @@ describe('Interpreter', () => {
 	});
 });
 
+describe('error handler', () => {
+	test.concurrent('error from outside caller', async () => {
+		let outsideCaller: () => Promise<null> = async () => {};
+		let errCount: number = 0;
+		const aiscript = new Interpreter({
+			emitError: FN_NATIVE((_args, _opts) => {
+				throw Error('emitError');
+			}),
+			genOutsideCaller: FN_NATIVE(([fn], opts) => {
+				utils.assertFunction(fn);
+				outsideCaller = async () => {
+					opts.topCall(fn);
+				};
+			}),
+		}, {
+			err(e){ errCount+=1 },
+		});
+		await aiscript.exec(Parser.parse(`
+		genOutsideCaller(emitError)
+		`));
+		assert.strictEqual(errCount, 0);
+		await outsideCaller();
+		assert.strictEqual(errCount, 1);
+	});
+	test.concurrent('array.map calls the handler just once', async () => {
+		let errCount: number = 0;
+		const aiscript = new Interpreter({}, {
+			err(e){ errCount+=1 },
+		});
+		await aiscript.exec(Parser.parse(`
+		Core:range(1,5).map(@(){ hoge })
+		`));
+		assert.strictEqual(errCount, 1);
+});
+
 describe('ops', () => {
 	test.concurrent('==', async () => {
 		eq(await exe('<: (1 == 1)'), BOOL(true));