refactor(sydnix-cli): rewrite sydnix-cli

holy shit. why. why did i write the first version LOL. so fucking ocmplicated. and half broken.
This commit is contained in:
2025-12-01 14:05:21 -07:00
parent 1a685b4bf4
commit ea0c455824
30 changed files with 103 additions and 1154 deletions

View File

@@ -1,89 +0,0 @@
(ns asciidoc.render
(:require
[clojure.pprint :refer [pprint]]
[asciidoc.types :as types]
[clojure.spec.alpha :as s]
[clojure.zip :as zip]))
(defn- block-zip [root-block]
(zip/zipper #(s/conform ::block %)
:content
(fn [block children] (assoc block :content children))
root-block))
(defn- escape-line [line]
;; TODO
line)
(declare render*)
(defn- render-attributes [attributes]
(let [sorted-attributes (->> attributes
seq
(sort-by first))]
(doseq [[k v] sorted-attributes]
(printf ":%s: %s\n" (name k) v))))
(defn- render-document-header [depth {:keys [arguments] :as block}]
(println "=" (:title arguments))
(when-let [author (:author arguments)]
(println author)
(when-let [version (:version arguments)]
(println version)))
(when-let [attributes (:a arguments)]
(render-attributes attributes))
(println)
(run! #(render* (inc depth) %) (:content block)))
(defn- render-p [block]
(apply println (:content block)))
(defn- render-description [depth {{:keys [described]} :arguments :as block}]
(printf "%s::\n" described)
(run! #(render* (inc depth) %) (:content block)))
(defn- render-section [depth {:keys [arguments] :as block}]
(print (apply str (repeat (inc depth) \=))
(:title arguments)
"\n\n")
(run! #(render* (inc depth) %) (:content block))
(println))
(defn- render* [depth block]
(case (:context block)
:document (do (assert (zero? depth)
"Document block should only occur as root node.")
(render-document-header depth block))
:section (render-section depth block)
:p (render-p block)
:description (render-description depth block)
:<> (run! #(render* (inc depth) %) (apply concat (:content block)))
(throw (ex-info "no case" {:for (:context block)
:block block}))))
(def my-manpage
[:document {:title "sydnix(1)"
:author "Madeleine Sydney Ślaga"
:a {:doctype "manpage"
:manmanual "SYDNIX"
:mansource "SYDNIX"}}
[:section {:title "Name"}
[:p "sydnix - Inspect and operate upon the system."]]
[:section {:title "Synopsis"}
[:p "*sydnix* [_COMMAND_]... [_OPTION_]... [_FILE_]..."]]
[:section {:title "Options"}
[:description {:described "*--flake=URI*"}
"UJri is a flake"]]])
(defn conform! [spec x]
(let [x' (s/conform spec x)]
(if (= x' :clojure.spec.alpha/invalid)
(throw (ex-info "invalid" (s/explain-data spec x)))
x')))
;; Currently unrelentingly recursive. We'll fix this if/when it bnecomes a
;; problem. Shame Clojure skimps out on general TCO. }:(
(defn render
"Render an AsciiDoc block to `*out*`."
[block]
(render* 0 (conform! :asciidoc.types/document block)))

View File

@@ -1,57 +0,0 @@
(ns asciidoc.types
(:require
[clojure.spec.alpha :as s]
[spec-dict.main :refer [->opt dict]]))
(defn- make-block-arguments [req opt]
(let [args (dict req
(->opt opt)
^:opt {:a map?})]
(if (empty? req)
(s/? (s/and map? args))
(s/& (s/? map?) args))))
(defn- make-block
[& {:keys [name required-args optional-args content-spec]
:or {required-args {}
optional-args {}
content-spec (s/* ::block)}}]
(s/and seqable?
(s/cat :context (s/and keyword? #{name})
:arguments (make-block-arguments required-args optional-args)
:content content-spec)
;; Set arguments to {} if none were provided.
(s/conformer #(if (contains? % :arguments)
%
(assoc % :arguments {})))))
(s/def ::document
(make-block :name :document
:required-args {:title string?}
:optional-args {:author string?
:version string?}))
(s/def ::p
(make-block :name :p
:content-spec (s/* string?)))
(s/def ::description
(make-block :name :description
:required-opts {:described string?}))
(s/def ::section
(make-block :name :section
:required-args {:title string?}))
(s/def ::<>
(make-block :name :<>
:content (s/coll-of ::block)))
(s/def ::block
(s/and (s/or :document ::document
:section ::section
:description ::description
:<> ::<>
:p ::p)
;; `s/or` provides tagging that `make-block` already does.
(s/conformer #(nth % 1))))

View File

@@ -1,3 +0,0 @@
(ns sydnix-cli.cli-table)
(def *cli-table (atom nil))

View File

@@ -1,42 +1,8 @@
(ns sydnix-cli.commands.help
(:require
[babashka.process :as p]
[sydnix-cli.mangen :as mangen]))
(:require [babashka.cli :as cli]))
(defn adorn-with-help-option [wrapped-command-fn]
(fn [{:keys [opts dispatch]}]
(if (:help opts)
(mangen/with-pipe
(fn [man->]
(mangen/write-man-for-command (mangen/find-dispatched dispatch)
:out man->))
(fn [->man]
(p/shell {:in ->man}
"man -l -")))
(wrapped-command-fn opts))))
(defn- view-man-for-command [command-spec]
(mangen/with-pipe
(fn [man->]
(mangen/write-man-for-command command-spec :out man->))
(fn [->man]
(p/shell {:in ->man} "man -l -"))))
(defn- wrap-command-fn [wrapped-fn]
(fn [info]
(if (:help (:opts info))
(do (mangen/render-docs-for-command
(mangen/find-dispatched (:dispatch info)))
#_
(view-man-for-command (mangen/find-dispatched (:dispatch info))))
(when-not (nil? wrapped-fn)
(wrapped-fn info)))))
(defn adorn-with-help-option* [command-spec]
(update command-spec :fn wrap-command-fn))
(defn- command-fn [_opts]
(view-man-for-command (mangen/find-dispatched [])))
(defn command-fn [{:keys [args opts]}]
(println "hallp"))
(def command
{:cmds ["help"]

View File

@@ -1,7 +1,5 @@
(ns sydnix-cli.commands.rebuild
(:require
[babashka.process :as p]
[sydnix-cli.commands.help :refer [adorn-with-help-option*]]))
(:require [babashka.process :as p]))
(defn- parse-target [target]
(when-some [[_ _ user host] (re-matches #"(([-a-zA-Z0-9_]+)@)?(.+)"
@@ -23,15 +21,19 @@
:desc "Deploy config to HOST."
:coerce parse-target}})
(defn- command-fn [{:keys [args opts]}]
(defn- default-flake [host]
(str "path:///persist/dots"
(some->> host (str "#"))))
(defn command-fn [{:keys [args opts]}]
(let [rebuild-cmd
(concat (when-not (:target opts)
["sudo"])
["nixos-rebuild"]
(or args ["switch"])
["--flake" (if-some [host (:host (:target opts))]
(format "/persist/dots#%s" host)
"/persist/dots")]
["--flake" (or (:flake opts)
(-> opts :target :host
default-flake))]
(when-some [target (:target opts)]
["--sudo" "--ask-sudo-password"
"--target-host" (str-target target)]))]
@@ -40,8 +42,8 @@
:exit
System/exit)))
(def commands
[{:cmds ["rebuild"]
:desc "Rebuild the system NixOS and Home-manager configuration"
:fn command-fn
:spec command-options-spec}])
(def command
{:cmds ["rebuild"]
:desc "Rebuild the system's NixOS configuration"
:fn command-fn
:spec command-options-spec})

View File

@@ -1,11 +0,0 @@
(ns sydnix-cli.commands.status
(:require
[sydnix-cli.commands.help :refer [adorn-with-help-option*]]))
(defn- command-fn [opts]
(prn opts))
(def commands
[{:cmds ["status"]
:desc "View system info"
:fn command-fn}])

View File

@@ -1,8 +0,0 @@
(ns sydnix-cli.commands.util
(:require
[sydnix-cli.commands.util.mangen :as cmd-util-mangen]))
(def commands
[{:cmds ["util"]
:desc "Infrequently-used utilities such as docgen and shell completions."}
cmd-util-mangen/command])

View File

@@ -1,46 +0,0 @@
(ns sydnix-cli.commands.util.mangen
(:require
[asciidoc.render]
[babashka.fs :as fs]
[babashka.process :as p]
[clojure.java.io :as io]
[clojure.spec.alpha :as s]
[clojure.string :as str]
[sydnix-cli.mangen :as mangen]
[sydnix-cli.cli-table :refer [*cli-table]]
[sydnix-cli.commands.help :refer [adorn-with-help-option*]]
[sydnix-cli.prelude :as prelude]))
(defn command-fn [{:keys [yes output-directory]}]
(if (or yes
(prelude/y-or-n? (format "Write a bunch of man pages to %s?"
output-directory)))
(doseq [cmd-spec (deref *cli-table)]
(let [man-file-name (fs/file
output-directory
(str (str/join "-" (cons "sydnix" (:cmds cmd-spec)))
".1"))]
(with-open [man-file (io/writer man-file-name)]
(mangen/write-man-for-command cmd-spec :out man-file))
(printf "wrote %s\n" man-file-name)
(flush)))
(System/exit 1)))
(def command-options-spec
{:output-directory {:coerce :string
:ref "DIRECTORY"
:alias :o
:desc "Directory to which the generated man pages will be
written."
:require true
:validate fs/directory?}
:yes {:coerce :bool
:desc "Don't ask for confirmation."
:default false}})
(def command
(adorn-with-help-option*
{:cmds ["util" "mangen"]
:desc "Generate man pages for each subcommand of `sydnix`."
:fn command-fn
:spec command-options-spec}))

View File

@@ -1,25 +1,33 @@
(ns sydnix-cli.main
(:require
[babashka.cli :as cli]
[sydnix-cli.cli-table :refer [*cli-table]]
[sydnix-cli.commands.help :as cmd-help :refer [adorn-with-help-option*]]
[sydnix-cli.commands.rebuild :as cmd-rebuild]
[sydnix-cli.commands.status :as cmd-status]
[sydnix-cli.commands.util :as cmd-util])
(:gen-class))
(:require [babashka.cli :as cli]
[sydnix-cli.commands.rebuild :as rebuild]
[sydnix-cli.commands.help :as help]
[clojure.pprint :refer [pprint]]))
(def real-cli-table
(map adorn-with-help-option*
(concat cmd-status/commands
cmd-rebuild/commands
cmd-util/commands
[cmd-help/command
;; Show help when no other command matches.
(assoc cmd-help/command :cmds [])])))
(defn help [opts]
(println "Hellllpp"))
(def cli-table
[rebuild/command
help/command
;; Assume help command when no subcommand is given.
(assoc help/command :cmds [])])
(defmacro stderr [& body]
`(binding [*out* *err*]
~@body))
(defn error-fn [{:keys [spec type cause msg option] :as data}]
(if (= :org.babashka/cli type)
(stderr (printf "Error: %s\n" msg))
(stderr
(println "a mysterious error has occured...")
(pprint data))))
(defn -main [& args]
(reset! *cli-table real-cli-table)
(cli/dispatch @*cli-table args)
;; Process may hang without this form. D:{
;; https://github.com/babashka/process?tab=readme-ov-file#script-termination
(cli/dispatch cli-table args
{:restrict true
:error-fn #(do (error-fn %)
(stderr (flush))
(System/exit 1))})
(shutdown-agents))

View File

@@ -1,94 +0,0 @@
(ns sydnix-cli.mangen
(:require
[asciidoc.render]
[asciidoc.types]
[babashka.fs :as fs]
[babashka.process :as p]
[clojure.spec.alpha :as s]
[clojure.string :as str]
[sydnix-cli.cli-table :refer [*cli-table]])
(:import
[java.io BufferedReader PipedReader PipedWriter]))
(defn- format-p [s]
(-> s
(str/replace #"^ +" " ")
(str/replace #"\n +" "\n")))
(defn- docs-for-command [command-spec]
(let [command (cons "sydnix" (:cmds command-spec))]
[:document {:title (str (str/join "-" command)
"(1)")
:author "Madeleine Sydney Ślaga"
:a {:doctype "manpage"
:manmanual "SYDNIX"
:mansource "SYDNIX"}}
[:section {:title "Name"}
[:p (format "%s - %s"
(str/join " " command)
(:desc command-spec))]]
[:section {:title "Synopsis"}
[:p (format "%s [_option_…]"
(str/join " " command))]]
(concat [:section {:title "Options"}]
(for [[opt opt-spec] (:spec command-spec)]
[:description {:described (format "*--%s%s*"
(name opt)
(if-let [ref (:ref opt-spec)]
(str "=" ref)
""))}
[:p (format-p (:desc opt-spec))]]))]))
(defn render-docs-for-command [command-spec]
(try (asciidoc.render/render (docs-for-command command-spec))
(catch Exception e
(prn e)
(s/explain :asciidoc.types/document command-spec)
(throw e))))
(defn- find-satisfying [p xs]
(->> xs (filter p) first))
(defn find-dispatched [dispatch]
(find-satisfying #(= dispatch (:cmds %)) (deref *cli-table)))
(defn- find-adoc-processor
"Looks for a suitable AsciiDoc processor. In order of precedence, it will try
`$ADOC_PROCESSOR`, then `asciidoctor`."
[& {:keys [throw?]}]
(letfn [(suitable [x]
(when (and x (fs/executable? x))
x))]
(or (suitable (System/getenv "ADOC_PROCESSOR"))
(suitable (fs/which "asciidoctor"))
(when throw?
(throw (ex-info "Can't find a suitable AsciiDoc processor" {}))))))
(defn asciidoctor
"Shell out to AsciiDoctor (`$ADOC_PROCESSOR`, actually) to create ROFF markup
for man pages."
[& {:keys [asciidoctor-bin in out err]
:or {asciidoctor-bin (find-adoc-processor :throw? true)
in *in*
out *out*
err *err*}}]
(p/shell {:in in :out out :err err}
asciidoctor-bin "-o" "-" "-b" "manpage" "-"))
(defn with-pipe [with-out with-in]
(let [out (PipedWriter.)
in (PipedReader. out)]
(future (with-out out)
(.close out))
(with-in (BufferedReader. in))))
(defn write-man-for-command
[command-spec & {:keys [out err]
:or {out *out*
err *err*}}]
(with-pipe
(fn [adoc->]
(binding [*out* adoc->]
(render-docs-for-command command-spec)))
(fn [->adoc]
(asciidoctor :in ->adoc :out out :err err))))

View File

@@ -1,21 +0,0 @@
(ns sydnix-cli.prelude
(:require
[clojure.core.match :refer [match]]))
(defn y-or-n?
[prompt
& {:keys [default]
:or {default :n}
:as opts}]
(let [y-n-indicator (case default
:y " [Y/n] "
:n " [y/N] ")]
(print (str prompt y-n-indicator))
(flush))
(let [line (read-line)]
(match line
"" (case default :y true :n false)
(:or "y" "Y") true
(:or "n" "N") false
_ (do (println "Please enter y or n.")
(recur prompt opts)))))