Skip to content

Commit

Permalink
Enable basic CMake language services (#4204)
Browse files Browse the repository at this point in the history
* Enable colorization

* Colorization matching twxs. Initial in memory support of hover.

* initial naive providing of completion items

* fix nit with variables and resolveCompletionItem

* initial support for modules

* add gulp tasks for localizing language service data

* make the insertion a better snippet

* add slight protections

* update changelog
  • Loading branch information
gcampbell-msft authored Jan 10, 2025
1 parent 73cc13f commit d3f8f05
Show file tree
Hide file tree
Showing 8 changed files with 7,375 additions and 12 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Features:
executed. This adds test execution type, "Run with coverage", on the `ctest`
section of the Testing tab.
[#4040](https://github.com/microsoft/vscode-cmake-tools/issues/4040)
- Add basic CMake language services: quick hover and completions for CMake built-ins. [PR #4204](https://github.com/microsoft/vscode-cmake-tools/pull/4204)

Improvements:

Expand Down
1,060 changes: 1,060 additions & 0 deletions assets/commands.json

Large diffs are not rendered by default.

1,015 changes: 1,015 additions & 0 deletions assets/modules.json

Large diffs are not rendered by default.

5,035 changes: 5,035 additions & 0 deletions assets/variables.json

Large diffs are not rendered by default.

28 changes: 21 additions & 7 deletions gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ const jsonSchemaFilesPatterns = [
"*/*-schema.json"
];

// Patterns to find language services json files.
const languageServicesFilesPatterns = [
"assets/*.json"
];

const languages = [
{ id: "zh-tw", folderName: "cht", transifexId: "zh-hant" },
{ id: "zh-cn", folderName: "chs", transifexId: "zh-hans" },
Expand Down Expand Up @@ -94,7 +99,7 @@ const traverseJson = (jsonTree, descriptionCallback, prefixPath) => {

// Traverses schema json files looking for "description" fields to localized.
// The path to the "description" field is used to create a localization key.
const processJsonSchemaFiles = () => {
const processJsonFiles = () => {
return es.through(function (file) {
let jsonTree = JSON.parse(file.contents.toString());
let localizationJsonContents = {};
Expand Down Expand Up @@ -133,10 +138,13 @@ gulp.task("translations-export", (done) => {

// Scan schema files
let jsonSchemaStream = gulp.src(jsonSchemaFilesPatterns)
.pipe(processJsonSchemaFiles());
.pipe(processJsonFiles());

let jsonLanguageServicesStream = gulp.src(languageServicesFilesPatterns)
.pipe(processJsonFiles());

// Merge files from all source streams
es.merge(jsStream, jsonSchemaStream)
es.merge(jsStream, jsonSchemaStream, jsonLanguageServicesStream)

// Filter down to only the files we need
.pipe(filter(['**/*.nls.json', '**/*.nls.metadata.json']))
Expand Down Expand Up @@ -214,7 +222,7 @@ const generatedSrcLocBundle = () => {
.pipe(gulp.dest('dist'));
};

const generateLocalizedJsonSchemaFiles = () => {
const generateLocalizedJsonFiles = (paths) => {
return es.through(function (file) {
let jsonTree = JSON.parse(file.contents.toString());
languages.map((language) => {
Expand All @@ -237,7 +245,7 @@ const generateLocalizedJsonSchemaFiles = () => {
traverseJson(jsonTree, descriptionCallback, "");
let newContent = JSON.stringify(jsonTree, null, '\t');
this.queue(new vinyl({
path: path.join("schema", language.id, relativePath),
path: path.join(...paths, language.id, relativePath),
contents: Buffer.from(newContent, 'utf8')
}));
});
Expand All @@ -249,11 +257,17 @@ const generateLocalizedJsonSchemaFiles = () => {
// Generate new version of the JSON schema file in dist/schema/<language_id>/<path>
const generateJsonSchemaLoc = () => {
return gulp.src(jsonSchemaFilesPatterns)
.pipe(generateLocalizedJsonSchemaFiles())
.pipe(generateLocalizedJsonFiles(["schema"]))
.pipe(gulp.dest('dist'));
};

const generateJsonLanguageServicesLoc = () => {
return gulp.src(languageServicesFilesPatterns)
.pipe(generateLocalizedJsonFiles(["languageServices"]))
.pipe(gulp.dest('dist'));
};

gulp.task('translations-generate', gulp.series(generatedSrcLocBundle, generatedAdditionalLocFiles, generateJsonSchemaLoc));
gulp.task('translations-generate', gulp.series(generatedSrcLocBundle, generatedAdditionalLocFiles, generateJsonSchemaLoc, generateJsonLanguageServicesLoc));

const allTypeScript = [
'src/**/*.ts',
Expand Down
35 changes: 30 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
},
"categories": [
"Other",
"Debuggers"
"Debuggers",
"Programming Languages"
],
"galleryBanner": {
"color": "#13578c",
Expand Down Expand Up @@ -63,7 +64,9 @@
"workspaceContains:*/*/CMakeLists.txt",
"workspaceContains:*/*/*/CMakeLists.txt",
"workspaceContains:.vscode/cmake-kits.json",
"onFileSystem:cmake-tools-schema"
"onFileSystem:cmake-tools-schema",
"onLanguage:cmake",
"onLanguage:cmake-cache"
],
"main": "./dist/main",
"contributes": {
Expand Down Expand Up @@ -111,6 +114,31 @@
}
}
},
"languages": [
{
"id": "cmake",
"extensions": [".cmake"],
"filenames": ["CMakelists.txt"],
"aliases": ["CMake"]
},
{
"id": "cmake-cache",
"filenames": ["CMakeCache.txt"],
"aliases": ["CMake Cache"]
}
],
"grammars": [
{
"language": "cmake",
"scopeName": "source.cmake",
"path": "./syntaxes/CMake.tmLanguage"
},
{
"language": "cmake-cache",
"scopeName": "source.cmakecache",
"path": "./syntaxes/CMakeCache.tmLanguage"
}
],
"commands": [
{
"command": "cmake.openCMakePresets",
Expand Down Expand Up @@ -3817,8 +3845,5 @@
"minimatch": "^3.0.5",
"**/braces": "^3.0.3"
},
"extensionPack": [
"twxs.cmake"
],
"packageManager": "yarn@1.22.19"
}
64 changes: 64 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { getCMakeExecutableInformation } from '@cmt/cmakeExecutable';
import { DebuggerInformation, getDebuggerPipeName } from '@cmt/debug/cmakeDebugger/debuggerConfigureDriver';
import { DebugConfigurationProvider, DynamicDebugConfigurationProvider } from '@cmt/debug/cmakeDebugger/debugConfigurationProvider';
import { deIntegrateTestExplorer } from "@cmt/ctest";
import { LanguageServiceData } from './languageServices/languageServiceData';

nls.config({ messageFormat: nls.MessageFormat.bundle, bundleFormat: nls.BundleFormat.standalone })();
const localize: nls.LocalizeFunc = nls.loadMessageBundle();
Expand Down Expand Up @@ -2357,6 +2358,69 @@ export async function activate(context: vscode.ExtensionContext): Promise<api.CM
await vscode.window.showWarningMessage(localize('uninstall.old.cmaketools', 'Please uninstall any older versions of the CMake Tools extension. It is now published by Microsoft starting with version 1.2.0.'));
}

const CMAKE_LANGUAGE = "cmake";
const CMAKE_SELECTOR: vscode.DocumentSelector = [
{ language: CMAKE_LANGUAGE, scheme: 'file'},
{ language: CMAKE_LANGUAGE, scheme: 'untitled'}
];

try {
const languageServices = await LanguageServiceData.create();
vscode.languages.registerHoverProvider(CMAKE_SELECTOR, languageServices);
vscode.languages.registerCompletionItemProvider(CMAKE_SELECTOR, languageServices);
} catch {
log.error(localize('language.service.failed', 'Failed to initialize language services'));
}

vscode.languages.setLanguageConfiguration(CMAKE_LANGUAGE, {
indentationRules: {
// ^(.*\*/)?\s*\}.*$
decreaseIndentPattern: /^(.*\*\/)?\s*\}.*$/,
// ^.*\{[^}"']*$
increaseIndentPattern: /^.*\{[^}"']*$/
},
wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,
comments: {
lineComment: '#'
},
brackets: [
['{', '}'],
['(', ')']
],

__electricCharacterSupport: {
brackets: [
{ tokenType: 'delimiter.curly.ts', open: '{', close: '}', isElectric: true },
{ tokenType: 'delimiter.square.ts', open: '[', close: ']', isElectric: true },
{ tokenType: 'delimiter.paren.ts', open: '(', close: ')', isElectric: true }
]
},

__characterPairSupport: {
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '(', close: ')' },
{ open: '"', close: '"', notIn: ['string'] }
]
}
});

if (vscode.workspace.getConfiguration('cmake').get('showOptionsMovedNotification')) {
void vscode.window.showInformationMessage(
localize('options.moved.notification.body', "Some status bar options in CMake Tools have now moved to the Project Status View in the CMake Tools sidebar. You can customize your view with the 'cmake.options' property in settings."),
localize('options.moved.notification.configure.cmake.options', 'Configure CMake Options Visibility'),
localize('options.moved.notification.do.not.show', "Do Not Show Again")
).then(async (selection) => {
if (selection !== undefined) {
if (selection === localize('options.moved.notification.configure.cmake.options', 'Configure CMake Options Visibility')) {
await vscode.commands.executeCommand('workbench.action.openSettings', 'cmake.options');
} else if (selection === localize('options.moved.notification.do.not.show', "Do Not Show Again")) {
await vscode.workspace.getConfiguration('cmake').update('showOptionsMovedNotification', false, vscode.ConfigurationTarget.Global);
}
}
});
}

// Start with a partial feature set view. The first valid CMake project will cause a switch to full feature set.
await enableFullFeatureSet(false);

Expand Down
149 changes: 149 additions & 0 deletions src/languageServices/languageServiceData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import * as vscode from "vscode";
import * as path from "path";
import { fs } from "@cmt/pr";
import { thisExtensionPath } from "@cmt/util";
import * as util from "@cmt/util";

interface Commands {
[key: string]: Command;
}

interface Command {
name: string;
description: string;
syntax_examples: string[];
}

// Same as variables right now. If we modify, create individual interfaces.
interface Modules extends Variables {

}

interface Variables {
[key: string]: Variable;
}

interface Variable {
name: string;
description: string;
}

enum LanguageType {
Variable,
Command,
Module
}

export class LanguageServiceData implements vscode.HoverProvider, vscode.CompletionItemProvider {
private commands: Commands = {};
private variables: Variables = {}; // variables and properties
private modules: Modules = {};

private constructor() {
}

private async getFile(fileEnding: string, locale: string): Promise<string> {
let filePath: string = path.join(thisExtensionPath(), "dist/languageServices", locale, "assets", fileEnding);
const fileExists: boolean = await util.checkFileExists(filePath);
if (!fileExists) {
filePath = path.join(thisExtensionPath(), "assets", fileEnding);
}
return fs.readFile(filePath);
}

private async load(): Promise<void> {
const locale: string = util.getLocaleId();
this.commands = JSON.parse(await this.getFile("commands.json", locale));
this.variables = JSON.parse(await this.getFile("variables.json", locale));
this.modules = JSON.parse(await this.getFile("modules.json", locale));
}

private getCompletionSuggestionsHelper(currentWord: string, data: Commands | Modules | Variables, type: LanguageType): vscode.CompletionItem[] {
function moduleInsertText(module: string): vscode.SnippetString {
if (module.indexOf("Find") === 0) {
return new vscode.SnippetString(`find_package(${module.replace("Find", "")}\${1: REQUIRED})`);
} else {
return new vscode.SnippetString(`include(${module})`);
}
}

function variableInsertText(variable: string): vscode.SnippetString {
return new vscode.SnippetString(variable.replace(/<(.*)>/g, "${1:<$1>}"));
}

function commandInsertText(func: string): vscode.SnippetString {
const scopedFunctions = ["if", "function", "while", "macro", "foreach"];
const is_scoped = scopedFunctions.includes(func);
if (is_scoped) {
return new vscode.SnippetString(`${func}(\${1})\n\t\nend${func}(\${1})\n`);
} else {
return new vscode.SnippetString(`${func}(\${1})`);
}
}

return Object.keys(data).map((key) => {
if (data[key].name.includes(currentWord)) {
const completionItem = new vscode.CompletionItem(data[key].name);
completionItem.insertText = type === LanguageType.Command ? commandInsertText(data[key].name) : type === LanguageType.Variable ? variableInsertText(data[key].name) : moduleInsertText(data[key].name);
completionItem.kind = type === LanguageType.Command ? vscode.CompletionItemKind.Function : type === LanguageType.Variable ? vscode.CompletionItemKind.Variable : vscode.CompletionItemKind.Module;
return completionItem;
}
return null;
}).filter((value) => value !== null) as vscode.CompletionItem[];
}

private getCompletionSuggestions(currentWord: string): vscode.CompletionItem[] {
return this.getCompletionSuggestionsHelper(currentWord, this.commands, LanguageType.Command)
.concat(this.getCompletionSuggestionsHelper(currentWord, this.variables, LanguageType.Variable))
.concat(this.getCompletionSuggestionsHelper(currentWord, this.modules, LanguageType.Module));
}

public static async create(): Promise<LanguageServiceData> {
const data = new LanguageServiceData();
await data.load();
return data;
}

provideCompletionItems(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken, _context: vscode.CompletionContext): vscode.ProviderResult<vscode.CompletionItem[] | vscode.CompletionList> {
const wordAtPosition = document.getWordRangeAtPosition(position);

let currentWord = "";
if (wordAtPosition && wordAtPosition.start.character < position.character) {
const word = document.getText(wordAtPosition);
currentWord = word.substr(0, position.character - wordAtPosition.start.character);
}

if (token.isCancellationRequested) {
return null;
}

return this.getCompletionSuggestions(currentWord);
}

resolveCompletionItem?(item: vscode.CompletionItem, _token: vscode.CancellationToken): vscode.ProviderResult<vscode.CompletionItem> {
return item;
}

provideHover(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken): vscode.ProviderResult<vscode.Hover> {
const range = document.getWordRangeAtPosition(position);
const value = document.getText(range);

if (token.isCancellationRequested) {
return null;
}

const hoverSuggestions = this.commands[value] || this.variables[value] || this.modules[value];

const markdown: vscode.MarkdownString = new vscode.MarkdownString();
markdown.appendMarkdown(hoverSuggestions.description);
hoverSuggestions.syntax_examples?.forEach((example) => {
markdown.appendCodeblock(`\t${example}`, "cmake");
});

if (hoverSuggestions) {
return new vscode.Hover(markdown);
}

return null;
}
}

0 comments on commit d3f8f05

Please sign in to comment.