app: 개밥 먹기 — 일하는 프로그램 하나와 그 마찰 보고
1단계 최소 IO: File(읽기), Args capability. 권한의 출처는 여전히 런타임 하나이고, IO 오류는 Result[a, String]이다 — 런타임이 사용자 정의 enum을 만들 수 없고, 만들 수 있게 하면 런타임이 프로그램의 타입을 알아야 한다. 2단계 std 확장: fold, filter, push, concat, reverse, is_empty, String.split/trim/starts_with/contains, Int.parse. 3단계 samples/app: 설정 파서 + 리포트 도구, 2모듈 304줄. 검사기를 시험 하려고 쓴 것이 아니라 일을 하려고 쓴 첫 프로그램이다. 산출물은 프로그램이 아니라 docs/friction.md다. 요약: - 되돌리기 비싼 결정은 하나도 후회되지 않았다. capability 전달, effect 명시, 실패를 버릴 수 없음, 소진적 match — 300줄 내내 거추장스럽지 않았고 소진성은 실제로 실수를 잡았다(Value에 경우 하나 추가하니 고칠 자리 넷을 정확히 짚었다). - 불편은 전부 되돌리기 싼 것들이었다. 리스트 n번째 접근이 없어 fold로 우회(40줄), else if가 없어 3~4단 중첩, String.concat이 2항이라 중첩 지옥. 304줄 중 70줄쯤이 이 셋 때문에 존재한다. v0의 질문은 "되돌리기 비싼 결정이 옳은가"였고 답은 그렇다이다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZVDeU6KLuUVL3gs18Hm3E
This commit is contained in:
@@ -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, "은(는) 참거짓이 아닙니다")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# 예제 설정
|
||||
|
||||
name = coollang
|
||||
threads = 4
|
||||
port = 8080
|
||||
verbose = true
|
||||
greeting = hello world
|
||||
|
||||
# 아래 두 줄은 일부러 틀렸다
|
||||
= 42
|
||||
broken = a = b
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user