Repository: aiscript-dev/aiscript
Pull request #818 (2024-10-21 08:11:26)

eslintによる自動修正
`npm run lint -- --fix`を行いました。

codecov-commenter: :warning: Please install the !['codecov app svg image'](https://github.com/codecov/engineering-team/assets/152432831/e90313f4-9d3a-4b63-8b54-cfe14e7ec20d) to ensure uploads and comments are reliably processed by Codecov.

## [Codecov](https://app.codecov.io/gh/aiscript-dev/aiscript/pull/818?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
All modified and coverable lines are covered by tests :white_check_mark:

:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

| [Files with missing lines](https://app.codecov.io/gh/aiscript-dev/aiscript/pull/818?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [src/interpreter/primitive-props.ts](https://app.codecov.io/gh/aiscript-dev/aiscript/pull/818?src=pr&el=tree&filepath=src%2Finterpreter%2Fprimitive-props.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2ludGVycHJldGVyL3ByaW1pdGl2ZS1wcm9wcy50cw==) | `98.11% <100.00%> (+0.13%)` | :arrow_up: |
| [src/utils/random/genrng.ts](https://app.codecov.io/gh/aiscript-dev/aiscript/pull/818?src=pr&el=tree&filepath=src%2Futils%2Frandom%2Fgenrng.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL3V0aWxzL3JhbmRvbS9nZW5ybmcudHM=) | `52.27% <100.00%> (+0.18%)` | :arrow_up: |

... and [28 files with indirect coverage changes](https://app.codecov.io/gh/aiscript-dev/aiscript/pull/818/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)


Patch:
diff --git a/docs/std-math.md b/docs/std-math.md
index ffa2f84..987462f 100644
--- a/docs/std-math.md
+++ b/docs/std-math.md
@@ -95,7 +95,7 @@ _y_ /_x_ の正接を返しますが、_x_ が負値の場合はπだけずれ
 
 ## 指数・対数関数
 ### @Math:pow(_x_: num, _y_: num): num
-_x_ の _y_ 乗を計算します。結果がNaNとなることを許容する点、内部的にJavascriptの`**`演算子ではなく`Math.pow`関数を用いている点の２つを除き、ほぼ`Core:pow`と同じものです。
+_x_ の _y_ 乗を計算します。内部的にJavascriptの`**`演算子ではなく`Math.pow`関数を用いている点を除き、ほぼ`Core:pow`と同じものです。
 
 ### @Math:exp(_x_: num): num
 eの _x_ 乗を計算します。  
diff --git a/etc/aiscript.api.md b/etc/aiscript.api.md
index 34eb921..ad73857 100644
--- a/etc/aiscript.api.md
+++ b/etc/aiscript.api.md
@@ -34,6 +34,13 @@ abstract class AiScriptError extends Error {
     pos?: Pos;
 }
 
+// @public
+class AiScriptHostsideError extends AiScriptError {
+    constructor(message: string, info?: unknown);
+    // (undocumented)
+    name: string;
+}
+
 // @public
 class AiScriptIndexOutOfRangeError extends AiScriptRuntimeError {
     constructor(message: string, info?: unknown);
@@ -281,7 +288,8 @@ declare namespace errors {
         AiScriptNamespaceError,
         AiScriptRuntimeError,
         AiScriptIndexOutOfRangeError,
-        AiScriptUserError
+        AiScriptUserError,
+        AiScriptHostsideError
     }
 }
 export { errors }
@@ -391,6 +399,8 @@ export class Interpreter {
         log?(type: string, params: LogObject): void;
         maxStep?: number;
         abortOnError?: boolean;
+        irqRate?: number;
+        irqSleep?: number | (() => Promise<void>);
     });
     // (undocumented)
     abort(): void;
@@ -854,7 +864,7 @@ type VUserFn = VFnBase & {
 
 // Warnings were encountered during analysis:
 //
-// src/interpreter/index.ts:39:4 - (ae-forgotten-export) The symbol "LogObject" needs to be exported by the entry point index.d.ts
+// src/interpreter/index.ts:38:4 - (ae-forgotten-export) The symbol "LogObject" needs to be exported by the entry point index.d.ts
 // src/interpreter/value.ts:46: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/playground/src/App.vue b/playground/src/App.vue
index 8a870b1..9fb1351 100644
--- a/playground/src/App.vue
+++ b/playground/src/App.vue
@@ -1,6 +1,7 @@
 <template>
 <div id="root">
-	<h1>AiScript (v{{ AISCRIPT_VERSION }}) Playground</h1>
+	<Settings v-if="showSettings" @exit="showSettings = false" />
+	<h1>AiScript (v{{ AISCRIPT_VERSION }}) Playground<button id="show-settings-button" @click="showSettings = true">Settings</button></h1>
 	<div id="grid1">
 		<div id="editor" class="container">
 			<header>Input<div class="actions"><button @click="setCode">FizzBuzz</button></div></header>
@@ -57,12 +58,14 @@ import { highlight, languages } from 'prismjs/components/prism-core';
 import 'prismjs/components/prism-clike';
 import 'prismjs/components/prism-javascript';
 import 'prismjs/themes/prism-okaidia.css';
+import Settings, { settings } from './Settings.vue';
 
 const script = ref(window.localStorage.getItem('script') || '<: "Hello, AiScript!"');
 
 const ast = ref(null);
 const logs = ref([]);
 const syntaxErrorMessage = ref(null);
+const showSettings = ref(false);
 
 watch(script, () => {
 	window.localStorage.setItem('script', script.value);
@@ -119,7 +122,9 @@ const run = async () => {
 				}); break;
 				default: break;
 			}
-		}
+		},
+		irqRate: settings.value.irqRate,
+		irqSleep: settings.value.irqSleep,
 	});
 
 	try {
@@ -167,6 +172,11 @@ pre {
 #root > h1 {
 	font-size: 1.5em;
 	margin: 16px 16px 0 16px;
+	display: flex;
+}
+
+#show-settings-button {
+	margin-left: auto;
 }
 
 #grid1 {
diff --git a/playground/src/Settings.vue b/playground/src/Settings.vue
new file mode 100644
index 0000000..28f7f60
--- /dev/null
+++ b/playground/src/Settings.vue
@@ -0,0 +1,59 @@
+<template>
+<div ref="bg" id="settings-bg" @click="(e) => e.target === bg && $emit('exit')">
+	<div id="settings-body">
+		<div class="settings-item">
+			<header>IRQ Rate</header>
+			<input type="number" v-model="settings.irqRate" />
+		</div>
+		<div class="settings-item">
+			<header>IRQ Sleep Time</header>
+			<div>
+				<input type="radio" value="time" v-model="irqSleepMethod" />
+				time(milliseconds)
+				<input type="number" v-model="irqSleepTime" :disabled="irqSleepMethod !== 'time'" />
+			</div>
+			<div>
+				<input type="radio" value="requestIdleCallback" v-model="irqSleepMethod" />
+				requestIdleCallback
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script>
+import { ref, computed } from 'vue';
+const irqSleepTime = ref(5);
+const irqSleepMethod = ref('time');
+export const settings = ref({
+	irqRate: 300,
+	irqSleep: computed(() => ({
+		time: irqSleepTime.value,
+		requestIdleCallback: () => new Promise(cb => requestIdleCallback(cb)),
+	})[irqSleepMethod.value]),
+});
+</script>
+
+<script setup>
+const emits = defineEmits(['exit']);
+const bg = ref(null);
+</script>
+
+<style>
+#settings-bg {
+	position: fixed;
+	top: 0;
+	left: 0;
+	z-index: 10;
+	width: 100vw;
+	height: 100vh;
+	background: #0008;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+}
+#settings-body {
+	display: grid;
+	gap: 1em;
+}
+</style>
diff --git a/src/error.ts b/src/error.ts
index ba01d0b..e62c9b6 100644
--- a/src/error.ts
+++ b/src/error.ts
@@ -86,3 +86,12 @@ export class AiScriptUserError extends AiScriptRuntimeError {
 		super(message, info);
 	}
 }
+/**
+ * Host side configuration errors.
+ */
+export class AiScriptHostsideError extends AiScriptError {
+	public name = 'Host';
+	constructor(message: string, info?: unknown) {
+		super(message, info);
+	}
+}
diff --git a/src/interpreter/index.ts b/src/interpreter/index.ts
index 9f8c772..a9517de 100644
--- a/src/interpreter/index.ts
+++ b/src/interpreter/index.ts
@@ -3,7 +3,7 @@
  */
 
 import { autobind } from '../utils/mini-autobind.js';
-import { AiScriptError, NonAiScriptError, AiScriptNamespaceError, AiScriptIndexOutOfRangeError, AiScriptRuntimeError } from '../error.js';
+import { AiScriptError, NonAiScriptError, AiScriptNamespaceError, AiScriptIndexOutOfRangeError, AiScriptRuntimeError, AiScriptHostsideError } 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';
@@ -14,9 +14,6 @@ import type { JsValue } from './util.js';
 import type { Value, VFn } from './value.js';
 import type * as Ast from '../node.js';
 
-const IRQ_RATE = 300;
-const IRQ_AT = IRQ_RATE - 1;
-
 export type LogObject = {
 	scope?: string;
 	var?: string;
@@ -29,6 +26,8 @@ export class Interpreter {
 	public scope: Scope;
 	private abortHandlers: (() => void)[] = [];
 	private vars: Record<string, Variable> = {};
+	private irqRate: number;
+	private irqSleep: () => Promise<void>;
 
 	constructor(
 		consts: Record<string, Value>,
@@ -39,6 +38,8 @@ export class Interpreter {
 			log?(type: string, params: LogObject): void;
 			maxStep?: number;
 			abortOnError?: boolean;
+			irqRate?: number;
+			irqSleep?: number | (() => Promise<void>);
 		} = {},
 	) {
 		const io = {
@@ -70,6 +71,25 @@ export class Interpreter {
 				default: break;
 			}
 		};
+
+		if (!((this.opts.irqRate ?? 300) >= 0)) {
+			throw new AiScriptHostsideError(`Invalid IRQ rate (${this.opts.irqRate}): must be non-negative number`);
+		}
+		this.irqRate = this.opts.irqRate ?? 300;
+
+		const sleep = (time: number) => (
+			(): Promise<void> => new Promise(resolve => setTimeout(resolve, time))
+		);
+
+		if (typeof this.opts.irqSleep === 'function') {
+			this.irqSleep = this.opts.irqSleep;
+		} else if (this.opts.irqSleep === undefined) {
+			this.irqSleep = sleep(5);
+		} else if (this.opts.irqSleep >= 0) {
+			this.irqSleep = sleep(this.opts.irqSleep);
+		} else {
+			throw new AiScriptHostsideError('irqSleep must be a function or a positive number.');
+		}
 	}
 
 	@autobind
@@ -262,7 +282,10 @@ export class Interpreter {
 	@autobind
 	private async __eval(node: Ast.Node, scope: Scope): Promise<Value> {
 		if (this.stop) return NULL;
-		if (this.stepCount % IRQ_RATE === IRQ_AT) await new Promise(resolve => setTimeout(resolve, 5));
+		// irqRateが小数の場合は不等間隔になる
+		if (this.irqRate !== 0 && this.stepCount % this.irqRate >= this.irqRate - 1) {
+			await this.irqSleep();
+		}
 		this.stepCount++;
 		if (this.opts.maxStep && this.stepCount > this.opts.maxStep) {
 			throw new AiScriptRuntimeError('max step exceeded');
diff --git a/src/interpreter/primitive-props.ts b/src/interpreter/primitive-props.ts
index bc3541b..df3c18c 100644
--- a/src/interpreter/primitive-props.ts
+++ b/src/interpreter/primitive-props.ts
@@ -334,12 +334,12 @@ const PRIMITIVE_PROPS: {
 		splice: (target: VArr): VFn => FN_NATIVE(async ([idx, rc, vs], opts) => {
 			assertNumber(idx);
 			const index = (idx.value < -target.value.length) ? 0
-						: (idx.value < 0) ? target.value.length + idx.value
-						: (idx.value >= target.value.length) ? target.value.length
-						: idx.value;
+				: (idx.value < 0) ? target.value.length + idx.value
+				: (idx.value >= target.value.length) ? target.value.length
+				: idx.value;
 
 			const remove_count = (rc != null) ? (assertNumber(rc), rc.value)
-								: target.value.length - index;
+				: target.value.length - index;
 
 			const items = (vs != null) ? (assertArray(vs), vs.value) : [];
 
@@ -378,7 +378,7 @@ const PRIMITIVE_PROPS: {
 			});
 			const mapped_vals = await Promise.all(vals);
 			return ARR(mapped_vals.flat());
-    }),
+		}),
 
 		every: (target: VArr): VFn => FN_NATIVE(async ([fn], opts) => {
 			assertFunction(fn);
diff --git a/src/utils/random/genrng.ts b/src/utils/random/genrng.ts
index f6a6a4a..799308f 100644
--- a/src/utils/random/genrng.ts
+++ b/src/utils/random/genrng.ts
@@ -1,9 +1,9 @@
 import seedrandom from 'seedrandom';
 import { FN_NATIVE, NULL, NUM } from '../../interpreter/value.js';
+import { textEncoder } from '../../const.js';
 import { SeedRandomWrapper } from './seedrandom.js';
 import { ChaCha20 } from './chacha20.js';
 import type { VNativeFn, VNull, Value } from '../../interpreter/value.js';
-import { textEncoder } from '../../const.js';
 
 export function GenerateLegacyRandom(seed: Value | undefined) : VNativeFn | VNull {
 	if (!seed || seed.type !== 'num' && seed.type !== 'str') return NULL;
diff --git a/unreleased/irq-config.md b/unreleased/irq-config.md
new file mode 100644
index 0000000..5e3edf6
--- /dev/null
+++ b/unreleased/irq-config.md
@@ -0,0 +1,3 @@
+- For Hosts: Interpreterのオプションに`irqRate`と`irqSleep`を追加
+  - `irqRate`はInterpreterの定期休止が何ステップに一回起こるかを指定する数値
+  - `irqSleep`は休止時間をミリ秒で指定する数値、または休止ごとにawaitされるPromiseを返す関数


Test patch:
diff --git a/test/interpreter.ts b/test/interpreter.ts
index 70c4089..203b5a4 100644
--- a/test/interpreter.ts
+++ b/test/interpreter.ts
@@ -1,9 +1,9 @@
 import * as assert from 'assert';
-import { describe, expect, test } from 'vitest';
+import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest';
 import { Parser, Interpreter, values, errors, utils, Ast } from '../src';
 
 let { FN_NATIVE } = values;
-let { AiScriptRuntimeError, AiScriptIndexOutOfRangeError } = errors;
+let { AiScriptRuntimeError, AiScriptIndexOutOfRangeError, AiScriptHostsideError } = errors;
 
 describe('Scope', () => {
 	test.concurrent('getAll', async () => {
@@ -114,3 +114,93 @@ describe('error location', () => {
 		`)).resolves.toEqual({ line: 2, column: 6});
 	});
 });
+
+describe('IRQ', () => {
+	describe('irqSleep is function', () => {
+		async function countSleeps(irqRate: number): Promise<number> {
+			let count = 0;
+			const interpreter = new Interpreter({}, {
+				irqRate,
+				// It's safe only when no massive loop occurs
+				irqSleep: async () => count++,
+			});
+			await interpreter.exec(Parser.parse(`
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'`));
+			return count;
+		}
+
+		test.concurrent.each([
+			[0, 0],
+			[1, 10],
+			[2, 5],
+			[10, 1],
+			[Infinity, 0],
+		])('rate = %d', async (rate, count) => {
+			return expect(countSleeps(rate)).resolves.toEqual(count);
+		});
+
+		test.concurrent.each(
+			[-1, NaN],
+		)('rate = %d', async (rate, count) => {
+			return expect(countSleeps(rate)).rejects.toThrow(AiScriptHostsideError);
+		});
+	});
+
+	describe('irqSleep is number', () => {
+		// This function does IRQ 10 times so takes 10 * irqSleep milliseconds in sum when executed.
+		async function countSleeps(irqSleep: number): Promise<void> {
+			const interpreter = new Interpreter({}, {
+				irqRate: 1,
+				irqSleep,
+			});
+			await interpreter.exec(Parser.parse(`
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'`));
+		}
+
+		beforeEach(() => {
+			vi.useFakeTimers();
+		})
+
+		afterEach(() => {
+			vi.restoreAllMocks();
+		})
+
+		test('It ends', async () => {
+			const countSleepsSpy = vi.fn(countSleeps);
+			countSleepsSpy(100);
+			await vi.advanceTimersByTimeAsync(1000);
+			return expect(countSleepsSpy).toHaveResolved();
+		});
+
+		test('It takes time', async () => {
+			const countSleepsSpy = vi.fn(countSleeps);
+			countSleepsSpy(100);
+			await vi.advanceTimersByTimeAsync(999);
+			return expect(countSleepsSpy).not.toHaveResolved();
+		});
+
+		test.each(
+			[-1, NaN]
+		)('Invalid number: %d', (time) => {
+			return expect(countSleeps(time)).rejects.toThrow(AiScriptHostsideError);
+		});
+	});
+});