std: 마찰 보고의 F1/F3/F6/F7 처리 — 292줄이 244줄로

문법은 건드리지 않았다. 표본이 한 사람이 쓴 300줄 하나뿐인데 되돌리기
비싼 축을 움직일 수는 없다. std 보강만 했다.

추가: List.first/nth, String.join, std/option.cool, std/result.cool.

samples/app 재작성 결과 config.cool 196 → 148줄. head_or, second_or,
Pick, take_at, first_text, first_entry가 통째로 사라졌다. 예측 40줄,
실제 48줄 — F1의 값이 확인됐다.

F3은 줄 수로 값이 안 보인다. main.cool은 96줄 그대로다. 4단 중첩
String.concat이 4줄짜리 String.join 배열이 됐으니 줄 수가 같다. 읽기는
확실히 나아졌다. 줄 수는 읽기 좋음의 대리 지표일 뿐이고 여기서 그 대리가
깨진다 — 다음 개밥 먹기는 다른 것을 재야 한다.

F7: Interp.implemented와 std/*.cool 선언이 서로를 덮는지 테스트가 양방향
으로 검사한다. 어긋나면 빌드가 깨진다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ZVDeU6KLuUVL3gs18Hm3E
This commit is contained in:
2026-08-30 16:20:23 +09:00
co-authored by Claude Opus 5
parent 7be05be77f
commit a9f6527825
11 changed files with 260 additions and 90 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ m101.cool:21:3: match가 모든 경우를 덮지 않습니다 (빠진 경우: Up
## 실제로 써본 결과
`samples/app`은 검사기를 시험하려고 쓴 것이 아니라 일을 하려고 쓴
프로그램이다 — 설정 파서 + 리포트 도구, 2모듈 292줄.
프로그램이다 — 설정 파서 + 리포트 도구, 2모듈 244줄.
```
$ coolc run samples/app/main.cool samples/app/example.conf
+61
View File
@@ -184,6 +184,67 @@ pub copyable struct Numbered {
---
---
## 후속 (같은 날)
F1, F3, F6을 std 보강으로 처리하고 `samples/app`을 다시 썼다. 문법은
건드리지 않았다 — 표본이 한 사람이 쓴 300줄 하나뿐인데 되돌리기 비싼 축을
움직일 수는 없다.
추가한 것: `List.first`, `List.nth`, `String.join`, 그리고 `std/option.cool`,
`std/result.cool` (`map`, `unwrap_or`, `ok_or`, `map_err`, `is_ok`).
결과:
| | 전 | 후 |
|---|---|---|
| config.cool | 196줄 | **148줄** |
| main.cool | 96줄 | 96줄 |
| 합계 | 292줄 | **244줄** |
출력은 한 글자도 다르지 않다.
**F1의 값이 확인됐다.** 48줄이 사라졌고 전부 config.cool에서 나왔다 —
`head_or`, `second_or`, `Pick`, `take_at`, `first_text`, `first_entry`
통째로 없어졌다. 예측(40줄쯤)과 실제(48줄)가 맞았다.
```cool
// 전
let key = String.trim(head_or(parts, "")) // + Pick struct + take_at 20줄
// 후
let key = String.trim(Option.unwrap_or(List.nth(parts, 0), ""))
```
**F3은 줄 수로는 값이 안 보인다.** main.cool이 96줄 그대로다. 4단 중첩
`String.concat`을 4줄짜리 `String.join` 배열로 바꿨으니 줄 수가 같다.
그런데 읽기는 확실히 낫다:
```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), ")")))))))
// 후
c.print(String.join("", [
" ", e.key, " = ", Cfg.show_value(e.value),
" (", Cfg.type_name(e.value), ")",
]))
```
**줄 수는 읽기 좋음의 대리 지표일 뿐이고, F3에서 그 대리가 깨진다.**
다음 개밥 먹기에서는 줄 수 말고 다른 것을 재야 한다.
**F7도 처리했다.** `Interp.implemented` 목록과 `std/*.cool`의 선언이
서로를 덮는지 테스트가 양방향으로 검사한다. 어긋나면 빌드가 깨진다.
남은 것: F4(struct 부분 갱신), F5(fold 인덱스). 둘 다 문법 결정이거나
튜플이 필요하다. 표본을 더 모은 뒤에 판단한다.
---
## 결론
v1로 넘길 때 **F1과 F3이 먼저다.** 둘 다 "언어가 틀렸다"가 아니라
+3
View File
@@ -375,6 +375,9 @@ samples/app — 설정 파서 + 리포트 도구, 2모듈 292줄. 검사기를
항목 하나는 확인해 보니 관찰자가 틀린 것이었다 — 개밥 먹기의 불편은
언어의 성질일 수도, 쓴 사람의 습관일 수도 있다.
전문과 증거는 docs/friction.md. 이것이 v1 설계의 첫 입력이다.
후속: std 보강(List.first/nth, String.join, Option/Result 함수)으로 292줄이
244줄이 됐다. 문법은 건드리지 않았다 — 표본 하나로 되돌리기 비싼 축을
움직일 수는 없다.
■ 측정 (2026-08, v0 fast path)
100,391줄 / 200 모듈 (사슬 의존). bench/bench.ml로 재현.
+86 -7
View File
@@ -144,10 +144,8 @@ let root_capability name : value option =
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: 경로가 필요합니다" ])
);
with Sys_error m -> VEnum ("Result", "Err", [ VStr m ]))
| _ -> VEnum ("Result", "Err", [ VStr "read: 경로가 필요합니다" ]) );
] ))
| "Args" ->
Some
@@ -160,15 +158,56 @@ let root_capability name : value option =
Some (VScope "root")
| _ -> None
(* 런타임이 구현한 이름 전부. std/*.cool의 선언과 이 목록이 어긋나면 검사는
통과하고 실행이 죽는다 — 그 간극을 테스트가 막는다 (docs/friction.md F7).
v1에서 std를 coollang으로 구현하면 이 목록 자체가 사라진다. *)
let implemented =
[
"string.len";
"string.is_empty";
"string.concat";
"string.split";
"string.join";
"string.trim";
"string.starts_with";
"string.contains";
"int.show";
"int.abs";
"int.parse";
"bool.show";
"list.len";
"list.is_empty";
"list.first";
"list.nth";
"list.push";
"list.concat";
"list.reverse";
"list.each";
"list.map";
"list.filter";
"list.fold";
"option.is_some";
"option.map";
"option.unwrap_or";
"option.ok_or";
"result.is_ok";
"result.map";
"result.map_err";
"result.unwrap_or";
]
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.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)
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)
@@ -182,6 +221,27 @@ let builtin pos name (args : value list) : value =
| "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)
| "list.first", [ VList xs ] -> (
match xs with
| [] -> VEnum ("Option", "None", [])
| x :: _ -> VEnum ("Option", "Some", [ x ]))
| "list.nth", [ VList xs; VInt i ] -> (
match List.nth_opt xs i with
| Some x -> VEnum ("Option", "Some", [ x ])
| None -> VEnum ("Option", "None", []))
| "string.join", [ VStr sep; VList parts ] ->
VStr
(String.concat sep
(List.map (function VStr s -> s | v -> show v) parts))
| "option.is_some", [ VEnum ("Option", v, _) ] -> VBool (v = "Some")
| "option.unwrap_or", [ VEnum ("Option", "Some", [ x ]); _ ] -> x
| "option.unwrap_or", [ _; fallback ] -> fallback
| "option.ok_or", [ VEnum ("Option", "Some", [ x ]); _ ] ->
VEnum ("Result", "Ok", [ x ])
| "option.ok_or", [ _; e ] -> VEnum ("Result", "Err", [ e ])
| "result.is_ok", [ VEnum ("Result", v, _) ] -> VBool (v = "Ok")
| "result.unwrap_or", [ VEnum ("Result", "Ok", [ x ]); _ ] -> x
| "result.unwrap_or", [ _; fallback ] -> fallback
| _ ->
fail pos (Printf.sprintf "%s은(는) 런타임이 제공하지 않습니다 (표준 라이브러리가 아직 없습니다)" name)
@@ -305,9 +365,28 @@ and apply st pos f args =
| [ VList xs; f ] ->
VList
(List.filter
(fun x -> match apply st pos f [ x ] with VBool b -> b | _ -> false)
(fun x ->
match apply st pos f [ x ] with VBool b -> b | _ -> false)
xs)
| _ -> fail pos "list.filter는 리스트와 함수를 받습니다")
| VBuiltin "option.map" -> (
match args with
| [ VEnum ("Option", "Some", [ x ]); f ] ->
VEnum ("Option", "Some", [ apply st pos f [ x ] ])
| [ o; _ ] -> o
| _ -> fail pos "option.map은 Option과 함수를 받습니다")
| VBuiltin "result.map" -> (
match args with
| [ VEnum ("Result", "Ok", [ x ]); f ] ->
VEnum ("Result", "Ok", [ apply st pos f [ x ] ])
| [ r; _ ] -> r
| _ -> fail pos "result.map은 Result와 함수를 받습니다")
| VBuiltin "result.map_err" -> (
match args with
| [ VEnum ("Result", "Err", [ e ]); f ] ->
VEnum ("Result", "Err", [ apply st pos f [ e ] ])
| [ r; _ ] -> r
| _ -> fail pos "result.map_err는 Result와 함수를 받습니다")
| VBuiltin "list.fold" -> (
match args with
| [ VList xs; init; f ] ->
+13 -61
View File
@@ -7,6 +7,8 @@
import "cool.dev/std/list" as List
import "cool.dev/std/string" as String
import "cool.dev/std/int" as Int
import "cool.dev/std/option" as Option
import "cool.dev/std/result" as Result
// 설정 값. 타입이 셋뿐이므로 열거형이 맞다.
pub enum Value {
@@ -75,14 +77,14 @@ pub fn parse_line(cfg: Config, no: Int, raw: String) -> Config {
cfg
} else if List.len(parts) != 2 {
add_problem(cfg, no, String.concat("= 가 하나여야 합니다: ", line))
} else if String.is_empty(String.trim(head_or(parts, ""))) {
add_problem(cfg, no, "이름이 비어 있습니다")
} else {
add_entry(cfg, Entry {
line: no,
key: String.trim(head_or(parts, "")),
value: parse_value(String.trim(second_or(parts, ""))),
})
let key = String.trim(Option.unwrap_or(List.nth(parts, 0), ""))
let val = String.trim(Option.unwrap_or(List.nth(parts, 1), ""))
if String.is_empty(key) {
add_problem(cfg, no, "이름이 비어 있습니다")
} else {
add_entry(cfg, Entry { line: no, key: key, value: parse_value(val) })
}
}
}
@@ -100,37 +102,6 @@ pub fn add_problem(cfg: Config, no: Int, msg: String) -> Config {
}
}
// 인덱싱 연산자가 없으므로 앞의 둘을 꺼내는 일은 이름 있는 함수가 한다.
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 {
@@ -154,29 +125,10 @@ pub copyable struct Numbered {
// 이름으로 찾는다. 없으면 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),
}
})
Result.map(
Option.ok_or(List.first(hit), String.concat("설정에 없습니다: ", key)),
fn(e) { e.value },
)
}
pub fn get_int(cfg: Config, key: String) -> Result[Int, String] {
+15 -15
View File
@@ -8,6 +8,7 @@ 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
import "cool.dev/std/option" as Option
pub capability Console {
fn print(s: String) effects {Console.print}
@@ -24,14 +25,17 @@ pub capability Args {
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(String.join("", [
" ", e.key, " = ", Cfg.show_value(e.value),
" (", 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)), "개")))))
c.print(String.join("", [
"항목 ", Int.show(List.len(cfg.entries)),
"개, 문제 ", Int.show(List.len(cfg.problems)), "개",
]))
if List.is_empty(cfg.problems) {
c.print("문제 없음")
@@ -39,8 +43,7 @@ pub fn report(c: Console, cfg: Cfg.Config) effects {Console.print} {
c.print("")
c.print("문제")
List.each(cfg.problems, fn(p) {
c.print(String.concat(" ", String.concat(Int.show(p.line),
String.concat("행: ", p.message))))
c.print(String.join("", [ " ", Int.show(p.line), "행: ", p.message ]))
})
}
}
@@ -56,25 +59,22 @@ pub fn check_known(c: Console, cfg: Cfg.Config) effects {Console.print} {
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))))),
Ok(n) => c.print(String.join("", [ " ", key, " = ", 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" })))),
Ok(b) => c.print(String.join("", [
" ", key, " = ", 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("설정 파일 경로가 필요합니다"),
}
Option.ok_or(List.first(xs), "설정 파일 경로가 필요합니다")
}
pub fn main(c: Console, f: File, a: Args)
+7
View File
@@ -11,6 +11,13 @@ pub fn len[a](xs: List[a]) -> Int
pub fn is_empty[a](xs: List[a]) -> Bool
// 인덱싱 연산자가 언어에 없다. n번째를 꺼내는 일은 이름 있는 함수가 하고,
// 없을 수 있다는 사실은 Option이 말한다 — 범위를 벗어나면 예외도 기본값도
// 아니고 None이다.
pub fn first[a](xs: List[a]) -> Option[a]
pub fn nth[a](xs: List[a], i: Int) -> Option[a]
// 뒤에 하나 붙인 새 리스트. 제자리 수정이 아니다.
pub fn push[a](xs: List[a], x: a) -> List[a]
+15
View File
@@ -0,0 +1,15 @@
// 표준 라이브러리: Option.
//
// 타입 이름이 그 타입에 딸린 함수의 이름공간이다 — Option.map.
pub fn is_some[a](o: Option[a]) -> Bool
pub fn map[a, b, e: effects](
o: Option[a],
f: fn(a) effects e -> b,
) effects e -> Option[b]
pub fn unwrap_or[a](o: Option[a], fallback: a) -> a
// None을 오류로 바꾼다. "없음"과 "왜 없는지"는 다른 정보다.
pub fn ok_or[a, err](o: Option[a], e: err) -> Result[a, err]
+15
View File
@@ -0,0 +1,15 @@
// 표준 라이브러리: Result.
pub fn is_ok[a, err](r: Result[a, err]) -> Bool
pub fn map[a, b, err, e: effects](
r: Result[a, err],
f: fn(a) effects e -> b,
) effects e -> Result[b, err]
pub fn map_err[a, err, err2, e: effects](
r: Result[a, err],
f: fn(err) effects e -> err2,
) effects e -> Result[a, err2]
pub fn unwrap_or[a, err](r: Result[a, err], fallback: a) -> a
+3
View File
@@ -10,6 +10,9 @@ pub fn concat(a: String, b: String) -> String
// 구분자로 자른다. 구분자가 없으면 원본 하나짜리 리스트.
pub fn split(s: String, sep: String) -> List[String]
// split의 반대. concat이 2항이라 여러 조각을 이으면 중첩이 깊어진다.
pub fn join(sep: String, parts: List[String]) -> String
pub fn trim(s: String) -> String
pub fn starts_with(s: String, prefix: String) -> Bool
pub fn contains(s: String, needle: String) -> Bool
+41 -6
View File
@@ -1242,22 +1242,22 @@ let () =
let () =
let st = Session.create ~root:"../samples/app" ~std:"../std" () in
match Session.run ~args:[ "../samples/app/example.conf" ] st
"../samples/app/main.cool"
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 "항목 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행: = 가 하나여야 합니다");
(has_sub out "10행: 이름이 비어 있습니다" && has_sub out "11행: = 가 하나여야 합니다");
check "app: 타입 있는 조회" (has_sub out "port = 8080")
let () =
@@ -1271,3 +1271,38 @@ let () =
match Session.run ~args:[ "/없는/파일.conf" ] st "../samples/app/main.cool" with
| Ok out -> check "app: 없는 파일을 Err로 돌려준다" (has_sub out "오류: ")
| Error _ -> check "app: 없는 파일을 Err로 돌려준다" false
(* std 선언과 런타임 구현이 어긋나면 검사는 통과하고 실행이 죽는다.
v0에서 둘은 다른 파일에 있으므로 일치는 테스트가 지킨다 (friction F7). *)
let () =
let dir = "../std" in
let declared =
Sys.readdir dir |> Array.to_list
|> List.filter (fun f -> Filename.check_suffix f ".cool")
|> List.concat_map (fun f ->
let m = ref "" in
(match Driver.ast (Filename.concat dir f) with
| Error _ -> ()
| Ok _ -> m := Filename.remove_extension f);
match Driver.ast (Filename.concat dir f) with
| Error _ -> []
| Ok ast ->
List.filter_map
(function
| Ast.I_fn { decl; _ } -> Some (!m ^ "." ^ decl.fn_name)
| _ -> None)
ast.Ast.items)
in
check "std에 선언이 있다" (List.length declared > 20);
List.iter
(fun name ->
check
(Printf.sprintf "std 선언 %s에 런타임 구현이 있다" name)
(List.mem name Interp.implemented))
declared;
List.iter
(fun name ->
check
(Printf.sprintf "런타임 구현 %s에 std 선언이 있다" name)
(List.mem name declared))
Interp.implemented