Files
coollang/docs/grammar.ebnf
T
coolguyandClaude Opus 5 d85816abae own: 함수 타입과 클로저 파라미터의 소유권 — 구멍이 숨기던 버그가 나왔다
D5를 고친다. 함수 타입에 own을 적을 수 없어 "소유권을 가져가는 클로저"를
표현할 수 없었고, move 검사기가 클로저 파라미터를 무조건 소유로 봐서 고차
경계에서 소유권 검사가 뚫려 있었다.

클로저 파라미터의 소유권은 리터럴이 스스로 적는다. 타입은 기대 타입에서
읽어오지만 소유권은 읽어오지 않는다 — move 검사는 타입 검사와 별도 순회라
타입을 모르고, 소유권은 타입보다 결과가 크기 때문이다.
unify는 정확히 일치를 요구한다. 방향을 다루려면 부분 타입이 필요하고 없다.

그리고 구멍이 자기가 숨긴 버그를 덮고 있었다. std/list.cool의 fold가
f: fn(acc, a) -> acc 로 적혀 있었는데 틀렸다 — 누적자는 매 단계 소비되고
새것으로 바뀌므로 own이다. 빌림으로 적혀 있어 affine 값을 fold로 실어나를
수 없었는데, 클로저 파라미터를 소유로 봤으니 아무 오류도 안 났다.
고치니 samples/app이 즉시 깨졌고, own을 붙여 고쳤다.

남은 한계를 기록했다: move 검사는 타입이 없어 제네릭을 통과해 affinity를
보지 못한다. 양쪽 다 표기가 없으면 통과한다. 근본 해법은 두 순회를 합치는
것이고 v0에서는 하지 않는다.

대가도 기록했다: own이 흔해진다. fold가 항상 요구하므로 copyable 누적자에도
붙는다. 표기의 신호가 약해지는지 지켜본다.

문법 먼저 고치고 대조 장치가 파서를 지적하게 했다. 지금은 문장 500개,
파일 29개 모두 갈림 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ZVDeU6KLuUVL3gs18Hm3E
2026-08-30 19:06:17 +09:00

311 lines
16 KiB
EBNF

(* coollang v0 문법 — EBNF
*
* 표기 규약:
* = 정의
* | 선택
* [ ] 선택적 (0 또는 1)
* { } 반복 (0 이상)
* ( ) 묶음
* " " 단말
* < > 매개변수 (아래 참고)
* (* *) 주석
*
* 설계 제약: LL(1). backtracking 없음, 렉서 피드백 없음.
* 어떤 프로덕션도 무한 선읽기를 요구해서는 된다 (철학 2).
* 제약은 주장이 아니라 검사된다 tools/ebnf_tool.exe가 이 파일을 읽어
* FIRST/FOLLOW를 계산하고 충돌을 보고하며, 충돌이 있으면 테스트가 깨진다.
*
* 매개변수 프로덕션:
* name<p> = ... p ... 정의하고 name<arg> 참조한다.
* 인자마다 하나의 평범한 프로덕션으로 펼쳐진다. 인자에 따라 몸통이 달라야
* 하면 머리에 인자를 박아 특수화한다 (name<yes> = ... ; name<no> = ... ;).
* 목록과 expr_ns를 복제 없이 적기 위한 것이다.
*
* 흡수 규칙:
* [ NEWLINE ]은 최대한 먹는다(greedy). 줄바꿈은 값을 갖지 않으므로 바깥
* 프로덕션이 먹든 안쪽이 먹든 파스 트리가 같다. 규칙이 없으면 흡수
* 지점마다 형식적 중의성이 생기지만, 어느 쪽으로 읽어도 결과가 같다.
*)
(* ------------------------------------------------------------------ *)
(* 어휘 *)
(* ------------------------------------------------------------------ *)
(* 주석은 // 부터 줄 끝까지. 블록 주석 없음 (중첩 규칙이라는 변종을 만들지 않는다) *)
(* 문 구분자 NEWLINE은 렉서가 삽입한다:
* 줄의 마지막 토큰이 값으로 끝날 수 있는 토큰이면 그 줄 끝에 NEWLINE을 넣는다.
* 그 목록은 아래 생성 블록에 있다 — 손으로 적지 않고 코드에서 뽑는다.
* 그 외에는 넣지 않는다. 따라서 연산자나 여는 괄호로 끝나는 줄은 이어진다.
* 빈 줄과 주석만 있는 줄은 NEWLINE을 만들지 않는다.
*
* NEWLINE은 결코 연달아 나오지 않는다. 삽입 판정이 "마지막으로 낸 토큰"을
* 보는데 NEWLINE 자신은 값으로 끝날 수 있는 토큰이 아니기 때문이다.
* 그래서 이 문법의 모든 흡수 지점은 { NEWLINE }이 아니라 [ NEWLINE ]이다.
*
(* 여기부터 lib/token.ml에서 생성됩니다 — 손으로 고치지 마십시오 *)
* 줄을 끝낼 있는 토큰. 줄의 마지막 토큰이 하나이면 끝에
* NEWLINE이 삽입된다. Token.can_end_statement가 원본이다.
*
* ident | int_lit | string_lit | "return" | "true" | "false" | ")" |
* "}" | "]" | "?"
*
* 키워드. 이름으로 없다. Token.keyword가 원본이다.
*
* "pub" | "fn" | "struct" | "enum" | "capability" | "const" |
* "import" | "as" | "reexport" | "let" | "mut" | "own" | "affine" |
* "copyable" | "effects" | "return" | "if" | "else" | "match" |
* "scope" | "crash" | "test" | "true" | "false"
(* 생성 끝 *)
*
* 다중 목록(파라미터, 인자, 필드, variant, 리스트 리터럴)은 후행 콤마가
* 필수다. 콤마로 끝난 줄은 NEWLINE을 만들지 않으므로 목록이 자연히 이어진다.
* 공식 formatter가 이를 강제한다.
*
* 시그니처 머리에서는 NEWLINE이 문법적으로 허용되고 무시된다. effects 절이
* 끝에 오면 "}"가 종료 토큰이라 NEWLINE이 삽입되는데, 자리는 문이
* 끝날 있는 자리가 아니므로 아래 프로덕션이 [ NEWLINE ]으로 흡수한다.
* 흡수 위치를 프로덕션에 명시적으로 적는다 파서가 임의로 건너뛰지 않는다.
*)
ident = letter , { letter | digit | "_" } ;
int_lit = digit , { digit | "_" } ;
string_lit = '"' , { str_char } , '"' ;
str_char = ( char - '"' - "\" ) | escape ;
escape = "\" , ( "n" | "t" | "\" | '"' ) ;
bool_lit = "true" | "false" ;
literal = int_lit | string_lit | bool_lit ;
(* ------------------------------------------------------------------ *)
(* 목록 *)
(* ------------------------------------------------------------------ *)
(* 후행 콤마를 허용하는 목록. 우재귀로 적는 이유는 LL(1)이다 —
* X , { "," , X } , [ "," ] 로 적으면 콤마를 본 시점에 "항목이 더 있는지"와
* "이게 후행 콤마인지"가 갈리지 않는다. 콤마를 먹은 뒤 닫는 토큰을 보고
* 갈리는 것이 파서가 실제로 하는 일이고, 아래가 그것이다. *)
list<item> = item , list_rest<item> ;
list_rest<item> = [ "," , [ list<item> ] ] ;
(* 중괄호 안의 목록. 항목 사이에 줄바꿈이 올 수 있다는 점만 다르다.
* 항목 사이의 콤마는 필수이고 마지막 항목 뒤에서만 생략된다. *)
brace_list<item> = [ NEWLINE ] , [ item , brace_rest<item> ] ;
brace_rest<item> = [ NEWLINE ] ,
[ "," , [ NEWLINE ] , [ item , brace_rest<item> ] ] ;
(* ------------------------------------------------------------------ *)
(* 모듈 *)
(* ------------------------------------------------------------------ *)
module = [ NEWLINE ] , { item } ;
item = ( import | reexport | test_decl | decl ) , [ NEWLINE ] ;
import = "import" , string_lit , "as" , ident ;
reexport = "reexport" , ident ;
(* 테스트는 프로그램의 일부이고 같은 검사를 받는다. pub이 없다 — 밖에서
* 부르는 것이 아니다. 이름이 필수인 이유는 실패했을 때 무엇이 깨졌는지
* 말해야 하기 때문이다.
* 파라미터가 없으므로 capability를 받을 수 없고, capability를 만드는 문법도
* 없다. 따라서 테스트는 effect-free임이 증명된다 — 관례가 아니라 검사다.
* 그 결과 순서에 의존하지 않고, 병렬로 돌려도 같고, 캐시할 수 있다.
* interface hash에는 들어가지 않는다 (함수 본문과 같은 이유) *)
test_decl = "test" , string_lit , block ;
decl = [ "pub" ] , ( fn_decl | struct_decl | enum_decl
| capability_decl | const_decl ) ;
(* ------------------------------------------------------------------ *)
(* 선언 *)
(* ------------------------------------------------------------------ *)
fn_decl = "fn" , ident , [ gen_params ] , "(" , [ params ] , ")" ,
[ NEWLINE ] ,
[ eff_result , [ NEWLINE ] ] ,
[ "->" , type , [ NEWLINE ] ] ,
[ block ] ;
(* block이 없으면 시그니처 선언. std/*.cool과 capability 본문에서 쓴다 *)
struct_decl = [ "copyable" ] , "struct" , ident , [ gen_params ] ,
"{" , brace_list<field> , "}" ;
field = ident , ":" , type ;
enum_decl = "enum" , ident , [ gen_params ] ,
"{" , brace_list<variant> , "}" ;
variant = ident , [ "(" , type_list , ")" ] ;
capability_decl = "capability" , ident , "{" , [ NEWLINE ] , { cap_method } , "}" ;
cap_method = "fn" , ident , [ gen_params ] , "(" , [ params ] , ")" ,
[ NEWLINE ] ,
[ eff_result , [ NEWLINE ] ] ,
[ "->" , type ] ,
[ NEWLINE ] ;
const_decl = "const" , ident , ":" , type , "=" , expr ;
gen_params = "[" , list<gen_param> , "]" ;
gen_param = ident , [ ":" , "effects" ] ;
params = list<param> ;
param = [ "own" ] , [ "mut" ] , ident , ":" , type ;
(* ------------------------------------------------------------------ *)
(* effect *)
(* ------------------------------------------------------------------ *)
(* 결과 위치에서만 합집합이 가능하다 *)
eff_result = "effects" , eff_atom , { "|" , eff_atom } ;
(* 파라미터 위치. 합집합이 없다 — 문법에 그런 문장이 존재하지 않는다.
* "검사기가 거부"가 아니라 "쓸 수 없다"이다 *)
eff_param = "effects" , eff_atom ;
eff_atom = ident | eff_set ;
eff_set = "{" , brace_list<eff_name> , "}" ;
eff_name = ident , "." , ident ;
(* 타입 수준 이름만. capability 값의 identity는 정적 층에 등장하지 않는다 *)
(* ------------------------------------------------------------------ *)
(* 타입 *)
(* ------------------------------------------------------------------ *)
type = fn_type | named_type ;
(* 함수 타입의 파라미터에도 own을 적는다. 이것이 없으면 "소유권을 가져가는
* 클로저"를 타입으로 표현할 수 없고, 고차 경계에서 소유권 검사가 뚫린다 *)
fn_type = [ "affine" ] , "fn" , "(" , [ list<fn_param_ty> ] , ")" ,
[ eff_param ] , [ "->" , type ] ;
fn_param_ty = [ "own" ] , type ;
(* 다른 모듈의 타입은 별칭으로 한정한다: Shapes.Shape.
* 한 단계뿐이다 — 별칭은 이 모듈의 이름이므로 더 이어질 자리가 없다 *)
named_type = ident , [ "." , ident ] , [ type_args ] ;
type_args = "[" , list<targ> , "]" ;
targ = type | eff_set ;
(* 제네릭 인자는 타입 또는 effect다. 맨 이름은 둘 다일 수 있으므로 파서는
* 타입으로 읽고 이름 해소가 판정한다 — 구문 층에서 갈리지 않아도 된다 *)
type_list = list<type> ;
(* ------------------------------------------------------------------ *)
(* 문과 블록 *)
(* ------------------------------------------------------------------ *)
block = "{" , [ NEWLINE ] , [ stmt , stmt_rest ] , "}" ;
stmt_rest = [ NEWLINE , [ stmt , stmt_rest ] ] ;
(* 문 사이의 NEWLINE은 필수다. 선택적으로 적으면 식이 식 뒤에 바로 올 수
* 있게 되고, 그러면 "-"나 "("로 시작하는 다음 문과 앞 식의 이어짐이
* 갈리지 않는다 — 한 토큰으로 결정할 수 없게 된다.
* 마지막 문의 구분자는 "}" 앞에서 생략된다.
* fn(s) { String.concat(prefix, s) } 처럼 한 줄로 쓰는 자리가 있기 때문이다 *)
stmt = let_stmt | return_stmt | expr_stmt ;
let_stmt = "let" , [ "mut" ] , pattern , [ ":" , type ] , "=" , expr ;
return_stmt = "return" , [ expr ] ;
(* 값 생략은 다음 토큰이 NEWLINE이거나 "}"일 때다 *)
expr_stmt = expr , [ "=" , expr ] ;
(* 대입 왼쪽에 올 수 있는 것(이름 또는 필드 접근)은 구문이 아니라 정적 검사가
* 판정한다. 구문으로 가르면 ident 하나로 대입과 식이 갈리지 않는다 *)
(* 블록의 값 = 마지막 stmt가 expr이면 그 값, 아니면 Unit.
* return은 조기 탈출 전용이며, 꼬리 위치의 return은 formatter가 지적한다 *)
(* ------------------------------------------------------------------ *)
(* 식 *)
(* ------------------------------------------------------------------ *)
(* 매개변수 s는 이 자리에서 struct 리터럴을 쓸 수 있는지다.
* expr = 쓸 수 있는 자리 (거의 전부)
* expr_ns = 쓸 수 없는 자리 (if/match의 머리)
* if/match의 머리에서 "{"가 블록의 시작인지 struct 리터럴인지 갈리지
* 않으므로, 그 자리의 struct 리터럴은 괄호로 감싼다.
* 제약은 머리 식의 최상위에만 걸린다 — 괄호, 대괄호, 블록, 호출 인자 등
* 새 구문 문맥에 들어가는 순간 풀린다. 그래서 primary<no>의 괄호 안이
* expr(= yes)이다.
* scope의 머리에는 식이 없다(이름 둘뿐) — 제약이 걸릴 자리가 없다 *)
expr = or_expr<yes> ;
expr_ns = or_expr<no> ;
or_expr<s> = and_expr<s> , { "||" , and_expr<s> } ;
and_expr<s> = cmp_expr<s> , { "&&" , cmp_expr<s> } ;
cmp_expr<s> = add_expr<s> , [ cmp_op , add_expr<s> ] ;
cmp_op = "==" | "!=" | "<" | "<=" | ">" | ">=" ;
add_expr<s> = mul_expr<s> , { ( "+" | "-" ) , mul_expr<s> } ;
mul_expr<s> = unary<s> , { ( "*" | "/" | "%" ) , unary<s> } ;
unary<s> = [ "!" | "-" ] , postfix<s> ;
postfix<s> = primary<s> , { call_sfx | field_sfx | inst_sfx | "?" } ;
call_sfx = "(" , [ args ] , ")" ;
field_sfx = "." , ident ;
inst_sfx = type_args ;
(* 후위 "[" = 명시적 인스턴스화, 전위 "[" = 리스트 리터럴. 위치가 결정한다 *)
args = list<expr> ;
primary<s> = literal
| list_lit
| closure
| if_expr
| match_expr
| scope_expr
| "(" , expr , ")"
| crash_expr
| name_or_struct<s> ;
(* crash는 복구 불가능한 결함이다. 되돌아올 수 없으므로 타입이 Never이고,
* Never는 어떤 타입 자리에도 놓일 수 있다 — 그래야 match 팔에서 쓸 수 있다.
*
* effect가 아니다. 경계 검사 하나 넣었다고 {Crash}가 호출자 전부로
* 전염되면 effect 절은 신호가 아니라 잡음이 된다. 발산이 effect가 아닌
* 것과 같은 이유다 — 무한 루프도 추적하지 않는다.
*
* 언어 수준 recover가 없다. 붙잡는 것이 있으면 그것은 예외이고, 예외는
* 시그니처에 안 적히므로 철학 1과 충돌한다. 런타임은 격리 경계를 가질 수
* 있다 (테스트 러너가 첫 사례).
*
* 되감기를 하지 않는다. crash 시 자원 해제 여부는 자원 모델과 함께
* 결정한다 — 지금은 열어둔다.
*
* 키워드인 이유: prelude가 없어서 함수로 두면 쓸 때마다 import해야 한다 *)
crash_expr = "crash" , "(" , expr , ")" ;
(* ident 하나로는 이름인지 struct 리터럴인지 갈리지 않는다. "{"를 보고
* 갈리므로 왼쪽으로 인수분해해 적는다 — 파서가 실제로 하는 일이다 *)
name_or_struct<yes> = ident , [ struct_body ] ;
name_or_struct<no> = ident ;
struct_body = "{" , brace_list<field_init> , "}" ;
field_init = ident , ":" , expr ;
list_lit = "[" , [ args ] , "]" ;
(* 빈 리스트는 타입 주석이 필요하다: let xs: List[Int] = [] *)
closure = "fn" , "(" , [ cl_params ] , ")" ,
[ eff_param ] , [ "->" , type ] , block ;
cl_params = list<cl_param> ;
(* 클로저 파라미터의 소유권은 리터럴이 스스로 적는다. 타입은 기대 타입에서
* 읽어오지만 소유권은 읽어오지 않는다 — move 검사는 타입 검사와 별도 순회라
* 타입을 모르고, 소유권은 타입보다 결과가 크기 때문이다.
* 무표기는 빌림이다 (함수 파라미터와 같다) *)
cl_param = [ "own" ] , ident , [ ":" , type ] ;
(* 파라미터 타입 생략 가능. 호출 지점의 기대 타입에서 읽어온다 — 함수 로컬이다.
* 기대 타입이 없는 자리에서 생략하면 error *)
if_expr = "if" , expr_ns , block , [ "else" , ( block | if_expr ) ] ;
match_expr = "match" , expr_ns , "{" , brace_list<arm> , "}" ;
arm = pattern , "=>" , ( expr | block ) ;
scope_expr = "scope" , ident , "=" , ident , block ;
(* scope 자식 = 부모 { ... }
* 부모를 구문에 적는다. 적지 않으면 자식의 부모가 "가장 가까운 스코프"가 되어
* 정확히 ambient authority가 된다 — 이 언어가 배제하는 것 *)
(* ------------------------------------------------------------------ *)
(* 패턴 *)
(* ------------------------------------------------------------------ *)
pattern = "_" | literal | name_pattern ;
(* 이름 하나로는 바인딩인지 생성자인지 갈리지 않는다. 판정 규칙:
* 한정되었으면(Shapes.Dot) 언제나 생성자다
* 괄호가 붙으면 생성자다
* 둘 다 아니면 바인딩이거나, 이름 해소가 생성자로 판정한다 *)
name_pattern = ident , [ "." , ident ] , [ "(" , [ list<pattern> ] , ")" ] ;
(* 가드 없음. 중첩은 제한 없음 (exhaustiveness 알고리즘이 처리한다) *)