Utiliser std::filesystem::path de manière cross-plateforme sans pleurer

fr en

Mademoiselle Puff en camisole de force, assise dans une cellule matelassée dont les parois sont des visages de Bob l'éponge aux couleurs du logo Microsoft

F.E.I.S est mon tout premier gros projet personnel. C'est un logiciel de bureau, cross-plateforme. Il est écrit en C++ avec SFML et Dear ImGui comme bibliothèques principales.

Les "caractères spéciaux" dans les chemins n'ont jamais marché dans mon code et j'ai mis très très longtemps à apprendre et comprendre pourquoi, et à trouver une solution qui me convienne.

Comment j'en suis arrivé là

~ Déni ~

J'aime l'API de std::filesystem, elle me rappelle la chaleur et la douceur de mon pathlib natal en Python.

Je voulais utiliser std::filesystem::path dans F.E.I.S pour :

  • Manipuler et stocker des noms de fichiers et chemins
  • Ouvrir, lire et écrire des fichiers

Eh bah ça a été un tel BORDEL de juste parvenir à faire ces actions simples en gardant un code cross-plateforme que ça a suffi à me motiver à écrire cet article.

Sous Linux (OS avec lequel je code et teste), tout va bien, je peux ouvrir un fichier avec un chemin comme :

/home/syméon/charts/ありふれたせかいせいふく.memon

et tout roule, ça s'ouvre, ça se lit bien, tout va bien. (remarquez le é et les caractères japonais)

Par contre sous Windows, que dalle. On me dit au choix que le fichier n'existe pas, ou qu'il est complètement vide.

Aux racines du mal (encodé)

~ Colère ~

Ça commence à devenir un running gag dans mes articles, mais le problème au coeur de tout ça c'est encore un doux mélange de problèmes d'encodages et de spécificités Windows.

Du peu que j'ai compris, la lib standard C++ vous laisse sur le bas-côté dès qu'il s'agit de faire un truc un peu concret, comme d'habitude.

Peu importe la manière dont vous décidez d'ouvrir un fichier en C++ (fstream ou fopen), votre implémentation de la lib standard doit derrière utiliser l'API de votre OS pour réellement ouvrir ce fichier.

Je vais pas faire semblant que je comprends très bien ce qui se passe ici, mais en gros sous Linux y'a peu de problèmes :

  • vous pouvez stocker des chemins et noms de fichiers encodés en utf-8 dans des std::string
  • si vous le faites, la conversion std::stringstd::filesystem::path se fait bien
  • de haut en bas de l'OS ça marche plutôt bien avec de l'utf-8, de la GUI jusqu'au syscall

Sous Windows, c'est une bonne salade de débris de verre :

  • vous pouvez stocker des chemins et noms de fichiers encodés en utf-8 dans des std::string, dans le sens ou vous pouvez aussi bouffer du sable, si ça vous chante.
  • si vous le faites, la conversion std::stringstd::filesystem::path ne se fera pas correctement, du moins sans un effort très conscient du problème qu'il faut contourner.
  • même si vous arrivez à corriger ça, Windows possède deux API d'ouverture de fichier, une seule "moderne" qui supporte réellement les "caractères spéciaux", et on dirait qu'aucune implémentation de la lib standard C++ ne l'utilise, pour une raison que j'ignore. Il est donc impossible d'ouvrir un fichier avec des caractères spéciaux dans son nom en passant une std::string à un autre bout de la lib standard C++

Ça fait extrêmement mal de voir un langage qui se prend autant au sérieux que C++ se prendre les pieds dans le tapis avec une telle violence. La "solution" officielle proposée en C++ c'est de faire du code spécial Windows super moche avec des concepts purement Windows qui n'ont pas leur justification ailleurs genre convertir et/ou manipuler des chaines de caractères de deux types, certaines avec encodage "étroit" et d'autres "large" etc ...

Bref, allez ennuyer quelqu'un d'autre avec vos mauvais choix de design. On est en ${current_year}, utf-8 partout ou rien, pas de commentaires.

Ma solution

~ Marchandage ~

Quand il faut utiliser des std::string qui représentent des noms de fichiers ou des chemins, assurez-vous qu'elles contiennent bien de l'utf-8.

Ajoutez ces deux petites fonctions dans votre code pour convertir correctement des std::string en std::filesystem::path et vice versa :

std::filesystem::path string_to_path(const std::string& utf8s) {
    const std::u8string u8s{utf8s.cbegin(), utf8s.cend()};
    return std::filesystem::path{u8s};
}

std::string path_to_string(const std::filesystem::path& path) {
    const auto u8s = path.u8string();
    std::string result{u8s.cbegin(), u8s.cend()};
    return result;
}

Elles supposent que vos std::string contiennent bien de l'utf-8 et passent par des std::u8string pour forcer les "bonnes" conversions, peu importe l'OS.

Relisez tous les bouts de votre code qui manipulent des std::string ou des std::filesystem::path et modifiez-les si besoin (y'en a très souvent besoin) pour qu'ils utilisent ces fonctions.

Stockez et manipulez des std::filesystem::path dans votre code, pas des std::string

Utilisez la bibliothèque nowide pour ouvrir vos fichiers, je recommande la branche standalone pour ne pas devoir vous farcir tout boost avec.

Pour ouvrir un fichier, donnez à nowide vos std::filesystem::path reconvertis en std::string avec vos petites fonctions.

Patchez vos libs pour qu'elles utilisent nowide, des fois c'est facile des fois non.

Bref

~ Dépression et Acceptation ~

C++ est une immense blague, on programme en C++ quand on a un problème d'égo, quelque chose à prouver aux autres. J'essaye moi-même d'en guérir.