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.initialize(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(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.highlight(params.textDocument.uri, lines); + } } // Converts a string of base64 encoded tokens into the corresponding array of @@ -101,6 +119,88 @@ 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> = new Map(); + // DecorationTypes for the current theme that are used when highlighting. + private decorationTypes: vscode.TextEditorDecorationType[]; + // The clangd TextMate scope lookup table. + private scopeLookupTable: string[][]; + constructor(scopeLookupTable: string[][]) { + this.scopeLookupTable = scopeLookupTable; + } + // Update the themeRuleMatcher that is used when highlighting. Also triggers a + // recolorization for all current highlighters. Safe to call multiple times. + public initialize(themeRuleMatcher: ThemeRuleMatcher) { + this.decorationTypes = this.scopeLookupTable.map((scopes) => { + const options: vscode.DecorationRenderOptions = { + 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) => { + // A TextEditor might not be a cpp file. So we must check we have + // highlightings for the file before applying them. + if (this.files.has(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, tokens: SemanticHighlightingLine[]) { + if (!this.files.has(fileUri)) { + this.files.set(fileUri, new Map()); + } + const fileHighlightings = this.files.get(fileUri); + tokens.forEach((line) => fileHighlightings.set(line.line, line)); + this.applyHighlights(fileUri); + } + + // Exists to make the initialize method testable. + protected getVisibleTextEditorUris() { + 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[][] { + 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; + } + + applyHighlights(fileUri: string) { + if (!this.decorationTypes) + 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])); + }); + } +} + // 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,71 @@ assert.deepEqual(tm.getBestThemeRule('variable.other.parameter.cpp').scope, 'variable.other.parameter'); }); + test('Colorizer groups decorations correctly', async () => { + // 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'}, + ]; + class MockHighlighter extends SM.Highlighter { + applicationUriHistory: string[] = []; + applyHighlights(fileUri: string) { + this.applicationUriHistory.push(fileUri); + } + getDecorationRanges(fileUri: string) { + return super.getDecorationRanges(fileUri); + } + getVisibleTextEditorUris() { return [ 'a', 'b' ]; } + } + const highlighter = new MockHighlighter(scopeTable); + const tm = new SM.ThemeRuleMatcher(rules); + // Recolorizes when initialized. + highlighter.highlight('a', []); + assert.deepEqual(highlighter.applicationUriHistory, [ 'a' ]); + highlighter.initialize(tm); + assert.deepEqual(highlighter.applicationUriHistory, [ 'a', '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} + ]; + highlighter.highlight( + 'a', [ {line : 1, tokens : line}, {line : 2, tokens : line} ]); + assert.deepEqual(highlighter.applicationUriHistory, [ 'a', 'a', 'a' ]); + assert.deepEqual(highlighter.getDecorationRanges('a'), [ + [], + [ + createRange(1, 1, 2), createRange(1, 5, 2), createRange(2, 1, 2), + createRange(2, 5, 2) + ], + [ createRange(1, 10, 2), createRange(2, 10, 2) ], + ]); + // Keeps state separate between files. + highlighter.highlight('b', [ + {line : 1, tokens : [ {character : 1, length : 1, scopeIndex : 0} ]} + ]); + assert.deepEqual(highlighter.applicationUriHistory, [ 'a', 'a', 'a', 'b' ]); + assert.deepEqual(highlighter.getDecorationRanges('b'), + [ [ createRange(1, 1, 1) ], [], [] ]); + // Does full colorizations. + highlighter.highlight('a', [ + {line : 1, tokens : [ {character : 2, length : 1, scopeIndex : 0} ]} + ]); + assert.deepEqual(highlighter.applicationUriHistory, + [ 'a', 'a', 'a', 'b', 'a' ]); + assert.deepEqual(highlighter.getDecorationRanges('a'), [ + [ createRange(1, 2, 1) ], + [ createRange(2, 1, 2), createRange(2, 5, 2) ], + [ createRange(2, 10, 2) ], + ]); + }); });