Coloration et complétion — explication
De quoi s'agit-il ?
Les deux fonctions qui font de Turbo Python un éditeur pour Python plutôt qu'un éditeur de texte qui ouvre des fichiers .py : la coloration syntaxique, et la complétion venue d'un serveur de langage. Elles fonctionnent très différemment, et la différence est instructive.
La coloration est à nous ; la complétion ne l'est pas
La coloration se fait ici, en quelque six cents lignes de Go écrites à la main. La complétion est faite par python-lsp-server, et Turbo Python se contente de demander et de dessiner.
Ce partage n'est pas un accident d'effort. La coloration doit être instantanée et tolérante : elle tourne à chaque frappe, sur du texte invalide la plupart du temps qu'on l'écrit, et un coloriseur qui s'arrête pour réfléchir ou qui abandonne devant du code cassé est pire que pas de coloriseur du tout. La complétion doit être juste, ce qui pour Python veut dire suivre les imports, résoudre un nom à travers la hiérarchie de classes où il a été affecté, et lire la surface publique de chaque paquet installé — et rien de ce qui doit être instantané ne peut être cela aussi.
L'éditeur dessine donc des couleurs qu'il a calculées lui-même, et montre des complétions calculées par quelqu'un d'autre.
Pourquoi Python est analysé à la main
Go a un analyseur lexical dans sa bibliothèque standard, et Turbo Go s'en sert : go/scanner est le code qu'utilise le compilateur, si bien que l'éditeur et le compilateur s'accordent sur ce qu'est un lexème, sans rien à tenir en phase.
Python n'a rien de tel de disponible ici. Le tokeniseur de CPython est en C, tokenize est un module Python, et l'analyseur de jedi est un paquet Python. Le choix était donc : un scanner écrit à la main, ou lancer un processus Python à chaque frappe.
Ce sera le scanner. Quelque six cents lignes, un fichier chacun pour le répartiteur, les littéraux et les mots — et aucune tentative de moteur général. Pas de langage de motifs, pas de format de grammaire, pas de table d'expressions régulières : c'est du Go ordinaire qu'un lecteur peut suivre, la règle même que suivent les huit scanners de turbo-core.
La seule chose qui franchit un saut de ligne
Presque tout, en Python, se décide sur la ligne qu'on a sous les yeux. Une chaîne est la seule exception, et elle l'est de deux manières différentes — c'est pourquoi ce qui est reporté est une petite structure et non un drapeau.
Une chaîne à triple guillemet court jusqu'à ses trois guillemets fermants, si loin soient-ils. Toute docstring en est une : ce n'est pas un cas limite, c'est l'essentiel de ce qu'un fichier Python contient qui ne soit pas du code.
Une chaîne à guillemet simple ne continue que si la ligne se termine par une contre-oblique, qui échappe le saut de ligne. C'est une construction réelle, quoique rare — et c'est la raison pour laquelle une chaîne à guillemet simple qui manque simplement de ligne est abandonnée là. Le code sous le curseur est déséquilibré la plupart du temps qu'on l'écrit, et un " non fermé reporté peindrait tout le reste du fichier en vert.
Le guillemet qui l'a ouverte est reporté aussi. Un littéral ouvert par trois guillemets doubles et un littéral ouvert par trois apostrophes sont deux chaînes différentes, et le terminateur de l'une, apparaissant dans l'autre, ne ferme rien. Une docstring qui cite quoi que ce soit — """dire "bonjour" ici""" — est le cas qui piège un scanner ne reportant qu'« une chaîne est ouverte ».
Le caractère « brut » n'est délibérément pas reporté. r"\"" est une chaîne complète : dans une chaîne brute la contre-oblique reste dans la valeur, mais elle empêche toujours le guillemet suivant de terminer le littéral. La règle de terminaison est donc la même pour les chaînes brutes et ordinaires, et un drapeau prétendant le contraire serait un drapeau que rien ne lit.
Là où le scanner s'appuie sur les conventions
La syntaxe de Python laisse ouvertes trois questions auxquelles ses conventions répondent, et le scanner lit les conventions plutôt que de faire comme si les questions n'existaient pas.
Une classe s'appelle exactement comme une fonction. ValueError("non") et parse("non") ont la même forme ; une parenthèse ne peut pas distinguer un constructeur d'un appel. La PEP 8, si : une classe s'écrit en CapWords et rien d'autre ne s'écrit ainsi. Un nom capitalisé est donc un type, qu'une parenthèse suive ou non — c'est la seule règle que Turbo Python et Turbo Rust ordonnent délibérément différemment, parce qu'en Rust un nom capitalisé devant une parenthèse est le plus souvent une variante que le langage lui-même nomme.
Une constante ne ressemble en rien à une classe. MAX_SIZE et Measurement sont toutes deux « capitalisées », et la PEP 8 les sépare nettement : une constante de module s'écrit en SCREAMING_SNAKE_CASE. Turbo Rust n'a que la règle de la majuscule et documente les constantes en majuscules colorées en types comme une réponse fausse connue. Les conventions de Python sont assez séparées pour que cette réponse mérite d'être supprimée plutôt qu'héritée : ici, un nom écrit entièrement en majuscules est une constante. Ce que cela coûte, c'est une classe nommée HTTP, assez rare pour être écrite noir sur blanc.
self n'appartient pas au langage, et tout lecteur le traite comme s'il lui appartenait. Une méthode peut nommer son premier paramètre comme elle veut ; le compilateur s'en moque. Mais un lecteur de Python lit self comme un lecteur de Rust lit Some — comme une chose que le langage fournit — et tous les autres coloriseurs sont d'accord. Il est coloré en primitive pour cette raison, et le coût honnête est une fonction ordinaire qui nomme un paramètre self.
La seule vraie ambiguïté
match et case ont été ajoutés à Python sans être réservés. match x: ouvre une instruction match ; match = re.match(motif, texte) affecte une variable, et les deux sont du Python ordinaire et courant.
Rien dans le mot ne tranche, c'est donc la forme de l'instruction qui le fait : le mot ouvre la ligne, et la ligne se termine par le deux-points qui ouvre son bloc. Les deux conditions doivent tenir, ce qui attrape toutes les instructions match que l'on écrit et laisse match être un nom partout ailleurs.
Cette règle a une frontière, et la frontière est documentée plutôt que supprimée. Le deux-points est cherché en remontant depuis la fin de la ligne — c'est ce qui rend la question assez bon marché pour être posée de chaque mot — et un commentaire de fin le masque : match value: # aiguillage colore donc match comme un nom. Distinguer un vrai commentaire de fin d'un # à l'intérieur d'une chaîne suppose de parcourir la ligne à l'endroit, c'est-à-dire exactement le travail que la lecture à rebours existe pour éviter. C'est aussi le bon sens de l'erreur : un mot-clé montré comme un nom est une nuance trop terne, tandis qu'un nom montré comme un mot-clé est un mensonge.
Ce que le scanner refuse de deviner
Là où une construction ne peut pas être reconnue à partir de ce que contient une ligne, elle est laissée tranquille plutôt qu'approximée. Un coloriseur qui se trompe est pire qu'un coloriseur qui se tait :
| Non reconnu | Parce que |
|---|---|
L'{expression} à l'intérieur d'une f-string |
Depuis Python 3.12 elle peut contenir absolument n'importe quoi — des guillemets imbriqués du même type, des commentaires, une autre f-string. La colorer correctement suppose de faire tourner le scanner entier à l'intérieur de lui-même ; la colorer à moitié termine f"{n:{width}}" sur l'accolade intérieure. Une seule plage de chaîne est la réponse honnête |
| Une docstring comme autre chose qu'une chaîne | C'en est une — help() la relit comme telle — et dès que quelqu'un en affecte une à un nom, un scanner qui l'appelait un commentaire a visiblement tort |
| Si un nom est lié dans cette portée | Rien ici ne lit plus d'une ligne à la fois. C'est la question du serveur de langage |
Les huit autres langages viennent gratuitement
TOML, YAML, Markdown, JavaScript, HTML, XML, Dockerfile et shell sont colorés par turbo-core, pas ici. Un projet Python a un pyproject.toml, un README.md, quelques scripts, un workflow CI en YAML et souvent un Dockerfile, et un éditeur qui ne colorerait que les fichiers .py obligerait à le quitter pour tout le reste.
Qu'ils soient partagés plutôt que copiés est tout l'intérêt de la bibliothèque : ils ont été écrits une fois, pour Turbo Go, et Turbo Python les a obtenus en important un paquet.
La complétion, et pourquoi elle peut échouer en silence
Turbo Python ne sait rien du système de types de Python et n'essaie pas d'en savoir. Il interroge pylsp par le Language Server Protocol et dessine la réponse.
Trois choses méritent d'être connues, car toutes trois ressemblent à « la complétion est cassée » :
pylsp ne répond rien tant qu'il n'a pas indexé assez du projet. jedi résout un nom en suivant les imports vers l'extérieur, ce qui, à la première demande touchant une grosse dépendance, veut dire lire beaucoup du code de quelqu'un d'autre. Ce que l'on voit en attendant, c'est une liste vide.
Un serveur lancé à la mauvaise racine charge le mauvais code, puis ne répond plus rien du tout — sans erreur. C'est pourquoi l'éditeur remonte depuis le fichier jusqu'au pyproject.toml, setup.py ou setup.cfg le plus proche plutôt que d'utiliser le répertoire courant, et c'est la façon la plus déroutante dont la complétion peut échouer.
Un serveur installé sans ses extras répond aux questions mais ne signale jamais un problème de lui-même. Les linters de pylsp sont des dépendances optionnelles ; installé nu, il complète et saute parfaitement bien, et publie une liste vide de diagnostics pour un fichier qui ne s'analyse même pas. Une gouttière vide parce que le serveur n'a pas de linter et une gouttière vide parce que le code est correct sont indiscernables. C'est pourquoi la commande d'installation nomme les extras et pourquoi l'installateur les vérifie.
La réponse de l'éditeur aux deux premières est Run ▸ Language server status, qui dit ce qu'il a trouvé, où il l'a lancé et s'il est prêt — parce que « rien ne s'est passé » n'est pas quelque chose sur quoi un utilisateur peut agir.
Neuf questions, une connexion — et les deux auxquelles pylsp ne répond pas
La complétion est ce que le serveur de langage fait de plus bruyant et de moins révélateur. La même connexion pose huit questions de plus, et elles se répartissent en trois familles selon la forme de la réponse.
Quelque chose à lire. hover — qu'est-ce que c'est ? — dessiné dans une boîte.
Des endroits dans le code. definition, typeDefinition, implementation, references. Une requête chacun, une seule forme de réponse pour les quatre, ce qui explique qu'ils soient une seule fonction en dessous. Un seul endroit est ouvert ; plusieurs sont proposés en liste, parce qu'une réponse unique est l'exception plutôt que la règle — une méthode utilisée dans tout un paquet a autant de références que quelqu'un a pris la peine d'en écrire, et pendant longtemps cet éditeur prenait la première et jetait le reste.
Des noms. documentSymbol pour le plan d'un fichier, workspace/symbol pour une recherche à travers le projet. Le protocole a trois formes pour un symbole et l'éditeur en veut une, si bien que l'aplatissement se fait là où les réponses arrivent plutôt que là où elles sont dessinées.
Et une chose que personne ne demande : publishDiagnostics arrive sans y être invité, dès que le serveur a un avis, pour tous les fichiers qu'il a chargés — qui sont d'ordinaire plus nombreux que celui qu'on a sous les yeux. C'est pourquoi Problems liste tous les fichiers plutôt que le fichier courant, et pourquoi la marque dans la gouttière apparaît sans qu'on ait appuyé sur quoi que ce soit.
Deux des neuf reviennent vides avec pylsp, et c'est la frontière du serveur, pas celle de l'éditeur. pylsp n'annonce ni implementation ni workspace/symbol : Code ▸ Find implementations et Code ▸ Symbol in project signalent donc n'avoir rien trouvé. Tout le reste fonctionne. C'est écrit ici plutôt que caché parce que l'alternative — griser deux entrées de menu selon ce qu'un serveur a annoncé au démarrage — donnerait au menu une forme différente d'une machine à l'autre, et un utilisateur qui a lu cette page en sait plus qu'un utilisateur tombé sur une entrée grisée.
L'éditeur ne demande rien de tout cela avant que le serveur se soit dit prêt, et il dit de laquelle il s'agit quand une question ne peut pas trouver de réponse. « Rien trouvé » et « je n'ai pas fini de charger » sont la même réponse vide et deux nouvelles très différentes ; les confondre est la façon la plus déroutante dont la complétion ait jamais échoué ici.
Rapport avec le reste
- Ce qui est reconnu exactement : Langages colorés
- Faire marcher la complétion : Comment activer la complétion Python
- Où vit le scanner et pourquoi : Architecture
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
|