Introduction à l'analyse syntaxique
Dans la section précédente, nous avons examiné la structure de l'arbre syntaxique. Cette section présente le processus de transformation des tokens en arbre syntaxique.
Le code source correspondant se trouve dans src/compiler/parser.ts.
Fonction d'entrée principale
Pour analyser un code source, l'entrée comprend bien sûr le contenu du code (sous forme de chaîne), ainsi que le chemin du fichier (pour les messages d'erreur) et la version du langage (par exemple, ES3 et ES5 diffèrent sur certains détails).
createSourceFile est la fonction d'entrée chargée de transformer le code source en arbre syntaxique, pouvant être appelée directement par l'utilisateur : par exemple ts.createSourceFile('<stdio>', 'var xld;').
perfLogger.logStartParseSourceFile(nomFichier);
if (versionLangage === ScriptTarget.JSON) {
resultat = Analyseur.parseSourceFile(nomFichier, texteSource, versionLangage, /*curseurSyntaxe*/ undefined, definirNoeudsParents, ScriptKind.JSON);
}
else {
resultat = Analyseur.parseSourceFile(nomFichier, texteSource, versionLangage, /*curseurSyntaxe*/ undefined, definirNoeudsParents, genreScript);
}
perfLogger.logStopParseSourceFile();
performance.mark("apresAnalyse");
performance.measure("Analyse", "avantAnalyse", "apresAnalyse");
return resultat;
}
</div>Outre le code de test de performance, la fonction d'entrée appelle principalement `Analyseur.parseSourceFile` pour effectuer l'analyse.
Analyse de l'objet fichier source
---------------------------------
<div>```
export function parseSourceFile(nomFichier: string, texteSource: string, versionLangage: ScriptTarget, curseurSyntaxe: IncrementalParser.SyntaxCursor | undefined, definirNoeudsParents = false, genreScript?: ScriptKind): SourceFile {
genreScript = garantirGenreScript(nomFichier, genreScript);
/* ...(omis)... */
initialiserEtat(texteSource, versionLangage, curseurSyntaxe, genreScript);
const resultat = analyserFichierSourceTravailleur(nomFichier, versionLangage, definirNoeudsParents, genreScript);
nettoyerEtat();
return resultat;
}
function initialiserEtat(_texteSource: string, versionLangage: ScriptTarget, _curseurSyntaxe: IncrementalParser.SyntaxCursor | undefined, genreScript: ScriptKind) {
ConstructeurNoeud = allocateurObjet.getNodeConstructor();
ConstructeurToken = allocateurObjet.getTokenConstructor();
ConstructeurIdentifiant = allocateurObjet.getIdentifierConstructor();
ConstructeurFichierSource = allocateurObjet.getSourceFileConstructor();
texteSource = _texteSource;
curseurSyntaxe = _curseurSyntaxe;
diagnosticsAnalyse = [];
contexteAnalyse = 0;
identifiants = creerCarte<string>();
compteurIdentifiants = 0;
compteurNoeuds = 0;
switch (genreScript) {
case ScriptKind.JS:
case ScriptKind.JSX:
indicateursContexte = NodeFlags.JavaScriptFile;
break;
case ScriptKind.JSON:
indicateursContexte = NodeFlags.JavaScriptFile | NodeFlags.JsonFile;
break;
default:
indicateursContexte = NodeFlags.None;
break;
}
erreurAnalyseAvantProchainNoeudTermine = false;
// Initialiser et préparer le scanner avant d'analyser les éléments sources.
scanner.setText(texteSource);
scanner.setOnError(erreurScan);
scanner.setScriptTarget(versionLangage);
scanner.setLanguageVariant(obtenirVarianteLangage(genreScript));
}</string>
1. Que sont ConstructeurNoeud et autres ?
Vous pouvez les considérer comme les constructeurs de classe Noeud. new ConstructeurNoeud équivaut à new Noeud. Pourquoi ne pas utiliser directement new Noeud ? C'est une optimisation de performance. L'objectif de conception de TS est de fonctionner avec n'importe quel moteur JS, y compris les navigateurs, Node.js et le propre moteur JS de Microsoft. Puisque Noeud représente les nœuds de l'arbre syntaxique, leur nombre peut être très élevé. TS permet d'utiliser différents types de Noeud selon l'environnement pour économiser au maximum la mémoire.
2. Qu'est-ce que curseurSyntaxe ?
C'est un objet utilisé pour l'analyse incrémentielle. S'il n'exécute pas l'analyse incrémentielle, il est vide. L'analyse incrémentielle signifie que si le code source a déjà été analysé une fois auparavant, lors de la deuxième analyse, les résultats de la précédente peuvent être réutilisés, principalement utilisée dans les scénarios de compilateur : après modification du code source, il doit être réanalysé en arbre syntaxique. Grâce à l'analyse incrémentielle, le nombre d'analyses peut être considérablement réduit.
3. Qu'est-ce que identifiants ?
En général, nous pensons que : les mots dans le code source seront utilisés plus de deux fois (les noms de variables auront toujours une définition et une utilisation, ce qui fait deux fois ici). Si les chaînes de caractères identiques partagent la même référence, cela permet d'économiser de la mémoire. identifiants stocke la référence unique de chaque chaîne en mémoire.
4. Qu'est-ce que contexteAnalyse ?
Utilisé pour indiquer l'emplacement actuel de l'analyse, par exemple si la fonction actuelle est async, ce qui permet de déterminer si await est valide.
5. Qu'est-ce que erreurAnalyseAvantProchainNoeudTermine ?
Chaque nœud de l'arbre syntaxique est créé via creerNoeud, puis terminé avec terminerNoeud. Si une erreur survient lors de l'analyse d'un nœud (peut-être une erreur lexicale ou syntaxique), erreurAnalyseAvantProchainNoeudTermine devient vrai. Dans terminerNoeud, cette variable est vérifiée et le nœud est marqué comme contenant une erreur syntaxique. Ce qui distingue TypeScript des autres analyseurs syntaxiques, c'est qu'il ne s'arrête pas à la première erreur mais tente de corriger le code. Ici, marquer le nœud d'erreur permet d'interdire sa réutilisation lors de la prochaine analyse incrémentielle.
Processus d'analyse
L'analyseur lit un token à la fois et détermine la syntaxe suivante selon ce token. Par exemple, s'il rencontre if, il sait qu'il s'agit d'une instruction conditionnelle ; s'il voit var, il sait qu'il s'agit d'une déclaration de variable.
Après avoir détecté if, selon la définition de l'instruction if, il va ensuite lire de force un token "(" ; s'il ne le trouve pas, une erreur est signalée : erreur de syntaxe, parenthèse manquante. Après avoir lu "(", il analyse une expression, puis une "), puis une instruction. Si alors il découvre un else, il lit une autre instruction, sinon il s'arrête et repart à l'analyse du token suivant.
La définition syntaxique de l'instruction if est la suivante :
Implémentation du code
Un fichier source est composé d'instructions. On lit d'abord le token suivant (prochainToken) ; puis on analyse la liste d'instructions (analyserListe, analyserInstruction)
fichierSource = creerFichierSource(nomFichier, versionLangage, genreScript, estFichierDeclaration);
fichierSource.flags = indicateursContexte;
// Préparer le scanner.
prochainToken();
// Un membre de ReadonlyArray<t> n'est pas affectable à un membre de T[] (et empêche un cast direct) - mais c'est ici que nous définissons ces membres pour qu'ils puissent être en lecture seule à l'avenir
traiterCommentairesPragmas(fichierSource as {} as ContextePragma, texteSource);
traiterPragmasDansChamps(fichierSource as {} as ContextePragma, signalerErreurPragma);
fichierSource.instructions = analyserListe(ContexteAnalyse.ElementsSource, analyserInstruction);
Debug.assert(token() === SyntaxKind.EndOfFileToken);
fichierSource.tokenFinFichier = ajouterCommentaireJSDoc(analyserTokenNoeud());
definirIndicateurModuleExterne(fichierSource);
fichierSource.compteurNoeuds = compteurNoeuds;
fichierSource.compteurIdentifiants = compteurIdentifiants;
fichierSource.identifiants = identifiants;
fichierSource.diagnosticsAnalyse = diagnosticsAnalyse;
if (definirNoeudsParents) {
corrigerReferencesParent(fichierSource);
}
return fichierSource;
function signalerErreurPragma(pos: number, fin: number, diagnostic: DiagnosticMessage) {
diagnosticsAnalyse.push(creerDiagnosticFichier(fichierSource, pos, fin, diagnostic));
}
}
</div>Analyser une instruction :
<div>```
function analyserInstruction(): Instruction {
switch (token()) {
case SyntaxKind.SemicolonToken:
return analyserInstructionVide();
case SyntaxKind.OpenBraceToken:
return analyserBloc(/*ignorerAccoladeOuvertureManquante*/ false);
case SyntaxKind.VarKeyword:
return analyserInstructionVariable(<variablestatement>creerNoeudAvecJSDoc(SyntaxKind.VariableDeclaration));
// ...(omis)
}
}</variablestatement>
On regarde d'abord quel est le token actuel, par exemple var, ce qui indique une instruction var, donc on continue à analyser l'instruction var :
Regardons maintenant l'analyse d'une instruction while :
Parmi celles-ci, la plus complexe est probablement l'analyse de liste (analyserListe) :
while (!estTerminateurListe(genre)) {
if (estElementListe(genre, /*dansRecuperationErreur*/ false)) {
const element = analyserElementListe(genre, analyserElement);
liste.push(element);
continue;
}
if (abandonnerAnalyseListeOuPasserTokenSuivant(genre)) {
break;
}
}
contexteAnalyse = sauvegarderContexteAnalyse;
return creerTableauNoeud(liste, positionListe);
}
</div>Le cœur de `analyserListe` est une boucle : tant que la liste n'est pas terminée, elle continue à analyser la même syntaxe.
Par exemple, pour analyser une liste de paramètres, rencontrer ")" signifie la fin de la liste, sinon continuer à analyser "paramètre" ; par exemple, pour analyser une expression tableau, arrêter à "\]".
Si théoriquement, le prochain élément devrait être un paramètre mais le token suivant n'est pas un paramètre, une erreur de syntaxe se produira. Mais alors faut-il continuer à analyser les paramètres ou arrêter la liste de paramètres ? C'est là que `abandonnerAnalyseListeOuPasserTokenSuivant` intervient.
Où `genre: ContexteAnalyse` sert à distinguer différentes listes (paramètres, tableau ou autre ?)
### Fin de liste
<div>```
// Vrai si positionné sur un terminateur de liste
function estTerminateurListe(genre: ContexteAnalyse): boolean {
if (token() === SyntaxKind.EndOfFileToken) {
// Être à la fin du fichier termine toutes les listes.
return true;
}
switch (genre) {
case ContexteAnalyse.BlocInstructions:
case ContexteAnalyse.ClausesSwitch:
case ContexteAnalyse.MembresType:
return token() === SyntaxKind.CloseBraceToken;
// ...(omis)
}
}
Analyse d'élément
return analyserElement();
}
</div>Ici, essentiellement, on utilise `analyserElement`, les autres codes servent à l'analyse incrémentielle (détaillée plus tard)
### Continuer la liste ?
<div>```
function abandonnerAnalyseListeOuPasserTokenSuivant(genre: ContexteAnalyse) {
analyserErreurATokenCourant(diagnosticsContexteAnalyse(genre));
if (estDansUnContexteAnalyse()) {
return true;
}
prochainToken();
return false;
}
return false;
}
</div>Résumé : si le token suivant est un élément légal, continue l'analyse, l'analyseur pensant que l'utilisateur a oublié une virgule ou autre séparateur. Sinon, la liste est problématique, on arrête de poursuivre l'erreur.
Au-dessus, nous avons présenté en détail la syntaxe if, les autres sont similaires, donc nous n'entrerons pas dans les détails.
Contexte syntaxique
-------------------
Certaines syntaxes ont des exigences d'utilisation, par exemple `await` n'est mot-clé que dans une fonction async.
Le code source utilise des variables globales dans une fermeture pour stocker ces informations. L'idée est : d'abord définir l'indicateur autorisant await, puis analyser l'expression (l'indicateur autorisant await est maintenant défini), après l'analyse, effacer l'indicateur.
<div>```
function faireDansContexteAwait<t>(fonc: () => T): T {
return faireAvecContexte(NodeFlags.AwaitContext, fonc);
}
function faireAvecContexte<t>(contexte: NodeFlags, fonc: () => T): T {
// indicateursContexteADefinir ne contiendra que les indicateurs de contexte
// qui ne sont pas actuellement définis et que nous devons temporairement activer.
// Nous ne réinitialisons pas bêtement aux indicateurs précédents pour nous assurer
// que nous ne mutons pas les indicateurs mis en cache pour l'
// analyseur incrémentiel (ThisNodeHasError, ThisNodeOrAnySubNodesHasError, et
// HasAggregatedChildData).
const indicateursContexteADefinir = contexte & ~indicateursContexte;
if (indicateursContexteADefinir) {
// définir les indicateurs de contexte demandés
definirIndicateurContexte(/*valeur*/ true, indicateursContexteADefinir);
const resultat = fonc();
// réinitialiser les indicateurs de contexte que nous venons de définir
definirIndicateurContexte(/*valeur*/ false, indicateursContexteADefinir);
return resultat;
}
// pas besoin de faire quoi que ce soit de spécial car nous sommes déjà dans tous les contextes demandés
return fonc();
}</t></t>
Grâce au contexte syntaxique, le code suivant n'est pas autorisé :
}
</div>(Bien que in soit également un opérateur directement utilisable, il ne peut pas être utilisé comme valeur initiale pour for, sinon cela serait analysé comme for..in.)
Anticipation (lookahead)
------------------------
Dans les exemples ci-dessus, on pouvait toujours déterminer la syntaxe suivante par le premier token (par exemple, voir if et traiter comme instruction if). Est-il possible que regarder seulement le premier token ne permette pas de déterminer la syntaxe suivante ?
Dans les versions antérieures de JS, non (après tout, cela rendait les compilateurs plus simples à créer), mais avec l'ajout continu de fonctionnalités JS, de telles situations sont apparues.
Par exemple, x seul est une variable, mais si suivi d'une flèche, x =>, devient un paramètre.
Alors il faut utiliser l'anticipation syntaxique, qui consiste à jeter un coup d'œil anticipé aux tokens suivants, puis décider.
<div>```
/** Appelle le callback fourni puis restaure inconditionnellement l'analyseur à l'état où il était immédiatement avant d'appeler le callback. Le résultat de l'appel du callback est retourné par cette fonction. */
function anticiper<t>(callback: () => T): T {
return assistantSpeculation(callback, /*estAnticipation*/ true);
}
function assistantSpeculation<t>(callback: () => T, estAnticipation: boolean): T {
// Suivre l'état auquel nous devrons revenir si l'anticipation échoue (ou si le
// appelant nous a demandé de toujours réinitialiser notre état).
const sauverToken = tokenCourant;
const sauverLongueurDiagnosticsAnalyse = diagnosticsAnalyse.length;
const sauverErreurAnalyseAvantProchainNoeudTermine = erreurAnalyseAvantProchainNoeudTermine;
// Remarque : il n'est pas vraiment nécessaire de sauvegarder/restaurer les indicateurs de contexte ici. C'est
// parce que la sauvegarde/restauration de ces indicateurs se produit naturellement grâce à la nature
// descendante récursive de notre analyseur. Cependant, nous les stockons quand même ici juste pour
// pouvoir affirmer que cet invariant est respecté.
const sauverIndicateursContexte = indicateursContexte;
// Si nous ne faisons que regarder en avant, alors dire au scanner de ne faire que de l'anticipation aussi.
// Sinon, si nous analysons effectivement de manière spéculative, alors dire au scanner de faire de même.
const resultat = estAnticipation
? scanner.lookAhead(callback)
: scanner.tryScan(callback);
Debug.assert(sauverIndicateursContexte === indicateursContexte);
// Si notre callback a retourné quelque chose de 'faux' ou si nous ne faisons que regarder en avant,
// alors restaurer inconditionnellement à notre état précédent.
if (!resultat || estAnticipation) {
tokenCourant = sauverToken;
diagnosticsAnalyse.length = sauverLongueurDiagnosticsAnalyse;
erreurAnalyseAvantProchainNoeudTermine = sauverErreurAnalyseAvantProchainNoeudTermine;
}
return resultat;
}</t></t>
Quelles syntaxes dans TypeScript nécessitent l'anticipation ?
- <T> peut être une conversion de type (<any>x), une fonction fléchée (<T> x => {}) ou JSX (<T>X</T>)
- public peut être un modificateur (public class A {}) ou une variable (public++)
- type n'est un alias de type que lorsque suivi d'un identifiant, sinon c'est une variable
- let n'est une déclaration de variable que lorsqu'il est suivi d'un identifiant, sinon c'est une variable, mais let let = 1 est incorrect.
On voit que l'anticipation syntaxique augmente la complexité du compilateur et gaspille certaines performances.
Ambiguïtés syntxaiques
Ambiguïté de /
Comme mentionné précédemment, / peut être une division ou une expression régulière. En phase lexicale, cela ne peut pas encore être analysé, mais en phase d'analyse syntaxique, comme on sait déjà quelle syntaxe est nécessaire, on peut traiter correctement ce symbole.
Par exemple, quand une expression est attendue, rencontrer /, comme / ne peut pas être le début d'une expression, / doit être réanalysé comme un token d'expression régulière. Si / est rencontré après une expression, alors c'est une division.
Ambiguïté de <
Par exemple, call<number, any>(x) peut être interprété comme un appel à une fonction générique call (paramètre x), mais aussi comme : (call < number) , (any > (x))
Tous les langages C-style prenant en charge les génériques ont des problèmes similaires. La plupart des compilateurs font : comme vous le pensez, traiter comme générique, car rarement les gens écrivent de parenthèses après >.
Ajout de points-virgules
JS a toujours permis d'omettre les points-virgules, jugeant les cas où un point-virgule est attendu selon que le token suivant est "}" ou s'il y a un saut de ligne.
// On peut parser un point-virgule optionnel dans les cas ASI suivants.
return token() === SyntaxKind.CloseBraceToken || token() === SyntaxKind.EndOfFileToken || scanner.hasPrecedingLineBreak();
}
</div>JSDoc
-----
TS permettant une compatibilité maximale avec JS, les utliisateurs peuvent utiliser directement JS + JSDoc pour annoter les types, donc les commentaires JSDoc en JS sont également analysés comme une partie du code source.
<div>```
/** @type {string} */
var x = 120
return jsDoc ? { jsDoc, diagnostics } : undefined;
}
export function analyserCommentaireJSDoc(parent: HasJSDoc, debut: number, longueur: number): JSDoc | undefined { const sauverToken = tokenCourant; const sauverLongueurDiagnosticsAnalyse = diagnosticsAnalyse.length; const sauverErreurAnalyseAvantProchainNoeudTermine = erreurAnalyseAvantProchainNoeudTermine;
const commentaire = faireAvecContexte(NodeFlags.None, () => analyserCommentaireJSDocTravailleur(debut, longueur));
if (commentaire) {
commentaire.parent = parent;
}
if (indicateursContexte & NodeFlags.JavaScriptFile) {
if (!fichierSource.jsDocDiagnostics) {
fichierSource.jsDocDiagnostics = [];
}
fichierSource.jsDocDiagnostics.push(...diagnosticsAnalyse);
}
tokenCourant = sauverToken;
diagnosticsAnalyse.length = sauverLongueurDiagnosticsAnalyse;
erreurAnalyseAvantProchainNoeudTermine = sauverErreurAnalyseAvantProchainNoeudTermine;
return commentaire;
}
</div>Résumé
------
Cette section a présenté l'analyse syntaxique et comment TS continue à analyser après avoir rencontré des erreurs.
La prochaine section portera principalement sur l'analyse incrémentielle.