From a117c2f98003ad7f458e89740fedad5a9e4775f1 Mon Sep 17 00:00:00 2001 From: Haojian Wu <hokein.wu@gmail.com> Date: Fri, 7 Jan 2022 14:21:58 +0100 Subject: [PATCH] Remove legacy semantic-highlighting client code. The legacy semantic-highlighting support has been dropped since clangd13. --- package.json | 3 +- src/clangd-context.ts | 3 - src/semantic-highlighting.ts | 405 ----------------------------- test/semantic-highlighting.test.ts | 173 ------------ 4 files changed, 2 insertions(+), 582 deletions(-) delete mode 100644 src/semantic-highlighting.ts delete mode 100644 test/semantic-highlighting.test.ts diff --git a/package.json b/package.json index 91526456..7390fe89 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,8 @@ "clangd.semanticHighlighting": { "type": "boolean", "default": true, - "description": "Enable semantic highlighting in clangd." + "description": "Enable semantic highlighting in clangd.", + "deprecationMessage": "Legacy semanticHighlights is no longer supported. Please use `editor.semanticHighlighting.enabled` instead." }, "clangd.fallbackFlags": { "type": "array", diff --git a/src/clangd-context.ts b/src/clangd-context.ts index 0f0f6426..09a6e39f 100644 --- a/src/clangd-context.ts +++ b/src/clangd-context.ts @@ -9,7 +9,6 @@ import * as inlayHints from './inlay-hints'; import * as install from './install'; import * as memoryUsage from './memory-usage'; import * as openConfig from './open-config'; -import * as semanticHighlighting from './semantic-highlighting'; import * as switchSourceHeader from './switch-source-header'; import * as typeHierarchy from './type-hierarchy'; @@ -151,8 +150,6 @@ export class ClangdContext implements vscode.Disposable { this.client.createDefaultErrorHandler( // max restart count config.get<boolean>('restartAfterCrash') ? /*default*/ 4 : 0); - if (config.get<boolean>('semanticHighlighting')) - semanticHighlighting.activate(this); this.client.registerFeature(new EnableEditsNearCursorFeature); typeHierarchy.activate(this); inlayHints.activate(this); diff --git a/src/semantic-highlighting.ts b/src/semantic-highlighting.ts deleted file mode 100644 index e4a69437..00000000 --- a/src/semantic-highlighting.ts +++ /dev/null @@ -1,405 +0,0 @@ -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/node'; -import * as vscodelct from 'vscode-languageserver-types'; - -import {ClangdContext} from './clangd-context'; - -export function activate(context: ClangdContext) { - const feature = new SemanticHighlightingFeature(context); - context.subscriptions.push(feature); - context.client.registerFeature(feature); -} - -// 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>( - '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(context: ClangdContext) { - context.subscriptions.push(context.client.onDidChangeState(({newState}) => { - if (newState === vscodelc.State.Running) { - // Register handler for semantic highlighting notification. - context.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)))); - this.subscriptions.push(vscode.workspace.onDidCloseTextDocument( - (doc) => this.highlighter.removeFileHighlightings(doc.uri))); - } - - handleNotification(params: SemanticHighlightingParams) { - const lines: SemanticHighlightingLine[] = params.lines.map( - (line) => ({line: line.line, tokens: decodeTokens(line.tokens ?? '')})); - this.highlighter.highlight(vscode.Uri.parse(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: vscode.Uri, - highlightingLines: SemanticHighlightingLine[]) { - const fileUriStr = fileUri.toString(); - if (!this.files.has(fileUriStr)) { - this.files.set(fileUriStr, new Map()); - } - const fileHighlightings = this.files.get(fileUriStr)!; - 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: vscode.Uri) { - const fileUriStr = fileUri.toString(); - if (!this.files.has(fileUriStr)) - // 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() !== fileUriStr) - 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: vscode.Uri) { - // If there exists no entry the call to delete just returns false. - this.files.delete(fileUri.toString()); - } - - // Gets the uris as strings for the currently visible text editors. - protected getVisibleTextEditorUris(): vscode.Uri[] { - return vscode.window.visibleTextEditors.map((e) => e.document.uri); - } - - // 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: vscode.Uri): vscode.Range[][] { - const fileUriStr = fileUri.toString(); - if (!this.files.has(fileUriStr)) - // 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(fileUriStr)!.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> = - new Set()): Promise<TokenColorRule[]> { - // 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); - }); - }); -} diff --git a/test/semantic-highlighting.test.ts b/test/semantic-highlighting.test.ts deleted file mode 100644 index 7c56ca8f..00000000 --- a/test/semantic-highlighting.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import * as assert from 'assert'; -import * as path from 'path'; -import * as vscode from 'vscode'; - -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 semanticHighlighting.parseThemeFile(themePath); - const getScopeRule = (scope: string) => - scopeColorRules.find((v) => v.scope === scope); - assert.equal(scopeColorRules.length, 3); - assert.deepEqual(getScopeRule('a'), {scope: 'a', foreground: '#fff'}); - assert.deepEqual(getScopeRule('b'), {scope: 'b', foreground: '#000'}); - assert.deepEqual(getScopeRule('c'), {scope: 'c', foreground: '#bcd'}); - }); - test('Decodes tokens correctly', () => { - const testCases: string[] = [ - 'AAAAAAABAAA=', 'AAAAAAADAAkAAAAEAAEAAA==', - 'AAAAAAADAAkAAAAEAAEAAAAAAAoAAQAA' - ]; - const expected = [ - [{character: 0, scopeIndex: 0, length: 1}], - [ - {character: 0, scopeIndex: 9, length: 3}, - {character: 4, scopeIndex: 0, length: 1} - ], - [ - {character: 0, scopeIndex: 9, length: 3}, - {character: 4, scopeIndex: 0, length: 1}, - {character: 10, scopeIndex: 0, length: 1} - ] - ]; - testCases.forEach( - (testCase, i) => assert.deepEqual( - semanticHighlighting.decodeTokens(testCase), expected[i])); - }); - test('ScopeRules overrides for more specific themes', () => { - const rules = [ - {scope: 'variable.other.css', foreground: '1'}, - {scope: 'variable.other', foreground: '2'}, - {scope: 'storage', foreground: '3'}, - {scope: 'storage.static', foreground: '4'}, - {scope: 'storage', foreground: '5'}, - {scope: 'variable.other.parameter', foreground: '6'}, - ]; - const tm = new semanticHighlighting.ThemeRuleMatcher(rules); - assert.deepEqual(tm.getBestThemeRule('variable.other.cpp').scope, - 'variable.other'); - assert.deepEqual(tm.getBestThemeRule('storage.static').scope, - 'storage.static'); - assert.deepEqual( - tm.getBestThemeRule('storage'), - rules[2]); // Match the first element if there are duplicates. - assert.deepEqual(tm.getBestThemeRule('variable.other.parameter').scope, - 'variable.other.parameter'); - assert.deepEqual(tm.getBestThemeRule('variable.other.parameter.cpp').scope, - 'variable.other.parameter'); - }); - test('Colorizer groups decorations correctly', async () => { - 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(new vscode.Range( - new vscode.Position(line.line, token.character), - new vscode.Position(line.line, - token.character + token.length))); - }); - }); - return scopeRanges; - }; - - const fileUri1 = vscode.Uri.parse('file:///file1'); - const fileUri2 = vscode.Uri.parse('file:///file2'); - const fileUri1Str = fileUri1.toString(); - const fileUri2Str = fileUri2.toString(); - - 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: vscode.Uri) { - this.applicationUriHistory.push(fileUri.toString()); - } - // Override to make it accessible from the test. - getDecorationRanges(fileUri: vscode.Uri) { - return super.getDecorationRanges(fileUri); - } - // Override to make tests not depend on visible text editors. - getVisibleTextEditorUris() { return [fileUri1, fileUri2]; } - } - const highlighter = new MockHighlighter(scopeTable); - const tm = new semanticHighlighting.ThemeRuleMatcher([ - {scope: 'variable', foreground: '1'}, - {scope: 'entity.type', foreground: '2'}, - ]); - // Recolorizes when initialized. - highlighter.highlight(fileUri1, []); - assert.deepEqual(highlighter.applicationUriHistory, [fileUri1Str]); - highlighter.initialize(tm); - assert.deepEqual(highlighter.applicationUriHistory, - [fileUri1Str, fileUri1Str, fileUri2Str]); - // 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(fileUri1, highlightingsInLine); - assert.deepEqual(highlighter.applicationUriHistory, - [fileUri1Str, fileUri1Str, fileUri2Str, fileUri1Str]); - assert.deepEqual(highlighter.getDecorationRanges(fileUri1), - createHighlightingScopeRanges(highlightingsInLine)); - // Keeps state separate between files. - const highlightingsInLine1: - semanticHighlighting.SemanticHighlightingLine = { - line: 1, - tokens: [ - {character: 2, length: 1, scopeIndex: 0}, - ] - }; - highlighter.highlight(fileUri2, [highlightingsInLine1]); - assert.deepEqual( - highlighter.applicationUriHistory, - [fileUri1Str, fileUri1Str, fileUri2Str, fileUri1Str, fileUri2Str]); - assert.deepEqual(highlighter.getDecorationRanges(fileUri2), - createHighlightingScopeRanges([highlightingsInLine1])); - // Does full colorizations. - highlighter.highlight(fileUri1, [highlightingsInLine1]); - assert.deepEqual(highlighter.applicationUriHistory, [ - fileUri1Str, fileUri1Str, fileUri2Str, fileUri1Str, fileUri2Str, - fileUri1Str - ]); - // After the incremental update to line 1, the old highlightings at line 1 - // will no longer exist in the array. - assert.deepEqual( - highlighter.getDecorationRanges(fileUri1), - createHighlightingScopeRanges( - [highlightingsInLine1, ...highlightingsInLine.slice(1)])); - // Closing a text document removes all highlightings for the file and no - // other files. - highlighter.removeFileHighlightings(fileUri1); - assert.deepEqual(highlighter.getDecorationRanges(fileUri1), []); - assert.deepEqual(highlighter.getDecorationRanges(fileUri2), - createHighlightingScopeRanges([highlightingsInLine1])); - }); -});