Création de texte de balisage dans Visual Basic .NET
Darren Neimke
Résumé : Voici un outil qui vous permet de baliser du code dans n'importe quel langage, que vous pouvez personnaliser avec des couleurs, qui prend en charge les opérations de glisser-déplacer, qui produit du texte contenant des balises HTML ou XML, et qui génère des balises HTML.
Téléchargez le fichier exemple MarkUp.msi.
Introduction
J'ai toujours manipulé du code. J'écris du code à la fois pour mon travail et pour mon plaisir, et j'envoie des extraits de code à mes amis ou à des groupes de discussion. Lorsque je partage du code, je fais très attention à sa présentation. Par exemple, je vérifie que j'ai utilisé des noms de variable qui soient parlants et que toutes les variables déclarées sont effectivement utilisées. Je m'assure également que les couleurs appliquées à l'extrait de code correspondent à celles de mon éditeur : les chaînes en rouge, les commentaires en vert, les mots clés en bleu, les polices en Lucinda Console, etc. Il y a plusieurs manières d'effectuer ces vérifications, mais la méthode qui me semble la plus pratique consiste à utiliser Microsoft Word et Microsoft Visual Studio® comme suit :
- Copiez du code depuis la vue Code ou HTML d'un document dans Visual Studio .NET.
- Collez-le dans un document Word à l'aide de la commande Collage spécial | Texte mis en forme (RTF) {pour conserver les styles}.
- Copiez-le depuis le document Word et collez-le dans la vue Design d'un nouveau document HTML.
Les styles sont conservés, mais vous obtenez une syntaxe de formatage Office détaillée, du type :
<P class=MsoNormal style="mso-layout-grid-align: none">
<SPAN lang=EN-US style="FONT-SIZE: 8pt; COLOR: blue;
FONT-FAMILY: 'Courier New'; mso-ansi-language: EN-US">using</SPAN>
<SPAN lang=EN-US style="FONT-SIZE: 8pt;
FONT-FAMILY: 'Courier New'; mso-ansi-language: EN-US"> System;
<?xml:namespace prefix = o />
<o:p></o:p>
</SPAN>
</P>
Alors que vous n'avez besoin que de :
<font color="Blue">using</font> System ;
Le second exemple est bien évidemment plus souple à gérer, sans compter qu'il nécessite la transmission de beaucoup moins de texte que le premier exemple. Le temps de rendu des pages est dans ce cas plus long.
Il ne serait pas compliqué de développer une macro qui, à l'aide d'expressions régulières, analyserait le formatage Microsoft Office et le réduirait à une forme plus concentrée. Toutefois, cette macro ne résoudrait pas tous les problèmes, et impliquerait de recourir à Visual Studio et à Word chaque fois que vous souhaitez baliser du code, tout en nécessitant une bonne maîtrise des expressions régulières. De plus, vous seriez obligé d'utiliser Visual Studio .NET pour appliquer la couleur appropriée aux différents éléments du langage choisi.
J'ai donc décidé de créer un outil qui :
- me permettrait de baliser du code écrit dans n'importe quel langage ;
- serait suffisamment souple pour prendre en charge non seulement mon schéma de couleurs, mais aussi celui de tout autre utilisateur ;
- autoriserait les opérations de glisser-déplacer ;
- générerait du texte contenant des balises HTML ou XML afin que la personne à laquelle le texte serait transmis puisse le baliser avec son propre schéma de couleurs ;
-
restituerait des balises HTML sous la forme de balises FONT
ou de balises SPAN avec des noms de classe de
styles CSS, comme indiqué dans la figure 1.
Figure 1. Capture d'écran de balisage HTML restitué
Voici un exemple de ce que cette application me permettrait de faire. À partir de l'extrait de code suivant :
' This is a comment
Dim foo As New Bar()
Elle pourrait générer une sortie dans l'un des trois formats suivants :
Jetons bruts
<Comment>' This is a comment</Comment>
<Keyword>Dim</Keyword> foo <Keyword>As</Keyword>
<Keyword>New</Keyword> Bar()
Format HTML
<font color="green">' This is a comment</font>
<font color="blue">Dim</font> foo <font color="blue">As</font>
<font color="blue">New</font> Bar()
Format CSS
<span class="Comment">' This is a comment</span>
<span class="Keyword">Dim</span> foo <span class="Keyword">As</span>
<span class="Keyword">New</span> Bar()
De plus, si vous choisissez le format CSS, l'outil crée également une définition de feuille de style.
Figure 2. Feuille de style CSS générée par l'outil
Comment définir du code ?
Si les mots qui constituent un langage (la syntaxe) sont propres à chaque langage, tous les langages informatiques partagent heureusement des éléments communs, comme les mots clés, les opérateurs, les fonctions et les instructions monolignes et multilignes. En fait, quelle que soit la syntaxe utilisée par un langage, les opérations (on parle également de sémantique) que les langages vous permettent d'effectuer sont fondamentalement les mêmes. C'est notamment le cas des instructions de répétition, des instructions de contrôle, des fonctions et de la déclaration de variables. Pour mieux visualiser certaines de ces différences syntaxiques, voici un tableau récapitulatif.
| Opération | Visual Basic | C# |
|---|---|---|
| Instructions de contrôle | Do, For...Each, While | do, foreach, while |
| Chaînes | "..." | "..." |
| Commentaires | '..., Rem | //..., /*...*/ |
| Classes/Fonctions | Function, Class, Sub | function, class, void |
| Types de données | Integer, String, Boolean | int, string, bool |
Surtout, outre les regroupements opérationnels, il est également possible de caractériser les éléments d'un langage à un niveau encore supérieur, comme suit :
- les types définis par leurs caractères initial ou final ;
- les types définis par une suite de caractères situés dans un mot.
Pour illustrer mon propos, prenons le cas des chaînes et des types de données dans le tableau des opérations ci-dessus. Les chaînes font partie du premier type : elles sont définies par leurs caractères initial et final. Ainsi, lorsque je trouve le caractère initial d'une chaîne, je sais que tous ceux qui le suivront appartiendront à la chaîne jusqu'à ce que je rencontre le caractère final de la chaîne. En voici une illustration dans l'exemple ci-dessous dans lequel la chaîne n'est identifiée que par son caractère initial (") et son caractère final ("). Ces deux caractères peuvent être des caractères invisibles, comme le début d'une ligne ou un caractère de fin de ligne. Les commentaires Visual Basic®, par exemple, commencent par une apostrophe et se terminent par un caractère de fin de ligne.
Ceci ne fait pas partie de la chaîne
"mais cela oui, ainsi que ceci", mais pas cela.
Les types de données, quant à eux, appartiennent au second type : ils sont définis par des limites de mots et contiennent des suites de caractères. Contrairement aux premiers types, il est impossible de prévoir quels sont leurs caractères initiaux ou finaux ; seuls les caractères requis au milieu sont connus. Les limites de mot peuvent être définies comme la position à laquelle figurent, d'un côté, un caractère de mot et, de l'autre côté, un caractère n'intervenant pas dans la formation d'un mot. Ceci garantit que les mots détectés sont vraiment des mots à part entière et non des séquences de lettres intégrées dans des mots. Par exemple, la séquence "Rem" dans "Remémorer" ne constitue pas un mot à part entière. Une limite de mot peut être une parenthèse "(", un tiret "-", deux-points ":", voire le début d'une ligne.
Dans la suite de cet article, je ferai référence à ces types d'éléments comme suit :
Types Block ou Non-Word
- MultiLine Block
- SingleLine Block (défini par des caractères initial/final, mais limité à une seule ligne comme les chaînes Visual Basic)
Type Word
- Keyword
- Operator
Balisage du texte
Il est donc clair que la démarche à suivre pour créer un outil capable de baliser n'importe quel langage consiste à créer une définition de ces types, en fonction des remarques mentionnées dans la première partie. Pour ce faire, je vais créer un fichier de configuration XML qui définit le langage, les types d'éléments existant dans ce langage, ainsi que chacune des opérations, pour pouvoir appliquer des couleurs d'une manière similaire à celle de mon éditeur favori : Visual Studio .NET.
Chaque langage sera défini par un élément Language qui contiendra plusieurs éléments Pattern. Ces éléments décriront chacune des fonctions syntaxiques. Chaque élément Pattern doit contenir les constituants suivants :
- Type : MultiLine, SingleLine, Keyword, Operator
- Name : le nom de l'opération (String, Keyword, Comment).
- BeginChar : le caractère initial des types NonWord.
- EndChar : le caractère final des types NonWord.
- Words : une collection de mots pour les types Word.
- FontInfo : couleur, police, taille, etc.
Autre caractéristique originale mais importante, la présence de caractères d'échappement dans des types Non-mot autorise l'utilisation du caractère qui serait normalement le délimiteur final à incorporer dans le type NonWord. Par exemple, pour imprimer "Foo" avec les guillemets, C# fournit le caractère d'échappement "\" à utiliser comme suit :
string myString = "Norman says \"Hello\"." ; // Prints: Norman says "Hello".
Enfin, certains langages doivent prendre en charge la distinction majuscules/minuscules, à l'image de C#. Voici une version très abrégée de la définition du langage C# :
<Language name="CSharp" caseSensitive="true">
<Pattern type="MultiLine" name="BlockComment" beginChar="/*" endChar="*/">
<FontSettings name="Lucinda Console" color="Green" size="10" />
</Pattern>
<Pattern type="SingleLine" name="InlineComment" beginChar="//" endChar="\n">
<FontSettings name="Lucinda Console" color="Green" size="10" />
</Pattern>
<Pattern type="SingleLine" name="XmlComment" beginChar="///" endChar="\n">
<FontSettings name="Lucinda Console" color="LightGrey" size="10" />
</Pattern>
<Pattern type="MultiLine" name="String" beginChar=""" endChar="""
escapeChar="\">
<FontSettings name="Lucinda Console" color="Red" size="10" />
</Pattern>
<Pattern type="Keyword" name="ReferenceType">
<FontSettings name="Lucinda Console" color="Blue" size="10" />
<Words>
<Item>class</Item>
<Item>interface</Item>
<Item>delegate</Item>
<Item>object</Item>
<Item>string</Item>
</Words>
</Pattern>
.
.
.
.
</Language>
Comme vous le constatez, les types syntaxiques MultiLine et
SingleLine (NonWord) ont les attributs
beginChar/endChar, tandis que les types Word
contiennent une collection de Words. Le nœud de
langage a également l'attribut caseSensitive dont
la valeur est true, tandis que le type
de modèle (Pattern type) String autorise le caractère
d'échappement "\".
Création de l'outil
L'outil doit charger le fichier de configuration du langage et appliquer un jeu de règles pour dériver les expressions régulières qui permettront de localiser les différents éléments dans les extraits de code chargés.
Au niveau de son fonctionnement interne, la conception de l'outil est relativement simple. J'ai une classe Language et une classe Pattern pour abstraire les données dans mon fichier de configuration, ainsi qu'un module HtmlFormatting dont les routines permettent de formater et de colorier les extraits de code.
Lors de la création des classes Pattern, j'ai remarqué qu'il s'agissait d'une opportunité idéale pour utiliser l'héritage. J'ai donc créé une classe Base pour les fonctions courantes, que j'ai complétée avec des fonctions spécialisées comme les propriétés BeginChar/EndChar ou la collection Words selon le type de modèle. Les propriétés communes à tous les modèles sont Name, FontSettings, Type et RegexPattern.
Les classes spécialisées WordPattern et NonWordPattern complètent la classe de base Pattern. La classe NonWordPattern fournit les propriétés BeginChar et EndChar, tandis que la classe WordPattern affiche une collection de Words. Ces deux propriétés fournissent des implémentations spécifiques qui permettent d'afficher l'expression régulière qui la définit.
En résumé, la sémantique permettant de créer ces modèles regex est similaire à la syntaxe suivante :
Types NonWord
BeginChar<Any Text Until>EndChar
Pour des commentaires monolignes dans Visual Basic, le modèle se présente comme suit :
'[^\n\r]*
Types Word
WordBoundary<Any Single Word In Words Collection>WordBoundary
Pour des fonctions TSQL, le modèle se présente comme suit :
\b(AVG|MAX|BINARY_CHECKSUM|..)\b
Problèmes rencontrés et résolus
Maintenant, les classes Pattern fournissent chacune un modèle d'expression régulière qui permet de les localiser dans le corps d'un texte. L'étape suivante consiste à orchestrer le processus d'identification des modèles afin de repérer tous les éléments contenus dans l'extrait de code. Parmi les problèmes à résoudre, citons :
- la nécessité de ne pas baliser la même suite de caractères à plusieurs reprises ;
- la définition de types WORD limités par des caractères inhabituels ;
- la prise en charge des caractères d'échappement.
Le premier de ces trois problèmes survient généralement lorsque des mots clés sont incorporés dans des éléments BLOCK comme des chaînes ou des commentaires. Par exemple, dans la ligne de code inactivée ci-dessous, il n'est pas souhaitable de reconnaître et de colorier les mots clés séparément. Il vaut mieux que la ligne entière soit colorée comme un commentaire et balisée une seule fois.
' Dim foo As New Bar( whatever )
Un algorithme de base baliserait chaque élément l'un après l'autre. Si la tâche en cours consistait à localiser et à baliser des commentaires, c'est ainsi que les choses se passeraient. S'il s'agissait de baliser des mots clés, c'est également ce que l'on obtiendrait. J'avais besoin d'un traitement qui soit un peu plus abouti. Je cherchais un moyen de laisser traîner quelques indices qui indiqueraient à notre analyseur que cette section a déjà été balisée.
Insertion de jetons
Pour résoudre le problème des balisages
répétés, j'ai implémenté une phase
d'insertion de jetons, qui délimite des blocs de texte et
les identifie avec des jetons. De plus, l'extrait de code est
encadré par les jetons <available>...</available>.
Lorsqu'un élément du langage est localisé dans
l'extrait de code, les jetons correspondants sont
insérés, de même que les jetons <available> d'ouverture et de
fermeture.
Supposons que vous voulez baliser le code SQL suivant :
SELECT *
FROM dbo.Customers
WHERE dateCreated
BETWEEN @startDate
AND @endDate
La première étape consiste à encadrer
l'extrait avec des jetons <available> pour indiquer à
l'analyseur la partie du code à prendre en
compte :
<available>SELECT *
FROM dbo.Customers
WHERE dateCreated
BETWEEN @startDate
AND @endDate</available>
Maintenant, supposons que, pendant l'insertion des jetons, les mots sont trouvés et identifiés dans l'ordre suivant : BETWEEN, WHERE, SELECT, FROM et AND. La séquence d'ouverture, d'insertion de jeton et de fermeture de mon analyseur entraîne les modifications suivantes dans l'extrait de code à la fin de chaque identification dans la séquence :
Après avoir trouvé "BETWEEN"
<available>SELECT *
FROM dbo.Customers
WHERE dateCreated
</available><token>BETWEEN</token><available> @startDate
AND @endDate</available>
Après avoir trouvé "WHERE"
<available>SELECT *
FROM dbo.Customers
</available><token>WHERE</token><available> dateCreated
</available><token>BETWEEN</token><available> @startDate
AND @endDate</available>
Après avoir trouvé "SELECT"
<available></available><token>SELECT</token><available> *
FROM dbo.Customers
</available><token>WHERE</token><available> dateCreated
</available><token>BETWEEN</token><available> @startDate
AND @endDate</available>
Après avoir trouvé "FROM"
<available></available><token>SELECT</token><available> *
</available><token>FROM</token><available> dbo.Customers
</available><token>WHERE</token><available> dateCreated
</available><token>BETWEEN</token><available> @startDate
AND @endDate</available>
Après avoir trouvé "AND"
<available></available><token>SELECT</token><available> *
</available><token>FROM</token><available> dbo.Customers
</available><token>WHERE</token><available> dateCreated
</available><token>BETWEEN</token><available> @startDate
</available><token>AND</token><available> @endDate</available>
Comme vous pouvez le constater, après chaque
identification, le texte disponible est séparé
du reste et le texte identifié est encadré par
la balise <token>. Lorsque ce
mécanisme est opérationnel, il suffit de s'assurer
que la recherche visant à repérer le texte à
baliser ne s'effectue que sur le texte situé entre les
jetons <available>. Une fois le
processus d'identification terminé, je supprime les
marqueurs <available> et </available> et je conserve uniquement
les jetons qui identifient les éléments
syntaxiques.
codeSnippet = Regex.Replace(codeSnippet, "</?available>", "")
<token>SELECT</token> *
<token>FROM</token> dbo.Customers
<token>WHERE</token> dateCreated
<token>BETWEEN</token> @startDate
<token>AND</token> @endDate
Avant l'apparition de .NET, j'aurais rencontré plusieurs problèmes liés à la création d'un modèle servant à capturer les sections Available et Non-Available, à l'énumération de la collection Matches qui en découle, au balisage des captures Available, et au ré-assemblage de ces captures par concaténation pour vérifier que l'identification avait permis de n'extraire que certaines parties du texte. Cette méthode lourde n'était pas fiable (particulièrement sans les captures nommées) et on modifiait davantage de texte que prévu.
Dans .NET, la diversité des expressions régulières permet d'associer un délégué MatchEvaluator à la méthode Replace des expressions régulières. Ceci a pour effet de ne transmettre que le texte identifié à un gestionnaire d'événements et de renvoyer le texte de remplacement. L'ensemble de la concaténation s'effectue en arrière-plan. Ainsi, la quantité de texte modifiée est réduite au strict minimum, ce qui minimise le risque d'erreurs.
Transmission des identifications à un délégué MatchEvaluator
' important to ensure that we are only tokenizing parts of the snippet that
' haven't as yet been tokenized
Private m_AvailablePartPattern As String = _
"(?'available'<available>[^\<]*[\w\W\s\S]*?<\/available>)"
' matches the "available" text and hands it off
' to a delegate for further inspection.
Public Sub TokenizeWordElements( _
ByVal patternName As String, _
ByVal regexString As String, _
ByVal _caseSensitive As Boolean, _
ByRef codeSnippet As String _
)
m_CaseSensitive = _caseSensitive
m_Name = patternName
m_REString = regexString
Dim _delegate As New MatchEvaluator(AddressOf WordElementMatchHandler)
Dim r As New Regex(m_AvailablePartPattern, _
RegexOptions.IgnoreCase Or _
RegexOptions.Compiled _
)
codeSnippet = r.Replace(codeSnippet, _delegate)
End Sub
Private Function WordElementMatchHandler(ByVal _match As Match) As String
' if, for some reason no group was found... bail out
If m_Name Is String.Empty Then Return _match.Value
Dim opts As RegexOptions = RegexOptions.Multiline
If Not m_CaseSensitive Then
opts = opts Or RegexOptions.IgnoreCase
End If
Dim re As New Regex(m_REString, opts)
' tokenize the match
Return re.Replace(_match.Value, "</available><" & m_Name & ">$1</" & _
M_Name & "><available>")
End Function
L'importance de l'ordre
Je voudrais revenir sur un point que j'ai mentionné plus haut mais sans l'avoir expliqué : l'ordre dans lequel effectuer l'identification. Vous devez vous assurer que les éléments sont identifiés en commençant par ceux qui ont la portée la plus importante, et en terminant par ceux qui ont la portée la plus faible. Cela signifie que les types BLOCK MultiLine sont identifiés avant les types de modèle SingleLine, et que tous deux sont identifiés avant les types WORD. Ceci s'explique par le fait que les éléments BLOCK peuvent contenir des éléments qui, dans le cas contraire, seraient qualifiés en tant que types WORD, et non le contraire.
L'identification d'éléments BLOCK est relativement simple. Vous détectez le caractère BeginChar et poursuivez l'identification jusqu'au-delà du caractère EndChar. Dans le cas d'une chaîne Visual Basic, cela signifie que vous arrivez à la fin de la ligne sans rencontrer les guillemets de fermeture ("). Voici le code qui doit créer cette chaîne de modèle :
Public Function GetBlockPattern() As String
Dim rePattern As String = "("
Dim ptrn As PatternBase
For Each ptrn In Me.Patterns
If TypeOf ptrn Is NonWordPattern Then
If rePattern.Length > 1 Then
rePattern &= "|"
End If
rePattern &= "(?'" & ptrn.Name & "'" & _
ptrn.PatternString() & ")"
End If
Next
rePattern &= ")+"
Return rePattern
End Function
Comme vous pouvez le voir, il s'agit d'une expression Or portant sur chaque expression du type Pattern Block. Si vous examinez la logique de création de PatternString, vous constatez que l'implémentation dépend de la présence ou de l'absence de EscapeChar dans le modèle.
' class specific implementation of CreatePatternString
' logic for a non-Word type is: BeginChar <Any Text Until> EndChar
Private Function CreatePatternString() As String
Dim retVal As String
If Me.EndChar = "\n" Then
retVal = Regex.Escape(Me.BeginChar) & "[^\n\r]*"
Else
If Me.HasEscapeChar Then
retVal = String.Format("{0}(?>{1}.|[^{2}]|.)*?{3}", _
Regex.Escape(Me.BeginChar), Regex.Escape(Me.EscapeChar), _
Regex.Escape(Left(Me.EndChar, 1)), Regex.Escape(Me.EndChar))
Else
retVal = String.Format("{0}[^{1}]*(?>[^{1}]|.)*?{2}", _
Regex.Escape(Me.BeginChar), _
Regex.Escape(Left(Me.EndChar, 1)), Regex.Escape(Me.EndChar))
End If
End If
Return retVal
End Function
Conservation des états
Pour créer les modèles des types BLOCK, j'ai utilisé ExplicitCapture (?>...) afin de minimiser le nombre de retours en arrière disponibles. En fait, je peux utiliser ExplicitCapture car je sais que si je trouve le caractère BeginChar d'un modèle, je veux poursuivre la lecture du texte jusqu'à trouver le caractère EndChar ou jusqu'à échouer. En résumé, je ne veux rien laisser au hasard. La non-conservation des états enregistrés peut générer des erreurs plus rapidement que lorsque ces états sont conservés et réappliqués ultérieurement pendant l'identification.
Possibilités d'améliorations futures
J'ai essayé de créer les expressions régulières de façon à tenir compte le moins possible des implémentations de chaque langage. L'une des améliorations futures consisterait à introduire des contrôles sémantiques propres à chaque langage, afin d'accélérer le traitement. Ce qui pourrait se traduire, dans le cas de TSql, par le codage en dur du marqueur de limite de mot "\b" ou "@@". En effet, on sait qu'il est possible d'inclure "@@" dans un mot sans que le moteur regex ne considère ces caractères en tant que tels. La création d'une telle séquence en TSql réduirait la tolérance sur le caractère initial pour les langages qui ne le requièrent pas, accélérant ainsi la vitesse de traitement.
Conclusion
Dans cet article, vous avez vu comment créer un mini-analyseur utilisant des expressions régulières pour séparer des mots clés d'un langage de programmation et appliquer des couleurs aux éléments syntaxiques. Vous avez également vu que les nouvelles fonctionnalités des expressions régulières dans .NET offrent une plus grande puissance et une flexibilité accrue pour manipuler des modèles dans le texte.
Darren Neimke est développeur d'applications
senior. Il participe à la création d'applications ASP
depuis les débuts d'Internet, vers la fin des
années 90. Auparavant, il développait des
programmes de comptabilité à l'aide d'Access 2
et de VBA. Ces derniers temps, Darren a décidé
d'explorer le monde des expressions régulières.
Aujourd'hui, il consacre la majeure partie de son temps à
la maintenance de RegexLib.com et à la rédaction
d'articles sur les expressions régulières pour son
« blog », accessible à l'adresse http://weblogs.asp.net/DNeimke
.
Dernière mise à jour le mercredi 10 décembre 2003