blob: 930079b1b2dacea488d30682569772dc72955b3e [file] [log] [blame]
import * as fs from 'fs';
import * as jsonc from "jsonc-parser";
import * as path from 'path';
import * as vscode from 'vscode';
import * as vscodelc from 'vscode-languageclient';
import * as vscodelct from 'vscode-languageserver-types';
// Parameters for the semantic highlighting (server-side) push notification.
// Mirrors the structure in the semantic highlighting proposal for LSP.
interface SemanticHighlightingParams {
// The text document that has to be decorated with the semantic highlighting
// information.
textDocument: vscodelct.VersionedTextDocumentIdentifier;
// An array of semantic highlighting information.
lines: SemanticHighlightingInformation[];
}
// Contains the highlighting information for a specified line. Mirrors the
// structure in the semantic highlighting proposal for LSP.
interface SemanticHighlightingInformation {
// The zero-based line position in the text document.
line: number;
// A base64 encoded string representing every single highlighted characters
// with its start position, length and the "lookup table" index of of the
// semantic highlighting Text Mate scopes.
tokens?: string;
}
// A SemanticHighlightingToken decoded from the base64 data sent by clangd.
interface SemanticHighlightingToken {
// Start column for this token.
character: number;
// Length of the token.
length: number;
// The TextMate scope index to the clangd scope lookup table.
scopeIndex: number;
}
// A line of decoded highlightings from the data clangd sent.
export interface SemanticHighlightingLine {
// The zero-based line position in the text document.
line: number;
// All SemanticHighlightingTokens on the line.
tokens: SemanticHighlightingToken[];
}
// Language server push notification providing the semantic highlighting
// information for a text document.
const NotificationType =
new vscodelc.NotificationType<SemanticHighlightingParams, void>(
'textDocument/semanticHighlighting');
// The feature that should be registered in the vscode lsp for enabling
// experimental semantic highlighting.
export class SemanticHighlightingFeature implements vscodelc.StaticFeature {
// The TextMate scope lookup table. A token with scope index i has the scopes
// on index i in the lookup table.
scopeLookupTable: string[][];
// The object that applies the highlightings clangd sends.
highlighter: Highlighter;
// Any disposables that should be cleaned up when clangd crashes.
private subscriptions: vscode.Disposable[] = [];
constructor(client: vscodelc.BaseLanguageClient,
context: vscode.ExtensionContext) {
context.subscriptions.push(client.onDidChangeState(({newState}) => {
if (newState == vscodelc.State.Running) {
// Register handler for semantic highlighting notification.
client.onNotification(NotificationType,
this.handleNotification.bind(this));
} else if (newState == vscodelc.State.Stopped) {
// Dispose resources when clangd crashes.
this.dispose();
}
}));
}
fillClientCapabilities(capabilities: vscodelc.ClientCapabilities) {
// Extend the ClientCapabilities type and add semantic highlighting
// capability to the object.
const textDocumentCapabilities: vscodelc.TextDocumentClientCapabilities&
{semanticHighlightingCapabilities?: {semanticHighlighting : boolean}} =
capabilities.textDocument;
textDocumentCapabilities.semanticHighlightingCapabilities = {
semanticHighlighting : true,
};
}
async loadCurrentTheme() {
const themeRuleMatcher = new ThemeRuleMatcher(
await loadTheme(vscode.workspace.getConfiguration('workbench')
.get<string>('colorTheme')));
this.highlighter.initialize(themeRuleMatcher);
}
initialize(capabilities: vscodelc.ServerCapabilities,
documentSelector: vscodelc.DocumentSelector|undefined) {
// The semantic highlighting capability information is in the capabilities
// object but to access the data we must first extend the ServerCapabilities
// type.
const serverCapabilities: vscodelc.ServerCapabilities&
{semanticHighlighting?: {scopes : string[][]}} = capabilities;
if (!serverCapabilities.semanticHighlighting)
return;
this.scopeLookupTable = serverCapabilities.semanticHighlighting.scopes;
// Important that highlighter is created before the theme is loading as
// otherwise it could try to update the themeRuleMatcher without the
// highlighter being created.
this.highlighter = new Highlighter(this.scopeLookupTable);
this.subscriptions.push(vscode.Disposable.from(this.highlighter));
// Adds a listener to reload the theme when it changes.
this.subscriptions.push(
vscode.workspace.onDidChangeConfiguration((conf) => {
if (!conf.affectsConfiguration('workbench.colorTheme'))
return;
this.loadCurrentTheme();
}));
this.loadCurrentTheme();
// Event handling for handling with TextDocuments/Editors lifetimes.
this.subscriptions.push(vscode.window.onDidChangeVisibleTextEditors(
(editors: vscode.TextEditor[]) =>
editors.forEach((e) => this.highlighter.applyHighlights(
e.document.uri.toString()))));
this.subscriptions.push(vscode.workspace.onDidCloseTextDocument(
(doc) => this.highlighter.removeFileHighlightings(doc.uri.toString())));
}
handleNotification(params: SemanticHighlightingParams) {
const lines: SemanticHighlightingLine[] = params.lines.map(
(line) => ({line : line.line, tokens : decodeTokens(line.tokens)}));
this.highlighter.highlight(params.textDocument.uri, lines);
}
// Disposes of all disposable resources used by this object.
public dispose() {
this.subscriptions.forEach((d) => d.dispose());
this.subscriptions = [];
}
}
// Converts a string of base64 encoded tokens into the corresponding array of
// HighlightingTokens.
export function decodeTokens(tokens: string): SemanticHighlightingToken[] {
const scopeMask = 0xFFFF;
const lenShift = 0x10;
const uint32Size = 4;
const buf = Buffer.from(tokens, 'base64');
const retTokens = [];
for (let i = 0, end = buf.length / uint32Size; i < end; i += 2) {
const start = buf.readUInt32BE(i * uint32Size);
const lenKind = buf.readUInt32BE((i + 1) * uint32Size);
const scopeIndex = lenKind & scopeMask;
const len = lenKind >>> lenShift;
retTokens.push({character : start, scopeIndex : scopeIndex, length : len});
}
return retTokens;
}
// The main class responsible for processing of highlightings that clangd
// sends.
export class Highlighter {
// Maps uris with currently open TextDocuments to the current highlightings.
private files: Map<string, Map<number, SemanticHighlightingLine>> = new Map();
// DecorationTypes for the current theme that are used when highlighting. A
// SemanticHighlightingToken with scopeIndex i should have the decoration at
// index i in this list.
private decorationTypes: vscode.TextEditorDecorationType[] = [];
// The clangd TextMate scope lookup table.
private scopeLookupTable: string[][];
constructor(scopeLookupTable: string[][]) {
this.scopeLookupTable = scopeLookupTable;
}
public dispose() {
this.files.clear();
this.decorationTypes.forEach((t) => t.dispose());
// Dispose must not be not called multiple times if initialize is
// called again.
this.decorationTypes = [];
}
// This function must be called at least once or no highlightings will be
// done. Sets the theme that is used when highlighting. Also triggers a
// recolorization for all current highlighters. Should be called whenever the
// theme changes and has been loaded. Should also be called when the first
// theme is loaded.
public initialize(themeRuleMatcher: ThemeRuleMatcher) {
this.decorationTypes.forEach((t) => t.dispose());
this.decorationTypes = this.scopeLookupTable.map((scopes) => {
const options: vscode.DecorationRenderOptions = {
// If there exists no rule for this scope the matcher returns an empty
// color. That's ok because vscode does not do anything when applying
// empty decorations.
color : themeRuleMatcher.getBestThemeRule(scopes[0]).foreground,
// If the rangeBehavior is set to Open in any direction the
// highlighting becomes weird in certain cases.
rangeBehavior : vscode.DecorationRangeBehavior.ClosedClosed,
};
return vscode.window.createTextEditorDecorationType(options);
});
this.getVisibleTextEditorUris().forEach((fileUri) =>
this.applyHighlights(fileUri));
}
// Adds incremental highlightings to the current highlightings for the file
// with fileUri. Also applies the highlightings to any associated
// TextEditor(s).
public highlight(fileUri: string,
highlightingLines: SemanticHighlightingLine[]) {
if (!this.files.has(fileUri)) {
this.files.set(fileUri, new Map());
}
const fileHighlightings = this.files.get(fileUri);
highlightingLines.forEach((line) => fileHighlightings.set(line.line, line));
this.applyHighlights(fileUri);
}
// Applies all the highlightings currently stored for a file with fileUri.
public applyHighlights(fileUri: string) {
if (!this.files.has(fileUri))
// There are no highlightings for this file, must return early or will get
// out of bounds when applying the decorations below.
return;
if (!this.decorationTypes.length)
// Can't apply any decorations when there is no theme loaded.
return;
// This must always do a full re-highlighting due to the fact that
// TextEditorDecorationType are very expensive to create (which makes
// incremental updates infeasible). For this reason one
// TextEditorDecorationType is used per scope.
const ranges = this.getDecorationRanges(fileUri);
vscode.window.visibleTextEditors.forEach((e) => {
if (e.document.uri.toString() !== fileUri)
return;
this.decorationTypes.forEach((d, i) => e.setDecorations(d, ranges[i]));
});
}
// Called when a text document is closed. Removes any highlighting entries for
// the text document that was closed.
public removeFileHighlightings(fileUri: string) {
// If there exists no entry the call to delete just returns false.
this.files.delete(fileUri);
}
// Gets the uris as strings for the currently visible text editors.
protected getVisibleTextEditorUris(): string[] {
return vscode.window.visibleTextEditors.map((e) =>
e.document.uri.toString());
}
// Returns the ranges that should be used when decorating. Index i in the
// range array has the decoration type at index i of this.decorationTypes.
protected getDecorationRanges(fileUri: string): vscode.Range[][] {
if (!this.files.has(fileUri))
// this.files should always have an entry for fileUri if we are here. But
// if there isn't one we don't want to crash the extension. This is also
// useful for tests.
return [];
const lines: SemanticHighlightingLine[] =
Array.from(this.files.get(fileUri).values());
const decorations: vscode.Range[][] = this.decorationTypes.map(() => []);
lines.forEach((line) => {
line.tokens.forEach((token) => {
decorations[token.scopeIndex].push(new vscode.Range(
new vscode.Position(line.line, token.character),
new vscode.Position(line.line, token.character + token.length)));
});
});
return decorations;
}
}
// A rule for how to color TextMate scopes.
interface TokenColorRule {
// A TextMate scope that specifies the context of the token, e.g.
// "entity.name.function.cpp".
scope: string;
// foreground is the color tokens of this scope should have.
foreground: string;
}
export class ThemeRuleMatcher {
// The rules for the theme.
private themeRules: TokenColorRule[];
// A cache for the getBestThemeRule function.
private bestRuleCache: Map<string, TokenColorRule> = new Map();
constructor(rules: TokenColorRule[]) { this.themeRules = rules; }
// Returns the best rule for a scope.
getBestThemeRule(scope: string): TokenColorRule {
if (this.bestRuleCache.has(scope))
return this.bestRuleCache.get(scope);
let bestRule: TokenColorRule = {scope : '', foreground : ''};
this.themeRules.forEach((rule) => {
// The best rule for a scope is the rule that is the longest prefix of the
// scope (unless a perfect match exists in which case the perfect match is
// the best). If a rule is not a prefix and we tried to match with longest
// common prefix instead variables would be highlighted as `less`
// variables when using Light+ (as variable.other would be matched against
// variable.other.less in this case). Doing common prefix matching also
// means we could match variable.cpp to variable.css if variable.css
// occurs before variable in themeRules.
// FIXME: This is not defined in the TextMate standard (it is explicitly
// undefined, https://macromates.com/manual/en/scope_selectors). Might
// want to rank some other way.
if (scope.startsWith(rule.scope) &&
rule.scope.length > bestRule.scope.length)
// This rule matches and is more specific than the old rule.
bestRule = rule;
});
this.bestRuleCache.set(scope, bestRule);
return bestRule;
}
}
// Get all token color rules provided by the theme.
function loadTheme(themeName: string): Promise<TokenColorRule[]> {
const extension =
vscode.extensions.all.find((extension: vscode.Extension<any>) => {
const contribs = extension.packageJSON.contributes;
if (!contribs || !contribs.themes)
return false;
return contribs.themes.some((theme: any) => theme.id === themeName ||
theme.label === themeName);
});
if (!extension) {
return Promise.reject('Could not find a theme with name: ' + themeName);
}
const themeInfo = extension.packageJSON.contributes.themes.find(
(theme: any) => theme.id === themeName || theme.label === themeName);
return parseThemeFile(path.join(extension.extensionPath, themeInfo.path));
}
/**
* Parse the TextMate theme at fullPath. If there are multiple TextMate scopes
* of the same name in the include chain only the earliest entry of the scope is
* saved.
* @param fullPath The absolute path to the theme.
* @param seenScopes A set containing the name of the scopes that have already
* been set.
*/
export async function parseThemeFile(
fullPath: string, seenScopes?: Set<string>): Promise<TokenColorRule[]> {
if (!seenScopes)
seenScopes = new Set();
// FIXME: Add support for themes written as .tmTheme.
if (path.extname(fullPath) === '.tmTheme')
return [];
try {
const contents = await readFileText(fullPath);
const parsed = jsonc.parse(contents);
const rules: TokenColorRule[] = [];
// To make sure it does not crash if tokenColors is undefined.
if (!parsed.tokenColors)
parsed.tokenColors = [];
parsed.tokenColors.forEach((rule: any) => {
if (!rule.scope || !rule.settings || !rule.settings.foreground)
return;
const textColor = rule.settings.foreground;
// Scopes that were found further up the TextMate chain should not be
// overwritten.
const addColor = (scope: string) => {
if (seenScopes.has(scope))
return;
rules.push({scope, foreground : textColor});
seenScopes.add(scope);
};
if (rule.scope instanceof Array) {
return rule.scope.forEach((s: string) => addColor(s));
}
addColor(rule.scope);
});
if (parsed.include)
// Get all includes and merge into a flat list of parsed json.
return [
...(await parseThemeFile(
path.join(path.dirname(fullPath), parsed.include), seenScopes)),
...rules
];
return rules;
} catch (err) {
// If there is an error opening a file, the TextMate files that were
// correctly found and parsed further up the chain should be returned.
// Otherwise there will be no highlightings at all.
console.warn('Could not open file: ' + fullPath + ', error: ', err);
}
return [];
}
function readFileText(path: string): Promise<string> {
return new Promise((resolve, reject) => {
fs.readFile(path, 'utf8', (err, data) => {
if (err) {
return reject(err);
}
return resolve(data);
});
});
}