lundi 1 juin 2020
Ce billet a été vu pour la première fois sur le blog de Synbioz le 01 June 2020 sous licence CC BY-NC-SA.

Vous reprendrez bien un morceau ?

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 Synbioz. 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 !