Sprachlehrer für Visual Studio Code

Für Microsofts Sourcecode-Editor lassen sich ohne viel Aufwand Erweiterungen erstellen, die beim Entwickeln mit weniger verbreiteten Programmiersprachen helfen.

In Pocket speichern vorlesen Druckansicht 12 Kommentare lesen
Sprachlehrer für Visual Studio Code
Lesezeit: 15 Min.
Von
  • Nils Andresen
Inhaltsverzeichnis

Hilfen wie Syntax-Hervorhebung, Autovervollständigung und gute Code-Schnipsel sind für viele Entwickler das A und O einer Entwicklungsumgebung. Visual Studio Code bietet nicht nur viele Erweiterungen für zahlreiche Programmiersprachen, sondern ermöglicht darüber hinaus das Erstellen eigener Extensions, wenn die Programmiersprache der Wahl nicht auf der Liste steht. Der Artikel zeigt am Beispiel der Template-Sprache von FirstSpirit, wie Entwickler eine einfache eigene Erweiterung für die IDE erstellen können.

Das Produkt FirstSpirit ist ein Enterprise-CMS der Firma e-Spirit. Damit können Redakteure Inhalte nicht nur in Standard-Eingabekomponenten erfassen, sondern Entwickler können zusätzlich spezielle Eingabekomponenten erstellen. Die Anwendung gibt die von den Redakteuren erfassten Informationen anhand von Vorlagen aus, für deren Erstellung FirstSpirit über eine eigene Template-Sprache verfügt.

Zum Erstellen einer Erweiterung für Visual Studio Code sieht die Dokumentation die Werkzeuge npm, Yeoman und Visual Studio Code Extension Generator vor. Nach der Installation von npm installiert folgende Zeile Yeoman und den Generator auf dem System:

npm install -g yo generator-code
yo code

Der Extension Generator erstellt das Grundgerüst in einer passenden Dateistruktur, die er mit einem einfachen Beispiel befüllt. Dazu fragt er ein paar Punkte ab (s. Abb. 1).

Erstellen eines Grundgerüstes mit Yeoman und Visual Studio Code Extension Generator (Abb. 1).

Alle im Artikel gezeigten Beispiele sind unter Windows 10 und Ubuntu getestet und sollten sich auf jedem anderen aktuellen Linux sowie unter macOS ebenso durchführen lassen.

Unter Linux und macOS installieren die Befehle npm install -g ... die genannten Komponenten systemweit mit dem Parameter -g und gegebenenfalls einem vorgestellten sudo für administrative Rechte. Auf Windows Systemen muss das Kommandofenster administrative Rechte erhalten.

Das mit dem Generator erstellte Paket enthält drei wichtige Dateien: package.json mit den Grundeinstellungen des Projekts, language-configuration.json mit der eigentlichen Konfiguration der Sprache und fs.tmLanguage.json im Unterordner syntaxes mit der Syntax der Sprache im TextMate-Format.

package.json enthält neben den Grundeinstellungen des Projekts unter dem Punkt contributes die Elemente, die die Erweiterung zu Visual Studio Code beisteuert (s. Abb. 2).

package.json beschreibt die Grundeinstellungen und Inhalte der Erweiterung (Abb. 2).

Der erste Punkt unter contributes ist languages, der die Programmiersprache(n) definiert. Den in id definierten Wert referenzieren die folgenden Elemente. configuration verweist auf die Konfiguration der angegebenen Sprache – im konkreten Fall auf die Datei language-configuration.json.

Der Punkt grammars definiert die Grammatik der Sprache. Dessen Unterpunkt language gibt die id der Sprache an, für die die Grammatik gilt. path verweist auf die Datei mit der Definition der Grammatik.

Die Datei language-configuration.json enthält die Konfiguration der Sprache in folgenden Abschnitten:

  • comments bestimmt das Format der Kommentare, unterteilt in lineComment blockComment für einzeilige beziehungsweise Blockkommentare. Die Angaben verwendet Visual Studio Code für die Standardfunktion zum Ein- und Auskommentieren wie Shift | Alt | A zum Umschalten von Blockkommentaren. Da FirstSpirit keine Zeilenkommentare kennt, fehlt die Angabe im Beispiel. Blockkommentare starten mit $-- und enden auf --$.
  • brackets beschreiben die gültigen Klammerpaare. Der Editor verwendet die Angabe unter anderem, um das passende Gegenstück zu einer selektierten Klammer hervorzuheben.
  • autoClosingPairs definiert Kombinationen, bei denen Visual Studio Code den zweiten Teil automatisch einfügt, nachdem Entwickler den ersten Teil getippt haben.
  • Die Angabe unter surroundingPairs beschreibt Klammerpaare, die einen gewählten Bereich einschließen. Wenn beispielsweise ( und ) als Paar angegeben sind, können Entwickler einen beliebigen Text markieren und "(" eingeben, damit der Editor den Bereich mit dem gewählten Klammerpaar einschließt.

Die language-configuration.json sieht für die FirstSpirit-Definition folgendermaßen aus:

{
  "comments": {
    "blockComment": [ "$--", "--$" ]
  },
  "brackets": [
    ["{", "}"], ["[", "]"], ["(", ")"]
  ],
  "autoClosingPairs": [
    ["{", "}"],
    ["[", "]"],
    ["(", ")"],
    ["\"", "\""],
    ["'", "'"],
    { "open": "$--", "close": "--$", 
      "notIn": ["comment"] },
    ],
  "surroundingPairs": [
    ["{", "}"],
    ["[", "]"],
    ["(", ")"],
    ["\"", "\""],
    ["'", "'"]
  ]
}

Die Definition von Syntaxhervorhebungen in Form der farblichen Markierungen für einzelne Befehle und Schlüsselworte erfolgen in einer Erweiterung für Visual Studio Code in zwei Teilen:

  • dem Zerlegen des Editor-Inhalts in Tokens, also Worte oder Abschnitte, die eine bestimmte Bedeutung haben und
  • der Farbgebung.

Beides geschieht in der Datei fs.tmLanguage.json. Der Inhalt der Datei ist im TextMate-Format angelegt: ein JSON-Format, mit dem sich reguläre Ausdrücke (nach Oniguruma) verwenden lassen, um den Text in Tokens zu zerlegen und ihnen Namen beziehungsweise Scopes zuzuweisen. Dabei handelt es sich um die Einfärbung (ähnlich wie CSS-Klassen), die einem Farbschema entsprechen müssen.

Um der Grammatik der Sprache nicht zusätzlich eine eigene Farbgebung hinzuzufügen und den Nutzern der Erweiterung eigene Farben aufzudrängen, ist es sinnvoll, sich an den existierenden Namen beziehungsweise Scopes zu orientieren, die sich unter anderem in den Theme-Dateien wie light_plus.json für Visual Studio Code finden.

Eine einfache Regel sieht folgendermaßen aus:

"closetag": {
  "name": "keyword.control.fs",
  "match": "\\$CMS_END_([^\\$]+)\\$",
  "captures": {
    "1": {
      "name": "entity.name.class.fs"
    }
  }
}
  • Das Schlüsselwort der Regel (hier closetag) kann beliebig ausfallen, muss aber innerhalb der Datei eindeutig sein.
  • name bezeichnet den Scope und damit die Färbung des erkannten Tokens. Es stellt eine Hierarchie dar und muss laut Konvention in der ID der Sprache (fs) enden. keyword.control bezeichnet die Standardfarbe für Schlüsselworte, die den Programmablauf steuern (im konkreten Beispiel lila).
  • match bestimmt den regulären Ausdruck zum Erkennen des Tokens: Im konkreten Fall alle Bereiche in der Form $CMS_END_Kommando$, wobei der Teil Kommando – alles nach dem zweiten Unterstrich und vor dem zweiten Dollar-Zeichen – zusätzlich in eine sogenannte Capture Group gefasst ist, die an den runden Klammern erkennbar ist.
  • Mit capture lassen sich die Capture Groups des regulären Ausdrucks mit abweichenden Namen beziehungsweise Scopes versehen. Capture Groups beginnen in der Zählung immer bei 0, wobei die 0-Gruppe den ganzen erkannten Ausdruck darstellt. Im konkreten Fall wird einzig die 1-Gruppe mit entity.name.class.fs benannt. Der erste Teil, entity.name.class, bezeichnet die Standardfarbe für eine Klasse, die im konkreten Beispiel grün ist. fs ist erneut die ID der aktuellen Sprache.

Alternativ können Entwickler zwei reguläre Ausdrücke angeben: begin beschreibt den Anfang und end das Ende des Tokens. Falls in dieser Syntax Capture Groups vorkommen sollen, müsste entsprechend beginCaptures und endCaptures das captures ersetzen:

"comment": {
"name": "comment.line.fs",
"begin": "\\$--",
"end": "--\\$"
}

Bei Codeschnipseln handelt es sich um vorbereitete Fragmente, die Entwickler über eine Abkürzung in den bestehenden Code einfügen können. Die Definition erfolgt unter snippets im Abschnitt contributes in der Datei package.json:

"snippets": [{
"language": "fs",
"path": "./snippets/fs.json"
}]

Erneut beschreibt language die ID der Programmiersprache und path verweist auf die Datei, die die Schnipsel beschreibt. Entwickler müssen sie manuell anlegen, um darin die Definition der Schnipsel im TextMate-Format zu deklarieren. Folgender Abschnitt definiert ein $CMS_FOR()-Codeschnipsel.

"cms_for": {
  "prefix": "cms-for",
  "body": [
    "\\$CMS_FOR(${1:identifier}, ${2:object})\\$",
    "$0",
    "\\$CMS_END_FOR\\$"
  ],
  "description": "For Loop"
}

Der Code fügt drei Zeilen mit zwei Platzhaltern ein und platziert anschließend den Cursor in der zweiten Zeile zwischen $CMS_FOR()$ und $CMS_END_FOR$ (s. Abb. 3).

Der Schlüssel für den Schnipsel (cms_for) ist beliebig wählbar, muss aber in der Datei eindeutig sein. description gibt einen Text an, den Visual Studio Code als Beschreibung bei der Auswahl des Schnipsels darstellt. prefix definiert das Kürzel, das das Einfügen im Editor auslöst. body gibt den Inhalt als Array an: Jede Zeile des Codeschnipsels entspricht einem Element im Array.

Der Inhalt des Schnipsels kann auf einige Variablen wie den Namen der aktuell im Editor geöffneten Datei zugreifen. Eine vollständige Liste der Variablen ist in der Dokumentation verfügbar. Zusätzlich lassen sich Tabulator-Sprungpunkte für Platzhalter definieren, die nach dem Einfügen des Code-Schnipsels noch ausgefüllt werden müssen.

Der spezielle Sprungpunkt $0 bestimmt die Position des Cursors, nach dem Einfügen des Schnipsels und Auffüllen aller Platzhalter. Standardmäßig steht der Cursor hinter dem letzten Zeichen des Codeschnipsels.

Einfügen des $CMS_FOR()$-Code-Schnipsels (Abb. 3)

Code-Folding beschreibt das Zu- und Aufklappen bestimmter Bereiche im Editor, um den Inhalt für die besseren Übersicht auszublenden. Visual Studio Code bietet einen integrierten Mechanismus, für den es die Einrückungstiefe des Quellcodes nutzt. Diese Form des Ausblendens funktioniert immer und ist nicht abschaltbar.

Eine zusätzliche Regel lässt sich in der Sprachkonfiguration erstellen. Entwickler können in der language-configuration.json unter folding mit regulären Ausdrücken für die Start- und die Endzeile eines Bereichs eine zusätzliche Regel für das Code-Folding einrichten:

"folding": {
  "markers": {
    "start": "^.*\\$CMS_(?!END)[^\\$]+\\$.*$",
    "end": "^.*\\$CMS_END_[^\\$]+\\$.*$"
  }
}

Der Nachteil ist, dass sich auf diese einfache Weise keine komplexeren Sachverhalte abbilden lassen. Dass für die Beispielsprache das Code-Folding für Bereiche von $CMS_SET()$ bis $CMS_END_SET$ und von $CMS_IF()$ bis $CMS_END_IF$, aber nicht für Bereiche von $CMS_SET()$ bis $CMS_END_IF$ gelten soll, lässt sich nicht in zwei einfachen regulären Ausdrücken abbilden. Visual Studio Code kennt jedoch zwei erweiterte Formen der Definition: Das Anbinden einer API-Schnittstelle und der Einsatz eines Sprachservers.

Abbildung 4 zeigt einen weiteren Nachteil des gezeigten Vorgehens: Die regulären Ausdrücke sorgen für Code-Folding von $CMS_ELSE$ bis $CMS_END_IF$. Dadurch ist $CMS_IF$ frei, um es bis zu $CMS_END_FOR$ einzuklappen. Ein weiteres Code-Folding ergibt sich somit zwischen den nicht zusammenhängenden Ausdrücken $CMS_FOR$ aus der obersten Zeile und dem $CMS_END$.

Falsches Code-Folding für ein if-else-Konstrukt (Abb. 4)

Nach dem Erstellen der Erweiterung über das yeoman-template liegt eine vorbereitete launch.json im .vscode-Ordner der Erweiterung zum Testen bereit. Der Befehl Debug: Start Debugging (F5) startet eine neue Instanz von Visual Studio Code mit der installierten Erweiterung aus dem Entwicklungsordner.

Dadurch können Entwickler in einer Instanz von Visual Studio Code die Erweiterung schreiben, während sie in einer weiteren Instanz die Anpassungen direkt testen. Allerdings müssen sie nach Änderungen an der Erweiterung zunächst die Testinstanz über den Befehl Developer: Reload Window neu laden.

Zusätzlich zu den deklarativen Funktionen lassen sich auch API-Funktionen implementieren. Alle API-Endpunkte stehen im Bereich vscode.languages.* unter programmatic-language-features in der Dokumentation über Dokumentation zur Verfügung (s. Abb. 5).

Beispielhafte Registrierungsendpunkte (Abb. 5)

Derzeit existieren 19 Endpunkte, die sich ansprechen lassen. Beispielhaft kommt im Folgenden der für das Code-Folding zum Tragen. Um in TypeScript statt JavaScript zu entwickeln, gilt es zunächst, die passenden Dependencies hinzuzufügen:

npm install --save-dev typescript vscode @types/node

Die Dokumentation über Aktivierungs-Events beschreibt unter anderem, dass Entwickler ein activationEvent benennen und eine activate()-Methode aus dem main-Modul implementieren müssen. Für eine sprachspezifische Erweiterung können sie das onLanguage-Event verwenden. Es aktiviert die Erweiterung beim Laden einer Datei in der passenden Programmiersprache:

"main": "./js/index",
"activationEvents": [
"onLanguage:fs"
],

Die Sprach-ID findet sich dabei in der Zeile "onLanguage:fs", während "main": "./js/index" auf die JavaScript-Datei mit der zugehörigen activate()-Methode verweist. Wer in TypeScript entwickelt, muss die Ausgabe in das korrekte Verzeichnis (js/) umleiten. Dazu dient die Zeile "outdir": "js" in der tsconfig.json:

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es6",
    "outDir": "js",
    "lib": [
      "es6"
    ],
    "sourceMap": true,
    "rootDir": "src",
    "strict": true,
    "noImplicitAny": true,
    "noUnusedLocals": true
  }
}

Das Umwandeln von TypeScript in JavaScript übernimmt der tsc-compiler, der sich, deklariert als scripts in package.json, am einfachsten über npm run build aufrufen lässt.

Das Code-Folding lässt sich über folgende Methode registrieren:

registerFoldingRangeProvider(
selector: DocumentSelector,
provider: FoldingRangeProvider)

Der Parameter DocumentSelector ist im einfachsten Fall die id aus der languages-Definition. Der zweite Parameter legt den Provider für das Code-Folding fest. Der Rückgabewert des Aufrufs ist eine Referenz auf die Registrierung, über die sich der Provider wieder entfernen lässt, wenn die Erweiterung ihn nicht mehr benötigt wird. Visual Studio Code bringt dafür einen eigenen Mechanismus mit: Der ExtensionContext der activate()-Methode hat ein subscriptions-Array, mit dem Visual Studio Code eigenständig Registrierungen sauber entfernt. Der Provider lässt sich mit folgenden Codezeilen registrieren:

import { ExtensionContext,languages } from "vscode";
import { FsFoldingProvider } from "./foldingProvider"

export function activate(context: ExtensionContext): void {
  console.log("FS-Extension is activated!");

  context.subscriptions.push(
   languages.registerFoldingRangeProvider(
     "fs", new FsFoldingProvider));
}

Der Provider ist in der API-Dokumentation beschrieben und muss die folgende Methode implementieren:

  provideFoldingRanges(document: TextDocument,
context: FoldingContext,
token: CancellationToken):
ProviderResult<FoldingRange[]>

document: TextDocument ist eine Referenz auf das Dokument. context: FoldingContext ist derzeit ungenutzt und in der Dokumentation als "for future use" beschrieben. Der letzte Parameter token: CancellationToken ist für asynchrone oder lang andauernde Operationen ausgelegt. Anhand des Tokens lässt sich mit token.isCancellationRequested überprüfen, ob die aktuelle Berechnung noch notwendig ist. Eine weitere Methode hierfür ist die Callback-Funktion token.onCancellationRequested.

Folgender Codeausschnitt zeigt eine einfache Implementierung eines Code-Folding-Providers, der jeweils drei Zeilen einer Datei einklappt (s. Abb. 6):

import {
  FoldingRangeProvider,
  TextDocument,
  FoldingContext,
  CancellationToken,
  ProviderResult,
  FoldingRange } from 'vscode';

export class FsFoldingProvider 
  implements FoldingRangeProvider 
{
  provideFoldingRanges(document: TextDocument,
                       context: FoldingContext, 
                       token: CancellationToken): 
    ProviderResult<FoldingRange[]>
  {
    if(token.isCancellationRequested){
      return null;
    }

    // Immer drei Zeilen einklappen:
    const folds = Math.floor(document.lineCount / 3);
    const ranges:FoldingRange[] = [];

    for(let i=0; i<folds; i++){
      let start = i * 3;
      let end = start + 2;

      ranges.push(new FoldingRange(start, end));
    }

  return ranges;
  }
}

Der einfache Folding-Provider fasst immer drei Zeilen zusammenen (Abb. 6).

Obwohl der einfache Provider nicht wirklich sinnvoll erscheint, erfüllt er einen ersten Zweck: Der Code ist simpel und birgt wenig Potential für Fehler. Auf die Weise lässt sich das Einbinden auf einfache Weise testen.

Folgender Code führt das Code-Folding für tatsächliche Codeblöcke durch, hat aber nicht das in Abb. 4 gezeigte Problem des falschen Code-Foldings.

export class FsFoldingProvider implements FoldingRangeProvider {

  private foldingPairs: IFoldingPair[] = [
    { from: new RegExp("\\$CMS_FOR\\(", "i"), 
      to: new RegExp("\\$CMS_END_FOR\\$", "i") },
    { from: new RegExp("\\$CMS_SET\\([^,]+\\)", "i"), 
      to: new RegExp("\\$CMS_END_SET\\$", "i") }, 
    { from: new RegExp("\\$CMS_IF\\(", "i"),  
      to: new RegExp("\\$CMS_END_IF\\$", "i") },
    { from: new RegExp("\\$CMS_TRIM\\(", "i"),  
      to: new RegExp("\\$CMS_END_TRIM\\$", "i") },
    { from: new RegExp("\\$CMS_SWITCH\\(", "i"), 
      to: new RegExp("\\$CMS_END_SWITCH\\$", "i") }
  ];

  provideFoldingRanges(document: TextDocument,
                       context: FoldingContext, 
                       token: CancellationToken): 
    ProviderResult<FoldingRange[]>
  {
    const ranges:FoldingRange[] = [];
    const foldStack:IFoldingPairHit[] = [];

    for(let i =0; i<document.lineCount; i++) {
      if(token.isCancellationRequested){
        return null;
      }

      let line = document.lineAt(i).text;
      let startHit:IFoldingPairHit|null=null;
      let startHitAt:number=-1;

      this.foldingPairs.forEach((p, n) => {
        const startIdx = line.search(p.from);
        const endIdx = line.search(p.to);
        if(startIdx >= 0 && endIdx >= 0 && 
           startIdx < endIdx){
          return; // can not fold "in" a line
        }

        if(startIdx >= 0) {
          if(startIdx < startHitAt || startHitAt < 0){
            startHit = {
              pair: p,
              line: i
            }
            startHitAt = startIdx
          }
        }

        if(endIdx >= 0 && foldStack.length > 0){
          // found an end - compare to the top of the stack
          let topStart:IFoldingPairHit = foldStack.pop()!;
          if(topStart.pair.from === p.from) {
            // we have a match
            ranges.push(new FoldingRange(topStart.line, i));
          } else {
            // ignore - put top back on stack.
            foldStack.push(topStart);
          }

        }
      });

      if(startHit !== null){
        foldStack.push(startHit!);
      }
    }

    return ranges;
  }
}

Das Tool vsce erstellt aus dem gesamten Code für die Erweiterung ein Paket:

npm install -g vsce
vsce package -o ..\bin\fs-lang-1.2.3.vsix

Nach der Installation in der ersten Zeile ruft die zweite das Tool auf, um die vsix-Datei zu erstellen. Der Parameter -o ..\bin\fs-lang-1.2.3.vsix gibt den Dateinamen an. Wenn er fehlt, erstellt vsce eine Datei mit dem Namen [paketname]-[paketversion].vsix im Wurzelverzeichnis der Anwendung. Leider erlaubt das Tool nicht die reine Angabe eines Ausgabeverzeichnisses wie -o ..\bin\, sondern für andere Verzeichnisse müssen Entwickler immer den vollen Dateipfad übergeben.

vsce erstellt aus dem Projekt eine installierbare vsix-Datei, die sich sowohl aus Visual Studio Code heraus (s. Abb. 7) als auch über die Kommandozeile installieren lässt:

code --install-extension fs-lang-1.2.3.vsix

Installation einer vsix-Datei aus Visual Studio Code.

Um das Paket im Visual Studio Extension Marketplace bereitzustellen, benötigen Entwickler zunächst eine PublisherId. Sofern sie noch keine besitzen, können sie eine im Marktplatz von Visual Studio Code erstellen, was allerdings ein Windows-Konto voraussetzt. Anschließend können sie in der Übersicht des Publishers neue Erweiterungen registrieren (s. Abb. 8).

Eine neue Erweiterung lässt sich in der Übersicht im Marketplace veröffentlichen (Abb. 8).

Nach dem Hochladen des vsix-Pakets und einer kurzen systeminternen Prüfung steht es zum Download im Marketplace und direkt in Visual Studio Code zur Verfügung.

Visual Studio Code kennt zahlreiche Programmiersprachen und ermöglicht darüber hinaus das Erstellen passender Extensions für weniger bekannte Sprachen. Dabei haben Entwickler den vollen Zugriff auf die Syntaxhervorhebung, Autovervollständigung und sogar erweiterte Funktionen wie das Code-Folding.

Eigene Erweiterungen lassen sich nicht nur komfortabel schreiben, sondern auch direkt aus der Umgebung heraus testen. Mit einem zusätzlichen Werkzeug können Entwickler ihre eigene Erweiterung anderen Programmierern im Visual Studio Code Marketplace zur Verfügung stellen.

Sowohl Visual Studio Code selbst als auch die Extensions sind unter Windows, Linux und macOS lauffähig, sodass sich ohne Mehraufwand eine Entwicklung für mehrere Systeme problemlos realisieren lässt.

Nils Andresen
ist Softwarearchitekt und Berater bei der adesso SE. Er arbeitet in verschiedenen Projekten im Umfeld von Microsoft SharePoint, Azure und O365.

(rme)