;;; GNU Guix --- Functional package management for GNU ;;; Copyright © 2022 Philip McGrath ;;; ;;; This file is part of GNU Guix. ;;; ;;; GNU Guix is free software; you can redistribute it and/or modify it ;;; under the terms of the GNU General Public License as published by ;;; the Free Software Foundation; either version 3 of the License, or (at ;;; your option) any later version. ;;; ;;; GNU Guix is distributed in the hope that it will be useful, but ;;; WITHOUT ANY WARRANTY; without even the implied warranty of ;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;;; GNU General Public License for more details. ;;; ;;; You should have received a copy of the GNU General Public License ;;; along with GNU Guix. If not, see . (define-module (guix build elm-build-system) #:use-module ((guix build gnu-build-system) #:prefix gnu:) #:use-module (guix build utils) #:use-module (guix build json) #:use-module (guix build union) #:use-module (ice-9 ftw) #:use-module (ice-9 rdelim) #:use-module (ice-9 regex) #:use-module (ice-9 match) #:use-module (ice-9 popen) #:use-module (ice-9 vlist) #:use-module (srfi srfi-1) #:use-module (srfi srfi-26) #:use-module (srfi srfi-71) #:export (%standard-phases patch-application-dependencies patch-json-string-escapes read-offline-registry->vhash elm-build)) ;; COMMENTARY: ;; ;; Elm draws a sharp distinction between "projects" with `{"type":"package"}` ;; vs. `{"type":"application"}` in the "elm.json" file: see ;; and ;; . ;; For now, `elm-build-system` is designed for "package"s: packaging ;; "application"s requires ad-hoc replacements for some phases---but see ;; `patch-application-dependencies`, which helps to work around a known issue ;; discussed below. It would be nice to add more streamlined support for ;; "application"s one we have more experience building them in Guix. For ;; example, we could incorporate the `uglifyjs` advice from ;; . ;; ;; We want building an Elm "package" to produce: ;; ;; - a "docs.json" file with extracted documentation; and ;; ;; - an "artifacts.dat" file with compilation results for use in building ;; "package"s and "application"s. ;; ;; Unfortunately, there isn't an entry point to the Elm compiler that builds ;; those files directly. Building with `elm make` does something different, ;; more oriented toward development, testing, and building "application"s. We ;; work around this limitation by staging the "package" we're building as ;; though it were already installed in ELM_HOME, generating a trivial Elm ;; "application" that depends on the "package", and building the ;; "application", which causes the files for the "package" to be built. ;; ;; Much of the ceremony involved is to avoid using `elm` in ways that would ;; make it try to do network IO beyond the bare minimum functionality for ;; which we've patched a replacement into our `elm`. On the other hand, we ;; get to take advantage of the very regular structure required of Elm ;; packages. ;; ;; *Known issue:* Elm itself supports multiple versions of "package"s ;; coexisting simultaneously under ELM_HOME, but we do not support this yet. ;; Sometimes, parallel versions coexisting causes `elm` to try to write to ;; built "artifacts.dat" files. For now, two workarounds are possible: ;; ;; - Use `patch-application-dependencies` to rewrite an "application"'s ;; "elm.json" file to refer to the versions of its inputs actually ;; packaged in Guix. ;; ;; - Use a Guix package transformation to rewrite your "application"'s ;; dependencies recursively, so that only one version of each Elm ;; "package" is included in your "application"'s build environment. ;; ;; Patching `elm` more extensively---perhaps adding an `elm guix` ;; subcommand`---might let us address these issues more directly. ;; ;; CODE: ;; (define %essential-elm-packages ;; elm/json isn't essential in a fundamental sense, ;; but it's required for a {"type":"application"}, ;; which we are generating to trigger the build '("elm/core" "elm/json")) (define* (target-elm-version #:optional elm) "Return the version of ELM or whichever 'elm' is in $PATH. Return #false if it cannot be determined." (let* ((pipe (open-pipe* OPEN_READ (or elm "elm") "--version")) (line (read-line pipe))) (and (zero? (close-pipe pipe)) (string? line) line))) (define* (prepare-elm-home #:key native-inputs inputs #:allow-other-keys) "Set the ELM_HOME environment variable and populate the indicated directory with the union of the Elm \"package\" inputs. Also, set GUIX_ELM_VERSION to the version of the Elm compiler in use." (let* ((elm (search-input-file (or native-inputs inputs) "/bin/elm")) (elm-version (target-elm-version elm))) (setenv "GUIX_ELM_VERSION" elm-version) (mkdir "../elm-home") (with-directory-excursion "../elm-home" (union-build elm-version (search-path-as-list (list (string-append "share/elm/" elm-version)) (map cdr inputs)) #:create-all-directories? #t) (setenv "ELM_HOME" (getcwd))))) (define* (stage #:key native-inputs inputs #:allow-other-keys) "Extract the installable files from the Elm \"package\" into a staging directory and link it into the ELM_HOME tree. Also, set GUIX_ELM_PKG_NAME and GUIX_ELM_PKG_VERSION to the name and version, respectively, of the Elm package being built, as defined in its \"elm.json\" file." (let* ((elm-version (getenv "GUIX_ELM_VERSION")) (elm-home (getenv "ELM_HOME")) (info (match (call-with-input-file "elm.json" read-json) (('@ . alist) alist))) (name (assoc-ref info "name")) (version (assoc-ref info "version")) (rel-dir (string-append elm-version "/packages/" name "/" version)) (staged-dir (string-append elm-home "/../staged/" rel-dir))) (setenv "GUIX_ELM_PKG_NAME" name) (setenv "GUIX_ELM_PKG_VERSION" version) (mkdir-p staged-dir) (mkdir-p (string-append elm-home "/" (dirname rel-dir))) (symlink staged-dir (string-append elm-home "/" rel-dir)) (copy-recursively "src" (string-append staged-dir "/src")) (install-file "elm.json" staged-dir) (install-file "README.md" staged-dir) (when (file-exists? "LICENSE") (install-file "LICENSE" staged-dir)))) (define (patch-json-string-escapes file) "Work around a bug in the Elm compiler's JSON parser by attempting to replace REVERSE-SOLIDUS--SOLIDUS escape sequences in FILE with unescaped SOLIDUS characters." ;; https://github.com/elm/compiler/issues/2255 (substitute* file (("\\\\/") "/"))) (define (directory-list dir) "Like DIRECTORY-LIST from 'racket/base': lists the contents of DIR, not including the special \".\" and \"..\" entries." (scandir dir (lambda (f) (not (member f '("." "..")))))) (define* (make-offline-registry-file #:key inputs #:allow-other-keys) "Generate an \"offline-package-registry.json\" file and sets GUIX_ELM_OFFLINE_REGISTRY_FILE to its path, cooperating with a patch to `elm` to avoid attempting to download a list of all published Elm package names and versions from the internet." (let* ((elm-home (getenv "ELM_HOME")) (elm-version (getenv "GUIX_ELM_VERSION")) (registry-file (string-append elm-home "/../offline-package-registry.json")) (registry-alist ;; here, we don't need to look up entries, so we build the ;; alist directly, rather than using a vhash (with-directory-excursion (string-append elm-home "/" elm-version "/packages") (append-map (lambda (org) (with-directory-excursion org (map (lambda (repo) (cons (string-append org "/" repo) (directory-list repo))) (directory-list ".")))) (directory-list "."))))) (call-with-output-file registry-file (lambda (out) (write-json `(@ ,@registry-alist) out))) (patch-json-string-escapes registry-file) (setenv "GUIX_ELM_OFFLINE_REGISTRY_FILE" registry-file))) (define (read-offline-registry->vhash) "Return a vhash mapping Elm \"package\" names to lists of available version strings." (alist->vhash (match (call-with-input-file (getenv "GUIX_ELM_OFFLINE_REGISTRY_FILE") read-json) (('@ . alist) alist)))) (define (find-indirect-dependencies registry-vhash root-pkg root-version) "Return the recursive dependencies of ROOT-PKG, an Elm \"package\" name, at version ROOT-VERSION as an alist mapping Elm \"package\" names to (single) versions. The resulting alist will not include entries for %ESSENTIAL-ELM-PACKAGES or for ROOT-PKG itself. The REGISTRY-VHASH is used in conjunction with the ELM_HOME environment variable to find dependencies." (with-directory-excursion (string-append (getenv "ELM_HOME") "/" (getenv "GUIX_ELM_VERSION") "/packages") (define (get-dependencies pkg version acc) (let* ((elm-json-alist (match (call-with-input-file (string-append pkg "/" version "/elm.json") read-json) (('@ . alist) alist))) (deps-alist (match (assoc-ref elm-json-alist "dependencies") (('@ . alist) alist))) (deps-names (filter-map (match-lambda ((name . range) (and (not (member name %essential-elm-packages)) name))) deps-alist))) (fold register-dependency acc deps-names))) (define (register-dependency pkg acc) ;; Using vhash-cons unconditionally would add duplicate entries, ;; which would then cause problems when we must emit JSON. ;; Plus, we can avoid needlessly duplicating work. (if (vhash-assoc pkg acc) acc (match (vhash-assoc pkg registry-vhash) ((_ version . _) ;; in the rare case that multiple versions are present, ;; just picking an arbitrary one seems to work well enough for now (get-dependencies pkg version (vhash-cons pkg version acc)))))) (vlist->list (get-dependencies root-pkg root-version vlist-null)))) (define* (patch-application-dependencies #:key inputs #:allow-other-keys) "Rewrites the \"elm.json\" file in the working directory---which must be of `\"type\":\"application\"`, not `\"type\":\"package\"`---to refer to the dependency versions actually provided via Guix. The GUIX_ELM_OFFLINE_REGISTRY_FILE environment variable is used to find available versions." (let* ((registry-vhash (read-offline-registry->vhash)) (rewrite-dep-version (match-lambda ((name . _) (cons name (match (vhash-assoc name registry-vhash) ((_ version) ;; no dot version)))))) (rewrite-direct/indirect (match-lambda ;; a little checking to avoid confusing misuse with "package" ;; project dependencies, which have a different shape (((and key (or "direct" "indirect")) '@ . alist) `(,key @ ,@(map rewrite-dep-version alist))))) (rewrite-json-section (match-lambda (((and key (or "dependencies" "test-dependencies")) '@ . alist) `(,key @ ,@(map rewrite-direct/indirect alist))) ((k . v) (cons k v)))) (rewrite-elm-json (match-lambda (('@ . alist) `(@ ,@(map rewrite-json-section alist)))))) (with-atomic-file-replacement "elm.json" (lambda (in out) (write-json (rewrite-elm-json (read-json in)) out))) (patch-json-string-escapes "elm.json"))) (define* (configure #:key native-inputs inputs #:allow-other-keys) "Generate a trivial Elm \"application\" with a direct dependency on the Elm \"package\" currently being built." (let* ((info (match (call-with-input-file "elm.json" read-json) (('@ . alist) alist))) (name (getenv "GUIX_ELM_PKG_NAME")) (version (getenv "GUIX_ELM_PKG_VERSION")) (elm-home (getenv "ELM_HOME")) (registry-vhash (read-offline-registry->vhash)) (app-dir (string-append elm-home "/../fake-app"))) (mkdir-p (string-append app-dir "/src")) (with-directory-excursion app-dir (call-with-output-file "elm.json" (lambda (out) (write-json `(@ ("type" . "application") ("source-directories" "src") ;; intentionally no dot ("elm-version" . ,(getenv "GUIX_ELM_VERSION")) ("dependencies" @ ("direct" @ ,@(map (lambda (pkg) (match (vhash-assoc pkg registry-vhash) ((_ pkg-version . _) (cons pkg (if (equal? pkg name) version pkg-version))))) (if (member name %essential-elm-packages) %essential-elm-packages (cons name %essential-elm-packages)))) ("indirect" @ ,@(if (member name %essential-elm-packages) '() (find-indirect-dependencies registry-vhash name version)))) ("test-dependencies" @ ("direct" @) ("indirect" @))) out))) (patch-json-string-escapes "elm.json") (with-output-to-file "src/Main.elm" ;; the most trivial possible elm program (lambda () (display "module Main exposing (..) main : Program () () () main = Platform.worker { init = \\_ -> ( (), Cmd.none ) , update = \\_ -> \\_ -> ( (), Cmd.none ) , subscriptions = \\_ -> Sub.none }")))))) (define* (build #:key native-inputs inputs #:allow-other-keys) "Run `elm make` to build the Elm \"application\" generated by CONFIGURE." (with-directory-excursion (string-append (getenv "ELM_HOME") "/../fake-app") (invoke (search-input-file (or native-inputs inputs) "/bin/elm") "make" "src/Main.elm"))) (define* (check #:key tests? #:allow-other-keys) "Does nothing, because the `elm-test` executable has not yet been packaged for Guix." (when tests? (display "elm-test has not yet been packaged for Guix\n"))) (define* (install #:key outputs #:allow-other-keys) "Installs the contents of the directory generated by STAGE, including any files added by BUILD, to the Guix package output." (copy-recursively (string-append (getenv "ELM_HOME") "/../staged") (string-append (assoc-ref outputs "out") "/share/elm"))) (define* (validate-compiled #:key outputs #:allow-other-keys) "Checks that the files \"artifacts.dat\" and \"docs.json\" have been installed." (let ((base (string-append "/share/elm/" (getenv "GUIX_ELM_VERSION") "/packages/" (getenv "GUIX_ELM_PKG_NAME") "/" (getenv "GUIX_ELM_PKG_VERSION"))) (expected '("artifacts.dat" "docs.json"))) (for-each (lambda (name) (search-input-file outputs (string-append base "/" name))) expected))) (define %standard-phases (modify-phases gnu:%standard-phases (add-after 'unpack 'prepare-elm-home prepare-elm-home) (delete 'bootstrap) (add-after 'patch-source-shebangs 'stage stage) (add-after 'stage 'make-offline-registry-file make-offline-registry-file) (replace 'configure configure) (delete 'patch-generated-file-shebangs) (replace 'build build) (replace 'check check) (replace 'install install) (add-before 'validate-documentation-location 'validate-compiled validate-compiled))) (define* (elm-build #:key inputs (phases %standard-phases) #:allow-other-keys #:rest args) "Builds the given Elm project, applying all of the PHASES in order." (apply gnu:gnu-build #:inputs inputs #:phases phases args))