import std/[json, options, os, sequtils, strtabs, strutils] import docopt, identcasing, regex type CombinedConfig* = object docopt*: Table[string, Value] json*: JsonNode ValueProvenance* {.pure.} = enum arguments, environment, json, default const argStyleRegex = re2"(--)?[[:lower:][:digit:]][[:lower:][:digit:]\-]+" const envStyleRegex = re2"[[:upper:][:digit:]_]+" const jsonStyleRegex = re2"[[:lower:]][[:lower:][:upper:][:digit:]]+" proc keyNames*(key: string): tuple[arg, env, json: string] {.raises: [KeyError].} = ## CombinedConfig tries to be idiomatic in its usage of key names. For ## environment variables it uses `UPPER_SNAKE_CASE`-style naming. For ## arguments it uses `lower-kebab-case`, and for JSON it uses ## `lowerCamelCase`. ## ## *keyNames* takes a key in any one of the three styles and returns all ## three. This is primarily a helper for other parsing functions, but is ## exposed as it is sometimes useful. try: if match(key, argStyleRegex): let unprefixed = if key.startsWith("--"): key[2..^1] else: key return ( "--" & unprefixed, convertCase(unprefixed, lowerKebabCase, upperSnakeCase), convertCase(unprefixed, lowerKebabCase, lowerCamelCase)) elif match(key, envStyleRegex): return ( "--" & convertCase(key, upperSnakeCase, lowerKebabCase), key, convertCase(key, upperSnakeCase, lowerCamelCase)) elif match(key, jsonStyleRegex): return ( "--" & convertCase(key, lowerCamelCase, lowerKebabCase), convertCase(key, lowerCamelCase, upperSnakeCase), key) else: raise newException(KeyError, "key style must be --argument-style, ENV_STYLE, or jsonStyle") except Exception: raise newException(KeyError, "invalid config key '" & key & "': " & getCurrentExceptionMsg()) template walkFieldDefs*(t: NimNode, body: untyped) = let tTypeImpl = t.getTypeImpl var nodeToItr: NimNode if tTypeImpl.typeKind == ntyObject: nodeToItr = tTypeImpl[2] elif tTypeImpl.typeKind == ntyTypeDesc: nodeToItr = tTypeImpl.getType[1].getType[2] else: error $t & " is not an object or type desc (it's a " & $tTypeImpl.typeKind & ")." for fieldDef {.inject.} in nodeToItr.children: # ignore AST nodes that are not field definitions if fieldDef.kind == nnkIdentDefs: let fieldIdent {.inject.} = fieldDef[0] let fieldType {.inject.} = fieldDef[1] body elif fieldDef.kind == nnkSym: let fieldIdent {.inject.} = fieldDef let fieldType {.inject.} = fieldDef.getType body # TODO: Generic config loader # macro loadConfig*(filePath: string, cfgType: typed, args: Table[string, docopt.Value): untyped = # result = newNimNode(nnkObjConstr).(cfgType) # # var idx = 0 # cfgType.walkFieldDefs: # let valLookup = quote do: `getVal`( proc initCombinedConfig*( filename: string, docopt: Table[string, Value] = initTable[string, Value]() ): CombinedConfig = result = CombinedConfig(docopt: docopt, json: parseFile(filename)) proc findConfigFile*(name: string, otherLocations: seq[string] = @[]): string = let cfgLocations = @[ $getEnv(name.strip(chars = {'.'}).toUpper()), $getEnv("HOME") / name, "." / name ] & otherLocations result = cfgLocations.foldl(if fileExists(b): b else: a, "") if result == "" or not fileExists(result): raise newException(ValueError, "could not find configuration file") proc jsonConfigNodeToString(node: JsonNode): string = case node.kind of JString: node.getStr of JInt: $node.getInt of JFloat: $node.getFloat of JBool: $node.getBool of JNull: "" of JObject: $node of JArray: $node proc getValueAndProvenance*( cfg: CombinedConfig, key: string): tuple[value: string, provenance: ValueProvenance] {. raises: [ValueError] .} = let (argKey, envKey, jsonKey) = keyNames(key) if cfg.docopt.contains(argKey) and cfg.docopt[argKey]: return ($cfg.docopt[argKey], ValueProvenance.arguments) elif existsEnv(envKey): return (getEnv(envKey), ValueProvenance.environment) elif cfg.json.hasKey(jsonKey): return (jsonConfigNodeToString(cfg.json[jsonKey]), ValueProvenance.json) else: raise newException(ValueError, "cannot find a configuration value for \"" & key & "\"") proc getValueAndProvenance*( cfg: CombinedConfig, key: string, default: string): tuple[value: string, provenance: ValueProvenance] {.raises: [].} = try: return getValueAndProvenance(cfg, key) except CatchableError: return (default, ValueProvenance.default) proc getVal*(cfg: CombinedConfig, key: string): string = getValueAndProvenance(cfg, key).value proc getVal*(cfg: CombinedConfig, key, default: string): string {.raises: [].} = try: return getVal(cfg, key) except CatchableError: return default proc `[]`*(cfg: CombinedConfig, key: string): string = getVal(cfg, key) proc getValueFromSource*( cfg: CombinedConfig, source: ValueProvenance, key: string): Option[string] = result = none[string]() let (argKey, envKey, jsonKey) = keyNames(key) if source == ValueProvenance.arguments and cfg.docopt.contains(argKey): result = some($cfg.docopt[argKey]) elif source == ValueProvenance.environment and existsEnv(envKey): result = some(getEnv(envKey)) elif source == ValueProvenance.json and cfg.json.hasKey(jsonKey): result = some(jsonConfigNodeToString(cfg.json[jsonKey])) proc getJsonValueAndProvenance*( cfg: CombinedConfig, key: string): tuple[value: JsonNode, provenance: ValueProvenance] {.raises: [KeyError, ValueError, IOError, OSError]} = let (argKey, envKey, jsonKey) = keyNames(key) try: if cfg.docopt.contains(argKey) and cfg.docopt[argKey]: return (parseJson($cfg.docopt[argKey]), ValueProvenance.arguments) elif existsEnv(envKey): return (parseJson(getEnv(envKey)), ValueProvenance.environment) elif cfg.json.hasKey(jsonKey): return (cfg.json[jsonKey], ValueProvenance.json) else: raise newException(ValueError, "cannot find a configuration value for \"" & key & "\"") except Exception: raise newException(ValueError, "cannot parse [" & getVal(cfg, key) & "] as JSON:" & getCurrentExceptionMsg()) proc getJsonValueAndProvenance*( cfg: CombinedConfig, key: string, default: JsonNode): tuple[value: JsonNode, provenance: ValueProvenance] {.raises: [].} = try: return getJsonValueAndProvenance(cfg, key) except CatchableError: return (default, ValueProvenance.default) proc getJson*( cfg: CombinedConfig, key: string ): JsonNode {.raises: [KeyError, ValueError, IOError, OSError]} = getJsonValueAndProvenance(cfg, key).value proc getJson*( cfg: CombinedConfig, key: string, default: JsonNode ) : JsonNode {.raises: []} = try: return getJson(cfg, key) except CatchableError: return default proc getJsonFromSource*( cfg: CombinedConfig, source: ValueProvenance, key: string): Option[JsonNode] = result = none[JsonNode]() let (argKey, envKey, jsonKey) = keyNames(key) if source == ValueProvenance.arguments and cfg.docopt.contains(argKey): result = some(parseJson($cfg.docopt[argKey])) elif source == ValueProvenance.environment and existsEnv(envKey): result = some(parseJson(getEnv(envKey))) elif source == ValueProvenance.json and cfg.json.hasKey(jsonKey): result = some(cfg.json[jsonKey]) proc hasKey*(cfg: CombinedConfig, key: string): bool = let (argKey, envKey, jsonKey) = keyNames(key) return (cfg.docopt.contains(argKey) and cfg.docopt[argKey]) or existsEnv(envKey) or cfg.json.hasKey(jsonKey) proc contains*(cfg: CombinedConfig, key: string): bool = hasKey(cfg, key) proc loadEnv*(): StringTableRef = result = newStringTable() for k, v in envPairs(): result[k] = v