// 설정 파일 파서. // // 이 파일은 검사기를 시험하려고 쓴 것이 아니라 일을 하려고 쓴 것이다. // 그것이 이 파일의 목적이다 — 언어가 실제로 쓸 만한지는 이런 코드에서만 // 드러난다. 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 import "cool.dev/std/test" as Test // 설정 값. 타입이 셋뿐이므로 열거형이 맞다. 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) let parts = String.split(line, "=") if String.is_empty(line) { cfg } else if String.starts_with(line, "#") { cfg } else if List.len(parts) != 2 { add_problem(cfg, no, String.concat("= 가 하나여야 합니다: ", line)) } else { 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) }) } } } 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 parse(text: String) -> Config { let lines = List.enumerate(String.split(text, "\n")) let empty = Config { entries: [], problems: [] } List.fold(lines, empty, fn(cfg, l) { parse_line(cfg, l.i + 1, l.value) }) } // 이름으로 찾는다. 없으면 Err — 못 찾은 것은 오류지 빈 값이 아니다. pub fn lookup(cfg: Config, key: String) -> Result[Value, String] { let hit = List.filter(cfg.entries, fn(e) { e.key == key }) 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] { 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, "은(는) 참거짓이 아닙니다")), } } // ------------------------------------------------------------------ // 테스트. 일반 코드와 같은 검사를 받고, capability를 받지 않으므로 // effect-free임이 증명된다 — 파일도 시계도 못 건드린다. // ------------------------------------------------------------------ test "값의 타입은 모양으로 정한다" { Test.assert(type_name(parse_value("true")) == "flag") Test.assert(type_name(parse_value("42")) == "number") Test.assert(type_name(parse_value("hello")) == "text") } test "빈 줄과 주석은 항목이 아니다" { let cfg = parse("\n# 주석\n\n") Test.assert(List.is_empty(cfg.entries)) Test.assert(List.is_empty(cfg.problems)) } test "= 가 하나가 아니면 문제로 기록한다" { let cfg = parse("a = 1\nb = c = d\n") Test.assert(List.len(cfg.entries) == 1) Test.assert(List.len(cfg.problems) == 1) } test "없는 이름을 찾으면 Err다" { let cfg = parse("a = 1\n") Test.assert(!Result.is_ok(get_int(cfg, "없음"))) Test.assert(Result.is_ok(get_int(cfg, "a"))) }