all repos — pls @ 6a8b5023faaf8cf5226342802a7d47cb30c4c642

A polite but determined task runner.

Re-implemented using custom yaml parser.
h3rald h3rald@h3rald.com
Tue, 05 Oct 2021 10:55:45 +0200
commit

6a8b5023faaf8cf5226342802a7d47cb30c4c642

parent

ce30a6292f36cae6710577326cd939f636a94245

5 files changed, 164 insertions(+), 378 deletions(-)

jump to
M src/pls.nimsrc/pls.nim

@@ -1,63 +1,162 @@

import - json, os, parseopt, - logging, - algorithm, strutils, - sequtils + sequtils, + pegs, + tables import - plspkg/plslogger - -newPlsLogger().addHandler() -setLogFilter(lvlInfo) + plspkg/config -import - plspkg/config, - plspkg/project +type ConfigParseError = ref object of ValueError +type RuntimeError = ref object of ValueError -let usage* = """ $1 v$2 - $3 +let USAGE* = """ $1 v$2 - $3 (c) 2021 $4 Usage: - pls <task> [<target>] Executes <task> (on <target>). - - => For more information on available tasks, run: pls help + pls <action> [<thing>] Executes <action> (on <thing>). Options: --help, -h Displays this message. - --log, -l Specifies the log level (debug|info|notice|warn|error|fatal). - Default: info - --version, -h Displays the version of the application. + --actions, -a Display all known actions. + --things, -t Display all known things. + --version, -v Displays the version of the application. """ % [pkgTitle, pkgVersion, pkgDescription, pkgAuthor] +let placeholder = peg"'{{' {[^}]+} '}}'" + +var DATA = newTable[string, TableRef[string, TableRef[string, string]]]() +DATA["actions"] = newTable[string, TableRef[string, string]]() +DATA["things"] = newTable[string, TableRef[string, string]]() + +const defaultConfig = """ +actions: + # Define actions here +things: + # Define things here +""" + +var CONFIG: string + +if defined(windows): + CONFIG = getenv("USERPROFILE") / "pls.yml" +else: + CONFIG = getenv("HOME") / "pls.yml" # Helper Methods -proc update(PROJECT: var PlsProject, sysProject: JsonNode): bool {.discardable.} = - result = false - let sysTasks = sysProject["tasks"] - for k, v in sysTasks.pairs: - if PROJECT.tasks.hasKey(k): - let sysTask = sysTasks[k] - var prjTask = PROJECT.tasks[k] - for prop, val in sysTask.pairs: - let sysProp = sysTask[prop] - var prjProp = newJNull() - if prjTask.hasKey(prop): - prjProp = prjTask[prop] - if prjProp == newJNull(): - result = true - # Adding new property - prjTask[prop] = sysProp +proc parseProperty(line: string, index: int): tuple[name: string, value: string] = + let parts = line.split(":") + if parts.len < 2: + raise ConfigParseError(msg: "Line $1 - Invalid property.") + result.name = parts[0].strip + result.value = parts[1..parts.len-1].join(":").strip + +proc load(cfg: string): void = + var actions = false + var things = false + var section = "" + var itemId = "" + var indent = 0 + var count = 0 + for l in cfg.lines: + count += 1 + if l.startsWith(" "): + var line = l.strip + if line.len == 0: + raise ConfigParseError(msg: "Line $1 - Invalid empty line within item." % $count) + if line[0] == '#': + # comment + continue + if not (things or actions) or indent == 0: + raise ConfigParseError(msg: "Line $1 - Invalid property indentation." % $count) + if itemId == "": + raise ConfigParseError(msg: "Line $1 - Invalid property indentation (not within an item)." % $count) + let p = parseProperty(line, count) + DATA[section][itemId][p.name] = p.value + indent = 4 + continue + if l.startsWith(" "): + var line = l.strip + if line.len == 0: + raise ConfigParseError(msg: "Line $1 - Invalid empty line within section." % $count) + if line[0] == '#': + # comment + continue + if not (things or actions): + raise ConfigParseError(msg: "Line $1 - Invalid item indentation." % $count) + if line[line.len-1] != ':' or line == ":": + raise ConfigParseError(msg: "Line $1 - Invalid item identifier." % $count) + itemId = line[0..line.len-2] + # Start new item + DATA[section][itemId] = newTable[string, string]() + indent = 2 + continue + if l == "": + itemId = "" + continue + if l == "actions:": + if actions: + raise ConfigParseError(msg: "Line $1 - Duplicated 'actions' section." % $count) + actions = true + section = "actions" + continue + if l == "things:": + if things: + raise ConfigParseError(msg: "Line $1 - Duplicated 'things' section." % $count) + things = true + section = "things" + continue + if l.strip.startsWith("#"): + # comment + continue else: - result = true - # Adding new task - PROJECT.tasks[k] = sysTasks[k] + raise ConfigParseError(msg: "Line $1 - Invalid line." % $count) + +proc lookupTask(action: string, props: seq[string]): string = + result = "" + if not DATA["actions"].hasKey(action): + raise RuntimeError(msg: "Action '$1' not found" % action) + var defs = DATA["actions"][action] + var score = 0 + # Cycle through action definitions + for key, val in defs.pairs: + var params = key.split("+") + # Check if all params are available + var match = params.all do (x: string) -> bool: + props.contains(x) + if match and params.len > score: + score = params.len + result = val + +proc execute*(action, thing: string): int {.discardable.} = + if not DATA["things"].hasKey(thing): + raise RuntimeError(msg: "Thing '$1' not found. Nothing to do." % thing) + let props = DATA["things"][thing] + var keys = newSeq[string](0) + for key, val in props.pairs: + keys.add key + var cmd = lookupTask(action, keys) + if cmd != "": + cmd = cmd.replace(placeholder) do (m: int, n: int, c: openArray[string]) -> string: + return props[c[0]] + echo "Executing: $1" % cmd + result = execShellCmd cmd + else: + echo "Action '$1' not available for thing '$2'" % [action, thing] ### MAIN ### +if not CONFIG.fileExists: + CONFIG.writeFile(defaultConfig) + +try: + CONFIG.load() +except: + echo "(!) Unable to parse pls.yml file: $1" % getCurrentExceptionMsg() + var args = newSeq[string](0) for kind, key, val in getopt():

@@ -66,88 +165,43 @@ of cmdArgument:

args.add key of cmdLongOption, cmdShortOption: case key: - of "log", "l": - var val = val - setLogLevel(val) of "help", "h": - echo usage + echo USAGE quit(0) of "version", "v": echo pkgVersion quit(0) + of "actions", "a": + for action, props in DATA["actions"].pairs: + echo "\n$1:" % action + for key, val in props.pairs: + echo " $1: $2" % [key, val] + quit(0) + of "things", "t": + for thing, props in DATA["things"].pairs: + echo "\n$1:" % thing + for key, val in props.pairs: + echo " $1: $2" % [key, val] + quit(0) else: discard else: discard - -var PROJECT: PlsProject - -if defined(windows): - PROJECT = newPlsProject(getenv("USERPROFILE")) -if not defined(windows): - PROJECT = newPlsProject(getenv("HOME")) - -if not PROJECT.configured: - PROJECT.init() - -PROJECT.load() -let sysProject = plsTpl.parseJson() -let version = sysProject["version"].getInt -if PROJECT.version < version: - notice "Updating pls.json file..." - PROJECT.update(sysProject) - PROJECT.version = version - PROJECT.save() - notice "Done." - if args.len == 0: - echo usage + echo USAGE quit(0) -case args[0]: - of "info": - if args.len < 2: - for t, props in PROJECT.targets.pairs: - echo "\n $1:" % [t] - for k, v in props.pairs: - echo " - $1:\t$2" % [k, $v] - else: - let alias = args[1] - if not PROJECT.targets.hasKey(alias): - fatal "Target '$1' not defined." % [alias] - quit(4) - let data = PROJECT.targets[alias] - for k, v in data.pairs: - echo "\n $1:\t$2" % [k, $v] - of "help": - echo "" - if args.len < 2: - var sortedKeys = toSeq(PROJECT.help.keys) - sortedKeys.sort(cmp[string]) - for k in sortedKeys: - echo " pls $1" % PROJECT.help[k]["$syntax"].getStr - echo " $1\n" % PROJECT.help[k]["$description"].getStr - else: - let cmd = args[1] - let help = PROJECT.help[cmd] - if not PROJECT.help.hasKey(cmd): - fatal "Task '$1' is not defined." % cmd - quit(5) - echo " pls " & help["$syntax"].getStr - echo "\n $1\n" % help["$description"].getStr - else: - if args.len < 1: - echo usage - quit(1) - if args.len < 2: - var targets = toSeq(PROJECT.targets.pairs) - if targets.len == 0: - warn "No targets defined - nothing to do." - quit(0) - for key, val in PROJECT.targets.pairs: - PROJECT.execute(args[0], key) - else: - try: - PROJECT.execute(args[0], args[1]) - except: - warn getCurrentExceptionMsg() +elif args.len < 1: + echo USAGE + quit(1) +elif args.len < 2: + if DATA["things"].len == 0: + echo "(!) No targets defined - nothing to do." + quit(0) + for key in DATA["things"].keys: + execute(args[0], key) +else: + try: + execute(args[0], args[1]) + except: + echo "(!) " & getCurrentExceptionMsg()
D src/plspkg/help.json

@@ -1,10 +0,0 @@

-{ - "help": { - "$syntax": "help [<task>]", - "$description": "Display help on the specified task (or all tasks)." - }, - "info": { - "$syntax": "info [<target>]", - "$description": "Displays information on <target> (or all targets)." - } -}
D src/plspkg/pls.json

@@ -1,32 +0,0 @@

-{ - "version": 1633340696, - "tasks": { - "go": { - "$syntax": "go <target>", - "$description": "Goes to the folder identified by <target>.", - "$os:windows+folder": { - "cmd": "call cmd /k cd \"{{folder}}\"" - }, - "$os:linux+folder": { - "cmd": "cd \"{{folder}}\" && bash --login" - }, - "$os:macos+folder": { - "cmd": "cd \"{{folder}}\" && bash --login" - } - }, - "edit": { - "$syntax": "edit <target>", - "$description": "Opens <target> for editing.", - "$os:windows+file+value": { - "cmd": "notepad \"{{value}}\"" - }, - "$os:linux+file+value": { - "cmd": "vim \"{{value}}\"" - }, - "$os:macos+file+value": { - "cmd": "vim \"{{value}}\"" - } - } - }, - "targets": {} -}
D src/plspkg/plslogger.nim

@@ -1,72 +0,0 @@

-import - logging, - strutils, - terminal, - std/exitprocs - -exitprocs.addExitProc(resetAttributes) - -type - PlsLogger* = ref object of Logger - -proc logPrefix*(level: Level): tuple[msg: string, color: ForegroundColor] = - case level: - of lvlDebug: - return ("---", fgMagenta) - of lvlInfo: - return ("(i)", fgCyan) - of lvlNotice: - return (" ", fgWhite) - of lvlWarn: - return ("(!)", fgYellow) - of lvlError: - return ("(!)", fgRed) - of lvlFatal: - return ("(x)", fgRed) - else: - return (" ", fgWhite) - -method log*(logger: PlsLogger; level: Level; args: varargs[string, `$`]) = - var f = stdout - if level >= getLogFilter() and level >= logger.levelThreshold: - if level >= lvlWarn: - f = stderr - let ln = substituteLog(logger.fmtStr, level, args) - let prefix = level.logPrefix() - f.setForegroundColor(prefix.color) - f.write(prefix.msg) - f.write(ln) - resetAttributes() - f.write("\n") - if level in {lvlError, lvlFatal}: flushFile(f) - -proc newPlsLogger*(levelThreshold = lvlAll; fmtStr = " "): PlsLogger = - new result - result.fmtStr = fmtStr - result.levelThreshold = levelThreshold - -proc getLogLevel*(): string = - return LevelNames[getLogFilter()].toLowerAscii - -proc setLogLevel*(val: var string): string {.discardable.} = - var lvl: Level - case val: - of "debug": - lvl = lvlDebug - of "info": - lvl = lvlInfo - of "notice": - lvl = lvlNotice - of "warn": - lvl = lvlWarn - of "error": - lvl = lvlError - of "fatal": - lvl = lvlFatal - of "none": - lvl = lvlNone - else: - val = "warn" - lvl = lvlWarn - setLogFilter(lvl) - return val
D src/plspkg/project.nim

@@ -1,154 +0,0 @@

-import - os, - json, - logging, - strutils, - sequtils, - pegs - -type - PlsProject* = object - version*: int - dir*: string - tasks*: JsonNode - targets*: JsonNode - - -type PlsError = ref object of ValueError - -const plsTpl* = "pls.json".slurp -const systemHelp = "help.json".slurp - -let systemProps = @["$$os:$1" % hostOS, "$$cpu:$1" % hostCPU] -let placeholder = peg"'{{' {[^}]+} '}}'" - -proc newPlsProject*(dir: string): PlsProject = - result.dir = dir - -proc configFile*(prj: PlsProject): string = - return prj.dir/"pls.json" - -proc configured*(prj: PlsProject): bool = - return fileExists(prj.configFile) - -proc init*(prj: var PlsProject) = - var o = parseJson(plsTpl) - prj.configFile.writeFile(o.pretty) - -proc load*(prj: var PlsProject) = - if not prj.configFile.fileExists: - fatal "Project not initialized - configuration file not found." - quit(10) - let cfg = prj.configFile.parseFile - prj.version = cfg["version"].getInt - prj.tasks = cfg["tasks"] - prj.targets = cfg["targets"] - -proc help*(prj: var PlsProject): JsonNode = - result = newJObject() - if prj.configured: - prj.load - for k, v in systemHelp.parseJson.pairs: - result[k] = v - for k, v in prj.tasks.pairs: - if v.hasKey("$syntax") and v.hasKey("$description"): - result[k] = (""" - { - "$$syntax": "$1", - "$$description": "$2" - } - """ % [v["$syntax"].getStr, v["$description"].getStr]).parseJson - -proc save*(prj: PlsProject) = - var o = newJObject() - o["version"] = %prj.version - o["tasks"] = %prj.tasks - o["targets"] = %prj.targets - prj.configFile.writeFile(o.pretty) - -proc defTarget*(prj: var PlsProject, alias: string, props: var JsonNode) = - for k, v in props.mpairs: - if v == newJNull(): - props.delete(k) - if not prj.targets.hasKey alias: - notice "Adding target '$1'..." % alias - prj.targets[alias] = newJObject() - prj.targets[alias]["name"] = %alias - else: - notice "Updating target '$1'..." % alias - prj.targets[alias] = newJObject() - for key, val in props.pairs: - prj.targets[alias][key] = val - notice " $1: $2" % [key, $val] - prj.save - notice "Target '$1' saved." % alias - -proc undefTarget*(prj: var PlsProject, alias: string) = - prj.targets.delete(alias) - prj.save - notice "Target '$1' removed." % alias - -proc defTask*(prj: var PlsProject, alias: string, props: var JsonNode) = - for k, v in props.mpairs: - if v == newJNull(): - props.delete(k): - elif v.kind == JObject: - for kk, vv in v.pairs: - if vv == newJNull(): - v.delete(kk) - if not prj.tasks.hasKey alias: - notice "Adding task '$1'..." % alias - prj.tasks[alias] = newJObject() - else: - notice "Updating task '$1'..." % alias - prj.tasks[alias] = newJObject() - for key, val in props.pairs: - prj.tasks[alias][key] = val - notice " $1: $2" % [key, $val] - prj.save - notice "Task '$1' saved." % alias - -proc undefTask*(prj: var PlsProject, alias: string) = - prj.load - prj.tasks.delete(alias) - prj.save - notice "Task '$1' removed." % alias - -proc lookupTask(prj: PlsProject, task: string, ps: seq[string], cmd: var JsonNode): bool = - let props = ps.concat(systemProps); - if not prj.tasks.hasKey task: - warn "Task '$1' not found" % task - return - var cmds = prj.tasks[task] - var score = 0 - # Cycle through task definitions - for key, val in cmds: - if key == "$syntax" or key == "$description": - continue - var params = key.split("+") - # Check if all params are available - var match = params.all do (x: string) -> bool: - props.contains(x) - if match and params.len > score: - score = params.len - cmd = val - return score > 0 - -proc execute*(prj: var PlsProject, task, alias: string): int {.discardable.} = - prj.load - if not prj.targets.hasKey alias: - raise PlsError(msg: "Target definition '$1' not found. Nothing to do." % [alias]) - let target = prj.targets[alias] - var keys = newSeq[string](0) - for key, val in target.pairs: - keys.add key - var res: JsonNode - var cmd: string - if prj.lookupTask(task, keys, res): - cmd = res["cmd"].getStr.replace(placeholder) do (m: int, n: int, c: openArray[string]) -> string: - return target[c[0]].getStr - notice "Executing: $1" % cmd - result = execShellCmd cmd - else: - debug "Task '$1' not available for target '$2'" % [task, alias] - setCurrentDir(prj.dir)