Traduire les chaînes

Nous souhaitons écrire une macro #[translate] qui traduira d'anglais en français les chaînes de caractères contenant des nombres littéraux. Par exemple, le code suivant :

#[translate]
fn main() {
  let res = "forty two";
  println!("12 + 30 = {}", res);
}

affichera

12 + 30 = quarante-deux

Seules les chaînes entières correspondant à des nombres situés dans une plage arbitraire (par exemple 0..=100) seront traduits.

Nous nous aiderons des deux crates suivants pour implémenter cette fonctionnalité :

  1. Ajoutez ces crates comme dépendances au crate macros.

Préchargement des chaînes

Le crate english_numbers ne permet pas de reconnaître un nombre en anglais et d'en récupérer la valeur numérique. Nous allons donc construire un dictionnaire nous permettant de stocker une fois pour toutes la représentation sous forme de chaînes de caractères et d'y associer le nombre associé.

  1. Créez une structure Translate qui contient un dictionnaire associant une chaîne de caractères à un i64, le type utilisé par le crate english_numbers.

  2. Créez une fonction associée new() qui renvoie un objet Translate dont le dictionnaire a été préalablement rempli. Nous activerons uniquement l'option de formattage spaces et laisserons les autres désactivées.

Choix de la technique de remplacement des chaînes

Nous pourrions opter pour un visiteur mutable qui réécrit les nœuds de type LitStr qui correspondent à un nombre en anglais pour y substituer le terme en français. Cette technique qui semble fonctionner au premier abord échouera pourtant sur des tests simples :

#[test]
#[translate]
fn test_translate() {
  assert_eq!("trois", "three");
}

En effet, le visiteur de l'arbre visitera le nœud de type Macro lorsqu'il analysera cette fonction et rencontrera assert_eq!. Le visiteur fera bien une visite aux champs path et delimeter, mais il omettra d'aller visiter les tokens (disponibles sous la forme d'un proc_macro2::TokenStream) qui composent cette macro car ceux-ci ne sont pas forcément du code Rust valide à ce stade.

Il nous faudrait donc intercepter également la visite des nœuds de type Macro afin d'aller substituer les tokens litéraux qui nous intéressent. Si nous devons écrire ce code, étant donné que notre macro procédurale travaille déjà avec des TokenStream, pourquoi ne pas directement implémenter cette solution ? Nous n'avons pas besoin d'un visiteur.

Transformation du flux de tokens

  1. Écrivez une méthode qui substitue les tokens qui correspondant à une chaîne littérale correspondant à un nombre anglais enregistré dans notre dictionnaire par un le nombre français correspondant. Il faudra également prendre soin de rappeler récursivement cette méthode lorsque l'on trouve un groupe délimité de tokens.
impl Translate {

  fn substitute_tokens(stream: proc_macro2::TokenStream) -> proc_macro2::TokenStream {
    todo!()
  }

}

On pourra noter que la représentation littérale à laquelle on a accès est celle qui se trouve dans le code source, c'est-à-dire précédée et suivie des guillemets doubles (on pourra ignorer le cas des chaînes de caractères utilisant d'autres délimiteurs comme r#""#). Plutôt que d'ôter ces guillemets, il peut être plus facile de les ajouter dans le dictionnaire pour pouvoir effectuer une comparaison directe.

  1. Écrivez une macro procédurale #[translate] qui construit un objet Translate et l'utilise pour transformer le TokenStream. On rappelle que les conversions avec From et Into sont implémentées entre proc_macro::TokenStream (à l'interface de notre macro) et proc_macro2::TokenStream (utilisé à l'intérieur de notre macro).

  2. Écrivez des tests pour votre macro. Il pourra être utile de définir avec macro_rules! une macro str!(a, b) qui construit dynamiquement la chaîne constituée de a puis de b en permettant de ne pas faire apparaître de chaîne littérale ab :

// Check that out-of-range (1..=100) values are not translated
assert_eq!(str!("one h", "undred"), "one hundred");

Détermination des bornes positives ou nulles

On souhaite pouvoir indiquer en attribut facultatif les bornes à utiliser pour les nombres à traduire. Les notations suivantes devront être acceptées :

#[translate] fn f() { … }         // Bornes par défaut (0..=100)
#[translate(0..10)] fn f() { … }
#[translate(0..=10)] fn f() { … }

Nous voulons par contre rejeter avec des messages d'erreur clairs les constructions incorrectes :

error: unexpected end of input, expected `..=` or `..`
 --> tests/ui/translate.rs:3:1
  |
3 | #[translate(10)]
  | ^^^^^^^^^^^^^^^^
  |
  = note: this error originates in the attribute macro `translate` (in Nightly builds, run with -Z macro-backtrace for more info)

error: expected integer literal
 --> tests/ui/translate.rs:6:13
  |
6 | #[translate(..10)]
  |             ^^

error: unexpected end of input, expected integer literal
 --> tests/ui/translate.rs:9:1
  |
9 | #[translate(10..)]
  | ^^^^^^^^^^^^^^^^^^
  |
  = note: this error originates in the attribute macro `translate` (in Nightly builds, run with -Z macro-backtrace for more info)

error: expected integer literal
  --> tests/ui/translate.rs:12:13
   |
12 | #[translate(x)]
   |             ^

Pour cela, nous allons construire une structure sur lesquelles nous allons implémenter syn::parse::Parse :

struct Bounds { low: i64, high: i64 }
  1. Implémentez le trait Parse sur Bounds. Cela consiste à lire un entier de type LitInt (syn gère les éventuels moins unaires), à rechercher un symbole parmi ..= et .., à lire la borne haute et à construire l'objet Bounds. L'utilisation de Lookahead1 peut vous faciliter la tâche.

  2. Ajoutez des tests spécifiques pour vérifier que vous pouvez bien lire les différentes formes d'intervalles. Pour ne pas exporter les types privés, vous pouvez ajouter les tests dans un sous-module qui n'existe que lorsqu'on est en configuration de tests. Souvenez vous qu'on peut parser une chaîne de caractères avec syn::parse_str::<T>(s) permet de parser une chaîne de caractère avec le parseur T.

  3. Modifiez la macro translate pour qu'elle lise les bornes depuis son attribut s'il n'est pas vide et initialisez l'objet Translate en conséquence.

  4. Ajoutez des tests, le test ci-dessous doit passer notamment.

#[test]
#[translate(-10..=10)]
fn test_negative_bounds() {
  assert_eq!("moins dix", "negative ten");
  assert_eq!("dix", "ten");
  assert_eq!(str!("neg", "ative eleven"), "negative eleven");
  assert_eq!(str!("ele", "ven"), "eleven");
}

Conclusion

Nous avons vu ici que pour implémenter une macro, plusieurs méthodes peuvent être combinées. Ici nous n'avons pas utilisé de visiteur, mais avons écrit un parseur maison pour les bornes et travaillé directement avec le flux de tokens pour le cœur de l'entité modifiée.