From 45742a08ffad2697f4f47c21ee1abd2c4a2a7e70 Mon Sep 17 00:00:00 2001 From: K0IN Date: Sat, 20 Jun 2026 03:52:41 +0200 Subject: [PATCH] feat: Add ui tree for existing recipe, task provider and code lenses * Add multi justfile support * Add run with arguments * Add code task contribution (can be disabled) --- package.json | 384 ++++++++++++++++++++-------------- src/codeLens.ts | 82 ++++++++ src/const.ts | 19 +- src/extension.ts | 334 +++++++++++++++++++++++++---- src/launcher.ts | 120 +++++++---- src/recipe.ts | 7 +- src/recipeTree.ts | 219 +++++++++++++++++++ src/tasks.ts | 224 +++++++++++++++++--- src/types.ts | 1 + syntaxes/just.tmLanguage.json | 6 +- 10 files changed, 1110 insertions(+), 286 deletions(-) create mode 100644 src/codeLens.ts create mode 100644 src/recipeTree.ts diff --git a/package.json b/package.json index 4e60f11..c70f4e3 100644 --- a/package.json +++ b/package.json @@ -1,166 +1,234 @@ { - "name": "vscode-just-syntax", - "publisher": "nefrob", - "repository": "https://github.com/nefrob/vscode-just", - "bugs": { - "url": "https://github.com/nefrob/vscode-just/issues" - }, - "displayName": "vscode-just", - "description": "Justfile syntax support for Visual Studio Code", - "license": "MIT", - "version": "0.10.2", - "engines": { - "node": ">=22 || ^20.9.0", - "vscode": ">=1.94.0" - }, - "scripts": { - "compile-extension": "webpack", - "watch": "webpack --watch", - "package": "webpack --mode production --devtool hidden-source-map", - "compile": "tsc -p . --outDir out", - "watch-tests": "tsc -p . -w --outDir out", - "pretest": "yarn translate && yarn compile && yarn compile-extension", - "test-extension": "vscode-test --disable-extensions", - "test-grammar": "vscode-tmgrammar-snap syntaxes/tests/**/*.just -g syntaxes/test-grammars/JavaScript.tmLanguage.json -g syntaxes/test-grammars/MagicPython.tmLanguage.json -g syntaxes/test-grammars/MagicRegExp.tmLanguage.json -g syntaxes/test-grammars/perl.tmLanguage.json -g syntaxes/test-grammars/perl6.tmLanguage.json -g syntaxes/test-grammars/ruby.tmLanguage.json -g syntaxes/test-grammars/shell-unix-bash.tmLanguage.json -g syntaxes/test-grammars/TypeScript.tmLanguage.json", - "translate": "yarn --silent js-yaml syntaxes/just.tmLanguage.yml > syntaxes/just.tmLanguage.json", - "format": "prettier --log-level warn --cache .", - "lint": "eslint . --cache --cache-location node_modules/.cache/eslint", - "gen-scopes": "yarn compile && node out/genScopes.js" - }, - "categories": [ - "Programming Languages" - ], - "keywords": [ - "just", - "justfile", - "scripts" - ], - "icon": "icons/robot.png", - "main": "./dist/extension.js", - "contributes": { - "languages": [ - { - "id": "just", - "aliases": [ - "Just", - "vscode-just" - ], - "extensions": [ - ".just", - ".justfile", - "justfile", - "Justfile" - ], - "configuration": "./language-configuration.json", - "icon": { - "light": "./icons/robot.png", - "dark": "./icons/robot.png" - } - } + "name": "vscode-just-syntax", + "publisher": "nefrob", + "repository": "https://github.com/nefrob/vscode-just", + "bugs": { + "url": "https://github.com/nefrob/vscode-just/issues" + }, + "displayName": "vscode-just", + "description": "Justfile syntax support for Visual Studio Code", + "license": "MIT", + "version": "0.10.2", + "engines": { + "node": ">=22 || ^20.9.0", + "vscode": ">=1.94.0" + }, + "scripts": { + "compile-extension": "webpack", + "watch": "webpack --watch", + "package": "webpack --mode production --devtool hidden-source-map", + "compile": "tsc -p . --outDir out", + "watch-tests": "tsc -p . -w --outDir out", + "pretest": "yarn translate && yarn compile && yarn compile-extension", + "test-extension": "vscode-test --disable-extensions", + "test-grammar": "vscode-tmgrammar-snap syntaxes/tests/**/*.just -g syntaxes/test-grammars/JavaScript.tmLanguage.json -g syntaxes/test-grammars/MagicPython.tmLanguage.json -g syntaxes/test-grammars/MagicRegExp.tmLanguage.json -g syntaxes/test-grammars/perl.tmLanguage.json -g syntaxes/test-grammars/perl6.tmLanguage.json -g syntaxes/test-grammars/ruby.tmLanguage.json -g syntaxes/test-grammars/shell-unix-bash.tmLanguage.json -g syntaxes/test-grammars/TypeScript.tmLanguage.json", + "translate": "yarn --silent js-yaml syntaxes/just.tmLanguage.yml > syntaxes/just.tmLanguage.json", + "format": "prettier --log-level warn --cache .", + "lint": "eslint . --cache --cache-location node_modules/.cache/eslint", + "gen-scopes": "yarn compile && node out/genScopes.js" + }, + "categories": [ + "Programming Languages" ], - "grammars": [ - { - "language": "just", - "scopeName": "source.just", - "path": "./syntaxes/just.tmLanguage.json", - "embeddedLanguages": { - "source.js": "javascript", - "source.ts": "typescript", - "source.perl": "perl", - "source.python": "python", - "source.ruby": "ruby", - "source.shell": "shell" - } - } + "keywords": [ + "just", + "justfile", + "scripts" ], - "configuration": { - "properties": { - "vscode-just.justPath": { - "type": "string", - "default": "just", - "description": "Path to just binary." - }, - "vscode-just.lspPath": { - "type": "string", - "default": "just-lsp", - "description": "Path to just-lsp binary." + "icon": "icons/robot.png", + "main": "./dist/extension.js", + "contributes": { + "languages": [ + { + "id": "just", + "aliases": [ + "Just", + "vscode-just" + ], + "extensions": [ + ".just", + ".justfile", + "justfile", + "Justfile" + ], + "configuration": "./language-configuration.json", + "icon": { + "light": "./icons/robot.png", + "dark": "./icons/robot.png" + } + } + ], + "grammars": [ + { + "language": "just", + "scopeName": "source.just", + "path": "./syntaxes/just.tmLanguage.json", + "embeddedLanguages": { + "source.js": "javascript", + "source.ts": "typescript", + "source.perl": "perl", + "source.python": "python", + "source.ruby": "ruby", + "source.shell": "shell" + } + } + ], + "configuration": { + "properties": { + "vscode-just.justPath": { + "type": "string", + "default": "just", + "description": "Path to just binary." + }, + "vscode-just.lspPath": { + "type": "string", + "default": "just-lsp", + "description": "Path to just-lsp binary." + }, + "vscode-just.runInTerminal": { + "type": "boolean", + "default": false, + "description": "Enable/disable running recipes in a terminal. If disabled, recipes will be logged to the extension output channel instead." + }, + "vscode-just.useSingleTerminal": { + "type": "boolean", + "default": false, + "description": "If enabled along with runInTerminal, recipes will queued to run in a single terminal instance rather than creating a new terminal each time." + }, + "vscode-just.logLevel": { + "type": "string", + "enum": [ + "info", + "warning", + "error", + "none" + ], + "default": "info", + "description": "Set the log level. Recipe output is logged at the info level." + }, + "vscode-just.contributeTasks": { + "type": "boolean", + "default": true, + "description": "Enable/disable contributing just recipes as VS Code tasks." + } + } }, - "vscode-just.runInTerminal": { - "type": "boolean", - "default": false, - "description": "Enable/disable running recipes in a terminal. If disabled, recipes will be logged to the extension output channel instead." + "commands": [ + { + "command": "vscode-just.formatDocument", + "title": "Just: Format Document" + }, + { + "command": "vscode-just.runRecipeFromTree", + "title": "Just: Run Recipe", + "icon": "$(play)" + }, + { + "command": "vscode-just.runRecipeWithArgsFromTree", + "title": "Just: Run Recipe with Arguments", + "icon": "$(terminal)" + }, + { + "command": "vscode-just.refreshRecipes", + "title": "Just: Refresh Recipes", + "icon": "$(refresh)" + }, + { + "command": "vscode-just.openJustfile", + "title": "Just: Go to Justfile", + "icon": "$(go-to-file)" + }, + { + "command": "vscode-just.goToRecipe", + "title": "Just: Go to Recipe" + } + ], + "taskDefinitions": [ + { + "type": "vscode-just", + "when": "shellExecutionSupported", + "required": [ + "task" + ], + "properties": { + "task": { + "type": "string", + "description": "The just command." + }, + "args": { + "type": "array", + "description": "Arguments to pass to the task" + } + } + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "just-recipes", + "title": "Just Recipes", + "icon": "icons/robot.png" + } + ] }, - "vscode-just.useSingleTerminal": { - "type": "boolean", - "default": false, - "description": "If enabled along with runInTerminal, recipes will queued to run in a single terminal instance rather than creating a new terminal each time." + "views": { + "just-recipes": [ + { + "id": "justRecipes", + "name": "Recipes" + } + ] }, - "vscode-just.logLevel": { - "type": "string", - "enum": [ - "info", - "warning", - "error", - "none" - ], - "default": "info", - "description": "Set the log level. Recipe output is logged at the info level." + "menus": { + "view/title": [ + { + "command": "vscode-just.refreshRecipes", + "when": "view == justRecipes", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "vscode-just.openJustfile", + "when": "view == justRecipes && viewItem == 'justfile'", + "group": "inline" + }, + { + "command": "vscode-just.runRecipeFromTree", + "when": "view == justRecipes && viewItem =~ /^recipe/", + "group": "inline" + }, + { + "command": "vscode-just.runRecipeWithArgsFromTree", + "when": "view == justRecipes && viewItem == 'recipeWithParams'", + "group": "inline" + } + ] } - } }, - "commands": [ - { - "command": "vscode-just.formatDocument", - "title": "Just: Format Document" - }, - { - "command": "vscode-just.runRecipe", - "title": "Just: Run Recipe" - } - ], - "taskDefinitions": [ - { - "type": "vscode-just", - "when": "shellExecutionSupported", - "required": [ - "task" - ], - "properties": { - "task": { - "type": "string", - "description": "The just command." - }, - "args": { - "type": "array", - "description": "Arguments to pass to the task" - } - } - } - ] - }, - "devDependencies": { - "@types/mocha": "^10.0.9", - "@types/node": "^22.7.5", - "@types/vscode": "^1.94.0", - "@vscode/test-cli": "^0.0.10", - "@vscode/test-electron": "^2.4.1", - "@vscode/vsce": "^3.7.1", - "eslint": "^9.12.0", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-simple-import-sort": "^12.1.1", - "eslint-plugin-unused-imports": "^4.4.1", - "js-yaml": "^4.1.0", - "prettier": "^3.2.4", - "ts-loader": "^9.5.1", - "typescript": "~5.5.0", - "typescript-eslint": "^8.57.2", - "vscode-tmgrammar-test": "^0.1.3", - "webpack": "^5.93.0", - "webpack-cli": "^5.1.4" - }, - "dependencies": { - "@types/yargs-parser": "^21.0.3", - "vscode-languageclient": "^9.0.1", - "yargs-parser": "^21.1.1" - } -} + "devDependencies": { + "@types/mocha": "^10.0.9", + "@types/node": "^22.7.5", + "@types/vscode": "^1.94.0", + "@vscode/test-cli": "^0.0.10", + "@vscode/test-electron": "^2.4.1", + "@vscode/vsce": "^3.7.1", + "eslint": "^9.12.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-unused-imports": "^4.4.1", + "js-yaml": "^4.1.0", + "prettier": "^3.2.4", + "ts-loader": "^9.5.1", + "typescript": "~5.5.0", + "typescript-eslint": "^8.57.2", + "vscode-tmgrammar-test": "^0.1.3", + "webpack": "^5.93.0", + "webpack-cli": "^5.1.4" + }, + "dependencies": { + "@types/yargs-parser": "^21.0.3", + "vscode-languageclient": "^9.0.1", + "yargs-parser": "^21.1.1" + } +} \ No newline at end of file diff --git a/src/codeLens.ts b/src/codeLens.ts new file mode 100644 index 0000000..5dc3504 --- /dev/null +++ b/src/codeLens.ts @@ -0,0 +1,82 @@ +import * as vscode from 'vscode'; + +/** + * Regex to match recipe definitions in a justfile. + * A recipe starts at column 0: name, optional params, then `:`. + * Negative lookahead excludes `:=` (var/export assignments). + * Captures the recipe name and whether it has parameters. + */ +const RECIPE_REGEX = + /^([a-zA-Z_][a-zA-Z0-9_-]*)\s*((?:\+?[a-zA-Z_][a-zA-Z0-9_-]*(?:=("[^"]*"|'[^']*'|\S*))?\s*)*):(?!=)/gm; + +export class RecipeCodeLensProvider + implements vscode.CodeLensProvider, vscode.Disposable +{ + private emitter = new vscode.EventEmitter(); + readonly onDidChangeCodeLenses = this.emitter.event; + private subscription: vscode.Disposable; + + constructor() { + this.subscription = vscode.workspace.onDidChangeTextDocument((e) => { + if (e.document.languageId === 'just') { + this.emitter.fire(); + } + }); + } + + refresh(): void { + this.emitter.fire(); + } + + provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { + const lenses: vscode.CodeLens[] = []; + const justfilePath = document.uri.fsPath; + + for (const match of document.getText().matchAll(RECIPE_REGEX)) { + const name = match[1]; + const hasParams = (match[2]?.trim() ?? '').length > 0; + const range = document.lineAt(document.positionAt(match.index!).line).range; + + lenses.push(this.makeRunLens(range, name, justfilePath)); + + if (hasParams) { + lenses.push(this.makeRunWithArgsLens(range, name, justfilePath)); + } + } + + return lenses; + } + + dispose(): void { + this.subscription.dispose(); + this.emitter.dispose(); + } + + private recipeArg(name: string, justfilePath: string) { + return { name, doc: '', parameters: [], groups: [], justfilePath }; + } + + private makeRunLens( + range: vscode.Range, + name: string, + justfilePath: string, + ): vscode.CodeLens { + return new vscode.CodeLens(range, { + title: '▶ Run', + command: 'vscode-just.runRecipeFromTree', + arguments: [this.recipeArg(name, justfilePath)], + }); + } + + private makeRunWithArgsLens( + range: vscode.Range, + name: string, + justfilePath: string, + ): vscode.CodeLens { + return new vscode.CodeLens(range, { + title: '▶ Run with args', + command: 'vscode-just.runRecipeWithArgsFromTree', + arguments: [this.recipeArg(name, justfilePath)], + }); + } +} diff --git a/src/const.ts b/src/const.ts index 7111fbe..9873c88 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,12 +1,17 @@ export const EXTENSION_NAME = 'vscode-just'; export const COMMANDS = { - formatDocument: `${EXTENSION_NAME}.formatDocument`, - runRecipe: `${EXTENSION_NAME}.runRecipe`, + formatDocument: `${EXTENSION_NAME}.formatDocument`, + runRecipeFromTree: `${EXTENSION_NAME}.runRecipeFromTree`, + runRecipeWithArgsFromTree: `${EXTENSION_NAME}.runRecipeWithArgsFromTree`, + refreshRecipes: `${EXTENSION_NAME}.refreshRecipes`, + openJustfile: `${EXTENSION_NAME}.openJustfile`, + goToRecipe: `${EXTENSION_NAME}.goToRecipe`, }; export const SETTINGS = { - justPath: 'justPath', - lspPath: 'lspPath', - runInTerminal: 'runInTerminal', - useSingleTerminal: 'useSingleTerminal', - logLevel: 'logLevel', + justPath: 'justPath', + lspPath: 'lspPath', + runInTerminal: 'runInTerminal', + useSingleTerminal: 'useSingleTerminal', + logLevel: 'logLevel', + contributeTasks: 'contributeTasks', }; diff --git a/src/extension.ts b/src/extension.ts index e97042b..2266eef 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,59 +1,303 @@ +import { exec } from 'child_process'; +import * as path from 'path'; +import { promisify } from 'util'; import * as vscode from 'vscode'; -import { COMMANDS, EXTENSION_NAME } from './const'; +import { RecipeCodeLensProvider } from './codeLens'; +import { COMMANDS, EXTENSION_NAME, SETTINGS } from './const'; import { formatJustfileTempFile } from './format'; import { getLauncher } from './launcher'; import { getLogger } from './logger'; import { createLanguageClient, stopLanguageClient } from './lsp'; -import { runRecipeCommand } from './recipe'; +import { + JustfileGroup, + RecipeDecorationProvider, + RecipeTreeDataProvider, + RecipeTreeItem, +} from './recipeTree'; import { TaskProvider } from './tasks'; +import { RecipeParsed, RecipeResponse } from './types'; +import { getJustPath } from './utils'; + +let recipeTreeDataProvider: RecipeTreeDataProvider | undefined; export const activate = (context: vscode.ExtensionContext) => { - console.debug(`${EXTENSION_NAME} activated`); - - const documentFormatProviderDisposable = - vscode.languages.registerDocumentFormattingEditProvider('just', { - async provideDocumentFormattingEdits( - document: vscode.TextDocument, - ): Promise { - try { - const formattedText = await formatJustfileTempFile(document.getText()); - const fullRange = new vscode.Range(0, 0, document.lineCount, 0); - return [vscode.TextEdit.replace(fullRange, formattedText)]; - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - vscode.window.showErrorMessage(`Failed to format justfile: ${message}`); - return []; - } - }, + console.debug(`${EXTENSION_NAME} activated`); + + const documentFormatProviderDisposable = + vscode.languages.registerDocumentFormattingEditProvider('just', { + async provideDocumentFormattingEdits( + document: vscode.TextDocument, + ): Promise { + try { + const formattedText = await formatJustfileTempFile(document.getText()); + const fullRange = new vscode.Range(0, 0, document.lineCount, 0); + return [vscode.TextEdit.replace(fullRange, formattedText)]; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + vscode.window.showErrorMessage(`Failed to format justfile: ${message}`); + return []; + } + }, + }); + context.subscriptions.push(documentFormatProviderDisposable); + + context.subscriptions.push( + vscode.commands.registerCommand(COMMANDS.formatDocument, () => { + const editor = vscode.window.activeTextEditor; + if (editor && editor.document.languageId === 'just') { + vscode.commands.executeCommand('editor.action.formatDocument'); + } + }), + ); + + let currentTaskProvider: TaskProvider | undefined; + let taskRegistration: vscode.Disposable | undefined; + + const setupTaskProvider = () => { + const enabled = vscode.workspace + .getConfiguration(EXTENSION_NAME) + .get(SETTINGS.contributeTasks, false); + + if (!enabled) return; + + currentTaskProvider = new TaskProvider(); + taskRegistration = vscode.tasks.registerTaskProvider( + EXTENSION_NAME, + currentTaskProvider, + ); + }; + + const teardownTaskProvider = () => { + taskRegistration?.dispose(); + taskRegistration = undefined; + currentTaskProvider?.dispose(); + currentTaskProvider = undefined; + }; + + setupTaskProvider(); + + context.subscriptions.push({ + dispose: () => teardownTaskProvider(), + }); + + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration(`${EXTENSION_NAME}.${SETTINGS.contributeTasks}`)) { + teardownTaskProvider(); + setupTaskProvider(); + } + }), + ); + + recipeTreeDataProvider = new RecipeTreeDataProvider(); + const treeView = vscode.window.createTreeView('justRecipes', { + treeDataProvider: recipeTreeDataProvider, + showCollapseAll: false, }); - context.subscriptions.push(documentFormatProviderDisposable); - - context.subscriptions.push( - vscode.commands.registerCommand(COMMANDS.formatDocument, () => { - const editor = vscode.window.activeTextEditor; - if (editor && editor.document.languageId === 'just') { - vscode.commands.executeCommand('editor.action.formatDocument'); - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand(COMMANDS.runRecipe, async () => { - runRecipeCommand(); - }), - ); - - context.subscriptions.push( - vscode.tasks.registerTaskProvider(EXTENSION_NAME, new TaskProvider()), - ); - - createLanguageClient(); + context.subscriptions.push(treeView); + context.subscriptions.push(recipeTreeDataProvider); + + const decorationProvider = new RecipeDecorationProvider(); + context.subscriptions.push( + vscode.window.registerFileDecorationProvider(decorationProvider), + ); + + const recipeCodeLensProvider = new RecipeCodeLensProvider(); + context.subscriptions.push(recipeCodeLensProvider); + context.subscriptions.push( + vscode.languages.registerCodeLensProvider( + { language: 'just' }, + recipeCodeLensProvider, + ), + ); + + context.subscriptions.push( + vscode.workspace.onDidSaveTextDocument((doc) => { + if (doc.languageId === 'just') { + recipeTreeDataProvider!.refresh(); + recipeCodeLensProvider.refresh(); + currentTaskProvider?.refresh(); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + COMMANDS.runRecipeFromTree, + async (item?: RecipeTreeItem | RecipeParsed) => { + let recipe: RecipeParsed | undefined; + + if (item instanceof RecipeTreeItem) { + recipe = item.recipe; + } else if (item && 'name' in item && 'parameters' in item) { + recipe = item as RecipeParsed; + } + + if (!recipe) { + recipe = await pickRecipe(); + if (!recipe) return; + } + + const cwd = recipe.justfilePath ? path.dirname(recipe.justfilePath) : undefined; + const args = recipe.justfilePath + ? ['--justfile', recipe.justfilePath, recipe.name] + : [recipe.name]; + getLauncher().launchDedicated(getJustPath(), args, cwd); + }, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + COMMANDS.runRecipeWithArgsFromTree, + async (item?: RecipeTreeItem | RecipeParsed) => { + let recipe: RecipeParsed | undefined; + + if (item instanceof RecipeTreeItem) { + recipe = item.recipe; + } else if (item && 'recipe' in item) { + recipe = (item as { recipe: RecipeParsed }).recipe; + } else if (item && 'name' in item && 'parameters' in item) { + recipe = item as RecipeParsed; + } + + if (!recipe) { + recipe = await pickRecipe(); + if (!recipe) return; + } + + const enteredArgs: string[] = []; + + let params = recipe.parameters; + + if (params.length === 0 && recipe.justfilePath) { + params = await fetchRecipeParameters(recipe.justfilePath, recipe.name); + } + + for (const param of params) { + const paramLabel = param.kind === 'plus' ? `+${param.name}` : param.name; + const placeHolder = + param.default != null + ? `${paramLabel} (default: ${param.default})` + : paramLabel; + + const value = await vscode.window.showInputBox({ + title: `Just: ${recipe.name}`, + prompt: `Enter value for ${paramLabel}`, + placeHolder, + value: param.default ?? '', + }); + + if (value === undefined) return; // user cancelled + + if (value) { + enteredArgs.push(value); + } + } + + const cwd = recipe.justfilePath ? path.dirname(recipe.justfilePath) : undefined; + const args = recipe.justfilePath + ? ['--justfile', recipe.justfilePath, recipe.name, ...enteredArgs] + : [recipe.name, ...enteredArgs]; + getLauncher().launchDedicated(getJustPath(), args, cwd); + }, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand(COMMANDS.refreshRecipes, () => { + recipeTreeDataProvider!.refresh(); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand(COMMANDS.openJustfile, (item: JustfileGroup) => { + vscode.window.showTextDocument(item.justfileUri); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + COMMANDS.goToRecipe, + async (item: RecipeTreeItem) => { + const recipe = item.recipe; + if (!recipe.justfilePath) return; + + const doc = await vscode.workspace.openTextDocument(recipe.justfilePath); + const editor = await vscode.window.showTextDocument(doc); + + const text = doc.getText(); + const recipeRegex = new RegExp(`^${escapeRegex(recipe.name)}(\\s|:)`, 'gm'); + const match = recipeRegex.exec(text); + if (match) { + const pos = doc.positionAt(match.index); + editor.selection = new vscode.Selection(pos, pos); + editor.revealRange( + new vscode.Range(pos, pos), + vscode.TextEditorRevealType.InCenter, + ); + } + }, + ), + ); + + createLanguageClient(); }; export const deactivate = () => { - console.debug(`${EXTENSION_NAME} deactivated`); - getLogger().dispose(); - getLauncher().dispose(); - stopLanguageClient(); + console.debug(`${EXTENSION_NAME} deactivated`); + getLogger().dispose(); + getLauncher().dispose(); + stopLanguageClient(); }; + +const asyncExec = promisify(exec); + +async function fetchRecipeParameters( + justfilePath: string, + recipeName: string, +): Promise { + try { + const { stdout } = await asyncExec( + `${getJustPath()} --justfile "${justfilePath}" --dump --dump-format=json`, + ); + const data = JSON.parse(stdout); + const recipe: RecipeResponse | undefined = data.recipes?.[recipeName]; + if (!recipe) return []; + + return (recipe.parameters || []).map((p) => ({ + default: p.default, + kind: p.kind, + name: p.name, + })); + } catch { + return []; + } +} + +async function pickRecipe(): Promise { + if (!recipeTreeDataProvider) return; + + const allRecipes = recipeTreeDataProvider.getAllRecipes(); + if (allRecipes.length === 0) { + vscode.window.showInformationMessage('No recipes found in workspace.'); + return; + } + + const sorted = [...allRecipes].sort((a, b) => a.name.localeCompare(b.name)); + const items: vscode.QuickPickItem[] = sorted.map((r) => ({ + label: r.name, + description: r.doc || undefined, + })); + + const picked = await vscode.window.showQuickPick(items, { + placeHolder: 'Select a recipe to run', + }); + + return picked ? sorted.find((r) => r.name === picked.label) : undefined; +} + +function escapeRegex(name: string): string { + return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/launcher.ts b/src/launcher.ts index 2fc7ce2..448ac28 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -6,59 +6,91 @@ import { workspaceRoot } from './utils'; let LAUNCHER: Launcher; class Launcher implements vscode.Disposable { - private terminals: Set; + private terminals: Set; - private onTerminalClose = vscode.window.onDidCloseTerminal((terminal) => { - if (this.terminals.has(terminal)) { - this.terminals.delete(terminal); - } - }); - - constructor() { - this.terminals = new Set(); - } - - public launch(command: string, args: string[]) { - const terminalOptions: vscode.TerminalOptions = { - name: command, - env: process.env, - cwd: workspaceRoot(), - }; - - // Copied from Makefile launcher: - // https://github.com/microsoft/vscode-makefile-tools/blob/36a51746d263b6fc4a9054924c388d2c8a49ee1b/src/launch.ts#L445 - if (process.platform === 'win32') { - terminalOptions.shellPath = 'C:\\Windows\\System32\\cmd.exe'; + private dedicatedTerminal: vscode.Terminal | undefined; + + private onTerminalClose = vscode.window.onDidCloseTerminal((terminal) => { + if (this.terminals.has(terminal)) { + this.terminals.delete(terminal); + } + if (this.dedicatedTerminal === terminal) { + this.dedicatedTerminal = undefined; + } + }); + + constructor() { + this.terminals = new Set(); } - const reuseTerminal = vscode.workspace - .getConfiguration(EXTENSION_NAME) - .get(SETTINGS.useSingleTerminal); + public launch(command: string, args: string[]) { + const terminalOptions: vscode.TerminalOptions = { + name: command, + env: process.env, + cwd: workspaceRoot(), + }; + + // Copied from Makefile launcher: + // https://github.com/microsoft/vscode-makefile-tools/blob/36a51746d263b6fc4a9054924c388d2c8a49ee1b/src/launch.ts#L445 + if (process.platform === 'win32') { + terminalOptions.shellPath = 'C:\\Windows\\System32\\cmd.exe'; + } + + const reuseTerminal = vscode.workspace + .getConfiguration(EXTENSION_NAME) + .get(SETTINGS.useSingleTerminal); + + let terminal: vscode.Terminal; + if (reuseTerminal && this.terminals.size > 0) { + terminal = this.terminals.values().next().value!; + } else { + terminal = vscode.window.createTerminal(terminalOptions); + this.terminals.add(terminal); + } + + terminal.sendText(`${command} ${args.join(' ')}`); + terminal.show(); - let terminal: vscode.Terminal; - if (reuseTerminal && this.terminals.size > 0) { - terminal = this.terminals.values().next().value!; - } else { - terminal = vscode.window.createTerminal(terminalOptions); - this.terminals.add(terminal); + return terminal; } - terminal.sendText(`${command} ${args.join(' ')}`); - terminal.show(); + public launchDedicated(command: string, args: string[], cwd?: string) { + if (!this.dedicatedTerminal) { + const terminalOptions: vscode.TerminalOptions = { + name: 'just', + env: process.env, + cwd: cwd ?? workspaceRoot(), + }; - return terminal; - } + if (process.platform === 'win32') { + terminalOptions.shellPath = 'C:\\Windows\\System32\\cmd.exe'; + } - public dispose() { - this.terminals.forEach((terminal) => terminal.dispose()); - this.terminals.clear(); - this.onTerminalClose.dispose(); - } + this.dedicatedTerminal = vscode.window.createTerminal(terminalOptions); + } + + const cmdLine = cwd + ? `cd "${cwd}" && ${command} ${args.join(' ')}` + : `${command} ${args.join(' ')}`; + + this.dedicatedTerminal.sendText(cmdLine); + this.dedicatedTerminal.show(); + + return this.dedicatedTerminal; + } + + public dispose() { + this.terminals.forEach((terminal) => terminal.dispose()); + this.terminals.clear(); + this.dedicatedTerminal?.dispose(); + this.dedicatedTerminal = undefined; + this.onTerminalClose.dispose(); + } } export const getLauncher = () => { - if (!LAUNCHER) { - LAUNCHER = new Launcher(); - } - return LAUNCHER; + if (!LAUNCHER) { + LAUNCHER = new Launcher(); + } + return LAUNCHER; }; diff --git a/src/recipe.ts b/src/recipe.ts index 89ff1c2..1c9c22d 100644 --- a/src/recipe.ts +++ b/src/recipe.ts @@ -93,7 +93,7 @@ const parseRecipes = (output: string): RecipeParsed[] => { })); }; -const paramsToString = (params: RecipeParsed['parameters']): string => { +export const paramsToString = (params: RecipeParsed['parameters']): string => { return params .sort((a, b) => a.kind === RecipeParameterKind.PLUS ? 1 : a.name.localeCompare(b.name), @@ -110,6 +110,11 @@ const runRecipe = async (recipe: RecipeParsed, optionalArgs: yargsParser.Argumen const args = [recipe.name, ...optionalArgs._.map(String)]; LOGGER.info(`Running recipe: ${recipe.name} with args: ${args.join(' ')}`); + runRecipeWithArgs(args); +}; + +export const runRecipeWithArgs = (args: string[]) => { + LOGGER.info(`Running recipe with args: ${args.join(' ')}`); if (vscode.workspace.getConfiguration(EXTENSION_NAME).get(SETTINGS.runInTerminal)) { getLauncher().launch(getJustPath(), args); } else { diff --git a/src/recipeTree.ts b/src/recipeTree.ts new file mode 100644 index 0000000..7745c71 --- /dev/null +++ b/src/recipeTree.ts @@ -0,0 +1,219 @@ +import { exec } from 'child_process'; +import * as path from 'path'; +import { promisify } from 'util'; +import * as vscode from 'vscode'; + +import { RecipeParameter, RecipeParsed, RecipeResponse } from './types'; +import { getJustPath } from './utils'; + +const asyncExec = promisify(exec); + +const JUSTFILE_GLOB = '**/{justfile,Justfile,.justfile,*.just}'; + +export class RecipeTreeItem extends vscode.TreeItem { + constructor(public readonly recipe: RecipeParsed) { + super(recipe.name, vscode.TreeItemCollapsibleState.None); + + this.description = recipe.doc || undefined; + this.tooltip = recipe.doc ? `${recipe.name}\n${recipe.doc}` : recipe.name; + + const hasParams = recipe.parameters.length > 0; + this.contextValue = hasParams ? 'recipeWithParams' : 'recipe'; + + this.resourceUri = vscode.Uri.parse( + `just-recipe:/recipes/${recipe.name}?p=${hasParams ? '1' : '0'}`, + ); + + this.iconPath = hasParams + ? new vscode.ThemeIcon('gear', new vscode.ThemeColor('charts.yellow')) + : new vscode.ThemeIcon('play', new vscode.ThemeColor('charts.green')); + + if (hasParams) { + const paramList = recipe.parameters + .map((p) => `${p.kind === 'plus' ? '+' : ''}${p.name}`) + .join(', '); + this.description = paramList; + } + + this.command = { + command: 'vscode-just.goToRecipe', + title: 'Go to Recipe', + arguments: [this], + }; + } +} + +export class JustfileGroup extends vscode.TreeItem { + public readonly children: RecipeTreeItem[]; + + constructor( + public readonly justfileUri: vscode.Uri, + children: RecipeTreeItem[], + ) { + const wsFolder = vscode.workspace.getWorkspaceFolder(justfileUri); + const relPath = wsFolder + ? path.relative(wsFolder.uri.fsPath, justfileUri.fsPath) + : justfileUri.fsPath; + + const dir = path.dirname(relPath); + const base = path.basename(relPath); + + super(base, vscode.TreeItemCollapsibleState.Expanded); + + this.children = children; + this.description = `${dir !== '.' ? dir + '/ ' : ''}${children.length} recipe${children.length !== 1 ? 's' : ''}`; + this.tooltip = `${justfileUri.fsPath}\n${children.length} recipe${children.length !== 1 ? 's' : ''}`; + this.contextValue = 'justfile'; + this.iconPath = new vscode.ThemeIcon('file-text'); + + this.command = { + command: 'vscode-just.openJustfile', + title: 'Open File', + arguments: [this], + }; + } +} + +export class RecipeDecorationProvider implements vscode.FileDecorationProvider { + onDidChangeFileDecorations?: vscode.Event; + + provideFileDecoration(uri: vscode.Uri): vscode.ProviderResult { + if (uri.scheme !== 'just-recipe') { + return undefined; + } + + const query = JSON.parse(uri.query || '{}'); + const hasParams = query.p === '1'; + + if (hasParams) { + return { + badge: '~', + color: new vscode.ThemeColor('charts.yellow'), + tooltip: 'Has parameters', + }; + } + + return { + badge: '▶', + color: new vscode.ThemeColor('charts.green'), + tooltip: 'Run without arguments', + }; + } +} + +export class RecipeTreeDataProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private watcher: vscode.FileSystemWatcher | undefined; + private groups: JustfileGroup[] = []; + + constructor() { + this.refresh(); + this.setupFileWatcher(); + } + + private setupFileWatcher(): void { + this.watcher = vscode.workspace.createFileSystemWatcher(JUSTFILE_GLOB); + + this.watcher.onDidChange(() => this.refresh()); + this.watcher.onDidCreate(() => this.refresh()); + this.watcher.onDidDelete(() => this.refresh()); + } + + getTreeItem(element: vscode.TreeItem): vscode.TreeItem { + return element; + } + + getChildren(element?: vscode.TreeItem): vscode.TreeItem[] { + if (!element) { + return this.groups; + } + if (element instanceof JustfileGroup) { + return element.children; + } + return []; + } + + refresh(): void { + this.fetchAllRecipes().then(() => { + this._onDidChangeTreeData.fire(undefined); + }); + } + + private async fetchAllRecipes(): Promise { + try { + const justfileUris = await vscode.workspace.findFiles( + JUSTFILE_GLOB, + '**/node_modules/**', + ); + + justfileUris.sort((a, b) => a.fsPath.localeCompare(b.fsPath)); + + const results = await Promise.allSettled( + justfileUris.map((uri) => this.loadRecipesFromFile(uri)), + ); + + const newGroups: JustfileGroup[] = []; + for (const result of results) { + if (result.status === 'fulfilled' && result.value) { + newGroups.push(result.value); + } + } + this.groups = newGroups; + } catch { + /* silently ignore individual justfile errors */ + } + } + + private async loadRecipesFromFile( + justfileUri: vscode.Uri, + ): Promise { + const justfilePath = justfileUri.fsPath; + + try { + const { stdout } = await asyncExec( + `${getJustPath()} --justfile "${justfilePath}" --dump --dump-format=json`, + ); + + const data = JSON.parse(stdout); + const rawRecipes: RecipeResponse[] = Object.values(data.recipes || {}); + + const children = rawRecipes + .filter((r) => !r.private && !r.attributes?.some((a) => a === 'private')) + .map((r) => { + const parsed: RecipeParsed = { + name: r.name, + doc: r.doc || '', + parameters: (r.parameters || []).map((p: RecipeParameter) => ({ + default: p.default, + kind: p.kind, + name: p.name, + })), + groups: (r.attributes || []) + .filter( + (a): a is Record => + typeof a === 'object' && a !== null && 'group' in a, + ) + .map((a) => a.group), + justfilePath, + }; + return new RecipeTreeItem(parsed); + }); + + if (children.length === 0) return null; + return new JustfileGroup(justfileUri, children); + } catch { + return null; + } + } + + getAllRecipes(): RecipeParsed[] { + return this.groups.flatMap((g) => g.children.map((c) => c.recipe)); + } + + dispose(): void { + this.watcher?.dispose(); + this._onDidChangeTreeData.dispose(); + } +} diff --git a/src/tasks.ts b/src/tasks.ts index b1461b6..642502a 100644 --- a/src/tasks.ts +++ b/src/tasks.ts @@ -1,47 +1,211 @@ +import { exec } from 'child_process'; +import * as path from 'path'; +import { promisify } from 'util'; import * as vscode from 'vscode'; import { EXTENSION_NAME } from './const'; +import { RecipeResponse } from './types'; import { getJustPath } from './utils'; +const asyncExec = promisify(exec); + +const JUSTFILE_GLOB = '**/{justfile,Justfile,.justfile,*.just}'; + +interface RecipeWithSource { + recipe: RecipeResponse; + justfilePath: string; + fileLabel: string; +} + export interface TaskDefinition extends vscode.TaskDefinition { - task: string; - args?: string[]; + task: string; + args?: string[]; + justfilePath?: string; } export class TaskProvider implements vscode.TaskProvider { - public constructor() {} + private _onDidChangeTasks = new vscode.EventEmitter(); + readonly onDidChangeTasks: vscode.Event = this._onDidChangeTasks.event; - public provideTasks() { - return [getDefaultRecipeTask()]; - } + private watcher: vscode.FileSystemWatcher | undefined; + private allRecipes: RecipeWithSource[] | undefined; - public resolveTask(_task: vscode.Task) { - if (_task.definition.type !== EXTENSION_NAME) return undefined; + constructor() { + this.setupFileWatcher(); + } - const definition = _task.definition as TaskDefinition; + private setupFileWatcher(): void { + this.watcher = vscode.workspace.createFileSystemWatcher(JUSTFILE_GLOB); - return new vscode.Task( - definition, - _task.scope ?? vscode.TaskScope.Workspace, - definition.label ?? 'Run recipe', - definition.type, - new vscode.ShellExecution(definition.task, definition.args ?? []), - ); - } + const refresh = () => { + this.allRecipes = undefined; + this._onDidChangeTasks.fire(); + }; + + this.watcher.onDidChange(refresh); + this.watcher.onDidCreate(refresh); + this.watcher.onDidDelete(refresh); + } + + public async provideTasks(): Promise { + try { + const recipes = await this.getAllRecipes(); + if (recipes.length === 0) return [getDefaultRecipeTask()]; + + return recipes.map((r) => this.createTask(r)); + } catch { + return [getDefaultRecipeTask()]; + } + } + + public resolveTask(_task: vscode.Task): vscode.Task | undefined { + if (_task.definition.type !== EXTENSION_NAME) return undefined; + + const definition = _task.definition as TaskDefinition; + const args = definition.args ?? []; + const jp = definition.justfilePath; + const shellArgs = jp + ? ['--justfile', jp, definition.task, ...args] + : [definition.task, ...args]; + + return new vscode.Task( + definition, + _task.scope ?? vscode.TaskScope.Workspace, + definition.label ?? 'Run recipe', + definition.type, + new vscode.ShellExecution(getJustPath(), shellArgs), + ); + } + + public refresh(): void { + this.allRecipes = undefined; + this._onDidChangeTasks.fire(); + } + + private async getAllRecipes(): Promise { + if (this.allRecipes) return this.allRecipes; + + try { + const justfileUris = await vscode.workspace.findFiles( + JUSTFILE_GLOB, + '**/node_modules/**', + ); + + if (justfileUris.length === 0) { + this.allRecipes = []; + return []; + } + + const fileLabels = new Map( + justfileUris.map((uri) => { + const wsFolder = vscode.workspace.getWorkspaceFolder(uri); + const relPath = wsFolder + ? path.relative(wsFolder.uri.fsPath, uri.fsPath) + : uri.fsPath; + return [uri.fsPath, relPath] as const; + }), + ); + + const results = await Promise.allSettled( + justfileUris.map((uri) => this.fetchRecipesForFile(uri, fileLabels)), + ); + + this.allRecipes = results + .filter( + (r): r is PromiseFulfilledResult => + r.status === 'fulfilled', + ) + .flatMap((r) => r.value); + + return this.allRecipes; + } catch { + this.allRecipes = []; + return []; + } + } + + private async fetchRecipesForFile( + uri: vscode.Uri, + fileLabels: Map, + ): Promise { + const justfilePath = uri.fsPath; + const fileLabel = fileLabels.get(justfilePath) ?? path.basename(justfilePath); + + try { + const { stdout } = await asyncExec( + `${getJustPath()} --justfile "${justfilePath}" --dump --dump-format=json`, + ); + const data = JSON.parse(stdout); + return (Object.values(data.recipes || {}) as RecipeResponse[]) + .filter((r) => !r.private && !r.attributes?.some((attr) => attr === 'private')) + .map((recipe) => ({ recipe, justfilePath, fileLabel })); + } catch { + return []; + } + } + + private createTask(r: RecipeWithSource): vscode.Task { + const { recipe, justfilePath, fileLabel } = r; + + const label = this.multipleFiles() ? `${fileLabel}: ${recipe.name}` : recipe.name; + + const args = this.buildArgs(recipe); + const shellArgs = ['--justfile', justfilePath, recipe.name, ...args]; + + const definition: TaskDefinition = { + type: EXTENSION_NAME, + task: recipe.name, + args, + justfilePath, + }; + + const task = new vscode.Task( + definition, + vscode.TaskScope.Workspace, + label, + EXTENSION_NAME, + new vscode.ShellExecution(getJustPath(), shellArgs), + ); + + task.detail = recipe.doc || undefined; + task.presentationOptions = { + showReuseMessage: false, + close: false, + }; + + return task; + } + + private buildArgs(recipe: RecipeResponse): string[] { + return (recipe.parameters || []) + .filter((p) => p.default != null && p.default !== '') + .map((p) => p.default); + } + + private multipleFiles(): boolean { + if (!this.allRecipes) return false; + const paths = new Set(this.allRecipes.map((r) => r.justfilePath)); + return paths.size > 1; + } + + dispose(): void { + this.watcher?.dispose(); + this._onDidChangeTasks.dispose(); + } } export const getDefaultRecipeTask = () => { - const runDefaultRecipeTask = new vscode.Task( - { type: EXTENSION_NAME, task: 'just' }, - vscode.TaskScope.Workspace, - 'Run default recipe', - EXTENSION_NAME, - new vscode.ShellExecution(getJustPath()), - ); - runDefaultRecipeTask.presentationOptions = { - showReuseMessage: false, - close: false, - }; - - return runDefaultRecipeTask; + const runDefaultRecipeTask = new vscode.Task( + { type: EXTENSION_NAME, task: 'just' }, + vscode.TaskScope.Workspace, + 'Run default recipe', + EXTENSION_NAME, + new vscode.ShellExecution(getJustPath()), + ); + runDefaultRecipeTask.presentationOptions = { + showReuseMessage: false, + close: false, + }; + + return runDefaultRecipeTask; }; diff --git a/src/types.ts b/src/types.ts index a7915ce..e724000 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,4 +24,5 @@ export interface RecipeParsed { doc: string; parameters: Pick[]; groups: string[]; + justfilePath?: string; } diff --git a/syntaxes/just.tmLanguage.json b/syntaxes/just.tmLanguage.json index 770fd1f..6d66074 100644 --- a/syntaxes/just.tmLanguage.json +++ b/syntaxes/just.tmLanguage.json @@ -2,7 +2,11 @@ "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", "name": "Just", "scopeName": "source.just", - "fileTypes": ["just", "justfile", "Justfile"], + "fileTypes": [ + "just", + "justfile", + "Justfile" + ], "firstLineMatch": "#![\\s\\t]*\\/.*\\just\\b", "uuid": "8b0cfae0-229f-4688-a4b7-8b5c3db82855", "patterns": [