How I Created an Extension to Automate VSCode With External Scripts
Amine Ben hammou
Posted on January 23, 2024
I wanted to use VSCode to manage my todo lists and ended up creating a framework for VSCode automation.
Problem 1: Open File
it all started when I wanted to use VSCode to handle my tasks, I use Todo+ which is great. But at some point, I wanted to split tasks into multiple files and I needed a way to navigate between them easily. So I would have something like:
project-42/
index.todo
add-features.todo
add-tests.todo
the index.todo
would contain
ā create the project
ā add-features.todo
ā add-tests.todo
ā make the PR
and the add-features.todo
and add-tests.todo
would contain some subtasks.
TDD guy: actually you should group each feature with its tests and write the tests then implement the feature ...
Response: Yes, I know, it's just an example to illustrate the separation of tasks into multiple files.
I wanted a way to open the add-features.todo
and add-tests.todo
simply by placing the caret inside them and hitting a keybinding.
Solution 1
I found an extension called Open file that does what I need.
After installing it, I could open the file under the cursor using alt+p
(I could of course remap the command to any other keybinding).
Problem 2: Create file if missing then open it
if the file doesn't exist, I want the command to create it automatically.
The Open file doesn't support that so I need another way. Basically, when I press the keybinding, I want to execute the following commands:
- create the file if missing
- open the file
I found the Multi Command extension that can execute a sequence of commands. But I couldn't find a command or an extension that would create the relative file if missing without requiring any interaction/confirmation from me.
So, inspired by the JS ecosystem where everyone creates their own library/framework, I decided to create my own VSCode extension.
Problem 3: Create an extension to automate VSCode
The initial goal was to create a simple extension that creates the file under cursor then opens it. But I ended up creating a sort of framework that let's you automate VSCode using external scripts written with any language. Here is my journey step by step:
Step 1: Setup the project
npm install -g yo # install yoman globally
npm install -g generator-code # install the VSCode extensions generator globally
Then I run yo code
and chose:
What type of extension do you want to create? New Extension (TypeScript)
What's the name of your extension? Create and open file
What's the identifier of your extension? create-and-open-file
What's the description of your extension? Create the relative file under the cursor if missing then opens it
Initialize a git repository? Yes
Bundle the source code with webpack? No
Which package manager to use? npm
Then I opened package.json
of the generated project and modified the contributions
section to be as follows:
{
...
"contributes": {
"commands": [
{
"command": "create-and-open-file.run",
"title": "Create the relative file under the cursor if missing then opens it"
}
]
},
}
This tells VSCode that the extension adds the command create-and-open-file.run
.
Next I opened the src/extension.ts
and clears it to be as follows:
import * as vscode from "vscode"
export function activate(context: vscode.ExtensionContext) {
// runs when the extension is activated
}
export function deactivate() {
// runs when the extension is deactivated
}
Yes, basically an extension consists of two functions activate
and deactivate
. Most of the logic goes inside the activate
function, which I will fill in the following steps.
Step 2: Implement the create-and-open-file.run
command
Now all I needed to do is to implement the command in the src/extension.ts
file as follows:
import * as vscode from "vscode"
export function activate(context: vscode.ExtensionContext) {
const disposable = vscode.commands.registerCommand("create-and-open-file.run", async () => {
const editor = vscode.window.activeTextEditor // get current open tab
if (!editor) return // if no current tab, do nothing
const [line, position] = getLineAndPosition(editor) // get current line and position
let filePath = extractFilePath(line, position) // extract the path under the cursor
if (!filePath) return // if no path, do nothing
filePath = normalizeRelativePath(editor, filePath)
await createFileIfNotExists(filePath)
await openFile(filePath)
})
context.subscriptions.push(disposable)
}
// implementation of functions `getLineAndPosition`, `extractFilePath`, `normalizeRelativePath`, `createFileIfNotExists` and `openFile` ...
That's it, now I could press F5
and try my extension. It's working!
Step 3: Design a more generic extension
So far, I had a working solution for my initial problem, but other ideas started appearing in my mind:
- A command to list all pending tasks with certain tag.
- A command to print the tree of task files with progress and remaining time for each file.
- A command to show all tasks scheduled for today. ...
I wanted a way to be able to create these commands and any similar ones without having to create a whole new extension each time. After a bit of thinking, I found the following solution: Create an extension that can run any external script, give it the current context (the current file, the position/selection, ...) and execute the VSCode commands that are returned by the script. Let me explain the idea in more details:
- The extension would add a command
scripts.run
to VSCode. - I can use that command to define keybindings like:
[
// ...
{
"key": "alt+o",
"command": "scripts.run",
"args": {
"script": "/path/to/my/executable/script"
}
}
]
- When
alt+o
is pressed, the extension will run/path/to/my/executable/script
and give it the following JSON in the standard input
{
"file": "/absolute/path/to/current/file",
"position": {
"line": 10,
"col": 5
}
}
- The script does anything (reads the file, creates new files, changes the content of some file, ...)
- The script should output a JSON array of VSCode commands to the standard output. For example:
[
{
"command": "type",
"args": {
"text": "Hello From the external script!"
}
}
]
- The extension waits for the script to finish executing, parses the stdout and runs the returned commands.
So in this example, pressing alt+o
would type Hello From the external script!
in the current position.
Step 4: Implement the generic extension
I started by updating the name and description of the extension in package.json
, then I renamed the create-and-open-file.run
command as scripts.run
. Then I updated the implementation as follows:
import * as vscode from 'vscode'
import * as commands from './commands'
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(vscode.commands.registerCommand('scripts.run', commands.run))
}
export function deactivate() {}
And created the file src/commands/run.ts
that contains the implementation of the scripts.run
command:
import * as vscode from 'vscode'
import { exec } from '../utils'
export async function run(args: {script: string}) {
try {
const stdout = await exec(args.script, JSON.stringify(getContext()))
for (const command of JSON.parse(stdout)) {
await runCommand(command)
}
} catch (e) {
console.error(`Error: ${e}`)
}
}
// implementation of `getContext` and `runCommand` ...
Step 5: Test the extension with a simple script
To test this implementation, I wrote the following Python script that should duplicate the current line (Why Python? for no reason, I could have chosen any other language):
import json
import sys
context = json.load(sys.stdin)
file = open(context['file'], 'r')
line = file.readlines()[context['position']['line'] - 1]
print(json.dumps([
"cursorLineEnd",
{'command': 'type', 'args': {'text': '\n' + line}}
]))
Then I defined the following keybinding
[
// ...
{
"key": "alt+d",
"command": "scripts.run",
"args": {
"script": "python3 /path/to/script.py"
}
}
]
I run the extension and pressed alt+d
and the line is duplicated :D
Step 6: Handle selections and multi cursors
So far, I am passing a single cursor position to the script. What if there is a selection? I changed the context type to pass the start and the end of the selection (if there is no selection, then the start and the end will be equal):
type Context = {
file: string
selection: {
start: {line: number, col: number},
end: {line: number, col: number}
}
}
Then to support the case of multiple selections:
type Context = {
file: string
selections: Array<{
start: {line: number, col: number},
end: {line: number, col: number}
}>
}
Step 7: Solve the initial problem
At this point, I was happy with the result and wanted to use it to solve my initial problem: Open the file under cursor (creating it if missing). So I wrote the following script (I used Javascript this time):
async function main() {
const { file, selections } = JSON.parse(await stdin())
const position = selections[0].start // get the first cursor position
const line = await readLine(file, position.line)
let filename = extractPath(line, position.col)
if (!filename) return '[]'
await ensureFileExists(filename)
return JSON.stringify(['seito-openfile.openFileFromText'])
}
main().then(console.log).catch(console.error)
// implementations of stdin, readLine, extractPath, ensureFileExists, ...
This worked well, but I noticed something: I was using the command seito-openfile.openFileFromText
from the Open file extension, which has two cons:
- For my script to work, I need that extension to be installed.
- The
seito-openfile.openFileFromText
doesn't support specifying the file path programmatically (pass it as argument), it always uses the string under the cursor.
It was time for me to create some custom commands that I would need in my scripts and include them in the extension. Starting with scripts.files.open
.
Step 8: Add the scripts.files.open
command
I created a new file src/commands/files/open.ts
import * as vscode from 'vscode'
export async function open(args: {path: string}) {
try {
const document = await vscode.workspace.openTextDocument(args.path)
await vscode.window.showTextDocument(document)
} catch (e) {
vscode.window.showErrorMessage(`Could not open file at '${args.path}': ${e}`)
}
}
Then registered the command in src/extension.ts
import * as vscode from 'vscode'
import * as commands from './commands'
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(vscode.commands.registerCommand('scripts.run', commands.run))
context.subscriptions.push(vscode.commands.registerCommand('scripts.files.open', commands.files.open))
}
export function deactivate() {}
Step 9: Publish the extension
Now that I had a working version of the extension, I decided to publish it so that I can test it in real life usage. So I created an account in the VSCode marketplace and published the extension, I had to rename it as Neat Scripts
because scripts
was taken.
What's next
I consider the currently published version an alpha version as I am still testing it and may change many things. Some next steps are:
- Use the extension in real world and add common useful commands: show suggestions, add overlays, ...
- Handle errors properly.
- Add tests.
- Write a good documentation with examples of usage in different languages.
- Create a logo.
- Think about a way to organize and share scripts
What do you think about this extension? feel free to open issues in the Github repo or reach out on Twitter. All feedback is welcome :)
Posted on January 23, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.