02c6621813
Add support for running a command inside a `mg shell`, specified as an extra argument after the target to the shell command Change-Id: Icbbd9cf4e1f099fcd7e6b13655b8447775a236d2 Reviewed-on: https://cl.tvl.fyi/c/depot/+/10247 Autosubmit: grfn <grfn@gws.fyi> Tested-by: BuildkiteCI Reviewed-by: tazjin <tazjin@tvl.su>
375 lines
14 KiB
Scheme
375 lines
14 KiB
Scheme
;; magrathea helps you build planets
|
|
;;
|
|
;; it is a tiny tool designed to ease workflows in monorepos that are
|
|
;; modeled after the tvl depot.
|
|
;;
|
|
;; users familiar with workflows from other, larger monorepos may be
|
|
;; used to having a build tool that can work in any tree location.
|
|
;; magrathea enables this, but with nix-y monorepos.
|
|
|
|
(import (chicken base)
|
|
(chicken format)
|
|
(chicken irregex)
|
|
(chicken port)
|
|
(chicken file)
|
|
(chicken file posix)
|
|
(chicken process)
|
|
(chicken process-context)
|
|
(chicken string)
|
|
(matchable)
|
|
(only (chicken io) read-string))
|
|
|
|
(define usage #<<USAGE
|
|
usage: mg <command> [<target>]
|
|
mg run [<target>] [-- <arguments>]
|
|
mg shell [<target>] [<command>]
|
|
|
|
target:
|
|
a target specification with meaning inside of the repository. can
|
|
be absolute (starting with //) or relative to the current directory
|
|
(as long as said directory is inside of the repo). if no target is
|
|
specified, the current directory's physical target is built.
|
|
|
|
for example:
|
|
|
|
//tools/magrathea - absolute physical target
|
|
//foo/bar:baz - absolute virtual target
|
|
magrathea - relative physical target
|
|
:baz - relative virtual target
|
|
|
|
commands:
|
|
build - build a target
|
|
shell - enter a shell with the target's build dependencies
|
|
path - print source folder for the target
|
|
repl - start a nix repl in the repository root
|
|
run - build a target and execute its output
|
|
|
|
file all feedback on b.tvl.fyi
|
|
USAGE
|
|
)
|
|
|
|
;; parse target definitions. trailing slashes on physical targets are
|
|
;; allowed for shell autocompletion.
|
|
;;
|
|
;; component ::= any string without "/" or ":"
|
|
;;
|
|
;; physical-target ::= <component>
|
|
;; | <component> "/"
|
|
;; | <component> "/" <physical-target>
|
|
;;
|
|
;; virtual-target ::= ":" <component>
|
|
;;
|
|
;; relative-target ::= <physical-target>
|
|
;; | <virtual-target>
|
|
;; | <physical-target> <virtual-target>
|
|
;;
|
|
;; root-anchor ::= "//"
|
|
;;
|
|
;; target ::= <relative-target> | <root-anchor> <relative-target>
|
|
|
|
;; read a path component until it looks like something else is coming
|
|
(define (read-component first port)
|
|
(let ((keep-reading?
|
|
(lambda () (not (or (eq? #\/ (peek-char port))
|
|
(eq? #\: (peek-char port))
|
|
(eof-object? (peek-char port)))))))
|
|
(let reader ((acc (list first))
|
|
(condition (keep-reading?)))
|
|
(if condition (reader (cons (read-char port) acc) (keep-reading?))
|
|
(list->string (reverse acc))))))
|
|
|
|
;; read something that started with a slash. what will it be?
|
|
(define (read-slash port)
|
|
(if (eq? #\/ (peek-char port))
|
|
(begin (read-char port)
|
|
'root-anchor)
|
|
'path-separator))
|
|
|
|
;; read any target token and leave port sitting at the next one
|
|
(define (read-token port)
|
|
(match (read-char port)
|
|
[#\/ (read-slash port)]
|
|
[#\: 'virtual-separator]
|
|
[other (read-component other port)]))
|
|
|
|
;; read a target into a list of target tokens
|
|
(define (read-target target-str)
|
|
(call-with-input-string
|
|
target-str
|
|
(lambda (port)
|
|
(let reader ((acc '()))
|
|
(if (eof-object? (peek-char port))
|
|
(reverse acc)
|
|
(reader (cons (read-token port) acc)))))))
|
|
|
|
(define-record target absolute components virtual)
|
|
(define (empty-target) (make-target #f '() #f))
|
|
|
|
(define-record-printer (target t out)
|
|
(fprintf out (conc (if (target-absolute t) "//" "")
|
|
(string-intersperse (target-components t) "/")
|
|
(if (target-virtual t) ":" "")
|
|
(or (target-virtual t) ""))))
|
|
|
|
;; parse and validate a list of target tokens
|
|
(define parse-tokens
|
|
(lambda (tokens #!optional (mode 'root) (acc (empty-target)))
|
|
(match (cons mode tokens)
|
|
;; absolute target
|
|
[('root . ('root-anchor . rest))
|
|
(begin (target-absolute-set! acc #t)
|
|
(parse-tokens rest 'root acc))]
|
|
|
|
;; relative target minus potential garbage
|
|
[('root . (not ('path-separator . _)))
|
|
(parse-tokens tokens 'normal acc)]
|
|
|
|
;; virtual target
|
|
[('normal . ('virtual-separator . rest))
|
|
(parse-tokens rest 'virtual acc)]
|
|
|
|
[('virtual . ((? string? v)))
|
|
(begin
|
|
(target-virtual-set! acc v)
|
|
acc)]
|
|
|
|
;; chomp through all components and separators
|
|
[('normal . ('path-separator . rest)) (parse-tokens rest 'normal acc)]
|
|
[('normal . ((? string? component) . rest))
|
|
(begin (target-components-set!
|
|
acc (append (target-components acc) (list component)))
|
|
(parse-tokens rest 'normal acc ))]
|
|
|
|
;; nothing more to parse and not in a weird state, all done, yay!
|
|
[('normal . ()) acc]
|
|
|
|
;; oh no, we ran out of input too early :(
|
|
[(_ . ()) `(error . ,(format "unexpected end of input while parsing ~s target" mode))]
|
|
|
|
;; something else was invalid :(
|
|
[_ `(error . ,(format "unexpected ~s while parsing ~s target" (car tokens) mode))])))
|
|
|
|
(define (parse-target target)
|
|
(parse-tokens (read-target target)))
|
|
|
|
;; turn relative targets into absolute targets based on the current
|
|
;; directory
|
|
(define (normalise-target t)
|
|
(when (not (target-absolute t))
|
|
(target-components-set! t (append (relative-repo-path)
|
|
(target-components t)))
|
|
(target-absolute-set! t #t))
|
|
t)
|
|
|
|
;; nix doesn't care about the distinction between physical and virtual
|
|
;; targets, normalise it away
|
|
(define (normalised-components t)
|
|
(if (target-virtual t)
|
|
(append (target-components t) (list (target-virtual t)))
|
|
(target-components t)))
|
|
|
|
;; return the current repository root as a string
|
|
(define mg--repository-root #f)
|
|
(define (repository-root)
|
|
(or mg--repository-root
|
|
(begin
|
|
(set! mg--repository-root
|
|
(or (get-environment-variable "MG_ROOT")
|
|
(call-with-input-pipe "git rev-parse --show-toplevel"
|
|
(lambda (p) (read-chomping p)))))
|
|
mg--repository-root)))
|
|
|
|
;; determine the current path relative to the root of the repository
|
|
;; and return it as a list of path components.
|
|
(define (relative-repo-path)
|
|
(string-split
|
|
(substring (current-directory) (string-length (repository-root))) "/"))
|
|
|
|
;; escape a string for interpolation in nix code
|
|
(define (nix-escape str)
|
|
(string-translate* str '(("\"" . "\\\"")
|
|
("${" . "\\${"))))
|
|
|
|
;; create a nix expression to build the attribute at the specified
|
|
;; components
|
|
;;
|
|
;; an empty target will build the current folder instead.
|
|
;;
|
|
;; this uses builtins.getAttr explicitly to avoid problems with
|
|
;; escaping.
|
|
(define (nix-expr-for target)
|
|
(let nest ((parts (normalised-components (normalise-target target)))
|
|
(acc (conc "(import " (repository-root) " {})")))
|
|
(match parts
|
|
[() (conc "with builtins; " acc)]
|
|
[_ (nest (cdr parts)
|
|
(conc "(getAttr \""
|
|
(nix-escape (car parts))
|
|
"\" " acc ")"))])))
|
|
|
|
;; exit and complain at the user if something went wrong
|
|
(define (mg-error message)
|
|
(format (current-error-port) "[mg] error: ~A~%" message)
|
|
(exit 1))
|
|
|
|
(define (guarantee-success value)
|
|
(match value
|
|
[('error . message) (mg-error message)]
|
|
[_ value]))
|
|
|
|
(define-record build-args target passthru unknown)
|
|
(define (execute-build args)
|
|
(let ((expr (nix-expr-for (build-args-target args))))
|
|
(fprintf (current-error-port) "[mg] building target ~A~%" (build-args-target args))
|
|
(process-execute "nix-build" (append (list "-E" expr "--show-trace")
|
|
(or (build-args-passthru args) '())))))
|
|
|
|
;; split the arguments used for builds into target/unknown args/nix
|
|
;; args, where the latter occur after '--'
|
|
(define (parse-build-args acc args)
|
|
(match args
|
|
;; no arguments remaining, return accumulator as is
|
|
[() acc]
|
|
|
|
;; next argument is '--' separator, split off passthru and
|
|
;; return
|
|
[("--" . passthru)
|
|
(begin
|
|
(build-args-passthru-set! acc passthru)
|
|
acc)]
|
|
|
|
[(arg . rest)
|
|
;; set target if not already known (and if the first
|
|
;; argument does not look like an accidental unknown
|
|
;; parameter)
|
|
(if (and (not (build-args-target acc))
|
|
(not (substring=? "-" arg)))
|
|
(begin
|
|
(build-args-target-set! acc (guarantee-success (parse-target arg)))
|
|
(parse-build-args acc rest))
|
|
|
|
;; otherwise, collect unknown arguments
|
|
(begin
|
|
(build-args-unknown-set! acc (append (or (build-args-unknown acc) '())
|
|
(list arg)))
|
|
(parse-build-args acc rest)))]))
|
|
|
|
;; parse the passed build args, applying sanity checks and defaulting
|
|
;; the target if necessary, then execute the build
|
|
(define (build args)
|
|
(let ((parsed (parse-build-args (make-build-args #f #f #f) args)))
|
|
;; fail if there are unknown arguments present
|
|
(when (build-args-unknown parsed)
|
|
(let ((unknown (string-intersperse (build-args-unknown parsed))))
|
|
(mg-error (sprintf "unknown arguments: ~a
|
|
|
|
if you meant to pass these arguments to nix, please separate them with
|
|
'--' like so:
|
|
|
|
mg build ~a -- ~a"
|
|
unknown
|
|
(or (build-args-target parsed) "")
|
|
unknown))))
|
|
|
|
;; default the target to the current folder's main target
|
|
(unless (build-args-target parsed)
|
|
(build-args-target-set! parsed (empty-target)))
|
|
|
|
(execute-build parsed)))
|
|
|
|
(define (execute-shell target #!optional command)
|
|
(if command
|
|
(fprintf (current-error-port) "[mg] executing ~A in shell for ~A~%"
|
|
command
|
|
target)
|
|
(fprintf (current-error-port) "[mg] entering shell for ~A~%" target))
|
|
(let ((expr (nix-expr-for target))
|
|
(command (or command
|
|
(get-environment-variable "SHELL")
|
|
"bash")))
|
|
(process-execute "nix-shell"
|
|
(list "-E" expr "--command" command))))
|
|
|
|
(define (shell args)
|
|
(match args
|
|
[() (execute-shell (empty-target))]
|
|
[(target . args) (apply
|
|
execute-shell
|
|
(guarantee-success (parse-target target))
|
|
args)]))
|
|
|
|
(define (repl args)
|
|
(process-execute "nix" (append (list "repl" "--show-trace" (repository-root)) args)))
|
|
|
|
(define (read-chomping pipe)
|
|
(let ((s (read-string #f pipe)))
|
|
(if (eq? s #!eof) "" (string-chomp s))))
|
|
|
|
(define (execute-run t #!optional cmd-args)
|
|
(fprintf (current-error-port) "[mg] building target ~A~%" t)
|
|
(let* ((expr (nix-expr-for t))
|
|
(out
|
|
(receive (pipe _ pid)
|
|
;; TODO(sterni): temporary gc root
|
|
(process "nix-build" (list "-E" expr "--no-out-link"))
|
|
(let ((stdout (read-chomping pipe)))
|
|
(receive (_ _ status)
|
|
(process-wait pid)
|
|
(when (not (eq? status 0))
|
|
(mg-error (format "Couldn't build target ~A" t)))
|
|
stdout)))))
|
|
|
|
(fprintf (current-error-port) "[mg] running target ~A~%" t)
|
|
(process-execute
|
|
;; If the output is a file, we assume it's an executable à la writeExecline,
|
|
;; otherwise we look in the bin subdirectory and pick the only executable.
|
|
;; Handling multiple executables is not possible at the moment, the choice
|
|
;; could be made via a command line flag in the future.
|
|
(if (regular-file? out)
|
|
out
|
|
(let* ((dir-path (string-append out "/bin"))
|
|
(dir-contents (if (directory-exists? dir-path)
|
|
(directory dir-path #f)
|
|
'())))
|
|
(case (length dir-contents)
|
|
((0) (mg-error "no executables in build output")
|
|
(exit 1))
|
|
((1) (string-append dir-path "/" (car dir-contents)))
|
|
(else (mg-error "more than one executable in build output")
|
|
(exit 1)))))
|
|
cmd-args)))
|
|
|
|
(define (run args)
|
|
(match args
|
|
[() (execute-run (empty-target))]
|
|
[("--" . rest) (execute-run (empty-target) rest)]
|
|
[(target) (execute-run (guarantee-success (parse-target target)))]
|
|
[(target . ("--" . rest)) (execute-run (guarantee-success (parse-target target)) rest)]
|
|
;; TODO(sterni): flag for selecting binary name
|
|
[_ (mg-error "usage: mg run [<target>] [-- <arguments>] (hint: use \"--\" to separate the `mg run [<target>]` invocation from the arguments you're passing to the built executable)")]))
|
|
|
|
(define (path args)
|
|
(match args
|
|
[(arg)
|
|
(print (apply string-append
|
|
(intersperse
|
|
(cons (repository-root)
|
|
(target-components
|
|
(normalise-target
|
|
(guarantee-success (parse-target arg)))))
|
|
"/")))]
|
|
[() (mg-error "path command needs a target")]
|
|
[other (mg-error (format "unknown arguments: ~a" other))]))
|
|
|
|
(define (main args)
|
|
(match args
|
|
[() (print usage)]
|
|
[("build" . _) (build (cdr args))]
|
|
[("shell" . _) (shell (cdr args))]
|
|
[("path" . _) (path (cdr args))]
|
|
[("repl" . _) (repl (cdr args))]
|
|
[("run" . _) (run (cdr args))]
|
|
[other (begin (print "unknown command: mg " args)
|
|
(print usage))]))
|
|
|
|
(main (command-line-arguments))
|