Architecture — explication
De quoi s'agit-il ?
Turbo JS, c'est une commande, un profil et deux scanners. Tout le reste — le widget d'édition, les fenêtres, les menus, les dialogues, les thèmes, l'émulateur de terminal, l'arbre de fichiers, le client LSP, les fenêtres d'agent — c'est turbo-core, la bibliothèque sur laquelle tous les éditeurs Turbo sont construits.
Cette page parle de cette séparation : ce qui est ici, ce qui est là-bas, et pourquoi la frontière passe où elle passe.
Ce que contient ce dépôt
main.go les options, le terminal et le câblage
internal/jslang tout ce qui fait de cet éditeur Turbo JS
jslang.go le profil : nom, menu, serveur, où npm met un binaire global
scan.go le répartiteur du scanner JavaScript, les commentaires, les expressions régulières, ce qui franchit une ligne
literals.go les template literals et les nombres
words.go les mots-clés, les constantes, les globales, les conventions de nommage
json.go le scanner JSON
templates.go quatre déclarations //go:embed
*.toml.tmpl les quatre fichiers de départ d'un projet, embarqués
Douze cents lignes environ en comptant les commentaires, dont quelque six cents pour les deux scanners — moins de sept cents lignes de code au compte de qlty. Il n'y a pas d'internal/app, pas d'internal/ui, pas d'internal/buffer : ceux-là existent une fois, dans la bibliothèque, et les six éditeurs les utilisent tels quels.
Ce que fait main
Six choses, dans cet ordre :
- Il lit les options.
- Il appelle
jslang.Register(), qui apprend à la bibliothèque le JavaScript de cet éditeur — en remplaçant le scanner qu'elle livre pour ce langage — et le JSON. - Il construit
jslang.Profile()— la valeur qui dit que cet éditeur est Turbo JS. - Il lit
.turbo-js/settings.tomldans le répertoire courant, s'il existe. - Il ouvre le terminal et confie l'écran, le nom du thème et le profil à
app.New. - Il démarre
typescript-language-serverdans la racine du projet — le répertoire le plus proche, au niveau du fichier édité ou au-dessus, qui contient unpackage.json— et lance la boucle d'événements.
C'est toute la commande. Chaque décision qu'elle prend — quel thème l'emporte, quels fichiers ouvrir, faut-il démarrer un serveur de langage — porte sur cette exécution, pas sur JavaScript.
Le profil est la couture
profile.Profile{
Name: "Turbo JS",
Slug: "turbo-js",
Language: "JavaScript",
ToolsMenu: "~J~avaScript",
RootMarkers: []string{"package.json"},
Server: profile.Server{Command: "typescript-language-server", Args: []string{"--stdio"}, …},
Templates: profile.Templates{Settings: …, Snippets: …, Tools: …, Agents: …},
}
Tout ce qui serait sinon un "turbo-js", un "node" ou un ".js" codé en dur quelque part dans onze mille lignes est un champ ici. La bibliothèque les lit ; rien dans la bibliothèque ne sait ce qu'ils signifient.
Slug porte plus qu'il n'y paraît. Le binaire s'appelle turbo-js, le répertoire de projet .turbo-js, la configuration personnelle vit dans ~/.config/turbo-js, et les variables d'environnement qui la remplacent sont TURBO_JS_THEME_DIR et TURBO_JS_SNIPPET_DIR — toutes dérivées de ce seul mot.
RootMarkers contient un seul fichier, package.json. C'est la frontière d'un projet Node — son nom, ses dépendances, ses scripts — et dans un espace de travail qui contient plusieurs paquets, le plus proche en remontant est le paquet en cours d'édition, c'est-à-dire la racine depuis laquelle le serveur doit résoudre les imports. Le ProjectRoot de la bibliothèque fait la remontée ; le profil dit seulement quoi chercher. Un répertoire sans aucun package.json au-dessus de lui reçoit le répertoire courant, et typescript-language-server déduit un projet des fichiers qu'on lui montre.
Pourquoi le scanner JavaScript est ici alors que la bibliothèque en a un
turbo-core colore huit langages lui-même : TOML, YAML, Markdown, JavaScript, HTML, XML, les Dockerfiles et le shell. Ce sont ceux que tout éditeur rencontre quel que soit son langage — la configuration d'un projet est en TOML ou en YAML, sa documentation en Markdown, ses scripts en shell, la construction de son image dans un Dockerfile, et une page web ou le bloc de code d'un README est en JavaScript.
JavaScript est donc le seul langage de cette famille que la bibliothèque colore et pour lequel un éditeur existe. Le scanner de la bibliothèque est écrit pour le README de l'éditeur Rust : il connaît les mots-clés, les chaînes, les commentaires et les nombres, et il refuse délibérément les expressions régulières, parce que distinguer /x/g d'une division exige le token précédent, et qu'une mauvaise supposition à cet endroit colore le reste d'une ligne comme une chaîne — un mauvais marché pour un langage qu'on ne rencontre qu'en passant. Un éditeur pour JavaScript fait le marché inverse : les expressions régulières sont une ligne sur deux dans un programme Node, et la supposition peut être bornée. Il doit aussi à son utilisateur les globales de Node, la ligne shebang, le nom après function, les noms privés et les décorateurs.
La bibliothèque permet exactement cela. syntax.Register remplace un langage enregistré sous le même nom, et l'enregistrement le plus tardif l'emporte parce qu'il est l'énoncé le plus spécifique. Turbo JS s'enregistre sous le nom propre de la bibliothèque, javascript, si bien qu'un fichier de snippets disant languages = ["javascript"] et un bloc ```js dans une fenêtre d'agent atteignent tous deux ce scanner, et rien dans la bibliothèque n'a changé. La page sur la coloration dit ce que le remplacement ajoute et ce qu'il refuse encore.
Pourquoi JSON est ici aussi
JSON n'est pas l'un des huit de la bibliothèque, et un projet Node ne peut pas s'éditer sans lui : package.json est le manifeste et le marqueur de racine, package-lock.json est à côté, tsconfig.json et .eslintrc.json sont des voisins courants. L'éditeur qui est pour Node enregistre donc un scanner JSON, trente lignes, qui distingue une clé d'une valeur et tolère les commentaires que tsconfig.json contient.
Cela aurait pu aller dans la bibliothèque à la place — un éditeur Go rencontre aussi un package.json, dans un dépôt qui a un front-end. Cela n'a pas été fait, pour la raison qui tient les scanners Go et Rust hors de la bibliothèque : elle grossirait d'un langage chaque fois que quelqu'un en veut un, et « qu'est-ce que cet éditeur enregistre ? » cesserait d'être la première question à poser sur un nouveau venu. Si turbo-core apprend JSON un jour, cet enregistrement l'emportera toujours par l'ordre, et celui d'ici pourra alors être retiré.
Pourquoi le menu de l'outillage s'appelle ~J~avaScript et non ~N~ode ou npm
La touche chaude était la partie facile. Dix lettres sont prises par les menus fixes — F, E, S, R, C, O, W, N, H et, depuis le menu Agent, A — ce qui exclut le S, le C et le R de JavaScript, et le N de Node, mais pas le J ; la touche chaude tombe donc sur la première lettre du mot, ce qui ne coûte à personne un second regard. Turbo Rust n'a pas eu cette chance et a fini sur Rus~t~.
Le nom était la vraie décision, et elle a été prise comme chez tous les frères. Le menu contient ce que le projet a mis dans son fichier d'outils, et ce n'est pas toujours npm : le fichier de départ lance déjà node, npm et npx, et le premier fichier d'outils qu'on écrit dépasse les trois, parce que les commandes d'un projet comprennent des conteneurs, des bases de données et une cible de Makefile ajoutée en 2019. Un menu appelé npm qui contient node main.js est déjà un petit mensonge, et un qui contient docker compose up en est un gros. JavaScript est le langage, et le langage est ce pour quoi cet éditeur existe.
Pourquoi le serveur de langage n'est pas celui de TypeScript 7
Il y a deux serveurs pour JavaScript aujourd'hui. typescript-language-server enveloppe tsserver, le moteur sur lequel le support JavaScript de tous les éditeurs tourne depuis dix ans ; TypeScript 7 — le portage natif — livre un serveur de langage à lui dans son compilateur, tsc --lsp --stdio, qui n'exige rien d'installé que typescript.
Les deux ont été mesurés contre les mêmes neuf tests de bout en bout de ce dépôt. Le natif répond aux huit requêtes, quatre fois plus vite. Il ne publie aucun diagnostic : il les propose en mode pull, par textDocument/diagnostic, que turbo-core ne demande pas, si bien qu'avec lui la gouttière reste vide sur un fichier qui ne parse pas. Un éditeur dont la gouttière est vide parce que le serveur attend qu'on lui demande ressemble exactement à un éditeur qui n'a rien à signaler, et c'est le seul échec contre lequel cette famille a appris à se prémunir. Le profil nomme donc la paire mûre — typescript-language-server avec TypeScript 6, la dernière version qui livre tsserver.js — et le dit en une ligne, là où un utilisateur la lit. Le jour où la bibliothèque saura tirer les diagnostics, tsc --lsp --stdio, obtenu d'un seul npm install -g typescript, sera la meilleure réponse, et le changement tient en deux champs du profil.
Pourquoi les tests pilotent le vrai éditeur
internal/jslang/editor_test.go construit un Turbo JS entier sur un terminal simulé — app.New(screen, "turbo-classic", jslang.Profile()) — ouvre un fichier et vérifie la coloration, la barre de menus et les touches chaudes. Il n'utilise que l'API publique de la bibliothèque.
C'est délibéré. La suite de la bibliothèque prouve que la bibliothèque fonctionne ; ce que ces tests prouvent, c'est que cet éditeur est correctement assemblé — que Register a été appelé, que le profil a atteint la barre de menus, qu'un fichier .js sort coloré par ce scanner (une expression régulière en est la preuve) et qu'un fichier .ts ne l'est pas, que package.json est du JSON, et que le menu Agent propose d'écrire le fichier de départ. Un bogue où main oublierait d'enregistrer JavaScript passerait tous les tests de turbo-core.
Le même fichier pilote un vrai typescript-language-server de bout en bout, neuf fois. Il écrit un projet avec un package.json, ouvre son fichier, démarre le serveur, puis :
- tape une déclaration de fonction qui n'existe que dans le tampon, puis tape ses premières lettres dans une autre fonction et demande une complétion — le serveur propose toutes les globales pour n'importe quel fichier, une complétion contenant
consolene prouverait donc rien ; une qui contient une fonction absente du disque prouve que le tampon a été envoyé ; - demande la définition d'un appel, les références à une fonction déclarée une fois et appelée deux fois, les implémentations d'une classe avec deux sous-classes, et la définition de type d'une variable contenant une instance — quatre questions, une seule forme de réponse, et deux d'entre elles reviennent en liste ;
- demande le survol d'un appel, qui revient avec le commentaire JSDoc écrit au-dessus de la déclaration ;
- demande les symboles du fichier et un symbole dans tout le projet par son nom ;
- ouvre un fichier qui ne parse pas et attend qu'un diagnostic arrive sans qu'on le demande — la seule fonctionnalité dont l'échec ressemble exactement au succès, parce qu'un éditeur qui n'a aucune erreur à montrer et un éditeur qui ne trouve pas l'erreur ont la même gouttière vide.
diagram_test.go tient docs/diagrams/packages.drawio à go list, si bien que le diagramme lié plus bas ne peut pas décrire un paquet qui n'existe pas. reference_test.go tient chaque ligne de la référence des langages au scanner.
Alternatives rejetées
Forker Turbo Golo. La façon évidente d'obtenir un sixième éditeur, et la raison pour laquelle la bibliothèque existe à la place : six copies de onze mille lignes divergent en un mois, et chaque correction doit être faite six fois par quelqu'un qui se souvient qu'il y en a six.
Garder le scanner JavaScript de la bibliothèque. Cela aurait fait de celui-ci le seul éditeur de la famille sans scanner à lui, et de son utilisateur le seul sans expressions régulières colorées. Pesé plus haut.
Un système de plugins. Turbo JS est un programme Go qui importe une bibliothèque. Il n'y a ni chargement dynamique ni ABI. En ajouter un reviendrait à figer l'API de chaque paquet de turbo-core plutôt que celle des quelques-uns qu'un profil touche.
Un fichier de configuration au lieu d'un profil. Le profil aurait pu être du TOML lu au démarrage, ce qui ferait d'un nouvel éditeur un fichier plutôt qu'un programme. Cela rendrait aussi le scanner inexprimable, et un éditeur à moitié configurable — tout sauf la coloration — est pire que l'une ou l'autre des réponses entières.
Embarquer Node. Certains éditeurs livrent le runtime pour lequel ils éditent. Node pèse cent mégaoctets et l'utilisateur l'a déjà ; l'éditeur est un binaire de six mégaoctets qui lui parle.
Liens avec le reste
- Ce que fait chaque paquet de la bibliothèque : la référence des paquets de turbo-core
- Le diagramme des paquets, vérifié contre
go list:docs/diagrams/packages.drawio - Comment la coloration fonctionne ici : Coloration et complétion
- Pourquoi le menu des outils est une donnée : Outils JavaScript
- Les décisions qui ont survécu au refactoring : Décisions de conception
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 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
|