Utiliser, créer et publier une extension VS Code en TypeScript : commandes, configuration, vues, webviews, diagnostics, tests, bundling et publication sur le Marketplace et Open VSX.
Les vagues jaunes sous le code, l'ampoule qui propose une correction, la liste de suggestions pendant la frappe : ce sont des fournisseurs de langage. Une extension les enregistre auprès de vscode.languages, et VS Code les appelle au bon moment. TODO Radar va s'en servir pour signaler les TODO sans ticket.
Quand todoRadar.requireTicket est activé, un TODO doit référencer un ticket :
// TODO #142 : gérer la pagination ← accepté
// TODO PROJ-87 : cache Redis ← accepté
// TODO : nettoyer cette fonction ← signaléOn ajoute la détection au scanner, toujours sans dépendance à vscode :
const TICKET = /(#\d+|\b[A-Z][A-Z0-9]+-\d+\b)/
export function hasTicket(match: TodoMatch): boolean {
return TICKET.test(match.text)
}Un diagnostic est un problème attaché à une plage de texte. Il apparaît souligné dans l'éditeur, dans le panneau Problems et dans la barre de défilement.
import * as vscode from 'vscode'
import { scanText, hasTicket } from './scanner'
import { getConfig } from './config'
export const DIAGNOSTIC_CODE = 'todo-without-ticket'
export function registerDiagnostics(context: vscode.ExtensionContext) {
const collection = vscode.languages.createDiagnosticCollection('todoRadar')
context.subscriptions.push(collection)
const check = (document: vscode.TextDocument) => {
const { tags, requireTicket } = getConfig()
if (!requireTicket || document.uri.scheme !== 'file') {
collection.delete(document.uri)
return
}
const diagnostics = scanText(document.getText(), tags)
.filter((m) => m.tag === 'TODO' && !hasTicket(m))
.map((m) => {
const range = new vscode.Range(m.line, m.column, m.line, m.column + m.tag.length)
const diagnostic = new vscode.Diagnostic(
range,
'TODO sans référence de ticket',
vscode.DiagnosticSeverity.Warning
)
diagnostic.source = 'TODO Radar'
diagnostic.code = DIAGNOSTIC_CODE
return diagnostic
})
collection.set(document.uri, diagnostics)
}
// Attendre une pause dans la frappe avant de recalculer
const timers = new Map<string, NodeJS.Timeout>()
const checkLater = (document: vscode.TextDocument) => {
const key = document.uri.toString()
clearTimeout(timers.get(key))
timers.set(key, setTimeout(() => check(document), 300))
}
vscode.workspace.textDocuments.forEach(check)
context.subscriptions.push(
vscode.workspace.onDidOpenTextDocument(check),
vscode.workspace.onDidChangeTextDocument((e) => checkLater(e.document)),
vscode.workspace.onDidCloseTextDocument((doc) => collection.delete(doc.uri)),
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration('todoRadar')) vscode.workspace.textDocuments.forEach(check)
})
)
}Le délai de 300 ms (debounce) évite de rescanner à chaque caractère. C'est la valeur qu'utilisent la plupart des linters. On l'applique aussi à la barre d'état et aux décorations du chapitre 4.
Les quatre niveaux de gravité ont un sens précis pour l'utilisateur :
| Gravité | Affichage | Usage |
|---|---|---|
Error | Vague rouge | Le code ne fonctionnera pas |
Warning | Vague jaune | Problème probable, à corriger |
Information | Vague bleue | Remarque |
Hint | Trois points gris discrets | Suggestion, absent du panneau Problems |
Un TODO sans ticket n'empêche rien de fonctionner : Warning suffit, Error serait abusif.
L'ampoule (Ctrl+.) affiche les code actions. On propose d'insérer un modèle de référence à la place du TODO signalé :
import * as vscode from 'vscode'
import { DIAGNOSTIC_CODE } from './diagnostics'
export class AddTicketFix implements vscode.CodeActionProvider {
static readonly kinds = [vscode.CodeActionKind.QuickFix]
provideCodeActions(
document: vscode.TextDocument,
_range: vscode.Range,
context: vscode.CodeActionContext
): vscode.CodeAction[] {
return context.diagnostics
.filter((d) => d.code === DIAGNOSTIC_CODE)
.map((diagnostic) => {
const action = new vscode.CodeAction(
'Ajouter une référence de ticket',
vscode.CodeActionKind.QuickFix
)
action.edit = new vscode.WorkspaceEdit()
action.edit.insert(document.uri, diagnostic.range.end, ' #000')
action.diagnostics = [diagnostic]
action.isPreferred = true
return action
})
}
}context.subscriptions.push(
vscode.languages.registerCodeActionsProvider({ scheme: 'file' }, new AddTicketFix(), {
providedCodeActionKinds: AddTicketFix.kinds,
})
)WorkspaceEdit décrit la modification sans l'appliquer : VS Code l'exécute quand l'utilisateur choisit l'action, et elle s'annule avec Ctrl+Z comme une frappe normale. isPreferred permet de l'appliquer directement avec la commande Auto Fix.
Le premier argument, { scheme: 'file' }, est un sélecteur de document. Il restreint le fournisseur à certains fichiers : { language: 'typescript' }, { pattern: '**/*.test.ts' }, ou une combinaison.
Quand l'utilisateur tape TODO #, on lui suggère les numéros de tickets récemment utilisés dans le projet. Le fournisseur reçoit le document et la position du curseur :
import * as vscode from 'vscode'
export function registerTicketCompletion(
context: vscode.ExtensionContext,
recentTickets: () => string[]
) {
const provider: vscode.CompletionItemProvider = {
provideCompletionItems(document, position) {
const before = document.lineAt(position).text.slice(0, position.character)
if (!/\bTODO\s+#$/.test(before)) return undefined
return recentTickets().map((ticket, index) => {
const item = new vscode.CompletionItem(ticket, vscode.CompletionItemKind.Reference)
item.insertText = ticket.replace('#', '')
item.detail = 'Ticket déjà référencé dans le projet'
item.sortText = String(index).padStart(3, '0')
return item
})
},
}
context.subscriptions.push(
vscode.languages.registerCompletionItemProvider({ scheme: 'file' }, provider, '#')
)
}Le dernier argument, '#', est un caractère déclencheur : la liste s'ouvre dès qu'il est tapé, sans attendre Ctrl+Espace. sortText contrôle l'ordre, sinon VS Code trie par libellé.
Un HoverProvider affiche une info-bulle au survol. Pour TODO Radar : l'âge du TODO, tiré de Git, serait idéal, mais on se contente ici d'un rappel de la règle et d'un lien.
vscode.languages.registerHoverProvider({ scheme: 'file' }, {
provideHover(document, position) {
const range = document.getWordRangeAtPosition(position, /\b(TODO|FIXME|HACK)\b/)
if (!range) return undefined
const markdown = new vscode.MarkdownString(
`**${document.getText(range)}** suivi par TODO Radar\n\n` +
'[Voir tous les marqueurs](command:todoRadar.list.focus)'
)
markdown.isTrusted = true
return new vscode.Hover(markdown, range)
},
})isTrusted autorise les liens command: dans le Markdown. Sans lui, ils sont désactivés : un texte de document ne doit pas pouvoir déclencher de commande à votre insu.
Certaines fonctions de langage sont purement déclaratives. Un fichier de snippets :
{
"TODO avec ticket": {
"prefix": "todo",
"body": ["// TODO #${1:ticket} : ${2:description}"],
"description": "Commentaire TODO avec référence de ticket"
}
}"snippets": [
{ "language": "typescript", "path": "./snippets/todo.code-snippets" },
{ "language": "javascript", "path": "./snippets/todo.code-snippets" }
]${1:ticket} crée un point d'arrêt de tabulation avec une valeur par défaut. Tab passe au suivant.
Pour la coloration syntaxique d'un nouveau langage, on déclare languages et grammars avec une grammaire TextMate (fichier JSON d'expressions régulières). C'est un sujet à part entière, peu utile pour TODO Radar.
Tout ce chapitre s'exécute dans l'extension host. Pour un vrai langage (analyse de tout le projet, typage, renommage), on déporte le travail dans un serveur de langage, un processus séparé qui parle le Language Server Protocol (LSP).
| Approche | Avantages | Inconvénients |
|---|---|---|
| Fournisseurs dans l'extension | Simple, une seule base de code | Bloque l'extension host si le calcul est lourd, réservé à VS Code |
| Serveur LSP | Processus isolé, serveur réutilisable dans Neovim, Zed, JetBrains, Helix | Deux projets à maintenir, protocole à apprendre |
Microsoft fournit vscode-languageclient côté extension et vscode-languageserver côté serveur, et un exemple complet (lsp-sample dans le dépôt microsoft/vscode-extension-samples). Le serveur peut être écrit dans n'importe quel langage : gopls est en Go, rust-analyzer en Rust. L'idée est proche de celle du Model Context Protocol côté IA : un protocole standard pour qu'un outil serve plusieurs clients.
Pour TODO Radar, des fournisseurs directs suffisent largement.
L'extension est complète fonctionnellement. Avant de la publier, il faut la tester et réduire son paquet à l'essentiel : c'est le chapitre suivant.