diff --git a/clang-tools-extra/clangd/clients/clangd-vscode/src/semantic-highlighting.ts b/clang-tools-extra/clangd/clients/clangd-vscode/src/semantic-highlighting.ts --- a/clang-tools-extra/clangd/clients/clangd-vscode/src/semantic-highlighting.ts +++ b/clang-tools-extra/clangd/clients/clangd-vscode/src/semantic-highlighting.ts @@ -34,6 +34,13 @@ // The TextMate scope index to the clangd scope lookup table. scopeIndex: number; } +// A line of decoded highlightings from the data clangd sent. +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. @@ -49,6 +56,8 @@ scopeLookupTable: string[][]; // The rules for the current theme. themeRuleMatcher: ThemeRuleMatcher; + // The object that colorizes the highlightings clangd sends. + colorizer: Colorizer; fillClientCapabilities(capabilities: vscodelc.ClientCapabilities) { // Extend the ClientCapabilities type and add semantic highlighting // capability to the object. @@ -64,6 +73,7 @@ this.themeRuleMatcher = new ThemeRuleMatcher( await loadTheme(vscode.workspace.getConfiguration('workbench') .get('colorTheme'))); + this.colorizer.updateThemeRuleMatcher(this.themeRuleMatcher); } initialize(capabilities: vscodelc.ServerCapabilities, @@ -76,10 +86,19 @@ if (!serverCapabilities.semanticHighlighting) return; this.scopeLookupTable = serverCapabilities.semanticHighlighting.scopes; + // Important that colorizer is created before the theme is loading as + // otherwise it could try to update the themeRuleMatcher without the + // colorizer being created. + this.colorizer = + new Colorizer(FileColorizer, this.scopeLookupTable); this.loadCurrentTheme(); } - handleNotification(params: SemanticHighlightingParams) {} + handleNotification(params: SemanticHighlightingParams) { + const lines: SemanticHighlightingLine[] = params.lines.map( + (line) => ({line : line.line, tokens : decodeTokens(line.tokens)})); + this.colorizer.setFileLines(params.textDocument.uri, lines); + } } // Converts a string of base64 encoded tokens into the corresponding array of @@ -101,6 +120,136 @@ return retTokens; } +// A collection of ranges where all ranges should be decorated with the same +// decoration. +interface DecorationRangePair { + // The decoration to apply to the ranges. + decoration: vscode.TextEditorDecorationType; + // The ranges that should be decorated. + ranges: vscode.Range[]; +} +// A simple interface of a class that applies decorations to make the Colorizer +// to be testable. +interface IFileColorizer { + // Function that is called whenever new decorations should be applied. Must be + // called with all decorations that should be visible in the TextEditor + // responsible for uriString currently. + colorize: (uriString: string, + decorationRangePairs: DecorationRangePair[]) => void; + // Called when any old decorations should be removed. + dispose: () => void; +} +// The main class responsible for colorization of highlightings that clangd +// sends. +export class Colorizer { + private files: Map> = new Map(); + private colorizers: Map = new Map(); + private themeRuleMatcher: ThemeRuleMatcher; + private scopeLookupTable: string[][]; + private ColorizerType: {new(): IFileColorizer;}; + constructor(ColorizerType: {new(): IFileColorizer;}, + scopeLookupTable: string[][]) { + this.ColorizerType = ColorizerType; + this.scopeLookupTable = scopeLookupTable; + } + /// Update the themeRuleMatcher that is used when colorizing. Also triggers a + /// recolorization for all current colorizers. + public updateThemeRuleMatcher(themeRuleMatcher: ThemeRuleMatcher) { + this.themeRuleMatcher = themeRuleMatcher; + Array.from(this.colorizers.keys()).forEach((uri) => this.colorize(uri)); + } + + // Called when clangd sends an incremental update of highlightings. + public setFileLines(uriString: string, tokens: SemanticHighlightingLine[]) { + // Patch in the new highlightings to the highlightings cache. If this is the + // first time the file should be highlighted a new colorizer and a file + // container are created. + if (!this.files.has(uriString)) { + this.files.set(uriString, new Map()); + this.colorizers.set(uriString, new this.ColorizerType()); + } + + const fileHighlightings = this.files.get(uriString); + tokens.forEach((line) => fileHighlightings.set(line.line, line)); + this.colorize(uriString); + } + + // Applies all highlightings to the file with uri uriString. + private colorize(uriString: string) { + if (!this.colorizers.has(uriString)) { + this.colorizers.set(uriString, new this.ColorizerType()); + } + + // Can't colorize if there is no matcher. When a matcher is set a + // colorization will be triggered. So it's ok to simply return here. + if (!this.themeRuleMatcher) { + return; + } + // This must always do a full re-colorization 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. + // FIXME: It might be faster to cache the TextEditorDecorationTypes and only + // update the ranges for them. + const lines: SemanticHighlightingLine[] = []; + Array.from(this.files.get(uriString).values()) + .forEach((line) => lines.push(line)); + // Maps scopeIndexes -> the DecorationRangePair used for the scope. + const decorations: Map = new Map(); + lines.forEach((line) => { + line.tokens.forEach((token) => { + if (!decorations.has(token.scopeIndex)) { + const options: vscode.DecorationRenderOptions = { + color : this.themeRuleMatcher + .getBestThemeRule( + this.scopeLookupTable[token.scopeIndex][0]) + .foreground, + // If the rangeBehavior is set to Open in any direction the + // colorization becomes weird in certain cases. + rangeBehavior : vscode.DecorationRangeBehavior.ClosedClosed, + }; + decorations.set(token.scopeIndex, { + decoration : vscode.window.createTextEditorDecorationType(options), + ranges : [] + }); + } + decorations.get(token.scopeIndex) + .ranges.push(new vscode.Range( + new vscode.Position(line.line, token.character), + new vscode.Position(line.line, + token.character + token.length))); + }); + }); + + this.colorizers.get(uriString).colorize(uriString, + Array.from(decorations.values())); + } +} + +// Implementation of IFileColorizer to colorize text editors. +class FileColorizer implements IFileColorizer { + // The decoration datatypes used last time a colorization was triggered. + private oldDecorations: DecorationRangePair[] = []; + // Apply decorations to the textEditor with uri. + public colorize(uri: string, decorationRangePairs: DecorationRangePair[]) { + vscode.window.visibleTextEditors.forEach((e) => { + if (e.document.uri.toString() !== uri) { + return; + } + decorationRangePairs.forEach( + (dp) => { e.setDecorations(dp.decoration, dp.ranges); }); + }); + // Clear the old decoration after the new ones have already been applied as + // otherwise there might be flicker. + this.dispose(); + this.oldDecorations = decorationRangePairs; + } + public dispose() { + this.oldDecorations.forEach((decorations) => + decorations.decoration.dispose()); + } +} + // A rule for how to color TextMate scopes. interface TokenColorRule { // A TextMate scope that specifies the context of the token, e.g. diff --git a/clang-tools-extra/clangd/clients/clangd-vscode/test/semantic-highlighting.test.ts b/clang-tools-extra/clangd/clients/clangd-vscode/test/semantic-highlighting.test.ts --- a/clang-tools-extra/clangd/clients/clangd-vscode/test/semantic-highlighting.test.ts +++ b/clang-tools-extra/clangd/clients/clangd-vscode/test/semantic-highlighting.test.ts @@ -1,5 +1,6 @@ import * as assert from 'assert'; import * as path from 'path'; +import * as vscode from 'vscode'; import * as SM from '../src/semantic-highlighting'; @@ -57,4 +58,73 @@ assert.deepEqual(tm.getBestThemeRule('variable.other.parameter.cpp').scope, 'variable.other.parameter'); }); + test('Colorizer groups decorations correctly', () => { + const colorizations: {uri: string, decorations: any[]}[] = []; + // Mock of a colorizer that saves the parameters in the colorizations array. + class MockFileColorizer { + public colorize(uri: string, decorationRangePairs: any[]) { + colorizations.push({uri : uri, decorations : decorationRangePairs}); + } + public dispose() {} + } + // Helper for creating a vscode Range. + const createRange = (line: number, startCharacter: number, + length: number) => + new vscode.Range(new vscode.Position(line, startCharacter), + new vscode.Position(line, startCharacter + length)); + const scopeTable = [ + [ 'variable' ], [ 'entity.type.function' ], + [ 'entity.type.function.method' ] + ]; + const rules = [ + {scope : 'variable', foreground : '1'}, + {scope : 'entity.type', foreground : '2'}, + ]; + const tm = new SM.ThemeRuleMatcher(rules); + const colorizer = + new SM.Colorizer(MockFileColorizer, scopeTable); + // No colorization if themeRuleMatcher has not been set. + colorizer.setFileLines('a', []); + assert.deepEqual(colorizations, []); + colorizer.updateThemeRuleMatcher(tm); + assert.deepEqual(colorizations, [ {decorations : [], uri : 'a'} ]); + // Groups decorations into the scopes used. + let line = [ + {character : 1, length : 2, scopeIndex : 1}, + {character : 5, length : 2, scopeIndex : 1}, + {character : 10, length : 2, scopeIndex : 2} + ]; + colorizer.setFileLines( + 'a', [ {line : 1, tokens : line}, {line : 2, tokens : line} ]); + assert.equal(colorizations[1].uri, 'a'); + assert.equal(colorizations[1].decorations.length, 2); + // Can't test the actual decorations as vscode does not seem to have an api + // for getting the actual decoration objects. + assert.deepEqual(colorizations[1].decorations[0].ranges, [ + createRange(1, 1, 2), createRange(1, 5, 2), createRange(2, 1, 2), + createRange(2, 5, 2) + ]); + assert.deepEqual(colorizations[1].decorations[1].ranges, + [ createRange(1, 10, 2), createRange(2, 10, 2) ]); + // Keeps state separate between files. + colorizer.setFileLines('b', [ + {line : 1, tokens : [ {character : 1, length : 1, scopeIndex : 0} ]} + ]); + assert.equal(colorizations[2].uri, 'b'); + assert.equal(colorizations[2].decorations.length, 1); + assert.deepEqual(colorizations[2].decorations[0].ranges, + [ createRange(1, 1, 1) ]); + // Does full colorizations. + colorizer.setFileLines('a', [ + {line : 1, tokens : [ {character : 2, length : 1, scopeIndex : 0} ]} + ]); + assert.equal(colorizations[3].uri, 'a'); + assert.equal(colorizations[3].decorations.length, 3); + assert.deepEqual(colorizations[3].decorations[0].ranges, + [ createRange(1, 2, 1) ]); + assert.deepEqual(colorizations[3].decorations[1].ranges, + [ createRange(2, 1, 2), createRange(2, 5, 2) ]); + assert.deepEqual(colorizations[3].decorations[2].ranges, + [ createRange(2, 10, 2) ]); + }); });