diff --git a/clang-tools-extra/clangd/clients/clangd-vscode/src/extension.ts b/clang-tools-extra/clangd/clients/clangd-vscode/src/extension.ts --- a/clang-tools-extra/clangd/clients/clangd-vscode/src/extension.ts +++ b/clang-tools-extra/clangd/clients/clangd-vscode/src/extension.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import * as vscodelc from 'vscode-languageclient'; - +import * as SM from './semantic-highlighting'; /** * Method to get workspace configuration option * @param option name of the option (e.g. for clangd.path should be path) @@ -91,6 +91,16 @@ const clangdClient = new vscodelc.LanguageClient( 'Clang Language Server', serverOptions, clientOptions); + const semanticHighlightingFeature = new SM.SemanticHighlightingFeature(); + clangdClient.registerFeature(semanticHighlightingFeature); + // The notification handler must be registered after the client is ready or + // the client will crash. + clangdClient.onReady().then( + () => clangdClient.onNotification( + SM.NotificationType, + semanticHighlightingFeature.handleNotification.bind( + semanticHighlightingFeature))); + console.log('Clang Language Server is now active!'); context.subscriptions.push(clangdClient.start()); context.subscriptions.push(vscode.commands.registerCommand( 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 applies the highlightings clangd sends. + highlighter: Highlighter; 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.highlighter.updateThemeRuleMatcher(this.themeRuleMatcher); } initialize(capabilities: vscodelc.ServerCapabilities, @@ -76,10 +86,18 @@ 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(FileHighlighter, 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.highlighter.setFileLines(params.textDocument.uri, lines); + } } // Converts a string of base64 encoded tokens into the corresponding array of @@ -101,6 +119,134 @@ 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[]; +} + +// Applies highlightings to text editors. +export class FileHighlighter { + // The decoration datatypes used last time highlight was called. + private oldDecorations: DecorationRangePair[] = []; + // Apply decorations to the textEditor with uri and remove the old ones. + public highlight(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()); + } +} + +// 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> = new Map(); + // Maps uris with currently open TextEditors to a FileHighlighter. + private highlighters: Map = new Map(); + // The matcher controling how tokens are highlighted. + private themeRuleMatcher: ThemeRuleMatcher; + // The clangd TextMate scope lookup table. + private scopeLookupTable: string[][]; + // The FileHighlighter type that should be used when applying new + // highlightings. + private HighlighterType: {new(): FileHighlighter;}; + constructor(HighlighterType: {new(): FileHighlighter;}, + scopeLookupTable: string[][]) { + this.HighlighterType = HighlighterType; + this.scopeLookupTable = scopeLookupTable; + } + // Update the themeRuleMatcher that is used when highlighting. Also triggers a + // recolorization for all current highlighters. + public updateThemeRuleMatcher(themeRuleMatcher: ThemeRuleMatcher) { + this.themeRuleMatcher = themeRuleMatcher; + Array.from(this.highlighters.keys()).forEach((uri) => this.highlight(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 highlighter and a file + // container are created. + if (!this.files.has(uriString)) { + this.files.set(uriString, new Map()); + this.highlighters.set(uriString, new this.HighlighterType()); + } + + const fileHighlightings = this.files.get(uriString); + // FIXME: If the number of lines in the file decreased the old highlightings + // outside the file will still exist in the file cache. + tokens.forEach((line) => fileHighlightings.set(line.line, line)); + this.highlight(uriString); + } + + // Applies all highlightings to the file with uri uriString. + private highlight(uriString: string) { + if (!this.highlighters.has(uriString)) { + this.highlighters.set(uriString, new this.HighlighterType()); + } + + // Can't highlight if there is no matcher. When a matcher is set a + // highlighting will be triggered. So it's ok to simply return here. + if (!this.themeRuleMatcher) { + 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. + // 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 + // highlighting 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.highlighters.get(uriString).highlight( + uriString, Array.from(decorations.values())); + } +} + // 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,72 @@ 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 extends SM.FileHighlighter { + public highlight(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.Highlighter(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) ]); + }); });