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,5 +1,6 @@ import * as vscode from 'vscode'; import * as vscodelc from 'vscode-languageclient'; +import * as semanticHighlighting from './semantic-highlighting'; /** * Method to get workspace configuration option @@ -91,6 +92,17 @@ const clangdClient = new vscodelc.LanguageClient( 'Clang Language Server', serverOptions, clientOptions); + const semanticHighlightingFeature = + new semanticHighlighting.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( + semanticHighlighting.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. +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. @@ -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,94 @@ 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. 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; + } + // 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.forEach((t) => t.dispose()); + 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, + 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); + } + + // 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; + } + + // Applies all the highlightings currently stored for a file with fileUri. + protected applyHighlights(fileUri: string) { + 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])); + }); + } +} + // 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,13 +1,15 @@ import * as assert from 'assert'; import * as path from 'path'; +import * as vscode from 'vscode'; -import * as SM from '../src/semantic-highlighting'; +import * as semanticHighlighting from '../src/semantic-highlighting'; suite('SemanticHighlighting Tests', () => { test('Parses arrays of textmate themes.', async () => { const themePath = path.join(__dirname, '../../test/assets/includeTheme.jsonc'); - const scopeColorRules = await SM.parseThemeFile(themePath); + const scopeColorRules = + await semanticHighlighting.parseThemeFile(themePath); const getScopeRule = (scope: string) => scopeColorRules.find((v) => v.scope === scope); assert.equal(scopeColorRules.length, 3); @@ -32,8 +34,9 @@ {character : 10, scopeIndex : 0, length : 1} ] ]; - testCases.forEach((testCase, i) => assert.deepEqual( - SM.decodeTokens(testCase), expected[i])); + testCases.forEach( + (testCase, i) => assert.deepEqual( + semanticHighlighting.decodeTokens(testCase), expected[i])); }); test('ScopeRules overrides for more specific themes', () => { const rules = [ @@ -44,7 +47,7 @@ {scope : 'storage', foreground : '5'}, {scope : 'variable.other.parameter', foreground : '6'}, ]; - const tm = new SM.ThemeRuleMatcher(rules); + const tm = new semanticHighlighting.ThemeRuleMatcher(rules); assert.deepEqual(tm.getBestThemeRule('variable.other.cpp').scope, 'variable.other'); assert.deepEqual(tm.getBestThemeRule('storage.static').scope, @@ -57,4 +60,103 @@ 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' ] + ]; + // Create the scope source ranges the highlightings should be highlighted + // at. Assumes the scopes used are the ones in the "scopeTable" variable. + const createHighlightingScopeRanges = + (highlightingLines: + semanticHighlighting.SemanticHighlightingLine[]) => { + // Initialize the scope ranges list to the correct size. Otherwise + // scopes that don't have any highlightings are missed. + let scopeRanges: vscode.Range[][] = scopeTable.map(() => []); + highlightingLines.forEach((line) => { + line.tokens.forEach((token) => { + scopeRanges[token.scopeIndex].push( + createRange(line.line, token.character, token.length)); + }); + }); + return scopeRanges; + }; + + const rules = [ + {scope : 'variable', foreground : '1'}, + {scope : 'entity.type', foreground : '2'}, + ]; + class MockHighlighter extends semanticHighlighting.Highlighter { + applicationUriHistory: string[] = []; + // Override to make the highlighting calls accessible to the test. Also makes the test not depend on visible text editors. + applyHighlights(fileUri: string) { + this.applicationUriHistory.push(fileUri); + } + // Override to make it accessible from the test. + getDecorationRanges(fileUri: string) { + return super.getDecorationRanges(fileUri); + } + // Override to make tests not depend on visible text editors. + getVisibleTextEditorUris() { return [ 'a', 'b' ]; } + } + const highlighter = new MockHighlighter(scopeTable); + const tm = new semanticHighlighting.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 highlightingsInLine: semanticHighlighting.SemanticHighlightingLine[] = [ + { + line : 1, + tokens : [ + {character : 1, length : 2, scopeIndex : 1}, + {character : 10, length : 2, scopeIndex : 2}, + ] + }, + { + line : 2, + tokens : [ + {character : 3, length : 2, scopeIndex : 1}, + {character : 6, length : 2, scopeIndex : 1}, + {character : 8, length : 2, scopeIndex : 2}, + ] + }, + ]; + + highlighter.highlight('a', highlightingsInLine); + assert.deepEqual(highlighter.applicationUriHistory, [ 'a', 'a', 'a' ]); + assert.deepEqual(highlighter.getDecorationRanges('a'), + createHighlightingScopeRanges(highlightingsInLine)); + // Keeps state separate between files. + const smallHighlightingsInLine: + semanticHighlighting.SemanticHighlightingLine[] = [ + { + line : 1, + tokens : [ + {character : 2, length : 1, scopeIndex : 0}, + ] + }, + ]; + highlighter.highlight('b', smallHighlightingsInLine); + assert.deepEqual(highlighter.applicationUriHistory, [ 'a', 'a', 'a', 'b' ]); + assert.deepEqual(highlighter.getDecorationRanges('b'), + createHighlightingScopeRanges(smallHighlightingsInLine)); + // Does full colorizations. + highlighter.highlight('a', smallHighlightingsInLine); + assert.deepEqual(highlighter.applicationUriHistory, + [ 'a', 'a', 'a', 'b', 'a' ]); + // After the incremental update to line 1, the old highlightings at line 1 + // will no longer exist in the array. + assert.deepEqual( + highlighter.getDecorationRanges('a'), + createHighlightingScopeRanges( + [...highlightingsInLine.slice(1), ...smallHighlightingsInLine ])); + }); });