guix/guix/build/elm-build-system.scm

381 lines
17 KiB
Scheme

;;; GNU Guix --- Functional package management for GNU
;;; Copyright © 2022 Philip McGrath <philip@philipmcgrath.com>
;;;
;;; 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 <http://www.gnu.org/licenses/>.
(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
;;; <https://github.com/elm/compiler/blob/master/docs/elm.json/package.md> and
;;; <https://github.com/elm/compiler/blob/master/docs/elm.json/application.md>.
;;; 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
;;; <https://github.com/elm/compiler/blob/master/hints/optimize.md>.
;;;
;;; 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 set
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))