Repository: aiscript-dev/aiscript (ts)

Issue:
ifのthen句がstatement1つの場合でもスコープを生成するように
例えば、以下のような場合に実行されるまで変数bが定義されているか分からない。
```
$a <- Math:rnd(1, 2)
if (a > 1)
  $b <- 2
<: b
```
スコープを生成すると変数bの定義状態を静的に解析できる。


Pull request description:
enhance: if式やmatch式、for文が文1つでもスコープを生成するように
<!-- ℹ お読みください
PRありがとうございます！ PRを作成する前に、以下をご確認ください:
- 可能であればタイトルに、以下で示すようなPRの種類が分かるキーワードをプリフィクスしてください。
  - fix / refactor / feat / enhance / perf / chore
  - また、PRの粒度が適切であることを確認してください。ひとつのPRに複数の種類の変更や関心を含めることは避けてください。
- このPRによって解決されるIssueがある場合は、そのIssueへの参照を本文内に含めてください。
- CHANGELOG.mdに変更点を追記してください。リファクタリングなど、利用者に影響を与えない変更についてはこの限りではありません。
- この変更により新たに作成、もしくは更新すべきドキュメントがないか確認してください。
- 機能追加やバグ修正をした場合は、可能であればテストケースを追加してください。
- テスト、Lintが通っていることを予め確認してください。
  - `npm run test`、`npm run lint`でぞれぞれ実施可能です
- `npm run api`を実行してAPIレポートを更新し、差分がある場合はコミットしてください。
ご協力ありがとうございます🤗
-->
<!-- ℹ README
Thank you for your PR! Before creating a PR, please check the following:
- If possible, prefix the title with a keyword that identifies the type of this PR, as shown below.
  - fix / refactor / feat / enhance / perf / chore
  - Also, make sure that the granularity of this PR is appropriate. Please do not include more than one type of change or interest in a single PR.
- If there is an Issue which will be resolved by this PR, please include a reference to the Issue in the text.
- Please add the summary of the changes to CHANGELOG.md. However, this is not necessary for changes that do not affect the users, such as refactoring.
- Check if there are any documents that need to be created or updated due to this change.
- If you have added a feature or fixed a bug, please add a test case if possible.
- Please make sure that tests and Lint are passed in advance.
  - You can run it with `npm run test` and `npm run lint`.
- Run `npm run api` to update the API report and commit it if there are any diffs.
Thanks for your cooperation 🤗
-->

# What
<!-- このPRで何をしたのか？ どう変わるのか？ -->
<!-- What did you do with this PR? How will it change things? -->
以下の構文要素の内容が1つの文である場合でもスコープを生成するようにします。
内容がブロック式だとスコープが2重に生成されて無駄になるため、式である場合はスコープを生成しません。
- if式 (then節、elif節、else節)
- match式 (case節、default節)
- for文

以下のようなコードで、`a`がこれらの構文要素の外部から参照できなくなります。
```js
if true let a = 1
if false null elif true let a = 1
if false null else let a = 1
match 1 { case 1 => let a = 1 }
match 1 { default => let a = 1 }
for 1 let a = 1
<: a // Runtime: No such variable 'a' in scope '<root>' (Line 7, Column 4)
```

# Why
<!-- なぜそうするのか？ どういう意図なのか？ 何が困っているのか？ -->
<!-- Why do you do it? What are your intentions? What is the problem? -->
Resolve #107

# Additional info (optional)
<!-- テスト観点など -->
<!-- Test perspective, etc -->


Patch:
diff --git a/src/interpreter/index.ts b/src/interpreter/index.ts
index a9517de..33520fe 100644
--- a/src/interpreter/index.ts
+++ b/src/interpreter/index.ts
@@ -4,6 +4,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 { 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';
@@ -12,7 +13,6 @@ import { getPrimProp } from './primitive-props.js';
 import { Variable } from './variable.js';
 import type { JsValue } from './util.js';
 import type { Value, VFn } from './value.js';
-import type * as Ast from '../node.js';
 
 export type LogObject = {
 	scope?: string;
@@ -266,6 +266,11 @@ export class Interpreter {
 		}
 	}
 
+	@autobind
+	private _evalClause(node: Ast.Statement | Ast.Expression, scope: Scope): Promise<Value> {
+		return this._eval(node, Ast.isStatement(node) ? scope.createChildScope() : scope);
+	}
+
 	@autobind
 	private _eval(node: Ast.Node, scope: Scope): Promise<Value> {
 		return this.__eval(node, scope).catch(e => {
@@ -303,21 +308,21 @@ export class Interpreter {
 				const cond = await this._eval(node.cond, scope);
 				assertBoolean(cond);
 				if (cond.value) {
-					return this._eval(node.then, scope);
+					return this._evalClause(node.then, scope);
 				} else {
 					if (node.elseif && node.elseif.length > 0) {
 						for (const elseif of node.elseif) {
 							const cond = await this._eval(elseif.cond, scope);
 							assertBoolean(cond);
 							if (cond.value) {
-								return this._eval(elseif.then, scope);
+								return this._evalClause(elseif.then, scope);
 							}
 						}
 						if (node.else) {
 							return this._eval(node.else, scope);
 						}
 					} else if (node.else) {
-						return this._eval(node.else, scope);
+						return this._evalClause(node.else, scope);
 					}
 				}
 				return NULL;
@@ -328,11 +333,11 @@ export class Interpreter {
 				for (const qa of node.qs) {
 					const q = await this._eval(qa.q, scope);
 					if (eq(about, q)) {
-						return await this._eval(qa.a, scope);
+						return await this._evalClause(qa.a, scope);
 					}
 				}
 				if (node.default) {
-					return await this._eval(node.default, scope);
+					return await this._evalClause(node.default, scope);
 				}
 				return NULL;
 			}
@@ -355,7 +360,7 @@ export class Interpreter {
 					const times = await this._eval(node.times, scope);
 					assertNumber(times);
 					for (let i = 0; i < times.value; i++) {
-						const v = await this._eval(node.for, scope);
+						const v = await this._evalClause(node.for, scope);
 						if (v.type === 'break') {
 							break;
 						} else if (v.type === 'return') {
diff --git a/src/parser/syntaxes/common.ts b/src/parser/syntaxes/common.ts
index 9b1bc54..c94226b 100644
--- a/src/parser/syntaxes/common.ts
+++ b/src/parser/syntaxes/common.ts
@@ -138,6 +138,30 @@ export function parseType(s: ITokenStream): Ast.TypeSource {
 	}
 }
 
+/**
+ * ```abnf
+ * OptionalSeparator = [SEP]
+ * ```
+*/
+export function parseOptionalSeparator(s: ITokenStream): boolean {
+	switch (s.getTokenKind()) {
+		case TokenKind.NewLine: {
+			s.next();
+			return true;
+		}
+		case TokenKind.Comma: {
+			s.next();
+			if (s.is(TokenKind.NewLine)) {
+				s.next();
+			}
+			return true;
+		}
+		default: {
+			return false;
+		}
+	}
+}
+
 /**
  * ```abnf
  * FnType = "@" "(" ParamTypes ")" "=>" Type
diff --git a/src/parser/syntaxes/expressions.ts b/src/parser/syntaxes/expressions.ts
index 342644a..2736e0f 100644
--- a/src/parser/syntaxes/expressions.ts
+++ b/src/parser/syntaxes/expressions.ts
@@ -2,7 +2,7 @@ import { AiScriptSyntaxError } from '../../error.js';
 import { NODE } from '../utils.js';
 import { TokenStream } from '../streams/token-stream.js';
 import { TokenKind } from '../token.js';
-import { parseBlock, parseParams, parseType } from './common.js';
+import { parseBlock, parseOptionalSeparator, parseParams, parseType } from './common.js';
 import { parseBlockOrStatement } from './statements.js';
 
 import type * as Ast from '../../node.js';
@@ -404,9 +404,7 @@ function parseFnExpr(s: ITokenStream): Ast.Fn {
 
 /**
  * ```abnf
- * Match = "match" Expr "{" [MatchCases] [defaultCase] "}"
- * MatchCases = "case" Expr "=>" BlockOrStatement *(SEP "case" Expr "=>" BlockOrStatement) [SEP]
- * DefaultCase = "default" "=>" BlockOrStatement [SEP]
+ * Match = "match" Expr "{" [(MatchCase *(SEP MatchCase) [SEP DefaultCase] [SEP]) / DefaultCase [SEP]] "}"
  * ```
 */
 function parseMatch(s: ITokenStream): Ast.Match {
@@ -424,65 +422,27 @@ function parseMatch(s: ITokenStream): Ast.Match {
 	}
 
 	const qs: Ast.Match['qs'] = [];
-	while (!s.is(TokenKind.DefaultKeyword) && !s.is(TokenKind.CloseBrace)) {
-		s.expect(TokenKind.CaseKeyword);
-		s.next();
-		const q = parseExpr(s, false);
-		s.expect(TokenKind.Arrow);
-		s.next();
-		const a = parseBlockOrStatement(s);
-		qs.push({ q, a });
-
-		// separator
-		switch (s.getTokenKind()) {
-			case TokenKind.NewLine: {
-				s.next();
-				break;
-			}
-			case TokenKind.Comma: {
-				s.next();
-				if (s.is(TokenKind.NewLine)) {
-					s.next();
-				}
-				break;
-			}
-			case TokenKind.DefaultKeyword:
-			case TokenKind.CloseBrace: {
-				break;
-			}
-			default: {
+	let x: Ast.Match['default'];
+	if (s.is(TokenKind.CaseKeyword)) {
+		qs.push(parseMatchCase(s));
+		let sep = parseOptionalSeparator(s);
+		while (s.is(TokenKind.CaseKeyword)) {
+			if (!sep) {
 				throw new AiScriptSyntaxError('separator expected', s.getPos());
 			}
+			qs.push(parseMatchCase(s));
+			sep = parseOptionalSeparator(s);
 		}
-	}
-
-	let x: Ast.Match['default'];
-	if (s.is(TokenKind.DefaultKeyword)) {
-		s.next();
-		s.expect(TokenKind.Arrow);
-		s.next();
-		x = parseBlockOrStatement(s);
-
-		// separator
-		switch (s.getTokenKind()) {
-			case TokenKind.NewLine: {
-				s.next();
-				break;
-			}
-			case TokenKind.Comma: {
-				s.next();
-				if (s.is(TokenKind.NewLine)) {
-					s.next();
-				}
-				break;
-			}
-			case TokenKind.CloseBrace: {
-				break;
-			}
-			default: {
+		if (s.is(TokenKind.DefaultKeyword)) {
+			if (!sep) {
 				throw new AiScriptSyntaxError('separator expected', s.getPos());
 			}
+			x = parseDefaultCase(s);
+			parseOptionalSeparator(s);
 		}
+	} else if (s.is(TokenKind.DefaultKeyword)) {
+		x = parseDefaultCase(s);
+		parseOptionalSeparator(s);
 	}
 
 	s.expect(TokenKind.CloseBrace);
@@ -491,6 +451,34 @@ function parseMatch(s: ITokenStream): Ast.Match {
 	return NODE('match', { about, qs, default: x }, startPos, s.getPos());
 }
 
+/**
+ * ```abnf
+ * MatchCase = "case" Expr "=>" BlockOrStatement
+ * ```
+*/
+function parseMatchCase(s: ITokenStream): Ast.Match['qs'][number] {
+	s.expect(TokenKind.CaseKeyword);
+	s.next();
+	const q = parseExpr(s, false);
+	s.expect(TokenKind.Arrow);
+	s.next();
+	const a = parseBlockOrStatement(s);
+	return { q, a };
+}
+
+/**
+ * ```abnf
+ * DefaultCase = "default" "=>" BlockOrStatement
+ * ```
+*/
+function parseDefaultCase(s: ITokenStream): Ast.Match['default'] {
+	s.expect(TokenKind.DefaultKeyword);
+	s.next();
+	s.expect(TokenKind.Arrow);
+	s.next();
+	return parseBlockOrStatement(s);
+}
+
 /**
  * ```abnf
  * Eval = "eval" Block
diff --git a/unreleased/match-sep-before-default.md b/unreleased/match-sep-before-default.md
new file mode 100644
index 0000000..3b87a32
--- /dev/null
+++ b/unreleased/match-sep-before-default.md
@@ -0,0 +1,1 @@
+- **Breaking Change** match式において、case節とdefault節の間に区切り文字が必須になりました。case節の後にdefault節を区切り文字なしで続けると文法エラーになります。
diff --git a/unreleased/single-statement-clause-scope.md b/unreleased/single-statement-clause-scope.md
new file mode 100644
index 0000000..a86206a
--- /dev/null
+++ b/unreleased/single-statement-clause-scope.md
@@ -0,0 +1,1 @@
+- **Breaking Change** if式やmatch式、for文の内容が1つの文である場合にもスコープが生成されるようになりました。これらの構文内で定義された変数は外部から参照できなくなります。


Test patch:
diff --git a/test/syntax.ts b/test/syntax.ts
index 84d2553..913dc32 100644
--- a/test/syntax.ts
+++ b/test/syntax.ts
@@ -176,6 +176,24 @@ describe('separator', () => {
 			`);
 			eq(res, STR('c'));
 		});
+
+		test.concurrent('no separator', async () => {
+			await assert.rejects(async () => {
+				await exe(`
+				let x = 1
+				<:match x{case 1=>"a" case 2=>"b"}
+				`);
+			});
+		});
+
+		test.concurrent('no separator (default)', async () => {
+			await assert.rejects(async () => {
+				await exe(`
+				let x = 1
+				<:match x{case 1=>"a" default=>"b"}
+				`);
+			});
+		});
 	});
 
 	describe('call', () => {
@@ -725,6 +743,15 @@ describe('for', () => {
 		}
 		assert.fail();
 	});
+
+	test.concurrent('scope', async () => {
+		await assert.rejects(async () => {
+			await exe(`
+			for 1 let a = 1
+			<: a
+			`);
+		});
+	});
 });
 
 describe('each', () => {
@@ -1460,6 +1487,27 @@ describe('if', () => {
 		`);
 		eq(res2, STR('kawaii'));
 	});
+
+	test.concurrent('scope', async () => {
+		await assert.rejects(async () => {
+			await exe(`
+			if true let a = 1
+			<: a
+			`);
+		});
+		await assert.rejects(async () => {
+			await exe(`
+			if false null elif true let a = 1
+			<: a
+			`);
+		});
+		await assert.rejects(async () => {
+			await exe(`
+			if false null else let a = 1
+			<: a
+			`);
+		});
+	});
 });
 
 describe('eval', () => {
@@ -1541,6 +1589,21 @@ describe('match', () => {
 		`);
 		eq(res, STR('ai'));
 	});
+
+	test.concurrent('scope', async () => {
+		await assert.rejects(async () => {
+			await exe(`
+			match 1 { case 1 => let a = 1 }
+			<: a
+			`);
+		});
+		await assert.rejects(async () => {
+			await exe(`
+			match 1 { default => let a = 1 }
+			<: a
+			`);
+		});
+	});
 });
 
 describe('exists', () => {