elm-build-system.scm 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. ;;; GNU Guix --- Functional package management for GNU
  2. ;;; Copyright © 2022 Philip McGrath <philip@philipmcgrath.com>
  3. ;;;
  4. ;;; This file is part of GNU Guix.
  5. ;;;
  6. ;;; GNU Guix is free software; you can redistribute it and/or modify it
  7. ;;; under the terms of the GNU General Public License as published by
  8. ;;; the Free Software Foundation; either version 3 of the License, or (at
  9. ;;; your option) any later version.
  10. ;;;
  11. ;;; GNU Guix is distributed in the hope that it will be useful, but
  12. ;;; WITHOUT ANY WARRANTY; without even the implied warranty of
  13. ;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. ;;; GNU General Public License for more details.
  15. ;;;
  16. ;;; You should have received a copy of the GNU General Public License
  17. ;;; along with GNU Guix. If not, see <http://www.gnu.org/licenses/>.
  18. (define-module (guix build elm-build-system)
  19. #:use-module ((guix build gnu-build-system) #:prefix gnu:)
  20. #:use-module (guix build utils)
  21. #:use-module (guix build json)
  22. #:use-module (guix build union)
  23. #:use-module (ice-9 ftw)
  24. #:use-module (ice-9 rdelim)
  25. #:use-module (ice-9 regex)
  26. #:use-module (ice-9 match)
  27. #:use-module (ice-9 popen)
  28. #:use-module (ice-9 vlist)
  29. #:use-module (srfi srfi-1)
  30. #:use-module (srfi srfi-26)
  31. #:use-module (srfi srfi-71)
  32. #:export (%standard-phases
  33. patch-application-dependencies
  34. patch-json-string-escapes
  35. read-offline-registry->vhash
  36. elm-build))
  37. ;;; Commentary:
  38. ;;;
  39. ;;; Elm draws a sharp distinction between "projects" with `{"type":"package"}`
  40. ;;; vs. `{"type":"application"}` in the "elm.json" file: see
  41. ;;; <https://github.com/elm/compiler/blob/master/docs/elm.json/package.md> and
  42. ;;; <https://github.com/elm/compiler/blob/master/docs/elm.json/application.md>.
  43. ;;; For now, `elm-build-system` is designed for "package"s: packaging
  44. ;;; "application"s requires ad-hoc replacements for some phases---but see
  45. ;;; `patch-application-dependencies`, which helps to work around a known issue
  46. ;;; discussed below. It would be nice to add more streamlined support for
  47. ;;; "application"s one we have more experience building them in Guix. For
  48. ;;; example, we could incorporate the `uglifyjs` advice from
  49. ;;; <https://github.com/elm/compiler/blob/master/hints/optimize.md>.
  50. ;;;
  51. ;;; We want building an Elm "package" to produce:
  52. ;;;
  53. ;;; - a "docs.json" file with extracted documentation; and
  54. ;;;
  55. ;;; - an "artifacts.dat" file with compilation results for use in building
  56. ;;; "package"s and "application"s.
  57. ;;;
  58. ;;; Unfortunately, there isn't an entry point to the Elm compiler that builds
  59. ;;; those files directly. Building with `elm make` does something different,
  60. ;;; more oriented toward development, testing, and building "application"s.
  61. ;;; We work around this limitation by staging the "package" we're building as
  62. ;;; though it were already installed in ELM_HOME, generating a trivial Elm
  63. ;;; "application" that depends on the "package", and building the
  64. ;;; "application", which causes the files for the "package" to be built.
  65. ;;;
  66. ;;; Much of the ceremony involved is to avoid using `elm` in ways that would
  67. ;;; make it try to do network IO beyond the bare minimum functionality for
  68. ;;; which we've patched a replacement into our `elm`. On the other hand, we
  69. ;;; get to take advantage of the very regular structure required of Elm
  70. ;;; packages.
  71. ;;;
  72. ;;; *Known issue:* Elm itself supports multiple versions of "package"s
  73. ;;; coexisting simultaneously under ELM_HOME, but we do not support this yet.
  74. ;;; Sometimes, parallel versions coexisting causes `elm` to try to write to
  75. ;;; built "artifacts.dat" files. For now, two workarounds are possible:
  76. ;;;
  77. ;;; - Use `patch-application-dependencies` to rewrite an "application"'s
  78. ;;; "elm.json" file to refer to the versions of its inputs actually
  79. ;;; packaged in Guix.
  80. ;;;
  81. ;;; - Use a Guix package transformation to rewrite your "application"'s
  82. ;;; dependencies recursively, so that only one version of each Elm
  83. ;;; "package" is included in your "application"'s build environment.
  84. ;;;
  85. ;;; Patching `elm` more extensively---perhaps adding an `elm guix`
  86. ;;; subcommand`---might let us address these issues more directly.
  87. ;;;
  88. ;;; Code:
  89. ;;;
  90. (define %essential-elm-packages
  91. ;; elm/json isn't essential in a fundamental sense,
  92. ;; but it's required for a {"type":"application"},
  93. ;; which we are generating to trigger the build
  94. '("elm/core" "elm/json"))
  95. (define* (target-elm-version #:optional elm)
  96. "Return the version of ELM or whichever 'elm' is in $PATH.
  97. Return #false if it cannot be determined."
  98. (let* ((pipe (open-pipe* OPEN_READ
  99. (or elm "elm")
  100. "--version"))
  101. (line (read-line pipe)))
  102. (and (zero? (close-pipe pipe))
  103. (string? line)
  104. line)))
  105. (define* (prepare-elm-home #:key native-inputs inputs #:allow-other-keys)
  106. "Set the ELM_HOME environment variable and populate the indicated directory
  107. with the union of the Elm \"package\" inputs. Also, set GUIX_ELM_VERSION to
  108. the version of the Elm compiler in use."
  109. (let* ((elm (search-input-file (or native-inputs inputs) "/bin/elm"))
  110. (elm-version (target-elm-version elm)))
  111. (setenv "GUIX_ELM_VERSION" elm-version)
  112. (mkdir "../elm-home")
  113. (with-directory-excursion "../elm-home"
  114. (union-build elm-version
  115. (search-path-as-list
  116. (list (string-append "share/elm/" elm-version))
  117. (map cdr inputs))
  118. #:create-all-directories? #t)
  119. (setenv "ELM_HOME" (getcwd)))))
  120. (define* (stage #:key native-inputs inputs #:allow-other-keys)
  121. "Extract the installable files from the Elm \"package\" into a staging
  122. directory and link it into the ELM_HOME tree. Also, set GUIX_ELM_PKG_NAME and
  123. GUIX_ELM_PKG_VERSION to the name and version, respectively, of the Elm package
  124. being built, as defined in its \"elm.json\" file."
  125. (let* ((elm-version (getenv "GUIX_ELM_VERSION"))
  126. (elm-home (getenv "ELM_HOME"))
  127. (info (match (call-with-input-file "elm.json" read-json)
  128. (('@ . alist) alist)))
  129. (name (assoc-ref info "name"))
  130. (version (assoc-ref info "version"))
  131. (rel-dir (string-append elm-version "/packages/" name "/" version))
  132. (staged-dir (string-append elm-home "/../staged/" rel-dir)))
  133. (setenv "GUIX_ELM_PKG_NAME" name)
  134. (setenv "GUIX_ELM_PKG_VERSION" version)
  135. (mkdir-p staged-dir)
  136. (mkdir-p (string-append elm-home "/" (dirname rel-dir)))
  137. (symlink staged-dir
  138. (string-append elm-home "/" rel-dir))
  139. (copy-recursively "src" (string-append staged-dir "/src"))
  140. (install-file "elm.json" staged-dir)
  141. (install-file "README.md" staged-dir)
  142. (when (file-exists? "LICENSE")
  143. (install-file "LICENSE" staged-dir))))
  144. (define (patch-json-string-escapes file)
  145. "Work around a bug in the Elm compiler's JSON parser by attempting to
  146. replace REVERSE-SOLIDUS--SOLIDUS escape sequences in FILE with unescaped
  147. SOLIDUS characters."
  148. ;; https://github.com/elm/compiler/issues/2255
  149. (substitute* file
  150. (("\\\\/")
  151. "/")))
  152. (define (directory-list dir)
  153. "Like DIRECTORY-LIST from 'racket/base': lists the contents of DIR, not
  154. including the special \".\" and \"..\" entries."
  155. (scandir dir (lambda (f)
  156. (not (member f '("." ".."))))))
  157. (define* (make-offline-registry-file #:key inputs #:allow-other-keys)
  158. "Generate an \"offline-package-registry.json\" file and set
  159. GUIX_ELM_OFFLINE_REGISTRY_FILE to its path, cooperating with a patch to `elm`
  160. to avoid attempting to download a list of all published Elm package names and
  161. versions from the internet."
  162. (let* ((elm-home (getenv "ELM_HOME"))
  163. (elm-version (getenv "GUIX_ELM_VERSION"))
  164. (registry-file
  165. (string-append elm-home "/../offline-package-registry.json"))
  166. (registry-alist
  167. ;; here, we don't need to look up entries, so we build the
  168. ;; alist directly, rather than using a vhash
  169. (with-directory-excursion
  170. (string-append elm-home "/" elm-version "/packages")
  171. (append-map (lambda (org)
  172. (with-directory-excursion org
  173. (map (lambda (repo)
  174. (cons (string-append org "/" repo)
  175. (directory-list repo)))
  176. (directory-list "."))))
  177. (directory-list ".")))))
  178. (call-with-output-file registry-file
  179. (lambda (out)
  180. (write-json `(@ ,@registry-alist) out)))
  181. (patch-json-string-escapes registry-file)
  182. (setenv "GUIX_ELM_OFFLINE_REGISTRY_FILE" registry-file)))
  183. (define (read-offline-registry->vhash)
  184. "Return a vhash mapping Elm \"package\" names to lists of available version
  185. strings."
  186. (alist->vhash
  187. (match (call-with-input-file (getenv "GUIX_ELM_OFFLINE_REGISTRY_FILE")
  188. read-json)
  189. (('@ . alist) alist))))
  190. (define (find-indirect-dependencies registry-vhash root-pkg root-version)
  191. "Return the recursive dependencies of ROOT-PKG, an Elm \"package\" name, at
  192. version ROOT-VERSION as an alist mapping Elm \"package\" names to (single)
  193. versions. The resulting alist will not include entries for
  194. %ESSENTIAL-ELM-PACKAGES or for ROOT-PKG itself. The REGISTRY-VHASH is used in
  195. conjunction with the ELM_HOME environment variable to find dependencies."
  196. (with-directory-excursion
  197. (string-append (getenv "ELM_HOME")
  198. "/" (getenv "GUIX_ELM_VERSION")
  199. "/packages")
  200. (define (get-dependencies pkg version acc)
  201. (let* ((elm-json-alist
  202. (match (call-with-input-file
  203. (string-append pkg "/" version "/elm.json")
  204. read-json)
  205. (('@ . alist) alist)))
  206. (deps-alist
  207. (match (assoc-ref elm-json-alist "dependencies")
  208. (('@ . alist) alist)))
  209. (deps-names
  210. (filter-map (match-lambda
  211. ((name . range)
  212. (and (not (member name %essential-elm-packages))
  213. name)))
  214. deps-alist)))
  215. (fold register-dependency acc deps-names)))
  216. (define (register-dependency pkg acc)
  217. ;; Using vhash-cons unconditionally would add duplicate entries,
  218. ;; which would then cause problems when we must emit JSON.
  219. ;; Plus, we can avoid needlessly duplicating work.
  220. (if (vhash-assoc pkg acc)
  221. acc
  222. (match (vhash-assoc pkg registry-vhash)
  223. ((_ version . _)
  224. ;; in the rare case that multiple versions are present,
  225. ;; just picking an arbitrary one seems to work well enough for now
  226. (get-dependencies pkg version (vhash-cons pkg version acc))))))
  227. (vlist->list
  228. (get-dependencies root-pkg root-version vlist-null))))
  229. (define* (patch-application-dependencies #:key inputs #:allow-other-keys)
  230. "Rewrites the \"elm.json\" file in the working directory---which must be of
  231. `\"type\":\"application\"`, not `\"type\":\"package\"`---to refer to the
  232. dependency versions actually provided via Guix. The
  233. GUIX_ELM_OFFLINE_REGISTRY_FILE environment variable is used to find available
  234. versions."
  235. (let* ((registry-vhash (read-offline-registry->vhash))
  236. (rewrite-dep-version
  237. (match-lambda
  238. ((name . _)
  239. (cons name (match (vhash-assoc name registry-vhash)
  240. ((_ version) ;; no dot
  241. version))))))
  242. (rewrite-direct/indirect
  243. (match-lambda
  244. ;; a little checking to avoid confusing misuse with "package"
  245. ;; project dependencies, which have a different shape
  246. (((and key (or "direct" "indirect"))
  247. '@ . alist)
  248. `(,key @ ,@(map rewrite-dep-version alist)))))
  249. (rewrite-json-section
  250. (match-lambda
  251. (((and key (or "dependencies" "test-dependencies"))
  252. '@ . alist)
  253. `(,key @ ,@(map rewrite-direct/indirect alist)))
  254. ((k . v)
  255. (cons k v))))
  256. (rewrite-elm-json
  257. (match-lambda
  258. (('@ . alist)
  259. `(@ ,@(map rewrite-json-section alist))))))
  260. (with-atomic-file-replacement "elm.json"
  261. (lambda (in out)
  262. (write-json (rewrite-elm-json (read-json in))
  263. out)))
  264. (patch-json-string-escapes "elm.json")))
  265. (define* (configure #:key native-inputs inputs #:allow-other-keys)
  266. "Generate a trivial Elm \"application\" with a direct dependency on the Elm
  267. \"package\" currently being built."
  268. (let* ((info (match (call-with-input-file "elm.json" read-json)
  269. (('@ . alist) alist)))
  270. (name (getenv "GUIX_ELM_PKG_NAME"))
  271. (version (getenv "GUIX_ELM_PKG_VERSION"))
  272. (elm-home (getenv "ELM_HOME"))
  273. (registry-vhash (read-offline-registry->vhash))
  274. (app-dir (string-append elm-home "/../fake-app")))
  275. (mkdir-p (string-append app-dir "/src"))
  276. (with-directory-excursion app-dir
  277. (call-with-output-file "elm.json"
  278. (lambda (out)
  279. (write-json
  280. `(@ ("type" . "application")
  281. ("source-directories" "src") ;; intentionally no dot
  282. ("elm-version" . ,(getenv "GUIX_ELM_VERSION"))
  283. ("dependencies"
  284. @ ("direct"
  285. @ ,@(map (lambda (pkg)
  286. (match (vhash-assoc pkg registry-vhash)
  287. ((_ pkg-version . _)
  288. (cons pkg
  289. (if (equal? pkg name)
  290. version
  291. pkg-version)))))
  292. (if (member name %essential-elm-packages)
  293. %essential-elm-packages
  294. (cons name %essential-elm-packages))))
  295. ("indirect"
  296. @ ,@(if (member name %essential-elm-packages)
  297. '()
  298. (find-indirect-dependencies registry-vhash
  299. name
  300. version))))
  301. ("test-dependencies"
  302. @ ("direct" @)
  303. ("indirect" @)))
  304. out)))
  305. (patch-json-string-escapes "elm.json")
  306. (with-output-to-file "src/Main.elm"
  307. ;; the most trivial possible elm program
  308. (lambda ()
  309. (display "module Main exposing (..)
  310. main : Program () () ()
  311. main = Platform.worker
  312. { init = \\_ -> ( (), Cmd.none )
  313. , update = \\_ -> \\_ -> ( (), Cmd.none )
  314. , subscriptions = \\_ -> Sub.none }"))))))
  315. (define* (build #:key native-inputs inputs #:allow-other-keys)
  316. "Run `elm make` to build the Elm \"application\" generated by CONFIGURE."
  317. (with-directory-excursion (string-append (getenv "ELM_HOME") "/../fake-app")
  318. (invoke (search-input-file (or native-inputs inputs) "/bin/elm")
  319. "make"
  320. "src/Main.elm")))
  321. (define* (check #:key tests? #:allow-other-keys)
  322. "Does nothing, because the `elm-test` executable has not yet been packaged
  323. for Guix."
  324. (when tests?
  325. (display "elm-test has not yet been packaged for Guix\n")))
  326. (define* (install #:key outputs #:allow-other-keys)
  327. "Installs the contents of the directory generated by STAGE, including any
  328. files added by BUILD, to the Guix package output."
  329. (copy-recursively
  330. (string-append (getenv "ELM_HOME") "/../staged")
  331. (string-append (assoc-ref outputs "out") "/share/elm")))
  332. (define* (validate-compiled #:key outputs #:allow-other-keys)
  333. "Checks that the files \"artifacts.dat\" and \"docs.json\" have been
  334. installed."
  335. (let ((base (string-append "/share/elm/"
  336. (getenv "GUIX_ELM_VERSION")
  337. "/packages/"
  338. (getenv "GUIX_ELM_PKG_NAME")
  339. "/"
  340. (getenv "GUIX_ELM_PKG_VERSION")))
  341. (expected '("artifacts.dat" "docs.json")))
  342. (for-each (lambda (name)
  343. (search-input-file outputs (string-append base "/" name)))
  344. expected)))
  345. (define %standard-phases
  346. (modify-phases gnu:%standard-phases
  347. (add-after 'unpack 'prepare-elm-home prepare-elm-home)
  348. (delete 'bootstrap)
  349. (add-after 'patch-source-shebangs 'stage stage)
  350. (add-after 'stage 'make-offline-registry-file make-offline-registry-file)
  351. (replace 'configure configure)
  352. (delete 'patch-generated-file-shebangs)
  353. (replace 'build build)
  354. (replace 'check check)
  355. (replace 'install install)
  356. (add-before 'validate-documentation-location 'validate-compiled
  357. validate-compiled)))
  358. (define* (elm-build #:key inputs (phases %standard-phases)
  359. #:allow-other-keys #:rest args)
  360. "Builds the given Elm project, applying all of the PHASES in order."
  361. (apply gnu:gnu-build #:inputs inputs #:phases phases args))