1 import Base 2 import ./json.bend as Json 3 import ./setload.bend as Setload 4 import ./settings.bend as Settings 5 import ./setwrite.bend as Setwrite 6 import ./profile.bend as P 7 import ./policy/safe.bend as SF 8 import ./fmt/yaml.bend as Yaml 9 import ./fmt/toon.bend as Toon 10 import ./fmt/csv.bend as Csv 11 import ./fmt/ylex.bend as L 12 import ./fmt/yscal.bend as S 13 14 # The pure half of `config import` / `profile import`: the format of the input, 15 # the document it holds, the checks `config set` and `profile set` make on 16 # each known key (unknown keys pass through), and the merge into the settings 17 # already on disk. No effects. 18 19 def or_null(m: Maybe<&2, Json.Json>) -> Json.Json: 20 match m: 21 case Some{v}: 22 v 23 case None{}: 24 Json.JNull{} 25 26 # ---- which format is the input? ------------------------------------------------------- 27 28 def last_part(ps: List<&2, String>) -> String: 29 match ps: 30 case Nil{}: 31 "" 32 case Con{h, Nil{}}: 33 h 34 case Con{h, Con{h2, t}}: 35 last_part(Con{h2, t}) 36 37 def ext_of2(ps: List<&2, String>) -> String: 38 match ps: 39 case Con{h, Con{h2, t}}: 40 String.to_lower(last_part(Con{h2, t})) 41 case _: 42 "" 43 44 # The lower-case extension of a path ("" when there is none). 45 def ext_of(+path: String) -> String: 46 ext_of2(String.split(path, '.')) 47 48 def known_name(+n: String) -> Maybe<&2, String>: 49 Bool.pick(Maybe<&2, String>, String.eq(n, "json"), Some{"json"}, 50 Bool.pick(Maybe<&2, String>, String.eq(n, "yaml") || String.eq(n, "yml"), Some{"yaml"}, 51 Bool.pick(Maybe<&2, String>, String.eq(n, "toon"), Some{"toon"}, 52 Bool.pick(Maybe<&2, String>, String.eq(n, "csv"), Some{"csv"}, None{})))) 53 54 def fmt.named(m: Maybe<&2, String>, +n: String) -> Result<&2, &2, String, String>: 55 match m: 56 case Some{f}: 57 Done{f} 58 case None{}: 59 Fail{"unknown format '" ++ n ++ "' (json, yaml, toon, csv)"} 60 61 def fmt.ext(m: Maybe<&2, String>, +path: String) -> Result<&2, &2, String, String>: 62 match m: 63 case Some{f}: 64 Done{f} 65 case None{}: 66 Fail{"cannot tell the format of '" ++ path ++ "' from its extension; pass --format json|yaml|toon|csv"} 67 68 # `--format NAME` when given, else the file's extension. 69 def pick_format(explicit: Maybe<&2, String>, +path: String) -> Result<&2, &2, String, String>: 70 match explicit: 71 case Some{+n}: 72 fmt.named(known_name(n), n) 73 case None{}: 74 fmt.ext(known_name(ext_of(path)), path) 75 76 # ---- the document ----------------------------------------------------------------------- 77 78 def set_path(ks: List<&2, String>, v: Json.Json, +j: Json.Json) -> Json.Json: 79 match ks: 80 case Nil{}: 81 v 82 case Con{+k, t}: 83 Json.set(k, set_path(t, v, Setload.obj_or_empty(or_null(Json.get(k, j)))), j) 84 85 # A CSV cell as a value: `{}` and `[]` are the empty collections; everything 86 # else reads as a YAML plain word (null, true/false, a number, a string). 87 def typed(+v: String) -> Json.Json: 88 Bool.pick(Json.Json, String.eq(v, "{}"), Json.JObj{Json.JNil{}}, 89 Bool.pick(Json.Json, String.eq(v, "[]"), Json.JArr{Json.JNil{}}, L.resolve(v))) 90 91 def csv_rows(rows: List<&2, List<&2, String>>, +doc: Json.Json) -> Result<&2, &2, String, Json.Json>: 92 match rows: 93 case Nil{}: 94 Done{doc} 95 case Con{Con{k, Con{v, Nil{}}}, t}: 96 csv_rows(t, set_path(String.split(k, '.'), typed(v), doc)) 97 case Con{r, t}: 98 Fail{"every csv row must be key,value"} 99 100 def csv_head_ok(h: List<&2, String>) -> Bool: 101 match h: 102 case Con{a, Con{b, Nil{}}}: 103 String.eq(a, "key") && String.eq(b, "value") 104 case _: 105 False{} 106 107 def csv_body(ok: Bool, t: List<&2, List<&2, String>>) -> Result<&2, &2, String, Json.Json>: 108 match ok: 109 case True{}: 110 csv_rows(t, Setload.empty()) 111 case False{}: 112 Fail{"a csv settings file starts with the header key,value"} 113 114 def csv_doc(rows: Result<&2, &2, String, List<&2, List<&2, String>>>) -> Result<&2, &2, String, Json.Json>: 115 match rows: 116 case Fail{e}: 117 Fail{"csv: " ++ e} 118 case Done{Con{h, t}}: 119 csv_body(csv_head_ok(h), t) 120 case Done{Nil{}}: 121 Done{Setload.empty()} 122 123 def parse_named(+f: String, +text: String) -> Result<&2, &2, String, Json.Json>: 124 Bool.pick(Result<&2, &2, String, Json.Json>, String.eq(f, "json"), Json.parse(text), 125 Bool.pick(Result<&2, &2, String, Json.Json>, String.eq(f, "yaml"), Yaml.parse(text), 126 Bool.pick(Result<&2, &2, String, Json.Json>, String.eq(f, "toon"), Toon.parse(text), csv_doc(Csv.parse(text))))) 127 128 def must_obj(r: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>: 129 match r: 130 case Done{Json.JObj{f}}: 131 Done{Json.JObj{f}} 132 case Done{x}: 133 Fail{"the settings document must be a mapping of keys to values"} 134 case Fail{e}: 135 Fail{e} 136 137 # The document in `text`, which is in format `f` (json, yaml, toon or csv). 138 def parse_doc(+f: String, +text: String) -> Result<&2, &2, String, Json.Json>: 139 must_obj(parse_named(f, text)) 140 141 # ---- checking the values ---------------------------------------------------------------------- 142 143 def join_more(+s: String, r: Result<&2, &2, String, String>) -> Result<&2, &2, String, String>: 144 match r: 145 case Done{rest}: 146 Done{s ++ "," ++ rest} 147 case Fail{e}: 148 Fail{e} 149 150 def list_text(items: Json.Json) -> Result<&2, &2, String, String>: 151 match items: 152 case Json.JNil{}: 153 Done{""} 154 case Json.JCons{Json.JStr{s}, Json.JNil{}}: 155 Done{s} 156 case Json.JCons{Json.JStr{s}, t}: 157 join_more(s, list_text(t)) 158 case _: 159 Fail{"expected a list of names"} 160 161 def scal_text(+j: Json.Json) -> Result<&2, &2, String, String>: 162 match j: 163 case Json.JStr{s}: 164 Done{s} 165 case Json.JNum{t}: 166 Done{t} 167 case Json.JBool{b}: 168 Done{Json.bool.show(b)} 169 case Json.JArr{items}: 170 list_text(items) 171 case _: 172 Fail{"expected a single value"} 173 174 def parsed_json(p: Setwrite.Parsed) -> Result<&2, &2, String, Json.Json>: 175 match p: 176 case Setwrite.Bad{m}: 177 Fail{m} 178 case Setwrite.Good{j}: 179 Done{j} 180 181 def known_val2(sp: Settings.Spec, r: Result<&2, &2, String, String>) -> Result<&2, &2, String, Json.Json>: 182 match r: 183 case Fail{e}: 184 Fail{Settings.dotted(sp) ++ ": " ++ e} 185 case Done{t}: 186 parsed_json(Setwrite.parse_value(sp, t)) 187 188 # A value of a known setting, checked as `config set` checks it (null stays null: unset). 189 def known_val(sp: Settings.Spec, +v: Json.Json) -> Result<&2, &2, String, Json.Json>: 190 match v: 191 case Json.JNull{}: 192 Done{Json.JNull{}} 193 case _: 194 known_val2(sp, scal_text(v)) 195 196 def sec_val(m: Maybe<&2, Settings.Spec>, +v: Json.Json) -> Result<&2, &2, String, Json.Json>: 197 match m: 198 case None{}: 199 Done{v} 200 case Some{sp}: 201 known_val(sp, v) 202 203 def sec_fields(+sec: String, fields: Json.Json) -> Result<&2, &2, String, Json.Json>: 204 match fields: 205 case Json.JField{+k, v, t}: 206 S.field.cons(k, sec_val(Settings.find(sec ++ "." ++ k, Settings.specs()), v), sec_fields(sec, t)) 207 case _: 208 Done{fields} 209 210 def sec_obj(+sec: String, +v: Json.Json) -> Result<&2, &2, String, Json.Json>: 211 match v: 212 case Json.JObj{f}: 213 S.wrap.obj(sec_fields(sec, f)) 214 case Json.JNull{}: 215 Done{Json.JNull{}} 216 case _: 217 Fail{sec ++ " must be a mapping of settings"} 218 219 # ---- profiles ------------------------------------------------------------------------------------ 220 221 # One profile key, checked as `profile set` checks it; the stored value comes 222 # back as the profile file would hold it ("inherit" and null are null). 223 def prof_upd(+k: String, u: P.Upd) -> Result<&2, &2, String, Json.Json>: 224 match u: 225 case P.UBad{m}: 226 Fail{m} 227 case P.UOk{p}: 228 Done{or_null(Json.get(k, P.to_json(p, Setload.empty())))} 229 230 def semi(+t: String) -> String: 231 String.join(String.split(t, ';'), ",") 232 233 def prof_text(+k: String, r: Result<&2, &2, String, String>) -> Result<&2, &2, String, Json.Json>: 234 match r: 235 case Fail{e}: 236 Fail{k ++ ": " ++ e} 237 case Done{+t}: 238 prof_upd(k, P.update(P.bare("x", SF.ABest{}), k, Bool.pick(String, String.eq(k, "models"), semi(t), t))) 239 240 def prof_known(+k: String, +v: Json.Json) -> Result<&2, &2, String, Json.Json>: 241 match v: 242 case Json.JNull{}: 243 Done{Json.JNull{}} 244 case _: 245 prof_text(k, scal_text(v)) 246 247 def prof_key(+k: String, +v: Json.Json) -> Result<&2, &2, String, Json.Json>: 248 Bool.pick(Result<&2, &2, String, Json.Json>, Setload.has_str(P.keys(), k), prof_known(k, v), Done{v}) 249 250 def prof_fields(fields: Json.Json) -> Result<&2, &2, String, Json.Json>: 251 match fields: 252 case Json.JField{+k, v, t}: 253 S.field.cons(k, prof_key(k, v), prof_fields(t)) 254 case _: 255 Done{fields} 256 257 def name_msg(+name: String) -> String: 258 "invalid profile name '" ++ name ++ "': use letters, digits, '-' and '_', starting with a letter or digit" 259 260 def prof_body(+name: String, +v: Json.Json) -> Result<&2, &2, String, Json.Json>: 261 match v: 262 case Json.JObj{f}: 263 S.wrap.obj(prof_fields(f)) 264 case Json.JNull{}: 265 Done{Json.JNull{}} 266 case _: 267 Fail{"profile '" ++ name ++ "' must be a mapping"} 268 269 def prof_one(+name: String, +v: Json.Json) -> Result<&2, &2, String, Json.Json>: 270 Bool.pick(Result<&2, &2, String, Json.Json>, String.eq(name, "default"), Fail{"profile 'default' follows the autoswitch settings and cannot be changed"}, 271 Bool.pick(Result<&2, &2, String, Json.Json>, P.valid_name(name), prof_body(name, v), Fail{name_msg(name)})) 272 273 def prof_list(fields: Json.Json) -> Result<&2, &2, String, Json.Json>: 274 match fields: 275 case Json.JField{+name, v, t}: 276 S.field.cons(name, prof_one(name, v), prof_list(t)) 277 case _: 278 Done{fields} 279 280 def profiles_val(+v: Json.Json) -> Result<&2, &2, String, Json.Json>: 281 match v: 282 case Json.JObj{f}: 283 S.wrap.obj(prof_list(f)) 284 case Json.JNull{}: 285 Done{Json.JNull{}} 286 case _: 287 Fail{"profiles must be a mapping of names to profiles"} 288 289 def active_val(+v: Json.Json) -> Result<&2, &2, String, Json.Json>: 290 match v: 291 case Json.JStr{s}: 292 Done{v} 293 case Json.JNull{}: 294 Done{v} 295 case _: 296 Fail{"active_profile must be a profile name"} 297 298 # ---- the whole document ------------------------------------------------------------------------------- 299 300 def top_val(+k: String, +v: Json.Json) -> Result<&2, &2, String, Json.Json>: 301 Bool.pick(Result<&2, &2, String, Json.Json>, String.eq(k, "autoswitch") || String.eq(k, "ui"), sec_obj(k, v), 302 Bool.pick(Result<&2, &2, String, Json.Json>, String.eq(k, "profiles"), profiles_val(v), 303 Bool.pick(Result<&2, &2, String, Json.Json>, String.eq(k, "active_profile"), active_val(v), Done{v}))) 304 305 def top(fields: Json.Json) -> Result<&2, &2, String, Json.Json>: 306 match fields: 307 case Json.JField{+k, v, t}: 308 S.field.cons(k, top_val(k, v), top(t)) 309 case _: 310 Done{fields} 311 312 def top_obj(+doc: Json.Json) -> Result<&2, &2, String, Json.Json>: 313 match doc: 314 case Json.JObj{f}: 315 S.wrap.obj(top(f)) 316 case _: 317 Fail{"the settings document must be a mapping of keys to values"} 318 319 # The document with every known key checked and written the way 320 # `config set` / `profile set` would write it. A bad value is the Fail. 321 # Unknown keys, sections and profile keys pass through. 322 def normalize(+doc: Json.Json) -> Result<&2, &2, String, Json.Json>: 323 top_obj(doc) 324 325 # `profile import` takes only these top-level keys. 326 def profile_key_ok(ok: Bool, +k: String, rest: Result<&2, &2, String, Unit>) -> Result<&2, &2, String, Unit>: 327 match ok: 328 case True{}: 329 rest 330 case False{}: 331 Fail{"profile import takes profiles and active_profile only; found '" ++ k ++ "'"} 332 333 def profile_only(ks: List<&2, String>) -> Result<&2, &2, String, Unit>: 334 match ks: 335 case Nil{}: 336 Done{Unit{}} 337 case Con{+k, t}: 338 profile_key_ok(Setload.has_str(["profiles", "active_profile", "schemaVersion"], k), k, profile_only(t)) 339 340 # ---- merging -------------------------------------------------------------------------------------------- 341 342 def is_obj(j: Json.Json) -> Bool: 343 match j: 344 case Json.JObj{f}: 345 True{} 346 case _: 347 False{} 348 349 def is_null(j: Json.Json) -> Bool: 350 match j: 351 case Json.JNull{}: 352 True{} 353 case _: 354 False{} 355 356 def strip.pick(skip: Bool, +k: String, v: Json.Json, rest: Json.Json) -> Json.Json: 357 match skip: 358 case True{}: 359 rest 360 case False{}: 361 Json.JField{k, v, rest} 362 363 # The value without its null fields (a new profile, a replaced file: null means "not set"). 364 def strip(j: Json.Json) -> Json.Json: 365 match j: 366 case Json.JObj{f}: 367 Json.JObj{strip(f)} 368 case Json.JField{k, +v, t}: 369 strip.pick(is_null(v), k, strip(v), strip(t)) 370 case _: 371 j 372 373 def put(+k: String, +v: Json.Json, +base: Json.Json, merged: Json.Json) -> Json.Json: 374 Bool.pick(Json.Json, is_null(v), Json.del(k, base), 375 Bool.pick(Json.Json, is_obj(v) && is_obj(or_null(Json.get(k, base))), Json.set(k, merged, base), Json.set(k, strip(v), base))) 376 377 # `over` laid on `base`: objects merge key by key, anything else replaces, and 378 # null removes the key. 379 def merge(over: Json.Json, +base: Json.Json) -> Json.Json: 380 match over: 381 case Json.JObj{f}: 382 merge(f, base) 383 case Json.JField{+k, +v, t}: 384 merge(t, put(k, v, base, merge(v, or_null(Json.get(k, base))))) 385 case Json.JNil{}: 386 base 387 case _: 388 over