diff --git a/README.md b/README.md index bd127fe..fa851d1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,29 @@ # Bash Options -Provide the options to export a string from bash command as variable +A VS Code extension offering a suite of refactoring and utility tools for Shell/Bash scripting. + - \ No newline at end of file +## Features + +This extension provides 10 powerful features to speed up your shell scripting workflow: + +### Refactoring +1. **Extract Variable (Interactive)**: Select a string and convert it into an exported variable. Prompts for the variable name. +2. **Extract Function**: Select a block of code and wrap it into a function. Prompts for the function name and places the function definition at the top of the file. +3. **Toggle Quotes**: Switch between single (`'`) and double (`"`) quotes for the selected text. +4. **Toggle Parameter Expansion**: Switch between simple (`$VAR`) and brace (`${VAR}`) parameter expansion. +5. **Convert to HereDoc**: Convert the selected text into a `cat < { + editBuilder.insert(insertPos, `${indent}${echoStmt}\n`); + }); +} diff --git a/src/commands/extractFunction.ts b/src/commands/extractFunction.ts new file mode 100644 index 0000000..5984872 --- /dev/null +++ b/src/commands/extractFunction.ts @@ -0,0 +1,53 @@ +import * as vscode from 'vscode'; + +export async function extractFunction() { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + const selection = editor.selection; + if (selection.isEmpty) { + return; + } + + const text = editor.document.getText(selection); + + const functionName = await vscode.window.showInputBox({ + prompt: 'Enter function name' + }); + + if (!functionName) { + return; + } + + // Find a good place to insert the function. + // Ideally at the top of the file, after shebang/comments, or before the current block. + // Let's put it before the current "block" if we can determine indentation, or just before current line? + // Or just at the top (after shebang). + + // Strategy: Insert at top of file, after shebang. + // If no shebang, insert at top. + + const doc = editor.document; + let insertLine = 0; + if (doc.lineAt(0).text.startsWith('#!')) { + insertLine = 1; + // Skip subsequent comments? + while (insertLine < doc.lineCount && doc.lineAt(insertLine).text.startsWith('#')) { + insertLine++; + } + // Skip empty lines + while (insertLine < doc.lineCount && doc.lineAt(insertLine).isEmptyOrWhitespace) { + insertLine++; + } + } + + const insertPos = new vscode.Position(insertLine, 0); + + const functionCode = `${functionName}() {\n${text}\n}\n\n`; + + editor.edit(editBuilder => { + editBuilder.insert(insertPos, functionCode); + editBuilder.replace(selection, functionName); + }); +} diff --git a/src/commands/extractVariable.ts b/src/commands/extractVariable.ts new file mode 100644 index 0000000..2d6edf0 --- /dev/null +++ b/src/commands/extractVariable.ts @@ -0,0 +1,85 @@ +import * as vscode from 'vscode'; + +export async function extractVariable() { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + + const selection = editor.selection; + if (selection.isEmpty) { + return; + } + + let line = editor.document.lineAt(selection.start.line); + const text = editor.document.getText(selection); + + if (line.lineNumber > 0) { + // Find previous line which does not end with a backslash + // Loop backwards to find the start of the command if it's multi-line + let prevLineNum = line.lineNumber - 1; + while (prevLineNum >= 0) { + const prevLine = editor.document.lineAt(prevLineNum); + if (!prevLine.text.endsWith('\\')) { + break; + } + prevLineNum--; + } + // The insert position should be after that "clean" line (so at prevLineNum + 1) + // Original logic: + // line = editor.document.lineAt(line.lineNumber - 1); + // while (line.text.endsWith('\\')) ... + // line = editor.document.lineAt(line.lineNumber + 1); + + // Let's replicate the original logic carefully but safely + let checkLine = editor.document.lineAt(line.lineNumber - 1); + while (checkLine.lineNumber > 0 && checkLine.text.endsWith('\\')) { + checkLine = editor.document.lineAt(checkLine.lineNumber - 1); + } + // If we stopped because no backslash, we want the line AFTER that one. + // If we stopped because lineNumber 0, we check if it has backslash? + // Actually, the logic is "Find the start of the current command block". + // The original logic: + // line = editor.document.lineAt(line.lineNumber - 1); + // while (line.text.endsWith('\\')) { line = editor.document.lineAt(line.lineNumber - 1); } + // line = editor.document.lineAt(line.lineNumber + 1); + + // If line 0 has backslash, loop might crash if not careful with indexes. + // But original code didn't check >0 inside while. + + // Improved logic: + let insertLineIndex = line.lineNumber; + if (insertLineIndex > 0) { + let i = insertLineIndex - 1; + while(i >= 0) { + const l = editor.document.lineAt(i); + if (!l.text.trim().endsWith('\\')) { + break; + } + i--; + } + insertLineIndex = i + 1; + } + + line = editor.document.lineAt(insertLineIndex); + } + + const position = new vscode.Position(line.lineNumber, 0); + + // Prompt for variable name (Batch 3 feature, but implementing now as we are refactoring) + const variableName = await vscode.window.showInputBox({ + prompt: 'Enter variable name', + value: 'var_' + text.replace(/[^a-zA-Z0-9]/g, '').toLowerCase() + }); + + if (!variableName) { + return; + } + + editor.edit(editBuilder => { + // Escape single quotes in the text + const escapedText = text.replace(/'/g, "'\\''"); + editBuilder.insert(position, `export ${variableName}='${escapedText}'\n`); + editBuilder.replace(selection, `$${variableName}`); + }); +} diff --git a/src/commands/insertShebang.ts b/src/commands/insertShebang.ts new file mode 100644 index 0000000..8dc375c --- /dev/null +++ b/src/commands/insertShebang.ts @@ -0,0 +1,20 @@ +import * as vscode from 'vscode'; + +export async function insertShebang() { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + + const doc = editor.document; + const firstLine = doc.lineAt(0); + + if (firstLine.text.startsWith('#!')) { + vscode.window.showInformationMessage('Shebang already exists.'); + return; + } + + editor.edit(editBuilder => { + editBuilder.insert(new vscode.Position(0, 0), '#!/bin/bash\n'); + }); +} diff --git a/src/commands/makeExecutable.ts b/src/commands/makeExecutable.ts new file mode 100644 index 0000000..0cceaab --- /dev/null +++ b/src/commands/makeExecutable.ts @@ -0,0 +1,22 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; + +export async function makeExecutable() { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + + const doc = editor.document; + if (doc.isUntitled) { + vscode.window.showWarningMessage('Please save the file first.'); + return; + } + + try { + await fs.promises.chmod(doc.fileName, 0o755); + vscode.window.showInformationMessage(`Marked "${doc.fileName}" as executable.`); + } catch (error) { + vscode.window.showErrorMessage(`Failed to make executable: ${error}`); + } +} diff --git a/src/commands/toggleParameterExpansion.ts b/src/commands/toggleParameterExpansion.ts new file mode 100644 index 0000000..0036ad8 --- /dev/null +++ b/src/commands/toggleParameterExpansion.ts @@ -0,0 +1,75 @@ +import * as vscode from 'vscode'; + +export async function toggleParameterExpansion() { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + + const selection = editor.selection; + let range = new vscode.Range(selection.start, selection.end); + let text = editor.document.getText(range); + + // If selection is empty, try to get word under cursor + if (selection.isEmpty) { + const wordRange = editor.document.getWordRangeAtPosition(selection.active); + if (wordRange) { + range = wordRange; + text = editor.document.getText(range); + } + } + + // Check surrounding characters for $ and {} + // We need to look a bit wider if the selection is just the var name. + + // Simplest approach: Text provided is the variable name (e.g. VAR) or $VAR or ${VAR} + + // Case 1: ${VAR} -> $VAR + if (text.startsWith('${') && text.endsWith('}')) { + const content = text.substring(2, text.length - 1); + // Ensure content is a valid identifier for simple expansion + if (/^[a-zA-Z0-9_]+$/.test(content)) { + editor.edit(editBuilder => { + editBuilder.replace(range, `$${content}`); + }); + return; + } + } + + // Case 2: $VAR -> ${VAR} + if (text.startsWith('$')) { + const content = text.substring(1); + if (!content.startsWith('{')) { + editor.edit(editBuilder => { + editBuilder.replace(range, `\${${content}}`); + }); + return; + } + } + + // Case 3: VAR -> ${VAR} (Assuming user selected just the name) + if (/^[a-zA-Z0-9_]+$/.test(text)) { + // check if preceded by $ + const startPos = range.start; + if (startPos.character > 0) { + const prevCharRange = new vscode.Range(startPos.translate(0, -1), startPos); + const prevChar = editor.document.getText(prevCharRange); + if (prevChar === '$') { + // It is $VAR, convert to ${VAR} + // We need to replace $VAR with ${VAR} + const fullRange = new vscode.Range(startPos.translate(0, -1), range.end); + editor.edit(editBuilder => { + editBuilder.replace(fullRange, `\${${text}}`); + }); + return; + } + } + + // If not preceded by $, assume it's just a string, wrap it? + // Maybe user wants to turn `echo var` into `echo ${var}`? + // Let's assume they want to turn a bare word into a variable expansion. + editor.edit(editBuilder => { + editBuilder.replace(range, `\${${text}}`); + }); + } +} diff --git a/src/commands/toggleQuotes.ts b/src/commands/toggleQuotes.ts new file mode 100644 index 0000000..185ff4b --- /dev/null +++ b/src/commands/toggleQuotes.ts @@ -0,0 +1,61 @@ +import * as vscode from 'vscode'; + +export async function toggleQuotes() { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + + const selection = editor.selection; + if (selection.isEmpty) { + // Try to find if cursor is inside quotes? + // For simplicity, let's require selection for now, or just select the word. + // If empty, let's select current word. + const range = editor.document.getWordRangeAtPosition(selection.active); + if (!range) { + return; + } + // Check if the range is surrounded by quotes + const start = range.start; + const end = range.end; + // Expand to check quotes? + // This is getting complex without parsing. + // Let's stick to "Selection Required" or operate on word if simple. + return; + } + + const text = editor.document.getText(selection); + + // Check if whole text is single quoted + if (text.startsWith("'") && text.endsWith("'") && text.length >= 2) { + const content = text.substring(1, text.length - 1); + // Convert to double quotes + // Escape existing double quotes + const newContent = content.replace(/"/g, '\\"'); + editor.edit(editBuilder => { + editBuilder.replace(selection, `"${newContent}"`); + }); + return; + } + + // Check if whole text is double quoted + if (text.startsWith('"') && text.endsWith('"') && text.length >= 2) { + const content = text.substring(1, text.length - 1); + // Convert to single quotes + // Unescape escaped double quotes + const unescaped = content.replace(/\\"/g, '"'); + // Escape single quotes (Bash style: ' -> '"'"') + const newContent = unescaped.replace(/'/g, `'"'"'`); + editor.edit(editBuilder => { + editBuilder.replace(selection, `'${newContent}'`); + }); + return; + } + + // If not quoted, wrap in double quotes? Or single? + // Let's wrap in double quotes as default. + const newContent = text.replace(/"/g, '\\"'); + editor.edit(editBuilder => { + editBuilder.replace(selection, `"${newContent}"`); + }); +} diff --git a/src/commands/wrapInFor.ts b/src/commands/wrapInFor.ts new file mode 100644 index 0000000..e65cf4b --- /dev/null +++ b/src/commands/wrapInFor.ts @@ -0,0 +1,28 @@ +import * as vscode from 'vscode'; + +export async function wrapInFor() { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + const selection = editor.selection; + if (selection.isEmpty) { + return; + } + + const text = editor.document.getText(selection); + // Indent the text + const lines = text.split('\n'); + const tabSize = editor.options.tabSize; + const insertSpaces = editor.options.insertSpaces; + const indent = insertSpaces ? ' '.repeat(Number(tabSize)) : '\t'; + const indentedText = lines.map(l => indent + l).join('\n'); + + const snippet = new vscode.SnippetString( + 'for ${1:i} in ${2:list}; do\n' + + indentedText + '\n' + + 'done' + ); + + editor.insertSnippet(snippet, selection); +} diff --git a/src/commands/wrapInIf.ts b/src/commands/wrapInIf.ts new file mode 100644 index 0000000..d4ed5ed --- /dev/null +++ b/src/commands/wrapInIf.ts @@ -0,0 +1,28 @@ +import * as vscode from 'vscode'; + +export async function wrapInIf() { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + const selection = editor.selection; + if (selection.isEmpty) { + return; + } + + const text = editor.document.getText(selection); + // Indent the text + const lines = text.split('\n'); + const tabSize = editor.options.tabSize; + const insertSpaces = editor.options.insertSpaces; + const indent = insertSpaces ? ' '.repeat(Number(tabSize)) : '\t'; + const indentedText = lines.map(l => indent + l).join('\n'); + + const snippet = new vscode.SnippetString( + 'if [ ${1:condition} ]; then\n' + + indentedText + '\n' + + 'fi' + ); + + editor.insertSnippet(snippet, selection); +} diff --git a/src/extension.ts b/src/extension.ts index 3223da3..ee3f585 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,52 +1,38 @@ import * as vscode from 'vscode'; +import { extractVariable } from './commands/extractVariable'; +import { makeExecutable } from './commands/makeExecutable'; +import { insertShebang } from './commands/insertShebang'; +import { toggleQuotes } from './commands/toggleQuotes'; +import { toggleParameterExpansion } from './commands/toggleParameterExpansion'; +import { wrapInIf } from './commands/wrapInIf'; +import { wrapInFor } from './commands/wrapInFor'; +import { convertToHereDoc } from './commands/convertToHereDoc'; +import { extractFunction } from './commands/extractFunction'; +import { debugPrint } from './commands/debugPrint'; // this method is called when your extension is activated // your extension is activated the very first time the command is executed export function activate(context: vscode.ExtensionContext) { - // Use the console to output diagnostic information (console.log) and errors (console.error) - // This line of code will only be executed once when your extension is activated - console.log('Congratulations, your extension "extension" is now active!'); - - //Define the command for bash.extractVariable - let bashExtractVariableCommand = vscode.commands.registerCommand('bash.extractVariable', () => { - - // Get complete text document - let editor = vscode.window.activeTextEditor; - if (editor) { - // Get the text document's selection - let selection = editor.selection; - if (!selection.isEmpty) { - - // Get the text document's current line - let line = editor.document.lineAt(selection.start.line); - // Get text from the selection - let text = editor.document.getText(selection); - - if(line.lineNumber > 0) { - // Find previous line which does not end with a backslash - line = editor.document.lineAt(line.lineNumber - 1); - while (line.text.endsWith('\\')) { - line = editor.document.lineAt(line.lineNumber - 1); - } - line = editor.document.lineAt(line.lineNumber + 1); - } - - // Convert line number into postiion - let position = new vscode.Position(line.lineNumber, 0); - - editor.edit(editBuilder => { - // Generate a random variable name from the text - let variableName = 'var_' + text.replace(/[^a-zA-Z0-9]/g, '').toLowerCase(); - - //Add a line before the selection - editBuilder.insert(position, `export ${variableName}='${text}'\n`); - - // Update the selection with $ infront of the word - editBuilder.replace(selection, `$${variableName}`); - }); - } - } - }); + console.log('Congratulations, your extension "bash-options" is now active!'); + + // Register commands + // Batch 1 & Refactor + context.subscriptions.push(vscode.commands.registerCommand('bash.extractVariable', extractVariable)); + + // Batch 1 + context.subscriptions.push(vscode.commands.registerCommand('bash.makeExecutable', makeExecutable)); + context.subscriptions.push(vscode.commands.registerCommand('bash.insertShebang', insertShebang)); + context.subscriptions.push(vscode.commands.registerCommand('bash.toggleQuotes', toggleQuotes)); + context.subscriptions.push(vscode.commands.registerCommand('bash.toggleParameterExpansion', toggleParameterExpansion)); + + // Batch 2 + context.subscriptions.push(vscode.commands.registerCommand('bash.wrapInIf', wrapInIf)); + context.subscriptions.push(vscode.commands.registerCommand('bash.wrapInFor', wrapInFor)); + context.subscriptions.push(vscode.commands.registerCommand('bash.convertToHereDoc', convertToHereDoc)); + + // Batch 3 + context.subscriptions.push(vscode.commands.registerCommand('bash.extractFunction', extractFunction)); + context.subscriptions.push(vscode.commands.registerCommand('bash.debugPrint', debugPrint)); } // this method is called when your extension is deactivated diff --git a/src/test/suite.test.ts b/src/test/suite.test.ts new file mode 100644 index 0000000..b13f0a9 --- /dev/null +++ b/src/test/suite.test.ts @@ -0,0 +1,12 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +// import * as myExtension from '../../extension'; + +suite('Extension Test Suite', () => { + vscode.window.showInformationMessage('Start all tests.'); + + test('Sample test', () => { + assert.strictEqual(-1, [1, 2, 3].indexOf(5)); + assert.strictEqual(-1, [1, 2, 3].indexOf(0)); + }); +});