My First Vscode Extension.

Introduction

I've been working on lots of projects based on React.js to create websites or develop software using Tauri Rust. Working with JavaScript is interesting, and assigning unique class names to each HTML element is a challenging task. What's even more challenging is when you create the CSS file and need to open the JavaScript file in a split view to copy and paste the class name. That's why I started using Tailwind CSS, but I want to find a way to overcome this without relying on Tailwind CSS. That's why I'm developing an extension called CSS GENIE.

What's the Extension do?

CSS GENIE provides help to users by suggesting all class names registered in CSS files found in the current project directory. This suggestion is made once the user types className=" in a javascript or typescript file. The suggestion shows the class name and also the path of CSS file which holds this class name.

Libraries Required

This extension uses two main libraries fs and css. To install these libraries use npm install fs css. Note: If you are using typescript install css types by using npm install --save-dev @types/css.

Creating Extension

Visual studio code allows us to create extensions using command a shell command yo code. This command creates an environment/project folder for our extension. This environment contains the package.json file which tells the dependencies and devdependencies used in the project. The main javascript code where the extension starts is extension.js.

Code Structure

The extension.js file contains the default code to create the extension. Which consists of two major functions to activate and deactivate the extension. The constant variable vscode allows the extension to access the vscode API. Activate function is provided with an argument context which allows us to register a command or function to the extension context.

const vscode = require('vscode')

function activate(context) { }
function deactivate() { }

module.exports = { 
    activate, deactivate
}

Steps to be followed

To create an extension that read all CSS file and suggest Classname the following steps are to be followed.

  • Make the Extension run on startup.

  • Read all CSS files found in the current/active directory.

  • Extract class names from each CSS file and store them in an object.

  • Suggest the stored extension when the user types className=" in the javascript / Typescript file.

Additional steps to be followed: Since the extension is only called at startup, we should find a way to run the extension whenever a file is created, changed, renamed or deleted.

  • Making the extension Run on startup

To make the extension run on startup we have to add activation events in the package.json file.

  "activationEvents": [
    "onStartupFinished"
  ],

the first step is completed now it's time to create the extension via the extension.js file.

  • Read all CSS file

This extension should be called multiple times based on whether a file is created, changed, renamed or deleted. So it is best to assign the context variable outside the activate function.

...
let CONTEXT;

function activate(context) {
    CONTEXT = context;
}
...

To Read all CSS file the extension first have to find out the location or path of an active text document. Luckily vscode API solves most of the problem, we can get the active text document by using vscode.window.activeTextEditor. This gives the active text document to get the current workspace we can use vscode.workspace.getWorkspaceFolder(activeEditor.document.uri). This gives control over the active workspace use workspace.document.uri to get the workspace directory. Since we use this directory multiple times it is proper to make it a global variable like context.

...
const vscode = require('vscode')

let CONTEXT;
let CURRENTWORKSPACE;

function activate(context) {
    CONTEXT = context;
    const activeEditor = vscode.window.activeTextEditor;

    if(activeEditor) {
        const workspaceFolder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri);
        if (workspaceFolder === undefined) {
            return;
        }
        const _path = workspaceFolder.uri.fsPath;
        CURRENTWORKSPACE = _path;
    }
}
...

Now it's time to read all CSS files. We can use fs package node js to read files and directories. We can create a separate function to read all CSS file exists in the current directory file.

...
function ReadFiles(directoryPath) {
    return new Promise((resolve, reject) => {
        fs.readdir(directoryPath, (err, files) => {
            if (err) {
                reject(err);
                return;
            }
            const filePromises = [];
            const fileNames = [];
            files.forEach(file => {
                const filePath = path.join(directoryPath, file);
                let relative = path.relative(CURRENTWORKSPACE, filePath);
                const isDirectory = fs.statSync(filePath).isDirectory();
                if (isDirectory) {
                    filePromises.push(ReadFiles(filePath));
                }
                else {
                    if (file.endsWith('.css')) {
                        fileNames.push({ name: file, path: directoryPath });
                    }
                }
        });
            Promise.all(filePromises)
                .then(nestedFiles => {
                nestedFiles.forEach(nestedFile => {
                    fileNames.push(...nestedFile);
                });
                resolve(fileNames);
            })
                .catch(error => {
                reject(error);
            });
        });
    });
}

function ReadAllCSSFiles() {
    return new Promise((resolve, reject) => {
        ReadFiles(CURRENTWORKSPACE)
    });
}
...
  • Extract class names from each CSS file and store them in an object.

The read files function uses the CURRENTWORKSPACE directory to read all files even inside directories and returns an output of an array that contains the file name and file path.

Once all css file is read, it's time to store all class names from the CSS file to the object variable. This can be done by using a separate library called CSS. Install the library using npm install css.

Note: if you are using typescript add types by using npm install --save-dev @types/css.

This CSS library accepts css files in string format which can be done by fs.readFile() and returns an output of an object which includes a stylesheet, and rules which contain a class name, comment, id etc. To do this modify the ReadAllCSSfiles function. ClassName can be separated using /^\.+/ Regrex.

...
const fs = require('fs')
const css = require('css')

let CLASSNAMES = [];
let _classNames = [];
let RECORDS = [];
let _records = [];
let CURRENTWORKSPACE = '';

function GetClassName(data, filePath) {
    return new Promise((resolve, reject) => {
        const cssData = css.parse(data);
        const _p = path.relative(CURRENTWORKSPACE, filePath);
        let classRxg = /^\.+/;
        cssData.stylesheet?.rules.forEach((rule) => {
            if (rule.selectors) {
                rule.selectors.forEach((selector) => {
                    let _class = selector.toString().replace(classRxg, '');
                    if (selector.match(classRxg) && !_classNames.includes(_class)) {
                        _classNames.push(_class);
                        CLASSNAMES.push({
                            path: _p,
                            value: _class
                        });
                        _records.push(_class);
                    }
                });
            }
        });
        RECORDS.push({
            data: _records, path: filePath, relativePath: _p
        });
        _records = [];
        _classNames = [];
    });
}

function GetAllStyles(files) {
    return new Promise((resolve, reject) => {
        const promises = files.map((file) => {
            const filePath = path.join(file.path, file.name);
            return new Promise((resolve, reject) => {
                fs.readFile(filePath, 'utf-8', (err, data) => {
                    if (err) {
                        reject();
                    }
                    else {
                        GetClassName(data, filePath).then(resolve).catch(reject);
                    }
                });
            });
        });
        Promise.all(promises).then(resolve).catch(reject);
    });
}

function ReadAllCSSFiles() {
    return new Promise((resolve, reject) => {
        ReadFiles(CURRENTWORKSPACE).then((files) => {
            GetAllStyles(files).then(resolve).catch(reject);
        }).catch(reject);
    });
}
...

CLASSNAME variable contains all class names and its file path inside one array structure, this variable is the one which will be used to show suggestions. But in the RECORD variable, each array object is separated by using its file name so once a file is changed or deleted. only the value of the particular file is changed other files are not affected. Once the RECORD variable is changed the CLASSNAME variable is modified based on the RECORD variable.

  • Suggest the stored class names

Once every step is completed it's time to make the main event of the extension which is to show the stored suggestion. vscode API provides a separate function to create suggestions for files. It can be done by using vscode.languages.registerCompletionItemProvider. This function requires 2 arguments. The first argument is document selector: A selector that defines the documents this provider applies to. The second argument is the provider Item which contains suggestions. Provider Item checks the current position of the file using the default argument provided and makes sure whether the current word is classname=" so we can show the suggestion this can be done by using /className\s*=\s*["'](?:[^"\s]+ )*([^("')]+|)\s*?/ Regrex.

...
let COMPLETIONPROVIDER;
...
function ProvideCompletionItems(document, position) {
    const completionItems = [];
    const linePrefix = document.lineAt(position).text.substr(0, position.character);
    const match = linePrefix.match(/className\s*=\s*["'](?:[^"\s]+ )*([^("')]+|)\s*?/);
    if (match === null) {
        return undefined;
    }
    const attributeValue = match[1];
    if (linePrefix.includes('className')) {
        CLASSNAMES.forEach((className) => {
            if (className.value.startsWith(attributeValue)) {
                const item = new vscode.CompletionItem(className.value, vscode.CompletionItemKind.Class);
                item.insertText = new vscode.SnippetString(`${className.value}`);
                item.detail = `path:${className.path}`;
                completionItems.push(item);
            }
        });
    }
    return completionItems;
}

function CreateCompletionProvider() {
    if (COMPLETIONPROVIDER) COMPLETIONPROVIDER.dispose();
    const _selector = ['javascript', 'javascriptreact', 'typescript', 'typescriptreact'];
    COMPLETIONPROVIDER = vscode.languages.registerCompletionItemProvider(_selector, { ProvideCompletionItems });
    CONTEXT.subscriptions.push(COMPLETIONPROVIDER);
}
...

Making the completion provider a global variable it is easy to modify the suggestion whenever any change is made. CompletionProvider.dispose is used to delete if the provider is created before so it doesn't overlap once a new completion provider is created. Context.Subscriptions.push is the function that registers the extension to vscode extension list.

Modify the activate function.

CONTEXT = context;
    const activeEditor = vscode.window.activeTextEditor;
    if (activeEditor) {
        const workspaceFolder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri);
        if (workspaceFolder === undefined) {
            return;
        }
        const _path = workspaceFolder.uri.fsPath;
        CURRENTWORKSPACE = _path;
        const ErrorLoading = () => { 
            console.error("Error in loading..")
        };
        ReadAllCSSFiles().then(CreateCompletionProvider).catch(ErrorLoading);
    }
  • Additional:

Now it's time for your programming skill to make this extension to update the class name suggestion whenever any file is created, changed, renamed or deleted. vscode.workspace.onDidCreateFiles(); vscode.workspace.onDidSaveTextDocument(); vscode.workspace.onDidDeleteFiles(); vscode.workspace.onDidRenameFiles(); use these functions to update the extension whenever any changes are made.

Support

If you appreciate the quality of my content, I kindly invite you to consider sponsoring me. Your support will enable me to pursue my work with enthusiasm and undertake more engaging projects.

Did you find this article valuable?

Support Jeslin Paul by becoming a sponsor. Any amount is appreciated!