| 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'; |
| import * as vscodelct from 'vscode-languageserver-types'; |
| |
| // 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. |
| export const NotificationType = |
| new vscodelc.NotificationType<SemanticHighlightingParams, void>( |
| '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; |
| 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.loadCurrentTheme(); |
| // Event handling for handling with TextDocuments/Editors lifetimes. |
| vscode.window.onDidChangeVisibleTextEditors( |
| (editors: vscode.TextEditor[]) => |
| editors.forEach((e) => this.highlighter.applyHighlights( |
| e.document.uri.toString()))); |
| vscode.workspace.onDidCloseTextDocument( |
| (doc) => this.highlighter.removeFileHighlightings(doc.uri.toString())); |
| } |
| |
| 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 |
| // 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; |
| } |
| // 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: 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); |
| } |
| |
| // Called when a text document is closed. Removes any highlighting entries for |
| // the text document that was closed. |
| public removeFileHighlightings(fileUri: string) { |
| // If there exists no entry the call to delete just returns false. |
| this.files.delete(fileUri); |
| } |
| |
| // Gets the uris as strings for the currently visible text editors. |
| protected getVisibleTextEditorUris(): string[] { |
| 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[][] { |
| if (!this.files.has(fileUri)) |
| // 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(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. |
| public applyHighlights(fileUri: string) { |
| if (!this.files.has(fileUri)) |
| // 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() !== 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. |
| // "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>): Promise<TokenColorRule[]> { |
| if (!seenScopes) |
| seenScopes = new Set(); |
| // 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); |
| }); |
| }); |
| } |