diff --git a/README.md b/README.md index 247cbc8..aaaaf36 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,28 @@ m100.cool:15:3: match가 모든 경우를 덮지 않습니다 (빠진 경우: Tr m101.cool:21:3: match가 모든 경우를 덮지 않습니다 (빠진 경우: Up.Tri(_)) ``` +## 실제로 써본 결과 + +`samples/app`은 검사기를 시험하려고 쓴 것이 아니라 일을 하려고 쓴 +프로그램이다 — 설정 파서 + 리포트 도구, 2모듈 304줄. + +``` +$ coolc run samples/app/main.cool samples/app/example.conf +설정 + name = "coollang" (text) + threads = 4 (number) + ... +항목 5개, 문제 2개 + +문제 + 10행: 이름이 비어 있습니다 + 11행: = 가 하나여야 합니다: broken = a = b +``` + +쓰면서 걸린 것들을 [`docs/friction.md`](docs/friction.md)에 남겼다. +요약하면: **되돌리기 비싼 결정은 하나도 후회되지 않았고, 불편은 전부 +되돌리기 싼 것들이었다.** + ## 빌드 OCaml 5.x와 dune이 필요하다. diff --git a/bin/main.ml b/bin/main.ml index 4b6299b..8fc9226 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -4,7 +4,7 @@ let usage = 사용법: coolc check ... 타입/effect/capability 검사 (import를 따라 모듈 그래프 전체) coolc iface interface 표면과 해시 출력 - coolc run typed IR 인터프리터로 실행 + coolc run [인자...] typed IR 인터프리터로 실행 coolc tokens 토큰 덤프 (렉서 디버깅) coolc ast 구문 트리 덤프 (파서 디버깅) coolc deps 외부 참조 목록 (모듈의 의존 표면) @@ -76,9 +76,9 @@ let () = match List.tl argv with | "check" :: files -> check_graph files | [ "iface"; file ] -> dump_iface file - | [ "run"; file ] -> ( + | "run" :: file :: args -> ( let st = Coollang.Session.create ~root:(Filename.dirname file) () in - match Coollang.Session.run st file with + match Coollang.Session.run ~args st file with | Ok out -> print_string out; 0 diff --git a/docs/friction.md b/docs/friction.md new file mode 100644 index 0000000..302ddc4 --- /dev/null +++ b/docs/friction.md @@ -0,0 +1,188 @@ +# 개밥 먹기 보고 — samples/app을 쓰면서 걸린 것들 + +2026-08-30. coollang으로 처음 쓴 "일하는 프로그램" 하나(설정 파서 + 리포트 +도구, 2모듈 304줄)에서 실제로 걸린 마찰을 적는다. + +v0의 샘플 16개는 전부 검사기를 시험하려고 쓴 것이고, 그래서 "언어가 쓸 +만한가"에 대해서는 아무것도 말해주지 않았다. 이 문서가 v0가 남기는 마지막 +데이터이자 v1 설계의 첫 입력이다. + +기록 원칙: **불편은 증거와 함께 적고, 해법은 제안까지만 한다.** 여기서 +바로 고치면 그것은 개밥 먹기가 아니라 기능 추가가 된다. + +--- + +## 잘 된 것부터 + +**1. 시그니처가 프로그램의 전부를 말한다.** + +```cool +pub fn main(c: Console, f: File, a: Args) + effects {Console.print, File.read, Args.all} +``` + +이 한 줄을 읽으면 이 프로그램이 할 수 있는 일이 끝난다. 네트워크를 쓸 수 +없고, 다른 파일을 쓸 수 없고, 프로세스를 띄울 수 없다 — 문서가 아니라 +컴파일러가 보장한다. 300줄을 쓰는 내내 이것이 어색하지 않았다. 오히려 +`f.read`를 쓰려고 `File`을 인자에 추가하는 순간이 "이 함수가 권한을 하나 +더 갖는다"는 사실을 자각하게 만들었다. + +**2. `?`가 기대대로 동작했다.** + +```cool +let path = first_arg(a.all())? +let text = f.read(path)? +``` + +읽기 좋고, 실패 경로가 보이고, 삼켜지지 않는다. + +**3. 소진성이 실전에서 값을 했다.** + +`Value`에 경우 하나(`List`)를 추가해 봤다: + +``` +config.cool:54:5: 빠진 경우: List(_) +config.cool:62:5: 빠진 경우: List(_) +config.cool:196:5: 빠진 경우: List(_) +config.cool:204:5: 빠진 경우: List(_) +``` + +고쳐야 할 자리 넷을 전부, 정확히 짚었다. 이것이 없으면 `show_value`만 +고치고 `get_int`를 잊는다. + +**4. 모듈 경계가 자연스러웠다.** 파싱(`config`)과 출력(`main`)을 나누는 데 +마찰이 없었고, `Cfg.Config` 같은 한정 이름이 오히려 읽기 좋았다. + +--- + +## 걸린 것 — 심각한 순서로 + +### F1. 리스트의 n번째를 꺼낼 방법이 없다 (심각) + +인덱싱 연산자를 뺀 결정 자체는 옳다고 본다. 그런데 `std`에 대안이 없다. +"첫 원소"를 꺼내려고 이 코드를 네 번 썼다: + +```cool +pub fn first_text(xs: List[String]) -> Option[String] { + List.fold(xs, None, fn(acc, x) { + match acc { + Some(prev) => Some(prev), + None => Some(x), + } + }) +} +``` + +`String.split(line, "=")`의 결과에서 앞의 둘을 꺼내는 데는 보조 struct +`Pick`까지 만들어야 했다 — 순전히 "몇 번째를 보고 있는가"를 나르려고. +**304줄 중 약 40줄이 이 문제 하나 때문에 존재한다.** + +> 제안: `List.first`, `List.nth(xs, i) -> Option[a]`, 그리고 `String.split` +> 같은 자리에서 흔한 `List.split_first(xs) -> Option[(a, List[a])]`. +> 튜플이 없으므로 마지막 것은 문법 결정을 동반한다. + +### F2. `else if`가 없다 (심각) + +`if`가 식이고 `else`는 식 하나를 받으므로, 문법상 `else if`가 가능해야 +하는데 파서가 받지 않는다. 그래서 이렇게 된다: + +```cool +if raw == "true" { + Flag(true) +} else { + if raw == "false" { + Flag(false) + } else { + match Int.parse(raw) { ... } + } +} +``` + +세 갈래 분기가 3단 중첩이 된다. `parse_line`은 4단까지 갔다. +**이것은 언어 결정이 아니라 파서의 빈틈으로 보인다.** 확인이 필요하다. + +> 제안: `else` 뒤에 블록 대신 `if` 식을 허용한다. 새 개념이 아니라 +> 이미 있는 규칙(else는 식을 받는다)의 적용이다. + +### F3. `String.concat`이 2항이라 중첩 지옥이 된다 (심각) + +리포트 한 줄이 이렇게 생겼다: + +```cool +c.print(String.concat(" ", String.concat(e.key, + String.concat(" = ", String.concat(Cfg.show_value(e.value), + String.concat(" (", String.concat(Cfg.type_name(e.value), ")"))))))) +``` + +읽을 수 없다. 이 프로그램에서 가장 나쁜 코드이고, 원인은 명확하다. + +> 제안 두 가지. (a) `String.join(sep, List[String])` — 작고 안전하다. +> (b) 문자열 보간 `"${key} = ${value}"` — 훨씬 낫지만 문법과 타입 규칙을 +> 정해야 하고, "한 개념 한 방식"에서 concat과 겹친다. +> 지금 판단으로는 (a)를 먼저 넣고 (b)는 체감 데이터를 더 모은 뒤. + +### F4. struct의 한 필드만 바꿀 방법이 없다 (중간) + +```cool +pub fn add_entry(cfg: Config, e: Entry) -> Config { + Config { + entries: List.push(cfg.entries, e), + problems: cfg.problems, // ← 안 바뀌는데 적어야 한다 + } +} +``` + +필드가 둘이라 견딜 만하지만 다섯이면 못 쓴다. `take_at`은 세 필드를 매번 +전부 나열한다. + +> 제안: `Config { ..cfg, entries: x }`. affine 타입에서는 `cfg`가 move되는 +> 것이므로 소유권 규칙과 충돌하지 않는다. 오히려 명시적이다. + +### F5. fold에 인덱스가 없다 (중간) + +줄 번호를 세려고 struct를 하나 더 만들었다: + +```cool +pub copyable struct Numbered { + no: Int, + cfg: Config, +} +``` + +"인덱스가 필요한 fold"는 드문 요구가 아니다. + +> 제안: `List.fold_indexed`. 또는 `List.enumerate`가 더 조합적이지만 +> 튜플이 없어서 지금은 불가능하다. F1의 튜플 문제와 같은 뿌리다. + +### F6. Option/Result에 조작 함수가 하나도 없다 (중간) + +`map`, `unwrap_or`, `or_else`가 없어서 전부 `match`로 풀었다. `match`가 +나쁜 것은 아니지만, 세 줄이면 될 것이 여섯 줄이 된다. + +> 제안: `Option.map/unwrap_or`, `Result.map/map_err/unwrap_or`. +> 타입 이름이 함수의 이름공간이라는 규칙이 이미 있으므로 문법 결정은 없다. + +### F7. std를 늘릴 때마다 인터프리터를 고쳐야 한다 (구조) + +`std/list.cool`에 `filter`를 적으면 `lib/interp.ml`에도 구현을 넣어야 +한다. 두 곳이 어긋나면 검사는 통과하고 실행이 죽는다. + +이것은 v0의 구조적 한계이고 v1에서 사라진다(std를 coollang으로 구현). +다만 v0 동안은 **std 시그니처와 런타임 구현이 일치하는지 검사하는 테스트**가 +있어야 한다. 지금은 없다. + +--- + +## 결론 + +v1로 넘길 때 **F1, F2, F3이 먼저다.** 셋 다 "언어가 틀렸다"가 아니라 +"없어서 우회했다"이고, 우회 비용이 코드에 그대로 보인다 — 304줄 중 +70줄쯤이 이 셋 때문에 존재한다. + +반대로 **되돌리기 비싼 결정들은 하나도 후회되지 않았다.** capability를 +인자로 나르는 것, effect를 시그니처에 적는 것, 실패를 버릴 수 없는 것, +소진적 match — 300줄을 쓰는 동안 이 넷이 거추장스러웠던 순간이 없었고, +소진성은 오히려 실수를 잡아줬다. + +v0의 질문은 "되돌리기 비싼 결정이 옳은가"였다. 답은 **그렇다**이고, +남은 불편은 전부 되돌리기 싼 것들이다. diff --git a/docs/thesis.md b/docs/thesis.md index 032ecb1..58f4183 100644 --- a/docs/thesis.md +++ b/docs/thesis.md @@ -366,6 +366,14 @@ prelude는 없다. std도 명시적으로 가져온다 — 암묵적으로 끌 것이고, 이것이 std를 "부채 상환"이 아니라 "검증"으로 본 이유다. 결과를 버릴 방법이 언어에 없다는 성질도 여기서 처음 확인됐다. +■ 개밥 먹기 (2026-08) +samples/app — 설정 파서 + 리포트 도구, 2모듈 304줄. 검사기를 시험하려고 +쓴 것이 아니라 일을 하려고 쓴 첫 프로그램이다. +결과: 되돌리기 비싼 결정은 하나도 후회되지 않았고(capability 전달, effect +명시, 실패를 버릴 수 없음, 소진적 match), 불편은 전부 되돌리기 싼 것들이었다 +— 리스트 n번째 접근 없음, else if 없음, String.concat 2항. +전문과 증거는 docs/friction.md. 이것이 v1 설계의 첫 입력이다. + ■ 측정 (2026-08, v0 fast path) 100,391줄 / 200 모듈 (사슬 의존). bench/bench.ml로 재현. cold 전체 검사 245ms diff --git a/lib/interp.ml b/lib/interp.ml index e14cfa1..f37a1d9 100644 --- a/lib/interp.ml +++ b/lib/interp.ml @@ -81,8 +81,46 @@ let bind (env : env) n v : env = (* 런타임이 제공하는 것 *) (* ------------------------------------------------------------------ *) +(* 문자열 도우미. 언어에 인덱싱 연산자가 없으므로 이 일은 런타임 몫이다. *) +let find_sub hay needle = + let n = String.length needle and h = String.length hay in + let rec go i = + if i + n > h then None + else if String.sub hay i n = needle then Some i + else go (i + 1) + in + if n = 0 then Some 0 else go 0 + +let split_on s sep = + if sep = "" then [ s ] + else + let n = String.length sep in + let rec go s acc = + match find_sub s sep with + | None -> List.rev (s :: acc) + | Some i -> + go + (String.sub s (i + n) (String.length s - i - n)) + (String.sub s 0 i :: acc) + in + go s [] + let out = Buffer.create 1024 +(* 프로그램 인자. 런타임이 들고 있다가 Args capability를 통해서만 준다 — + 전역 변수로 아무 데서나 읽을 수 있으면 그것이 ambient authority다. *) +let argv : string list ref = ref [] + +let read_whole path = + let ic = open_in_bin path in + let n = in_channel_length ic in + let s = really_input_string ic n in + close_in ic; + s + +(* IO 오류는 문자열로 돌려준다. 런타임이 사용자 정의 enum을 만들 수는 없고, + 만들 수 있게 하면 런타임이 프로그램의 타입을 알아야 한다. v0의 선은 + 여기다 — Result[a, String]. *) let root_capability name : value option = match name with | "Console" -> @@ -96,6 +134,26 @@ let root_capability name : value option = Buffer.add_char out '\n'; VUnit ); ] )) + | "File" -> + Some + (VCap + ( "File", + [ + ( "read", + fun args -> + match args with + | [ VStr path ] -> ( + try VEnum ("Result", "Ok", [ VStr (read_whole path) ]) + with Sys_error m -> + VEnum ("Result", "Err", [ VStr m ])) + | _ -> VEnum ("Result", "Err", [ VStr "read: 경로가 필요합니다" ]) + ); + ] )) + | "Args" -> + Some + (VCap + ( "Args", + [ ("all", fun _ -> VList (List.map (fun s -> VStr s) !argv)) ] )) | "TaskScope" -> (* 루트 스코프. 구조적 동시성의 뿌리도 런타임이 준다 — 프로그램이 스스로 만들 수 있으면 부모 없는 작업이 생긴다. *) @@ -106,10 +164,24 @@ let builtin pos name (args : value list) : value = match (name, args) with | "string.concat", [ VStr a; VStr b ] -> VStr (a ^ b) | "string.len", [ VStr a ] -> VInt (String.length a) + | "string.is_empty", [ VStr a ] -> VBool (a = "") + | "string.split", [ VStr s; VStr sep ] -> VList (List.map (fun x -> VStr x) (split_on s sep)) + | "string.trim", [ VStr s ] -> VStr (String.trim s) + | "string.starts_with", [ VStr s; VStr p ] -> + VBool (String.length s >= String.length p && String.sub s 0 (String.length p) = p) + | "string.contains", [ VStr s; VStr n ] -> VBool (find_sub s n <> None) | "int.show", [ VInt n ] -> VStr (string_of_int n) | "int.abs", [ VInt n ] -> VInt (abs n) + | "int.parse", [ VStr s ] -> ( + match int_of_string_opt (String.trim s) with + | Some n -> VEnum ("Result", "Ok", [ VInt n ]) + | None -> VEnum ("Result", "Err", [ VStr (s ^ "은(는) 정수가 아닙니다") ])) | "bool.show", [ VBool b ] -> VStr (if b then "true" else "false") | "list.len", [ VList xs ] -> VInt (List.length xs) + | "list.is_empty", [ VList xs ] -> VBool (xs = []) + | "list.push", [ VList xs; x ] -> VList (xs @ [ x ]) + | "list.concat", [ VList xs; VList ys ] -> VList (xs @ ys) + | "list.reverse", [ VList xs ] -> VList (List.rev xs) | _ -> fail pos (Printf.sprintf "%s은(는) 런타임이 제공하지 않습니다 (표준 라이브러리가 아직 없습니다)" name) @@ -228,6 +300,19 @@ and apply st pos f args = match args with | [ VList xs; f ] -> VList (List.map (fun x -> apply st pos f [ x ]) xs) | _ -> fail pos "list.map은 리스트와 함수를 받습니다") + | VBuiltin "list.filter" -> ( + match args with + | [ VList xs; f ] -> + VList + (List.filter + (fun x -> match apply st pos f [ x ] with VBool b -> b | _ -> false) + xs) + | _ -> fail pos "list.filter는 리스트와 함수를 받습니다") + | VBuiltin "list.fold" -> ( + match args with + | [ VList xs; init; f ] -> + List.fold_left (fun acc x -> apply st pos f [ acc; x ]) init xs + | _ -> fail pos "list.fold는 리스트, 초기값, 함수를 받습니다") | VBuiltin n -> builtin pos n args | VNative f -> f args | other -> fail pos (Printf.sprintf "%s은(는) 부를 수 없습니다" (show other)) @@ -313,10 +398,11 @@ and eval_binary st env op a b pos = (* main이 선언한 capability만 런타임이 넘긴다. 선언하지 않은 권한은 프로그램 안에 존재하지 않는다. *) -let run (prog : Ir.program) (entry : string) +let run ?(args = []) (prog : Ir.program) (entry : string) (main_params : (string * string) list) : (string, Token.pos * string) result = Buffer.clear out; + argv := args; let st = { prog } in match Hashtbl.find_opt prog.Ir.fns (entry ^ "#main") with | None -> Error (Token.{ line = 0; col = 0 }, "main 함수가 없습니다") diff --git a/lib/session.ml b/lib/session.ml index 480a009..db18e35 100644 --- a/lib/session.ml +++ b/lib/session.ml @@ -258,7 +258,7 @@ let main_params (m : Ast.modul) = | _ -> []) m.items -let run st path : (string, error) result = +let run ?(args = []) st path : (string, error) result = load st path; let errs = errors st in if errs <> [] then Error (List.hd errs) @@ -275,6 +275,6 @@ let run st path : (string, error) result = st.modules [] in let prog = Ir.of_program mods in - match Interp.run prog path (main_params e.ast) with + match Interp.run ~args prog path (main_params e.ast) with | Ok out -> Ok out | Error (pos, msg) -> Error (err_of path pos msg)) diff --git a/samples/app/config.cool b/samples/app/config.cool new file mode 100644 index 0000000..b502e6c --- /dev/null +++ b/samples/app/config.cool @@ -0,0 +1,208 @@ +// 설정 파일 파서. +// +// 이 파일은 검사기를 시험하려고 쓴 것이 아니라 일을 하려고 쓴 것이다. +// 그것이 이 파일의 목적이다 — 언어가 실제로 쓸 만한지는 이런 코드에서만 +// 드러난다. + +import "cool.dev/std/list" as List +import "cool.dev/std/string" as String +import "cool.dev/std/int" as Int + +// 설정 값. 타입이 셋뿐이므로 열거형이 맞다. +pub enum Value { + Text(String), + Number(Int), + Flag(Bool), +} + +pub copyable struct Entry { + line: Int, + key: String, + value: Value, +} + +// 오류에 줄 번호가 있어야 사람이 고칠 수 있다. +pub copyable struct Problem { + line: Int, + message: String, +} + +pub copyable struct Config { + entries: List[Entry], + problems: List[Problem], +} + +// 값 하나를 읽는다. 따옴표도 타입 표기도 없다 — 모양으로 정한다. +// true/false는 Flag, 정수로 읽히면 Number, 나머지는 Text. +pub fn parse_value(raw: String) -> Value { + if raw == "true" { + Flag(true) + } else { + if raw == "false" { + Flag(false) + } else { + match Int.parse(raw) { + Ok(n) => Number(n), + Err(_) => Text(raw), + } + } + } +} + +pub fn show_value(v: Value) -> String { + match v { + Text(s) => String.concat("\"", String.concat(s, "\"")), + Number(n) => Int.show(n), + Flag(b) => if b { "true" } else { "false" }, + } +} + +pub fn type_name(v: Value) -> String { + match v { + Text(_) => "text", + Number(_) => "number", + Flag(_) => "flag", + } +} + +// 한 줄을 읽는다. 빈 줄과 주석은 값이 없고, 그것은 오류가 아니다. +// 그래서 Result가 아니라 Option[Result[...]]가 필요해 보이지만, 그러면 +// 부르는 쪽이 두 겹을 벗겨야 한다. Config 하나에 둘 다 모으는 편이 낫다. +pub fn parse_line(cfg: Config, no: Int, raw: String) -> Config { + let line = String.trim(raw) + if String.is_empty(line) { + cfg + } else { + if String.starts_with(line, "#") { + cfg + } else { + let parts = String.split(line, "=") + if List.len(parts) == 2 { + let key = String.trim(head_or(parts, "")) + let val = String.trim(second_or(parts, "")) + if String.is_empty(key) { + add_problem(cfg, no, "이름이 비어 있습니다") + } else { + add_entry(cfg, Entry { + line: no, + key: key, + value: parse_value(val), + }) + } + } else { + add_problem(cfg, no, String.concat("= 가 하나여야 합니다: ", line)) + } + } + } +} + +pub fn add_entry(cfg: Config, e: Entry) -> Config { + Config { + entries: List.push(cfg.entries, e), + problems: cfg.problems, + } +} + +pub fn add_problem(cfg: Config, no: Int, msg: String) -> Config { + Config { + entries: cfg.entries, + problems: List.push(cfg.problems, Problem { line: no, message: msg }), + } +} + +// 인덱싱 연산자가 없으므로 앞의 둘을 꺼내는 일은 이름 있는 함수가 한다. +pub fn head_or(xs: List[String], fallback: String) -> String { + List.fold(xs, Pick { taken: false, at: 0, found: fallback }, fn(p, x) { + take_at(p, x, 0) + }).found +} + +pub fn second_or(xs: List[String], fallback: String) -> String { + List.fold(xs, Pick { taken: false, at: 0, found: fallback }, fn(p, x) { + take_at(p, x, 1) + }).found +} + +// fold로 n번째를 고른다. 셋 다 필요하다 — 몇 번째를 보고 있는지(at), +// 이미 골랐는지(taken), 무엇을 골랐는지(found). +pub copyable struct Pick { + taken: Bool, + at: Int, + found: String, +} + +pub fn take_at(p: Pick, x: String, want: Int) -> Pick { + if p.taken { + Pick { taken: true, at: p.at + 1, found: p.found } + } else { + if p.at == want { + Pick { taken: true, at: p.at + 1, found: x } + } else { + Pick { taken: false, at: p.at + 1, found: p.found } + } + } +} + +pub fn parse(text: String) -> Config { + let lines = String.split(text, "\n") + let start = Numbered { + no: 1, + cfg: Config { entries: [], problems: [] }, + } + List.fold(lines, start, fn(acc, line) { + Numbered { + no: acc.no + 1, + cfg: parse_line(acc.cfg, acc.no, line), + } + }).cfg +} + +// 줄 번호를 같이 나르기 위한 것. fold에 인덱스가 없으니 직접 센다. +pub copyable struct Numbered { + no: Int, + cfg: Config, +} + +// 이름으로 찾는다. 없으면 Err — 못 찾은 것은 오류지 빈 값이 아니다. +pub fn lookup(cfg: Config, key: String) -> Result[Value, String] { + let hit = List.filter(cfg.entries, fn(e) { e.key == key }) + match first_entry(hit) { + Some(e) => Ok(e.value), + None => Err(String.concat("설정에 없습니다: ", key)), + } +} + +pub fn first_entry(xs: List[Entry]) -> Option[Entry] { + List.fold(xs, None, fn(acc, e) { + match acc { + Some(prev) => Some(prev), + None => Some(e), + } + }) +} + +// 리스트의 첫 원소. fold로 쓴다 — 언어에 인덱싱이 없다. +pub fn first_text(xs: List[String]) -> Option[String] { + List.fold(xs, None, fn(acc, x) { + match acc { + Some(prev) => Some(prev), + None => Some(x), + } + }) +} + +pub fn get_int(cfg: Config, key: String) -> Result[Int, String] { + match lookup(cfg, key)? { + Number(n) => Ok(n), + Text(_) => Err(String.concat(key, "은(는) 정수가 아닙니다")), + Flag(_) => Err(String.concat(key, "은(는) 정수가 아닙니다")), + } +} + +pub fn get_flag(cfg: Config, key: String) -> Result[Bool, String] { + match lookup(cfg, key)? { + Flag(b) => Ok(b), + Text(_) => Err(String.concat(key, "은(는) 참거짓이 아닙니다")), + Number(_) => Err(String.concat(key, "은(는) 참거짓이 아닙니다")), + } +} diff --git a/samples/app/example.conf b/samples/app/example.conf new file mode 100644 index 0000000..7955c26 --- /dev/null +++ b/samples/app/example.conf @@ -0,0 +1,11 @@ +# 예제 설정 + +name = coollang +threads = 4 +port = 8080 +verbose = true +greeting = hello world + +# 아래 두 줄은 일부러 틀렸다 += 42 +broken = a = b diff --git a/samples/app/main.cool b/samples/app/main.cool new file mode 100644 index 0000000..aa8df4d --- /dev/null +++ b/samples/app/main.cool @@ -0,0 +1,96 @@ +// 설정 파일 리포트 도구. +// +// 파일을 읽고, 파싱하고, 문제를 보고하고, 요약을 낸다. +// 권한은 셋뿐이다 — 읽기(File), 인자(Args), 출력(Console). 그 밖의 일은 +// 이 프로그램이 할 수 없다. 시그니처에 적힌 것이 이 프로그램의 전부다. + +import "config" as Cfg +import "cool.dev/std/list" as List +import "cool.dev/std/string" as String +import "cool.dev/std/int" as Int + +pub capability Console { + fn print(s: String) effects {Console.print} +} + +pub capability File { + fn read(path: String) effects {File.read} -> Result[String, String] +} + +pub capability Args { + fn all() effects {Args.all} -> List[String] +} + +pub fn report(c: Console, cfg: Cfg.Config) effects {Console.print} { + c.print("설정") + List.each(cfg.entries, fn(e) { + c.print(String.concat(" ", String.concat(e.key, + String.concat(" = ", String.concat(Cfg.show_value(e.value), + String.concat(" (", String.concat(Cfg.type_name(e.value), ")"))))))) + }) + + c.print("") + c.print(String.concat("항목 ", String.concat(Int.show(List.len(cfg.entries)), + String.concat("개, 문제 ", String.concat(Int.show(List.len(cfg.problems)), "개"))))) + + if List.is_empty(cfg.problems) { + c.print("문제 없음") + } else { + c.print("") + c.print("문제") + List.each(cfg.problems, fn(p) { + c.print(String.concat(" ", String.concat(Int.show(p.line), + String.concat("행: ", p.message)))) + }) + } +} + +// 타입이 있는 조회. 없거나 타입이 다르면 Err이고, 둘 다 사람이 읽을 수 있다. +pub fn check_known(c: Console, cfg: Cfg.Config) effects {Console.print} { + c.print("") + c.print("알려진 설정") + show_int(c, cfg, "threads") + show_int(c, cfg, "port") + show_flag(c, cfg, "verbose") +} + +pub fn show_int(c: Console, cfg: Cfg.Config, key: String) effects {Console.print} { + match Cfg.get_int(cfg, key) { + Ok(n) => c.print(String.concat(" ", String.concat(key, + String.concat(" = ", Int.show(n))))), + Err(m) => c.print(String.concat(" ", m)), + } +} + +pub fn show_flag(c: Console, cfg: Cfg.Config, key: String) effects {Console.print} { + match Cfg.get_flag(cfg, key) { + Ok(b) => c.print(String.concat(" ", String.concat(key, + String.concat(" = ", if b { "true" } else { "false" })))), + Err(m) => c.print(String.concat(" ", m)), + } +} + +pub fn first_arg(xs: List[String]) -> Result[String, String] { + match Cfg.first_text(xs) { + Some(p) => Ok(p), + None => Err("설정 파일 경로가 필요합니다"), + } +} + +pub fn main(c: Console, f: File, a: Args) + effects {Console.print, File.read, Args.all} { + match run(c, f, a) { + Ok(_) => unit, + Err(m) => c.print(String.concat("오류: ", m)), + } +} + +pub fn run(c: Console, f: File, a: Args) + effects {Console.print, File.read, Args.all} -> Result[Unit, String] { + let path = first_arg(a.all())? + let text = f.read(path)? + let cfg = Cfg.parse(text) + report(c, cfg) + check_known(c, cfg) + Ok(unit) +} diff --git a/std/int.cool b/std/int.cool index 9ea6ec2..74ffb9b 100644 --- a/std/int.cool +++ b/std/int.cool @@ -2,3 +2,6 @@ pub fn show(n: Int) -> String pub fn abs(n: Int) -> Int + +// 실패할 수 있으므로 Result다. 예외도, 0을 돌려주는 관례도 없다. +pub fn parse(s: String) -> Result[Int, String] diff --git a/std/list.cool b/std/list.cool index 7acae52..00c8bc1 100644 --- a/std/list.cool +++ b/std/list.cool @@ -2,14 +2,22 @@ // // 본문이 없다. 런타임이 구현하고, 이 파일은 그 계약을 말한다. // 그래서 이 파일은 구현이 아니라 시험대다 — effect 다형성이 실제로 쓸 만한지가 -// 여기서 결정된다. each와 map이 effect 변수 하나로 표현되지 않으면 규칙이 -// 틀린 것이고, 그건 v1로 미룰 수 없는 발견이다. +// each, map, fold, filter에서 결정된다. 규칙이 틀렸으면 여기서 드러난다. // // e는 파라미터의 effect 슬롯에 홀로 나타난다 (결정 위치). 호출 지점에서 // 인자의 시그니처를 읽어 묶인다 — 추론이 아니라 읽기다. pub fn len[a](xs: List[a]) -> Int +pub fn is_empty[a](xs: List[a]) -> Bool + +// 뒤에 하나 붙인 새 리스트. 제자리 수정이 아니다. +pub fn push[a](xs: List[a], x: a) -> List[a] + +pub fn concat[a](xs: List[a], ys: List[a]) -> List[a] + +pub fn reverse[a](xs: List[a]) -> List[a] + pub fn each[a, e: effects]( xs: List[a], f: fn(a) effects e, @@ -19,3 +27,14 @@ pub fn map[a, b, e: effects]( xs: List[a], f: fn(a) effects e -> b, ) effects e -> List[b] + +pub fn filter[a, e: effects]( + xs: List[a], + keep: fn(a) effects e -> Bool, +) effects e -> List[a] + +pub fn fold[a, acc, e: effects]( + xs: List[a], + init: acc, + f: fn(acc, a) effects e -> acc, +) effects e -> acc diff --git a/std/string.cool b/std/string.cool index a836b62..6fb6c1e 100644 --- a/std/string.cool +++ b/std/string.cool @@ -1,4 +1,15 @@ // 표준 라이브러리: 문자열. +// +// 인덱싱 연산자가 언어에 없다. 문자열을 자르는 일은 이름 있는 함수가 한다 — +// 한 개념 한 방식. pub fn len(s: String) -> Int +pub fn is_empty(s: String) -> Bool pub fn concat(a: String, b: String) -> String + +// 구분자로 자른다. 구분자가 없으면 원본 하나짜리 리스트. +pub fn split(s: String, sep: String) -> List[String] + +pub fn trim(s: String) -> String +pub fn starts_with(s: String, prefix: String) -> Bool +pub fn contains(s: String, needle: String) -> Bool diff --git a/test/dune b/test/dune index bac39b0..80a9b72 100644 --- a/test/dune +++ b/test/dune @@ -3,4 +3,7 @@ (libraries coollang) (deps (glob_files %{workspace_root}/samples/*.cool) - (glob_files %{workspace_root}/std/*.cool))) + (glob_files %{workspace_root}/std/*.cool) + (glob_files %{workspace_root}/samples/app/*) + (glob_files %{workspace_root}/samples/modules/*.cool) + (glob_files %{workspace_root}/samples/run/*.cool))) diff --git a/test/test_coollang.ml b/test/test_coollang.ml index f5f10f3..88d6622 100644 --- a/test/test_coollang.ml +++ b/test/test_coollang.ml @@ -1232,3 +1232,42 @@ let () = in List.exists (fun m -> has_sub m "가져왔지만 쓰지 않습니다") ms && List.exists (fun m -> has_sub m "String이(가) 필요한데 Int") ms) + +(* ------------------------------------------------------------------ *) +(* 실제 프로그램 *) +(* *) +(* samples/app은 검사기를 시험하려고 쓴 것이 아니라 일을 하려고 쓴 것이다. *) +(* 언어가 쓸 만한지는 이런 코드에서만 드러난다. *) +(* ------------------------------------------------------------------ *) + +let () = + let st = Session.create ~root:"../samples/app" ~std:"../std" () in + match Session.run ~args:[ "../samples/app/example.conf" ] st + "../samples/app/main.cool" + with + | Error e -> + Printf.printf " (실행 오류: %s)\n" (Session.string_of_error e); + check "app: 설정 리포트가 돈다" false + | Ok out -> + check "app: 항목과 문제를 센다" + (has_sub out "항목 5개, 문제 2개"); + check "app: 값의 타입을 모양으로 정한다" + (has_sub out "threads = 4 (number)" + && has_sub out "verbose = true (flag)" + && has_sub out "name = \"coollang\" (text)"); + check "app: 문제에 줄 번호가 붙는다" + (has_sub out "10행: 이름이 비어 있습니다" + && has_sub out "11행: = 가 하나여야 합니다"); + check "app: 타입 있는 조회" (has_sub out "port = 8080") + +let () = + let st = Session.create ~root:"../samples/app" ~std:"../std" () in + match Session.run st "../samples/app/main.cool" with + | Ok out -> check "app: 인자가 없으면 말해준다" (has_sub out "경로가 필요합니다") + | Error _ -> check "app: 인자가 없으면 말해준다" false + +let () = + let st = Session.create ~root:"../samples/app" ~std:"../std" () in + match Session.run ~args:[ "/없는/파일.conf" ] st "../samples/app/main.cool" with + | Ok out -> check "app: 없는 파일을 Err로 돌려준다" (has_sub out "오류: ") + | Error _ -> check "app: 없는 파일을 Err로 돌려준다" false