Skip to main content

Le langage Nix

Avant d'être un gestionnaire de paquet, Nix est un langage de programmation fonctionnel pur (oui oui, comme ce bon vieux CAML !) qui est utilisé pour manipuler Nix, le gestionnaire de paquet. Il est donc important de comprendre les bases du langage avant d'aller plus loin.

Pour tester ce qui sera abordé, vous pouvez utiliser le REPL1 Nix en lançant la commande nix repl dans votre terminal (l'équivalent des commandes ocaml ou utop pour OCaml).

Spécificités du langage

Nix est un langage :

  • pur : les fonctions ne peuvent pas avoir d'effet de bord et les variables sont immutables.
  • déterministe : une expression doit toujours donner le même résultat pour les mêmes entrées.
  • fonctionnel : les fonctions sont des valeurs comme les autres, et peuvent donc être passées en paramètre ou retournées par d'autres fonctions.
  • haut-niveau : il est possible de faire des abstractions complexes en Nix, et vous n'aurez jamais à vous soucier de la gestion de la mémoire.
  • paresseux (ou lazy en anglais) : les expressions ne sont évaluées que si elles sont nécessaires. Il n'est pas forcément évident de comprendre ce que cela implique, mais nous le verrons plus tard.
  • dynamiquement typé : le type d'une expression ou d'une valeur n'est déduit qu'après son évaluation.
  • déclaratif : Nix n'a pas de notion d'instructions séquentielles.

Toutes ces notions peuvent paraître un peu étrange, mais chacune d'entre elles joue un rôle majeur dans la gestion des paquets.

Types de valeurs

Pour commencer, passons en revue les différents types de valeur possibles :

  • string - Une chaîne de caractère classique qui peut s'écrire sous trois formes :
    • Simple, via des doubles guillemets : "hello".
    • Multi-ligne, via deux guillemets simples (apostrophes) :
      ''
      hello
      ''
    • Par un lien brut : http://example.com/.
  • boolean : true ou false.
  • null - La valeur nulle.
  • integer - Un nombre entier : 1234.
  • float - Un nombre flottant (à virgule) : 1.234.
  • path - Un chemin de fichier : /home/litarvan, ./hello, ~/world, etc.
    • Les chemins de fichier sont des types spéciaux, c'est l'un des avantages d'avoir défini un langage de programmation spécialement fait pour gérer des paquets.
    • Ils peuvent être écrits directement sans passer par des guillemets.
  • list - Une liste : [ "hello" "world" 1 2 3 ]
    • Les éléments d'une liste peuvent être de n'importe quel type, possiblement plusieurs au sein d'une même liste.
    • Chaque élément est séparé par un espace et non une virgule (ce qui peut être déroutant).
  • set - Un ensemble d'attribut : { hello = "world"; example = 2; }
    • Chaque élément est terminé par un point-virgule, même le dernier !
    • Attention, l'ordre des valeurs n'est pas conservé (mais ce n'est pas censé être un problème).
    • Il est possible de définir un ensemble récursif (dont les valeurs peuvent en référencer d'autres) à l'aide du mot clé rec : rec { hello = "world"; example = hello; }.
    • Il existe deux syntaxes pour définir des sous-ensembles, ces deux exemples sont équivalents :
      { sub.a = "hello"; sub.b = "world"; }
      { sub = { a = "hello"; b = "world"; }; }
    • De multiples définitions de sous-ensembles sont fusionnées dans tous les cas, ces deux exemples sont donc équivalents :
      { sub.a = "hello"; sub.b = "world"; sub = { c = "example"; }; }
      { sub = { a = "hello"; b = "world"; c = "example"; }; }
  • function - une fonction : a: b: a + b - oui, c'est un peu flippant à première vue, mais pas d'inquiétude, ça sera démystifié plus bas.
Attention

Retenez bien les différentes règles sur les ensembles, c'est un type de valeur qui sera utilisé particulièrement souvent.

Conseil

Pour connaître le type d'une valeur dans le REPL, vous pouvez utiliser la commande REPL :t :

Example de la commande :t
Faites très attention

Lorsqu'une valeur de type path (donc tout chemin écrit sans guillemets) est évaluée, c'est-à-dire lorsqu'elle :

  • Est passée à une fonction qui va l'évaluer
  • Est concaténée ou interpolée avec une autre chaîne de caractère

Le fichier ou dossier correspondant au chemin sera entièrement copié dans le Nix Store. Ce que ça implique n'est pas forcément clair mais, en bref, il faut mieux éviter d'utiliser ce type de valeur, sauf pour référencer des fichiers .nix (par exemple dans le cas d'un import), mais on verra ça en temps voulu.

Les opérateurs

Ensuite, voici la liste des différents opérateurs possibles :

  • + - Addition de deux nombres (entiers ou non) ou concaténation de deux chaînes de caractères :
    • 1 + 2 -> 3
    • "hello" + "world" -> "helloworld"
    • 1.2 + 3 -> 4.2
  • -, *, / - Soustraction, multiplication et division de deux nombres (entiers ou non)
    • La division est entière (tronquée à l'unité) si les deux nombres sont entiers, et flottante sinon.
  • == et != - Vérification d'égalité et d'inégalité
    • Pour les ensembles et les listes, leur contenu est récursivement comparé.
    • Pour les fonctions, l'égalité est toujours fausse.
  • <, <=, >, >= - Comparaison de deux valeurs
    • Les chaînes de caractères et les chemins sont comparées par ordre lexicographique.
    • Pour les listes, les éléments sont comparés deux à deux, et ceux dépassant la longueur de la plus petite liste ne sont pas comparés.
  • && et || - Opérateurs logiques.
  • ! - Négation d'une valeur booléenne.
  • . - Sélection d'un attribut d'un ensemble :
    • { hello = "world"; example = 2; }.hello -> "world"
    • Il est possible de préciser une valeur par défaut à l'aide du mot clé or : { hello = "world"; }.what or "default" -> "default"
    • Si un attribut demandé n'existe pas et qu'une valeur par défaut n'est pas précisée, l'évaluation de l'expression échouera.
  • ? - Vérification de l'existence d'un attribut dans un ensemble :
    • { hello = "world"; } ? hello -> true
    • { hello = "world"; } ? what -> false
    • { sub = { hello = "world"; }; } ? sub.hello -> true
  • // - Fusion de deux ensembles :
    • { hello = "world"; } // { example = 2; } -> { hello = "world"; example = 2; }
    • Si deux ensembles ont un attribut en commun, celui de droite sera utilisé.
  • ++ - Concaténation de deux listes :
    • [ "hello" "world" ] ++ [ 1 2 3 ] -> [ "hello" "world" 1 2 3 ]
  • -> - Implication logique :
    • true -> false -> false
    • false -> false -> true
    • true -> true -> true
    • false -> true -> true
    • Équivalent à !a || b.
    • En vrai personne utilise ça.
Conseil

Vous aurez remarqué qu'il n'y a pas d'opérateur d'assignation direct =. En effet, Nix étant déclaratif, c'est let qui le remplace avec une syntaxe plus appropriée qu'on verra juste après.

Malgré ça, il est possible dans le REPL (et uniquement dans celui-ci) de déclarer des variables à l'aide du = classique.

Attention

Même si les opérateurs ne nécessitent normalement pas d'espace autour d'eux, l'utilisation de l'opérateur de division sans espace autour de lui donnera à la place une valeur de type path :

Exemple de division donnant en vérité un chemin

Les structures de contrôle

Nix dispose aussi de quelques structures de contrôle :

  • if - Conditionnelle :
    • if true then "hello" else "world" -> "hello"
    • Comme tout en Nix, les conditions sont des expressions. Leur retour correspond à celui de la branche qui a été exécutée. De plus, Nix étant lazy, seule ladite branche sera évaluée (dans l'autre branche, vous pourriez appeler une fonction qui n'existe pas sans qu'une erreur ne soit levée).
    • Il n'est pas possible d'omettre la branche else : une condition doit retourner quelque chose quoi qu'il arrive.
  • assert - Assertion :
    • assert true; "hello" -> "hello"
    • Si l'expression donnée s'évalue à false, une erreur sera levée et l'exécution stoppée.
    • Encore une fois, assert n'échappe pas à la règle et est aussi une expression qui doit donc renvoyer une valeur. Cette dernière correspond à l'évaluation de l'expression située après le point-virgule, qui est donc requise lors de l'écriture d'une assertion.
  • with - Portée étendue :
    • with { hello = "world"; }; hello -> "world"
    • Celle-ci n'est pas forcément évidente à première vue. Une expression with prend un ensemble en paramètre, et ajoute les variables contenues dans l'ensemble au 'scope' de l'expression située après le point-virgule.
    • Si vous n'avez pas complètement saisi, pas de panique, nous verrons plus tard des cas d'usage plus concrets.
  • let - Déclaration de variables :
    • let hello = "world"; a = "b"; in hello -> "world"
    • Cette syntaxe permet de déclarer des variables (qui portent mal leur nom, étant plutôt des constantes) puis de les utiliser dans l'expression qui suit le in.
    • Chaque définition de variable est terminée par un ;, y compris la dernière (comme pour un set).
    • Le retour d'une expression let est celui de l'expression située après le in.
    • Le mot clé rec n'existe pas pour les blocs let. Il est tout de même possible pour des variables au sein d'un même bloc de se référencer entre elles, car leur valeur ne sera évaluée qu'au moment de leur utilisation (et donc forcément après avoir défini toutes les autres variables).
Info

Ce n'est pas un oubli, il n'y a effectivement pas de boucle ! C'est un concept fortement lié aux langages impératifs. En fonctionnel pur, on privilégie plutôt la récursivité.

Interpolation

Nix supporte l'interpolation d'une valeur dans une chaîne de caractère ou un chemin, à l'aide de la syntaxe ${} :

  • "1 + 2 = ${1 + 2}" -> "1 + 2 = 3"
  • ./${"hello"}/world -> ./hello/world

Il est même possible d'utiliser cette syntaxe pour définir dynamiquement les clés d'un ensemble :

  • let key = "hello"; in { ${key} = "world"; } -> { hello = "world"; }

Ou encore pour accéder dynamiquement à un attribut d'un ensemble :

  • let key = "hello"; set = { hello = "world"; }; in set.${key} -> "world"

Les commentaires

Les commentaires en Nix existent sous deux formes :

  • Les commentaires sur une seule ligne : # Hello
  • Les commentaires multi-lignes :
    /* Hello
    World */

Les fonctions

Maintenant, parlons des fonctions. Leur syntaxe est la suivante :

  • Déclaration de fonction : paramètre: expression
  • Appel de fonction : fonction paramètre

Visiblement, c'est très simple à utiliser. Mais vous vous posez peut-être une question : comment fait-on pour définir plusieurs paramètres ?

Nix est un langage fonctionnel, et comme beaucoup d'entre eux, il supporte la curryfication des fonctions. Si vous avez fait la prépa intégrée d'EPITA, vous vous rappelez peut-être cette notion du CAML.

En Nix, une fonction ne peut pas réellement prendre plusieurs arguments. Alors quand on en a besoin, on l'écrit comme ça :

parametre1: parametre2: expression

Ceci n'est pas une nouvelle syntaxe, mais est en vérité l'expression d'une fonction prenant un seul paramètre et renvoyant une fonction qui, elle aussi, n'en prend qu'un seul. Rappelez-vous, Nix est un langage fonctionnel, il est donc possible de manipuler les fonctions comme n'importe quelle autre valeur. On aurait pu écrire la fonction précédente de cette manière :

parametre1: (parametre2: expression)

C'est un peu déroutant, mais ça ne complique en vérité pas son appel. Pour appeler la fonction, il suffit d'écrire :

fonction parametre1 parametre2

Ce qui, à nouveau, aurait pu s'écrire :

(fonction parametre1) parametre2

Ce qu'il se passe en vérité, c'est que la première fonction est d'abord appelée avec le premier paramètre. Elle renvoie ensuite une autre fonction, qui est ensuite appelée avec le second paramètre, et c'est là qu'on a notre résultat final. Mais alors qu'elle est l'intérêt ?

L'intérêt, c'est que l'on peut partiellement appeler les fonctions. Essayez de lancer cet exemple dans le REPL :

myAdd = a: b: a + b
myAdd 1 3
myAdd 1

La dernière ligne a dû vous afficher un résultat bizarre, c'est normal ! Si vous avez suivi plus haut, vous avez compris que myAdd était une fonction qui en renvoyait une autre. De ce fait, si on lui "donne un seul paramètre", elle renvoie alors une fonction. Cette fonction, elle, attend un seul paramètre, et renvoie le résultat de l'addition des deux.

On peut donc définir une fonction intermédiaire, essayez de lancer cet exemple-là :

addOne = myAdd 1
addOne 3

Si vous n'avez pas tout saisi, pas de panique. La curryfication n'est pas forcément primordiale à connaître en Nix, vous prendrez l'habitude petit à petit. En attendant, vous pouvez simplement retenir la syntaxe suivante :

  • Déclarer une fonction : paramètre1: paramètre2: paramètreX...: expression
  • Appeler une fonction : fonction paramètre1 paramètre2 paramètreX...

En revanche, retenez bien la chose suivante : les fonctions sont des valeurs. C'est pour ça qu'elles n'ont pas de nom ! Elles sont toutes anonymes. C'est pour ça que le REPL vous a affiché lambda quand vous avez lancé le premier exemple : c'est un surnom donné aux fonctions anonymes en programmation fonctionnelle.

La déstructuration

Il est courant qu'une fonction prenne un set en paramètre. Cela permet à la fonction de prendre un seul paramètre, mais de récupérer en réalité via le set plusieurs valeurs nommées que l'on peut ranger dans n'importe quel ordre. Ça resemble à ça :

myAdd = monEnsemble: monEnsemble.a + monEnsemble.b

C'est très pratique, mais il sera vite contraignant de répéter monEnsemble.X à chaque fois. Heureusement, Nix permet de déstructurer un set, c'est-à-dire d'extraire les attributs de ce set et de les assigner à des variables. Pour cela, on utilise la syntaxe suivante :

{ nom1, nom2, nomX... }: expression

Cette syntaxe est TRÈS courante en Nix, vous la verrez absolument partout et il est donc important de comprendre ce qu'elle signifie. Pour faire simple, ces deux morceaux de code sont équivalents :

myAdd = monEnsemble: monEnsemble.a + monEnsemble.b
myAdd { a = 1; b = 3; }
myAdd = { a, b }: a + b
myAdd { a = 1; b = 3; }

La seule différence, c'est que le second est plus simple à écrire, mais le comportement est exactement le même. Je vous invite à jouer avec cette syntaxe dans le REPL, et de ne passer à la suite qu'après l'avoir entièrement comprise.

La destructuration ajoute quelques syntaxes supplémentaires particulièrement utiles. Il est par exemple possible de définir des valeurs par défaut pour les clés d'un set :

myAdd = { a ? 1, b }: a + b
myAdd { b = 2; }
myAdd { a = 2; b = 2; }
myAdd { a = 2; }

Je vous invite à jouer avec cet exemple dans le REPL pour voir le résultat de chacune de ces expressions.

Lorsqu'une fonction accepte un set déstructuré, le contenu dudit set sera alors vérifié au moment de l'évaluation de la fonction. Comme vous avez pu le voir si vous avez lancé l'exemple précédent, un attribut manquant dans le set passé à une fonction entraînera une erreur. Il se trouve que Nix déclenchera aussi une erreur si un attribut en trop se trouve dans un set déstructuré, tentez de lancer l'exemple suivant :

myAdd = { a, b }: a + b
myAdd { a = 1; b = 2; c = 3; }

Malheureusement, ça peut poser un problème dans certains cas. Il existe donc une syntaxe permettant de signifier à Nix que des attributs en trop sont autorisés :

myAdd = { a, b, ... }: a + b
myAdd { a = 1; b = 2; c = 3; }

Mais comment faire si on veut récupérer et manipuler les attributs en trop ? Ou même les attributs dans leur totalité ? Il existe pour ça une syntaxe exprès :

example = { a, b, ... } @ myAttributes: myAttributes

Essayez de lancer cet exemple dans le REPL, vous verrez que le set retourné contient bien l'attribut c ainsi que tous les autres attributs.

La syntaxe @ n'a pas d'ordre précis, il est possible de l'utiliser dans n'importe quel sens :

myAdd = myAttributes @ { a, b, ... }: myAdd.a + myAdd.b + myAdd.c

Le mot clé inherit

Il arrive aussi parfois de devoir écrire dans un set un attribut ayant le même nom que sa valeur, par exemple pour passer les mêmes attributs d'une fonction à une autre ou pour y inclure des variables déclarées par un let. Par exemple :

hello = "hello"
world = "world"
myFunc = { hello, world, separator }: "${hello}${separator}${world}"
myFunc { hello = hello; world = world; separator = " "; }

Nix dispose d'un raccourci pour simplifier cette syntaxe, le code ci-dessous est équivalent au précédent :

hello = "hello"
world = "world"
myFunc = { hello, world, separator }: "${hello}${separator}${world}"
myFunc { inherit hello world; separator = " "; }

Il est même possible de l'utiliser pour récupérer les attributs d'un autre set, par exemple ces deux morceaux de code sont équivalents :

mySet = { hello = "hello"; world = "world"; }
myOtherSet = { hello = mySet.hello; world = mySet.world; }
mySet = { hello = "hello"; world = "world"; }
myOtherSet = { inherit (mySet) hello world; }

Cette syntaxe peut d'ailleurs être utilisée au sein d'un bloc let :

mySet = { hello = "hello"; world = "world"; }
let
inherit (mySet) hello world;
in
"${hello} ${world}"

Les imports

Il est possible d'importer dynamiquement d'autres fichiers Nix, c'est très pratique pour séparer son code. Pour importer un fichier, il suffit d'utiliser la syntaxe import chemin/du/fichier.nix;, dont l'évaluation correspond à l'évaluation du fichier.

Par exemple, vous pouvez créer un fichier myAdd.nix contenant :

{ a, b }: a + b

Et l'importer dans un autre fichier :

import ./myAdd.nix { a = 1; b = 3; }

Essayez de jouer avec cette syntaxe dans le REPL, et encore une fois, ne passez à la suite qu'après l'avoir entièrement comprise. Cette syntaxe est aussi particulièrement courante.

Il est aussi possible de donner un dossier à importer, dans ce cas-là, Nix va chercher un fichier default.nix dans le dossier. Ces deux expressions sont donc équivalentes :

import ./hello/default.nix
import ./hello

import accepte n'importe quelle expression qui s'évalue en string ou en chemin, par exemple :

name = "hello"
import "/${name}.nix"
Attention

import refusera un chemin relatif dans une chaîne de caractère.

En vérité, import requiert forcément un chemin absolu, mais dans le cas d'une valeur de type path, l'expression d'un de chemin relatif s'évalue automatiquement comme un chemin absolu, ne posant donc pas de problème, ce qui n'est pas le cas pour une chaîne de caractère.

Attention

Nix (et donc le REPL) possède un cache d'évaluation. Si une expression contenant un import vous donne le même résultat alors que vous avez changé le fichier importé, c'est que vous devez redémarrer le REPL.

Ce problème va sûrement vous arriver lorsque vous résoudrez les exercises de la première partie.

Builtins

Nix possède des fonctions et variables natives accessibles partout. Ces fonctions sont très puissantes et nombreuses, et nous ne vous demandons évidemment pas de les connaître par cœur.

Elles sont regroupées dans l'ensemble builtins, mais certaines d'entre elles (comme import ou fetchGit) peuvent être utilisées sans le préfixe builtins..

Quelques exemples sont :

  • builtins.fetchurl - Lors de son évaluation, Nix va télécharger le fichier à l'URL donnée et renvoyer le chemin dans le Nix Store où il a été téléchargé. Cette fonction sera abordée à nouveau plus tard.
  • fetchGit - similaire à builtins.fetchurl mais pour télécharger le contenu d'un dépôt Git.
  • import - En vérité, import n'est pas une syntaxe particulière, mais une fonction comme une autre.
  • map - Permet d'appliquer une fonction à chaque élément d'une liste.
  • builtins.mapAttrs - Permet d'appliquer une fonction à chaque élément d'un ensemble.
  • builtins.readFile - Permet de renvoyer le contenu d'un fichier.
  • throw - Affiche un message d'erreur et interrompt l'execution.
  • toString - Convertit une expression en une chaîne de caractère.
  • builtins.typeOf - Renvoie le type d'une valeur sous la forme d'une chaîne de caractère.
  • builtins.trace - Très utile pour le débogage, cette fonction prend deux paramètres : un message qui sera affiché dans la console lors de l'évaluation de l'appel, et une expression à retourner en résultat.
  • builtins.throw - Permet de lever une erreur, cette fonction prend en paramètre un message d'erreur qui sera affiché sur la sortie d'erreur et sera suivi de l'arrêt de l'évaluation.

Vous pouvez trouver la liste complète des builtins à cet endroit, n'hésitez pas à la garder sous la main !

Formatage

Il existe plusieurs formateurs pour Nix, dont les plus connus sont :

  • nixpkgs-fmt - Un formateur officiel et récent pour Nix, destiné à être utilisé sur le dépôt officiel.
  • nixfmt - Un formateur tiers, mais plus ancien et plus stable.
  • alejandra - Les mêmes avantages que nixfmt mais avec des règles différentes, et écrit en Rust plutôt qu'en Haskell.

Pour ce cours, nous vous recommandons d'utiliser nixpkgs-fmt qui est celui qu'utilise NixPIE ainsi que celui ayant formaté les exemples de code de ce cours, mais vous êtes libre d'utiliser celui que vous préférez (chacun ayant des règles un peu différentes).

Pour installer nixpkgs-fmt, vous pouvez utiliser la commande suivante :

nix-env -iA nixpkgs.nixpkgs-fmt

Vous pourrez ensuite l'utiliser avec la commande nixpkgs-fmt pour formater un ou plusieurs fichiers en place :

nixpkgs-fmt hello.nix

  1. Read-Eval-Print Loop - Un environnement interactif pour évaluer du code d'un certain langage de programmation, ici Nix.