Article écrit par François Vantomme
Le langage Ruby foisonne de méthodes diverses et variées pour manipuler des chaînes de caractères, des nombres, des collections, et bien d’autres. Prenons le cas des collections par exemple. Il en existe de plusieurs sortes : Array
, Hash
ou encore Set
pour ne citer que les plus utilisées. Et qu’ont-elles toutes en commun ? Ce sont des Enumerable
, c’est-à-dire qu’elles incluent ce module et ainsi partagent quantité de méthodes fort utiles lorsque l’on manipule des collections.
Parmi ces méthodes, certaines semblent extrêmement spécialisées, parfois obscures, à tel point qu’on peut avoir du mal à comprendre dans quelle situation elles peuvent s’avérer utiles. Jusqu’au jour où cette situation se présente, et là, c’est la révélation !
Cela m’est arrivé récemment alors que je travaillais à l’amélioration d’un outil développé en interne chez Ouidou Nord. Son but est de nous faciliter la tâche lors de la conversion de nos articles de blog — tel que celui que vous lisez en ce moment — rédigés en Markdown, vers leur mise en page finale en HTML.
Cet outil se comporte comme se comporteraient de petits utilitaires Unix spécialisés que l’on chaîne pour aboutir au résultat final.
╭────────────────── Pipeline ─────────────────╮ │ │ ╭──────────╮ │ ╭─────────╮ ╭─────────╮ ╭─────────╮ │ ╭──────╮ │ Markdown ├─────>│ Pipe #1 ├───>│ Pipe #2 ├───>│ Pipe #n ├─────>│ HTML │ ╰──────────╯ │ ╰─────────╯ ╰─────────╯ ╰─────────╯ │ ╰──────╯ │ │ ╰─────────────────────────────────────────────╯
Très vite, on se rend compte que toutes les lignes de texte de notre article n’ont pas la même portée et ne réclament pas le même traitement. Pour illustrer cela, on peut par exemple distinguer un paragraphe dans le corps de l’article, d’un bloc de code, ou encore du frontmatter, cet en-tête au format YAML qui déclare les méta-données.
C’est à ce moment précis que toute la lumière se fait sur Enumerable#chunk
! Cette méthode méconnue va nous être d’un grand secours. Que fait-elle ? Elle parcourt les éléments d’un Enumerable
en les regroupant en fonction de la valeur de retour d’un bloc qu’on lui passe en argument. En d’autres termes, elle découpe notre article en petits bouts homogènes selon un critère discriminent.
Pour le dire encore autrement, et si l’on m’autorise la comparaison, c’est un peu l’équivalent de String#split
pour un Enumerable
.
Mettons-nous à table !
À l’instar d’une recette de cuisine nous enjoignant à nous emparer d’un œuf pour en séparer le blanc du jaune, voyons comment Enumerable#chunk
peut nous aider à séparer le frontmatter du corps de texte.
Le frontmatter se trouve nécessairement au tout début de notre fichier et est encadré de 3 tirets, comme ceci :
--- author: Jeff B. Cohen <jeff@example.com> title: Titre de l'article description: Elle sera utilisée dans la balise <meta name='description'> --- Ici commence mon article.
Imaginons que l’extrait ci-dessus constitue l’intégralité d’un fichier nommé article.md
et que nous tâchions d’en extraire le frontmatter. Voici comment nous pourrions procéder. Tout d’abord, écrivons une méthode chunks
qui prendra en argument le contenu de notre fichier sous la forme d’une liste de lignes.
SEPARATOR_REGEX = /A---z/.freeze # Sépare des morceaux de données délimités par des lignes de 3 tirets def chunks(lines, separator: SEPARATOR_REGEX) lines.chunk do |line| line.chomp !~ separator end end
Nous pouvons ensuite l’utiliser comme ceci :
lines = File.readlines('article.md') chunks(lines).to_a # [ # [false, ["---"]], # [true, ["author: Jeff B. Cohen <jeff@example.com>", # "title: Titre de l'article", # "description: Elle sera utilisée dans la balise <meta name='description'>"]], # [false, ["---"]], # [true, ["", # "Ici commence mon article."]] # ]
Qu’avons-nous ? Une liste de listes, contenant chacune deux données : un booléen et un Array
. Le booléen représente le résultat de l’expression que contient notre bloc : false
si l’on se trouve face à notre séparateur, qui sera isolé dans une liste rien que pour lui ; ou true
dans le cas contraire. Tant qu’on ne rencontre pas de nouveau notre séparateur, toutes les lignes qui se présenteront seront regroupées au sein du même Array
. On observe bien ici une alternance, séparateur, frontmatter, séparateur, corps de l’article.
La singularité de notre exemple fait que nous ne nous retrouvons jamais avec plusieurs lignes consécutives constituées de 3 tirets. Cela dit, si nous voulions nous assurer que chaque séparateur se retrouve effectivement isolé, il existe un mot-clé forçant ce comportement ! Il s’agit de :_alone
. Voyons cela de plus près :
def chunks(lines, separator: SEPARATOR_REGEX) lines.chunk do |line| line.chomp !~ separator || :_alone end end
Le résultat de notre bloc sera ainsi true
ou :_alone
. Voici ce que l’on obtient à présent :
lines = File.readlines('article.md') chunks(lines).to_a # [ # [:_alone, ["---"]], # [true, ["author: Jeff B. Cohen <jeff@example.com>", # "title: Titre de l'article", # "description: Elle sera utilisée dans la balise <meta name='description'>"]], # [:_alone, ["---"]], # [true, ["", # "Ici commence mon article."]] # ]
Dans le cas qui nous occupe cependant, il ne nous est pas utile de récupérer le séparateur. Tout ce qui nous intéresse, ce sont nos deux blocs. Ça tombe bien, c’est prévu ! Les valeurs nil
ou :_separator
, au choix, indiquent que les éléments doivent être ignorés. Adaptons notre méthode chunks
et voyons ce que ça donne.
def chunks(lines, separator: SEPARATOR_REGEX) lines.chunk do |line| line.chomp !~ separator || nil end end
Le résultat de notre bloc sera cette fois true
ou nil
. Voici ce que l’on obtient à présent :
lines = File.readlines('article.md') chunks(lines).to_a # [ # [true, ["author: Jeff B. Cohen <jeff@example.com>", # "title: Titre de l'article", # "description: Elle sera utilisée dans la balise <meta name='description'>"]], # [true, ["", # "Ici commence mon article."]] # ]
Pour récupérer le frontmatter, il nous suffit cette fois d’extraire le second élément du premier Array
de l’Enumerator
que nous renvoie notre méthode chunks
:
lines = File.readlines('article.md') _, header = chunks(lines).first puts header # ["author: Jeff B. Cohen <jeff@example.com>", # "title: Titre de l'article", # "description: Elle sera utilisée dans la balise <meta name='description'>"]
Un dernier petit morceau
Pour aller plus en finesse, sachez qu’il existe également une méthode Enumerable#chunk_while
qui permet de décider où trancher en comparant chaque élément avec le précédent. Faut-il encore en avoir l’usage… mais qui sait !