diff --git a/clang-tools-extra/clangd/clients/clangd-vscode/package.json b/clang-tools-extra/clangd/clients/clangd-vscode/package.json --- a/clang-tools-extra/clangd/clients/clangd-vscode/package.json +++ b/clang-tools-extra/clangd/clients/clangd-vscode/package.json @@ -36,14 +36,15 @@ "test": "node ./node_modules/vscode/bin/test" }, "dependencies": { + "jsonc-parser": "^2.1.0", "vscode-languageclient": "^5.3.0-next.6", "vscode-languageserver": "^5.3.0-next.6" }, "devDependencies": { "@types/mocha": "^2.2.32", "@types/node": "^6.0.40", - "mocha": "^5.2.0", "clang-format": "1.2.4", + "mocha": "^5.2.0", "typescript": "^2.0.3", "vscode": "^1.1.0" }, diff --git a/clang-tools-extra/clangd/clients/clangd-vscode/src/TextMate.ts b/clang-tools-extra/clangd/clients/clangd-vscode/src/TextMate.ts new file mode 100644 --- /dev/null +++ b/clang-tools-extra/clangd/clients/clangd-vscode/src/TextMate.ts @@ -0,0 +1,174 @@ +import * as fs from 'fs'; +import * as jsonc from "jsonc-parser"; +import * as path from 'path'; +import * as vscode from 'vscode'; + +export namespace SemanticHighlighting { +export class TMColors { + // The clangd tm scopes. + private scopes: string[]; + // Mapping from a clangd scope index to a color hex string. + private colors: string[]; + // The current best matching scope for every index. + private colorScopes: string[]; + constructor(scopes: string[]) { + this.scopes = scopes; + this.colors = this.scopes.map(() => '#000'); + this.colorScopes = this.scopes.map(() => ''); + } + + setColor(scope: string|Array, color: string) { + if (scope instanceof Array) { + scope.forEach((s: string) => this.setColor(s, color)); + return; + } + + // Find the associated clangd scope(s) index for this scope. + // If "scope" is a candiate for a clangd scope the clangd scope must have + // "scope" as a prefix. + const allCandidates = + this.scopes.map((s, i) => ({s : s, i : i})) + .filter(({s}) => s.substr(0, scope.length) === scope); + // If this scope is more specific than any of current scopes for the clangd + // scopes it should be replaced. As both options are prefixes of the clangd + // scope it's enough to compare lengths. + allCandidates.forEach(({i}) => { + if (scope.length > this.colorScopes[i].length) { + this.colorScopes[i] = scope; + this.colors[i] = color; + } + }); + } + + getColor(idx: number) { return this.colors[idx]; } +} + +// Singleton for reading/writing TM scopes/colors. +export class TMColorProvider { + private static instance: TMColorProvider = new TMColorProvider(); + private colors: TMColors = undefined; + static get() { return TMColorProvider.instance; } + + setColors(colors: TMColors) { this.colors = colors; } + getColors(): TMColors { return this.colors; } +} + +/** + * @param scopes The TextMate scopes clangd sends on initialize. + * @param cb A callback that is called every time the theme changes and the new + * theme has been loaded (not called on the first load). + */ +export async function setupTMScopes(scopes: string[], + cb: Function): Promise { + let oldThemeName = ''; + async function setTMColors() { + const name = + vscode.workspace.getConfiguration('workbench').get('colorTheme'); + if (typeof name != 'string') { + console.warn('The current theme name is not a string, is: ' + + (typeof name) + ', value: ', + name); + return; + } + + if (oldThemeName == name) { + return; + } + + oldThemeName = name; + TMColorProvider.get().setColors( + await getTextMateColors(scopes, name as string)); + } + + // Initialize the TM scopes and colors. + await setTMColors(); + + // Need to change the color configuration if a theme changes otherwise the + // highlightings will have the wrong colors. + return vscode.workspace.onDidChangeConfiguration( + async (conf: vscode.ConfigurationChangeEvent) => { + if (conf.affectsConfiguration('workbench')) + // Configuration affected the workbench meaning the current theme + // might have changed. + await setTMColors(); + cb(); + }); +} + +// Gets a TM theme with themeName and returns class with the mapping from the +// clangd scope index to a color. +async function getTextMateColors(scopes: string[], + themeName: string): Promise { + const fileContents = await getFullNamedTheme(themeName); + const tmColors = new TMColors(scopes); + fileContents.forEach((content) => { + if (!content.tokenColors) + return; + content.tokenColors.forEach((rule: any) => { + if (!rule.scope || !rule.settings || !rule.settings.foreground) + return; + + tmColors.setColor(rule.scope, rule.settings.foreground); + }); + }); + + return tmColors; +} + +// Gets a TextMate theme by its name and all its included themes. +async function getFullNamedTheme(themeName: string): Promise { + const extension = + vscode.extensions.all.find((extension: vscode.Extension) => { + 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 extensionInfo = extension.packageJSON.contributes.themes.find( + (theme: any) => theme.id === themeName || theme.label === themeName); + return recursiveGetTextMateGrammarPath( + path.join(extension.extensionPath, extensionInfo.path)); +} + +// TM grammars can include other TM grammars, this function recursively gets all +// of them. +async function recursiveGetTextMateGrammarPath(fullPath: string): + Promise { + // If there is an error opening a file, the TM files that were correctly found + // and parsed further up the chain should be returned. Otherwise there will be + // no highlightings at all. + try { + const contents = await readFileText(fullPath); + const parsed = jsonc.parse(contents); + if (parsed.include) + // Get all includes and merge into a flat list of parsed json. + return [ + ...(await recursiveGetTextMateGrammarPath( + path.join(path.dirname(fullPath), parsed.include))), + parsed + ]; + return [ parsed ]; + } catch (err) { + console.warn('Could not open file: ' + fullPath + ', error: ', err); + } + + return []; +} + +function readFileText(path: string): Promise { + return new Promise((res, rej) => { + fs.readFile(path, 'utf8', (err, data) => { + if (err) { + rej(err); + } + res(data); + }); + }); +} +} diff --git a/clang-tools-extra/clangd/clients/clangd-vscode/test/TextMate.test.ts b/clang-tools-extra/clangd/clients/clangd-vscode/test/TextMate.test.ts new file mode 100644 --- /dev/null +++ b/clang-tools-extra/clangd/clients/clangd-vscode/test/TextMate.test.ts @@ -0,0 +1,32 @@ +/** The module 'assert' provides assertion methods from node */ +import * as assert from 'assert'; + +import * as vscode from 'vscode'; +import {SemanticHighlighting} from '../src/TextMate'; + +// TODO: add tests +suite("Extension Tests", () => { + test('overrides for more specific themes', () => { + const scopes = [ 'a.b.c.d', 'a.b.f', 'a' ]; + const colorPairs = [ + [ [ 'a.b.c', 'a.b.d' ], '1' ], + [ 'a.b', '2' ], + [ 'a.b.c.d', '3' ], + [ 'a', '4' ], + ]; + const tm = new SemanticHighlighting.TMColors(scopes); + colorPairs.forEach((p) => tm.setColor(p[0], p[1] as string)); + assert.deepEqual(tm.getColor(0), '3'); + assert.deepEqual(tm.getColor(1), '2'); + assert.deepEqual(tm.getColor(2), '4'); + }); + test('Sets an instance of TMColors on setup.', async () => { + const scopes = [ + 'variable', + ]; + const disp = await SemanticHighlighting.setupTMScopes(scopes, () => {}); + assert.notEqual(SemanticHighlighting.TMColorProvider.get().getColors(), + undefined); + disp.dispose(); + }); +}); \ No newline at end of file