PNDP: An internal DSL for Nix in PHP

v0.0.4 2022-02-26 22:15 UTC

This package is not auto-updated.

Last update: 2024-04-21 08:22:52 UTC


README

Many programming language environments provide their own language specific package manager, implementing features that are already well supported by generic ones. This package is a response to that phenomenon.

This package contains a library and command-line utility providing an internal DSL for the Nix package manager in PHP. Nix is a generic package manager that borrows concepts from purely functional programming languages to make deployment reliable, reproducible and efficient. It serves as the basis of the NixOS Linux distribution, but it can also be used seperately on regular Linux distributions, FreeBSD, Mac OS X and Windows (through Cygwin).

The internal PHP DSL makes it possible for developers to convienently perform deployment operations, such as building, upgrading and installing packages with the Nix package manager from PHP programs. Moreover, it offers a number of additional features.

Prerequisites

  • PHP 7.4.x or higher
  • Of course, since this package provides a feature for Nix, we require the Nix package manager to be installed

Installation

This package can be installed globally with composer:

$ php composer.phar global require svanderburg/pndp

It is also possible to install the package with Nix by checking out the Git repository and running:

$ nix-env -f release.nix -iA package.x86_64-linux

Usage

This package offers a number of interesting features.

Calling a Nix function from PHP

The most important use case of this package is to be able to call Nix functions from PHP. To call a Nix function, we must create a simple proxy that translates a PHP object method invocation to a string containing a semantically equivalent Nix function invocation.

The following code fragment demonstrates a PHP function proxy that relays a call to the stdenv.mkDerivation {} function in Nix:

namespace Pkgs;
use PNDP\AST\NixFunInvocation;
use PNDP\AST\NixExpression;

class Stdenv
{
    public function mkDerivation(array $args)
    {
        return new NixFunInvocation(new NixExpression("pkgs.stdenv.mkDerivation"), $args);
    }
}

As can be observed, the function proxy has a very simple structure. It takes an arbitrary PHP object as a parameter and returns a PHP object representing a Nix function invocation to stdenv.mkDerivation {} using the args object as an argument.

The conversion to Nix is done automatically by PHP through a function called phpToNix().

Compiling PHP language constructs into Nix language constructs

The phpToNix() function is used by PNDP to transform PHP language constructs to Nix expression language constructs.

PHP objects are translated into semantically equivalent (or similar) Nix expression language objects as follows:

  • Variables of type bool, int and float are translated verbatim
  • Variables of type string are translated verbatim and are automatically escaped
  • When an array appears to be sequential (i.e. it has numeric keys appearing in sequential order) it will be recursively translated into a list of objects. Associative arrays will be recursively translated into attribute sets of objects. Keys are translated into identifiers unless they contain characters not allowing it do to so. If the latter is the case, they are translated into strings.
  • objects (that are instantiated from classes) are translated into attribute sets exposing their public properties.
  • A variable that has a NULL reference is translated into a null value.

Some Nix expression language constructs have no semantic equivalent in PHP. Nonetheless, they can be generated by composing an abstract syntax tree of objects that are instances of classes that inherit from NixObject:

  • To force a PHP array to appear as a Nix list, you can compose a NixList object.
  • To force a PHP array to appear as a Nix attribute set, you can compose a NixAttrSet object.
  • An object instance of NixFile can be used to specify a relative or absolute path to a file. Nix checks whether the file exists and imports it into the Nix store.
  • To encode URLs, an object instance of NixURL can be used.
  • Recursive attribute sets (in which attributes are allowed to refer to each other) can be defined by creating an object instance of the NixRecursiveAttrSet prototype.
  • Referring to an attribute of an attribute set can be done by creating an object instance of NixAttrReference
  • Defining functions in the Nix expression language can be done by instantiating NixFunction.
  • A Nix function can be invoked by creating an object that is an instance of NixFunInvocation.
  • An external Nix expression file can be imported by creating a NixImport object that refers to an external file.
  • Referring to existing Nix store paths can be done by creating objects that are instances of NixStorePath.
  • An if-then-else block can be generated by defining an NixIf object.
  • An assert block can be defined with an NixAssert object.
  • A let-block containing private values can be defined by means of a NixLet object.
  • A value can be imported into the lexical scope of a block by assigning a member of an object, NixLet, or NixRecursiveAttrSet to an object instance of NixInherit.
  • Attributes member of an attribute set can be imported into the lexical scope by creating a NixWith object.
  • Two attribute sets can be merged by creating a NixMergeAttrs object.
  • To literally do stuff in the Nix expression language, compose objects that are instances of the NixExpression prototype.
  • Defining build instructions in PHP (as opposed to bash code embedded in strings) can be done by creating a NixInlinePHP object (see section: 'Writing inline PHP code in a PNDP package specification').

Check the API documentation for more details on how to use the above classes. More details on the Nix expression language can be found in the Nix manual.

Specifying packages in PHP

The Stdenv::mkDerivation() function shown earlier is a very important function in Nix. It's directly and indirectly used by nearly every package recipe to perform a build from source code. In the tests/ folder, we have defined a packages repository: Pkgs.php that provides a proxy to this function.

By using this proxy we can also describe our own package specifications in PHP, instead of the Nix expression language. Every package build recipe can be written as a class that provides a static composePackage method:

namespace Pkgs;
use PNDP\AST\NixURL;

class Hello
{
    public static function composePackage(object $args)
    {
        return $args->stdenv->mkDerivation(array(
            "name" => "hello-2.10",

            "src" => $args->fetchurl(array(
                "url" => new NixURL("mirror://gnu/hello/hello-2.10.tar.gz"),
                "sha256" => "0ssi1wpaf7plaswqqjwigppsg5fyh99vdlb9kzl7c9lng89ndq1i"
            )),

            "doCheck" => true,

            "meta" => array(
                "description" => "A program that produces a familiar, friendly greeting",
                "homepage" => new NixURL("http://www.gnu.org/software/hello/manual"),
                "license" => "GPLv3+"
            )
        ));
    }
}

In the body of the composePackage() method, we return the result of an invocation to the mkDerivation() method that builds a package from source code. To this method we pass essential build parameters, such as the URL from which the source code can be obtained.

Nix has special types for URLs and files to check whether they are in the valid format and that they are automatically imported into the Nix store for purity. As they are not in the PHP language, we can artificially create them through objects that are instances of the NixFile and NixURL classes.

Moreover, there are more prototypes for some other Nix expression language constructs that have no PHP equivalent. Check the API documentation for more information.

Composing packages

As with ordinary Nix expressions, we cannot use a class (that defines a method) to build a package directly. We have to compose it by calling the method with its required arguments. Composition is done in a composition class, named: Pkgs in the tests/ folder. The structure of this class looks as follows:

class Pkgs
{
    public $stdenv;

    public function __construct()
    {
        $this->stdenv = new Pkgs\Stdenv();
    }

    public function fetchurl(array $args)
    {
        return Pkgs\Fetchurl::composePackage($this, $args);
    }

    public function hello()
    {
        return Pkgs\Hello::composePackage($this);
    }
}

With the exception of stdenv, the above class exposes all packages as method invocations that will generate the Nix expression code for the corresponding package.

Each method invocation invokes the composition method of a package (shown in the previous code fragment) and propagates the entire package set as parameters so that its dependencies can be found.

Exposing package compositions through object methods is a very repetitive process. We can also generalize the method invocation of any package by implementing the __call() magic method:

public function __call(string $name, array $arguments)
{
    // Compose the classname from the function name
    $className = ucfirst($name);
    // Compose the name of the method to compose the package
    $methodName = 'Pkgs\\'.$className.'::composePackage';
    // Prepend $this so that it becomes the first function parameter
    array_unshift($arguments, $this);
    // Dynamically the invoke the class' composition method with $this as first parameter and the remaining parameters
    return call_user_func_array($methodName, $arguments);
}

The above magic method takes the name of the method to be invoked, translates it into the corresponding classname of the package (by generating a camel case name from the method name), composes an argument list (providing a reference to the packages object and any remaining function arguments) and finally invokes the composition method with the generated parameters.

Writing inline PHP code in a PNDP package specification

When implementing a custom build procedure in a PNDP package module, we may also run into the inconvenience of having to embed custom build steps as shell code embedded in strings. We can also use the pndpInlineProxy from a PNDP package class, by creating an object that is an instance of the NixInlinePHP prototype:

namespace Pkgs;
use PNDP\AST\NixInlinePHP;
use PNDP\AST\NixURL;

class CreateFileWithMessageTest
{
	public static function composePackage(object $args)
	{
		$buildCommand = <<<EOT
mkdir(getenv("out"));
file_put_contents(getenv("out")."/message.txt", "Hello world written through inline PHP!");
EOT;

		return $args->stdenv->mkDerivation(array(
			"name" => "createFileWithMessageTest",
			"buildCommand" => new NixInlinePHP($buildCommand)
		));
	}
}

The above PNDP class module shows the PNDP equivalent of our first Nix expression example containing inline PHP code.

The buildCommand parameter is bound to an instance of the NixInlinePHP prototype. The code parameter is a string that contains embedded PHP code.

Building packages programmatically

The PNDPBuild::callNixBuild() function can be used to build a generated Nix expression:

/* Evaluate the package */
$expr = PNDPBuild::evaluatePackage("Pkgs.php", "hello", false);

/* Call nix-build */
PNDPBuild::callNixBuild($expr, array());

In the code fragment above, we open the composition class file, named: Pkgs.php and we evaluate the hello() method to generate the Nix expression. Finally, we call the callNixBuild function, in which we evaluate the generated expression by the Nix package manager. When the build succeeds, the resulting Nix store path is printed on the standard output.

Building packages through a command-line utility

As the previous code example is so common, there is also a command-line utility that can do the same. The following instruction builds the hello package from the composition class (Pkgs.php):

$ pndp-build -f Pkgs.php -A hello

It may also be useful to see what kind of Nix expression is generated for debugging or testing purposes. The --eval-only option prints the generated Nix expression on the standard output:

$ pndp-build -f Pkgs.js -A hello --eval-only

We can also nicely format the generated expression to improve readability:

$ pndp-build -f Pkgs.js -A hello --eval-only --format

Building PNDP packages from a Nix expression

We can also call the composition class from a Nix expression. This is useful to build PNDP packages from Hydra, a continuous build and integration server built around Nix.

The following Nix expression builds the hello package defined in the Pkgs.php class shown earlier:

{nixpkgs, system, pndp}:

let
  pndpImportPackage = import ./src/PNDP/importPackage.nix {
    inherit nixpkgs;
    system = builtins.currentSystem;
    pndp = builtins.getAttr (builtins.currentSystem) (jobs.package);
  };
in
{
  hello = pndpImportPackage {
    pkgsPhpFile = "${./.}/tests/Pkgs.php";
    autoloadPhpFile = "${./.}/vendor/autoload.php";
    attrName = "hello";
  };
  ...
}

Transforming custom object structures into Nix expressions

As explained earlier, PNDP transforms objects in the PHP language to semantically equivalent (or similar) constructs in the Nix expression language.

Sometimes it may also be desired to generate Nix expressions from a domain model, designed for solving a specific non-deployment related problem, with properties and structures that cannot be literally translated into a representation in the Nix expression language.

It is also possible to specify for an object how to generate a Nix expression from it. This can be done by inheriting from the NixASTNode class and overriding the toNixAST method.

For example, we may have a system already providing a representation of a file that should be downloaded from an external source:

class HelloSourceModel
{
    private object $args;
    private string $src;
    private string $sha256;

    public function __construct(object $args)
    {
        $this->args = $args;
        $this->src = "mirror://gnu/hello/hello-2.10.tar.gz";
        $this->sha256 = "0ssi1wpaf7plaswqqjwigppsg5fyh99vdlb9kzl7c9lng89ndq1i";
    }
}

The above class' constructor composes an object that refers to the GNU Hello package provided by a GNU mirror site.

A direct translation of the above constructed object to the Nix expression language does not provide anything meaningful -- it can, for example, not be used to let Nix fetch the package from the mirror site.

We can inherit from NixASTNode and implement our own custom toNixAST() method to provide a more meaningful Nix translation:

use PNDP\AST\NixASTNode;
use PNDP\AST\NixURL;

class HelloSourceModel extends NixASTNode
{
    ...
    /**
     * @see NixASTConvertable::toNixAST()
     */
    public function toNixAST()
    {
        return $this->args->fetchurl(array(
            "url" => new NixURL($this->src),
            "sha256" => $this->sha256
        ));
    }
}

The toNixAST() method shown above composes an abstract syntax tree (AST) for a function invocation to fetchurl {} in the Nix expression language with the url and sha256 properties a parameters.

An object that inherits from the NixASTNode class also indirectly inherits from NixObject. This means that we can directly attach such an object to any other AST object. The generator uses the underlying toNixAST() method to automatically convert it to its AST representation.

For example, we can also define the GNU Hello package as an object that is an instance of a custom class:

class HelloModel
{
    private object $args;
    private string $name;
    private HelloSourceModel $source;
    private array $meta;

    public function __construct(object $args)
    {
        $this->args = $args;

        $this->name = "hello-2.10";
        $this->source = new HelloSourceModel($args);
        $this->meta = array(
            "description" => "A program that produces a familiar, friendly greeting",
            "homepage" => "http://www.gnu.org/software/hello/manual",
            "license" => "GPLv3+"
        );
    }
}

In the above function, we construct a build recipe for GNU Hello using a convention that is not directly usable in Nix. The object also has a reference to a source object that is an instance of the class shown in the previous example.

By inheriting from NixASTNode and overriding toNixAST(), we can construct an AST for a Nix expression that builds the package:

use PNDP\AST\NixASTNode;

class HelloModel extends NixASTNode
{
    ...
    /**
     * @see NixASTConvertable::toNixAST()
     */
    public function toNixAST()
    {
        return $this->args->stdenv->mkDerivation(array(
            "name" => $this->name,
            "src" => $this->source,
            "doCheck" => true,
            "meta" => $this->meta
        ));
    }
}

The above function constructs an AST for a function invocation to stdenv.mkDerivation {}, using the object's properties a parameters. It directly refers to the source object ($this->source) without any conversions -- because the source object inherits from NixAST and (indirectly) from NixObject, the generator will automatically convert it to an AST for a fetchurl {} function invocation.

In some cases, it may not be possible to inherit from NixASTNode, for example, when the object already inherits from another class that is beyond the user's control.

It is also possible to use the NixASTNode constructor function as an adapter for any object that implements the NixASTConvertable interface.

For example, we may want to use a wrapper to transform the metadata in a more suitable representation for conversion to a Nix expression:

use PNDP\AST\NixASTConvertable;
use PNDP\AST\NixURL;

class MetaDataWrapper implements NixASTConvertable
{
    private array $meta;

    public function __construct(array $meta)
    {
        $this->meta = $meta;
    }

    public function toNixAST()
    {
        return array(
            "description" => $this->meta["description"],
            "homepage" => new NixURL($this->meta["homepage"]),
            "license" => $this->meta["license"]
        );
    }
}

By wrapping the MetaDataWrapper object instance into the NixASTNode constructor, we can convert it to an object that is an instance of NixASTNode:

new NixASTNode(new MetaDataWrapper($this->meta))

Examples

The tests/ directory contains a collection of example packages:

  • Pkgs.php is a composition module containing a collection of PNDP packages. Each class in the tests/pkgs sub folder defines a package composition.

License

The contents of this package is available under the MIT license