Repository: aiscript-dev/aiscript (ts)

Issue:
match式においてcase節の後にdefault節を区切り文字なしで続けることができてしまう
このようなcase節の後にdefault節を区切り文字(コンマや改行)なしで続けたmatch式がパーサを通ってしまう。
```js
match x {case 1 => "yes" default => "no"}  // valid
```
case節の後に区切り文字なしでcase節はエラーになるから統一性に欠けるように思う。
```js
match x {case 1 => "yes" case 0 => "no"}  // separator expected
```
パーサがこの文法通りに動いてるからそうなっているが、
https://github.com/aiscript-dev/aiscript/blob/7a96bcf8387a1c88b3dfd199dfd46382b8c3313d/src/parser/syntaxes/expressions.ts#L407-L409
このようにcase節とdefault節の間に区切り文字を強制するといいかもしれない。
```abnf
Match = "match" Expr "{" [((MatchCases [SEP DefaultCase]) / DefaultCase) [SEP]] "}"
MatchCases = "case" Expr "=>" BlockOrStatement *(SEP "case" Expr "=>" BlockOrStatement)
DefaultCase = "default" "=>" BlockOrStatement
```

Pull request description:
fix: match式のdefault節の前に区切り文字を強制
<!-- ℹ お読みください
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? -->
match式において、case節とdefault節の間に区切り文字を強制するようにします。
case節の後にdefault節を区切り文字なしで続けると文法エラーになります。
変更後のmatch式の文法は次のABNFで表されます。
```abnf
Match = "match" Expr "{" [(MatchCase *(SEP MatchCase) [SEP DefaultCase] [SEP]) / DefaultCase [SEP]] "}"
MatchCase = "case" Expr "=>" BlockOrStatement
DefaultCase = "default" "=>" BlockOrStatement
```

# Why
<!-- なぜそうするのか？ どういう意図なのか？ 何が困っているのか？ -->
<!-- Why do you do it? What are your intentions? What is the problem? -->
Resolve #814

# Additional info (optional)
<!-- テスト観点など -->
<!-- Test perspective, etc -->
`parseMatch`関数を分割し、ABNFの各行に`parseMatch`, `parseMatchCase`, `parseDefaultCase`関数を対応させました。
また、区切り文字は`parseOptionalSeparator`関数でパースするようにしました。(区切り文字をパースするコードはボイラープレートみたいにあちこちにあるのでリファクタリングできそう)
テストはcase節同士の間、およびcase節とdefault節の間に区切り文字が無い場合にエラーとなることを確認するものを追加しました。


Patch:
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節を区切り文字なしで続けると文法エラーになります。


Test patch:
diff --git a/test/syntax.ts b/test/syntax.ts
index 84d2553..6cee217 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', () => {