diff --git a/README.md b/README.md index f4931f2..6deab78 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,20 @@ Simple VS Code Extension to toggle text features! With `vext` commands you can.. - Keybinding: Cmd+Opt+a (_Mac_), Ctrl+Alt+a (_Other_) - Settings: - `vext.caseExtraWordChars`: Additional characters that will be considered a part of `\w` when parsing words to toggle case. For example, if '-' is specified, then 'super-secret' would be considered a single word. Defaults to _most_ special characters. +*** + +- `Toggle URL Encoding`: Toggle a word or selection to URL-encoded and back + + ![URL Encoding Demo](resources/demos/url-encode.gif) + - Keybinding: Cmd+Opt+u (_Mac_), Ctrl+Alt+u (_Other_) + - Settings: N/A +*** + +- `Toggle Base64 Encoding`: Toggle a word or selection to base64-encoded and back + + ![Base64 Encoding Demo](resources/demos/base64-encode.gif) + - Keybinding: Cmd+Opt+6 (_Mac_), Ctrl+Alt+6 (_Other_) + - Settings: N/A ## Keybindings diff --git a/package.json b/package.json index 34c487b..ed6a7f2 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,14 @@ "command": "vext.toggleCase", "title": "Toggle Text Casing" }, + { + "command": "vext.toggleUrlEncoding", + "title": "Toggle URL Encoding" + }, + { + "command": "vext.toggleBase64Encoding", + "title": "Toggle Base64 Encoding" + }, { "command": "vext.toggleVariableNamingFormat", "title": "Toggle Variable Naming Format" @@ -107,6 +115,16 @@ "key": "ctrl+alt+a", "mac": "cmd+alt+a" }, + { + "command": "vext.toggleUrlEncoding", + "key": "ctrl+alt+u", + "mac": "cmd+alt+u" + }, + { + "command": "vext.toggleBase64Encoding", + "key": "ctrl+alt+6", + "mac": "cmd+alt+6" + }, { "command": "vext.toggleVariableNamingFormat", "key": "ctrl+alt+v", diff --git a/resources/demos/base64-encode.gif b/resources/demos/base64-encode.gif new file mode 100644 index 0000000..433fadc Binary files /dev/null and b/resources/demos/base64-encode.gif differ diff --git a/resources/demos/url-encode.gif b/resources/demos/url-encode.gif new file mode 100644 index 0000000..dfe2b07 Binary files /dev/null and b/resources/demos/url-encode.gif differ diff --git a/src/commands/index.ts b/src/commands/index.ts index ca7d483..64778cf 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,10 +1,12 @@ import vscode from 'vscode'; +import { TOGGLE_BASE64_ENCODING_CMD, toggleBase64Encoding } from './toggleBase64Encoding'; import { toggleCase, TOGGLE_CASE_CMD } from './toggleCase'; import { toggleCommentType, TOGGLE_COMMENT_TYPE_CMD } from './toggleCommentType'; import { toggleJsonToJsToYaml, TOGGLE_JSON_TO_JS_TO_YAML_CMD } from './toggleJsonToJsToYaml'; import { TOGGLE_NEWLINE_CHARS_CMD, toggleNewlineChars } from './toggleNewlineChars'; import { toggleQuotes, TOGGLE_QUOTES_CMD } from './toggleQuotes'; +import { TOGGLE_URL_ENCODING_CMD, toggleUrlEncoding } from './toggleUrlEncoding'; import { toggleVariableNamingFormat, TOGGLE_VARIABLE_NAMING_FORMAT_CMD } from './toggleVariableNamingFormat'; import { EXTENSION_NAME } from '../constants'; @@ -14,6 +16,11 @@ export function registerAllCommands(context: vscode.ExtensionContext): void { vscode.commands.registerTextEditorCommand(`${EXTENSION_NAME}.${TOGGLE_COMMENT_TYPE_CMD}`, toggleCommentType), vscode.commands.registerTextEditorCommand(`${EXTENSION_NAME}.${TOGGLE_QUOTES_CMD}`, toggleQuotes), vscode.commands.registerTextEditorCommand(`${EXTENSION_NAME}.${TOGGLE_CASE_CMD}`, toggleCase), + vscode.commands.registerTextEditorCommand(`${EXTENSION_NAME}.${TOGGLE_URL_ENCODING_CMD}`, toggleUrlEncoding), + vscode.commands.registerTextEditorCommand( + `${EXTENSION_NAME}.${TOGGLE_BASE64_ENCODING_CMD}`, + toggleBase64Encoding + ), vscode.commands.registerTextEditorCommand( `${EXTENSION_NAME}.${TOGGLE_VARIABLE_NAMING_FORMAT_CMD}`, toggleVariableNamingFormat diff --git a/src/commands/shared/regexBasedBinaryToggle.ts b/src/commands/shared/regexBasedBinaryToggle.ts new file mode 100644 index 0000000..43afc78 --- /dev/null +++ b/src/commands/shared/regexBasedBinaryToggle.ts @@ -0,0 +1,47 @@ +import vscode from 'vscode'; + +import { CursorWordOptions } from '../../types'; +import { getCursorWordAsSelection, handleError, isHighlightedSelection } from '../../utils'; + +/** + * This function will use a regex to test a selection or word, transforming it according to provided transformFn. + * It allows for a multiline selection, but assumes all lines follow the pattern of the first line. + */ +export async function regexBasedBinaryToggle( + editor: vscode.TextEditor, + regex: RegExp, + transformFn: (str: string, isMatch: boolean) => string, + cursorWordOptions?: CursorWordOptions +): Promise { + await handleError(async () => { + const selectionsToToggle: vscode.Selection[] = []; + + // Will have multiple selections if multi-line cursor is used + for (const selection of editor.selections) { + if (isHighlightedSelection(selection)) { + // Toggle the whole selection + selectionsToToggle.push(selection); + } else { + // Toggle the current word the cursor is in + const cursorSelection = getCursorWordAsSelection(editor, selection, cursorWordOptions); + selectionsToToggle.push(cursorSelection); + } + } + + if (selectionsToToggle.length) { + // Use first selection to drive pattern decision for all others + const isMatch = regex.test(editor.document.getText(selectionsToToggle[0])); + + await editor.edit((builder) => { + for (const selection of selectionsToToggle) { + const newText = transformFn(editor.document.getText(selection), isMatch); + builder.replace(selection, newText); + } + }); + /* c8 ignore next 4 */ + } else { + // I don't know if this can happen + throw Error('No selections found!'); + } + }); +} diff --git a/src/commands/toggleBase64Encoding.ts b/src/commands/toggleBase64Encoding.ts new file mode 100644 index 0000000..1628935 --- /dev/null +++ b/src/commands/toggleBase64Encoding.ts @@ -0,0 +1,34 @@ +import vscode from 'vscode'; + +import { regexBasedBinaryToggle } from './shared/regexBasedBinaryToggle'; +import { handleError } from '../utils'; + +export const TOGGLE_BASE64_ENCODING_CMD = 'toggleBase64Encoding'; + +/** + * When cursor is in the middle of a word--or there is an explicit selection--toggle the base64 encoding. + * + * @param editor the vscode TextEditor object + */ +export async function toggleBase64Encoding(editor: vscode.TextEditor): Promise { + await handleError(async () => { + // NOTE: Base64 attempts to translate 3 ascii/utf digits into 4 encoded digits using a defined set of 64 characters. + // Equals signs may appear at end of encoded string if number of input string characters isn't cleanly divisible by 3, + // which effectively pads the length of the encoded string to be divisible by 4 + const urlEncodedRegex = /^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$/; + await regexBasedBinaryToggle(editor, urlEncodedRegex, transform, { useWhitespaceDelimiter: true }); + }); +} + +function transform(originalText: string, isBase64Encoded: boolean): string { + // TODO: Use base64 here + return isBase64Encoded ? decodeBase64(originalText) : encodeBase64(originalText); +} + +function encodeBase64(plaintextStr: string): string { + return Buffer.from(plaintextStr).toString('base64'); +} + +function decodeBase64(base64Str: string): string { + return Buffer.from(base64Str, 'base64').toString('utf-8'); +} diff --git a/src/commands/toggleCase.ts b/src/commands/toggleCase.ts index fc9e780..3f530b7 100644 --- a/src/commands/toggleCase.ts +++ b/src/commands/toggleCase.ts @@ -1,9 +1,10 @@ import _ from 'lodash'; import vscode from 'vscode'; +import { regexBasedBinaryToggle } from './shared/regexBasedBinaryToggle'; import { getConfig } from '../configuration'; import { CASE_EXTRA_WORD_CHARS } from '../configuration/configuration.constants'; -import { getCursorWordAsSelection, handleError, isHighlightedSelection } from '../utils'; +import { handleError } from '../utils'; export const TOGGLE_CASE_CMD = 'toggleCase'; @@ -14,43 +15,12 @@ export const TOGGLE_CASE_CMD = 'toggleCase'; */ export async function toggleCase(editor: vscode.TextEditor): Promise { await handleError(async () => { - const caseExtraWordChars = getConfig(CASE_EXTRA_WORD_CHARS); - const selectionsToToggle: vscode.Selection[] = []; - - // Will have multiple selections if multi-line cursor is used - for (const selection of editor.selections) { - if (isHighlightedSelection(selection)) { - // Toggle the whole selection - selectionsToToggle.push(selection); - } else { - // Toggle the current word the cursor is in - const cursorSelection = getCursorWordAsSelection(editor, selection, caseExtraWordChars); - selectionsToToggle.push(cursorSelection); - } - } - - if (selectionsToToggle.length) { - // Use first selection to drive casing decision for all others - const transformFn = getCaseTransformFn(editor.document.getText(selectionsToToggle[0])); - - await editor.edit((builder) => { - for (const selection of selectionsToToggle) { - const newText = transformFn(editor.document.getText(selection)); - builder.replace(selection, newText); - } - }); - /* c8 ignore next 4 */ - } else { - // I don't know if this can happen - throw Error('No selections found!'); - } + const extraWordChars = getConfig(CASE_EXTRA_WORD_CHARS); + const hasLowercaseRegex = /[a-z]/; + await regexBasedBinaryToggle(editor, hasLowercaseRegex, transform, { extraWordChars }); }); } -/** - * Get target casing transform function (i.e., upper or lower) based on current text. - */ -function getCaseTransformFn(originalText: string): (s: string) => string { - const hasLowercase = /[a-z]/.test(originalText); - return hasLowercase ? _.toUpper : _.toLower; +function transform(originalText: string, hasLowercase: boolean): string { + return hasLowercase ? _.toUpper(originalText) : _.toLower(originalText); } diff --git a/src/commands/toggleQuotes.ts b/src/commands/toggleQuotes.ts index 465cb4d..ef9f429 100644 --- a/src/commands/toggleQuotes.ts +++ b/src/commands/toggleQuotes.ts @@ -96,7 +96,7 @@ export async function toggleQuotes(editor: vscode.TextEditor): Promise { // let's add quotes to this unquoted word. if (!quoteMatch) { try { - const cursorWordSelection = getCursorWordAsSelection(editor, selection, extraWordChars); + const cursorWordSelection = getCursorWordAsSelection(editor, selection, { extraWordChars }); quoteMatch = { startLine: lineNumber, endLine: lineNumber, diff --git a/src/commands/toggleUrlEncoding.ts b/src/commands/toggleUrlEncoding.ts new file mode 100644 index 0000000..7d05ec9 --- /dev/null +++ b/src/commands/toggleUrlEncoding.ts @@ -0,0 +1,24 @@ +import vscode from 'vscode'; + +import { regexBasedBinaryToggle } from './shared/regexBasedBinaryToggle'; +import { handleError } from '../utils'; + +export const TOGGLE_URL_ENCODING_CMD = 'toggleUrlEncoding'; + +/** + * When cursor is in the middle of a word--or there is an explicit selection--toggle the URL encoding. + * + * @param editor the vscode TextEditor object + */ +export async function toggleUrlEncoding(editor: vscode.TextEditor): Promise { + await handleError(async () => { + // Looking for any % followed by 2 hex digits, signifying an actual replacement has been done. Otherwise, + // there is nothing to "decode" + const urlEncodedRegex = /%[0-9a-fA-F]{2}/; + await regexBasedBinaryToggle(editor, urlEncodedRegex, transform, { useWhitespaceDelimiter: true }); + }); +} + +function transform(originalText: string, isUrlEncoded: boolean): string { + return isUrlEncoded ? decodeURIComponent(originalText) : encodeURIComponent(originalText); +} diff --git a/src/commands/toggleVariableNamingFormat.ts b/src/commands/toggleVariableNamingFormat.ts index 93b4665..fcc56b1 100644 --- a/src/commands/toggleVariableNamingFormat.ts +++ b/src/commands/toggleVariableNamingFormat.ts @@ -60,7 +60,7 @@ export async function toggleVariableNamingFormat(editor: vscode.TextEditor): Pro } // Toggle the current word the cursor is in - const cursorSelection = getCursorWordAsSelection(editor, selection, ['-']); + const cursorSelection = getCursorWordAsSelection(editor, selection, { extraWordChars: ['-'] }); selectionsToToggle.push(cursorSelection); } diff --git a/src/test/suite/toggleBase64Encoding.spec.ts b/src/test/suite/toggleBase64Encoding.spec.ts new file mode 100644 index 0000000..c2154cd --- /dev/null +++ b/src/test/suite/toggleBase64Encoding.spec.ts @@ -0,0 +1,73 @@ +import { expect } from 'chai'; +import dedent from 'dedent'; +import _ from 'lodash'; +import vscode from 'vscode'; + +import { toggleBase64Encoding } from '../../commands/toggleBase64Encoding'; +import { + openEditorWithContent, + openEditorWithContentAndSelectAll, + openEditorWithContentAndSetCursor, +} from '../utils/test-utils'; + +describe('toggleBase64Encoding cycles the base 64 encoding of a selection or word', () => { + afterEach(async () => { + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + }); + + describe('of a selection', () => { + it('basic usage', async () => { + const editor = await openEditorWithContentAndSelectAll('javascript', 'Base 64 encode this string!'); + await toggleBase64Encoding(editor); + expect(editor.document.getText()).to.equal('QmFzZSA2NCBlbmNvZGUgdGhpcyBzdHJpbmch'); + await toggleBase64Encoding(editor); + expect(editor.document.getText()).to.equal('Base 64 encode this string!'); + }); + }); + + describe('of a word', () => { + it('basic usage', async () => { + const editor = await openEditorWithContentAndSetCursor( + 'javascript', + `ignore? encode=me? ignore?`, + `ignore? enco`.length + ); + await toggleBase64Encoding(editor); + expect(editor.document.getText()).to.equal(`ignore? ZW5jb2RlPW1lPw== ignore?`); + await toggleBase64Encoding(editor); + expect(editor.document.getText()).to.equal(`ignore? encode=me? ignore?`); + }); + + it('multiple cursors - all selections use casing of first selection', async () => { + const editor = await openEditorWithContent( + 'javascript', + dedent` + four? + four? + four? + ` + ); + for (const _iter of _.times(2)) { + await vscode.commands.executeCommand('editor.action.insertCursorBelow'); + } + // All lines should be toggled to the same new quote character + await toggleBase64Encoding(editor); + expect(editor.document.getText()).to.equal(dedent` + Zm91cj8= + Zm91cj8= + Zm91cj8= + `); + await toggleBase64Encoding(editor); + expect(editor.document.getText()).to.equal(dedent` + four? + four? + four? + `); + }); + + it('error when cursor is not inside of word', async () => { + const editor = await openEditorWithContentAndSetCursor('javascript', 'three spaces', 'three '.length); + await expect(toggleBase64Encoding(editor)).to.be.rejectedWith('Cursor must be located within a word!'); + }); + }); +}); diff --git a/src/test/suite/toggleUrlEncoding.spec.ts b/src/test/suite/toggleUrlEncoding.spec.ts new file mode 100644 index 0000000..bd07732 --- /dev/null +++ b/src/test/suite/toggleUrlEncoding.spec.ts @@ -0,0 +1,78 @@ +import { expect } from 'chai'; +import dedent from 'dedent'; +import _ from 'lodash'; +import vscode from 'vscode'; + +import { toggleUrlEncoding } from '../../commands/toggleUrlEncoding'; +import { + openEditorWithContent, + openEditorWithContentAndSelectAll, + openEditorWithContentAndSetCursor, +} from '../utils/test-utils'; + +describe('toggleUrlEncoding cycles the URL encoding of a selection or word', () => { + afterEach(async () => { + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + }); + + describe('of a selection', () => { + it('basic usage', async () => { + const editor = await openEditorWithContentAndSelectAll( + 'javascript', + 'I am 99% sure this is not URL encoded/translated' + ); + await toggleUrlEncoding(editor); + expect(editor.document.getText()).to.equal( + 'I%20am%2099%25%20sure%20this%20is%20not%20URL%20encoded%2Ftranslated' + ); + await toggleUrlEncoding(editor); + expect(editor.document.getText()).to.equal('I am 99% sure this is not URL encoded/translated'); + }); + }); + + describe('of a word', () => { + it('basic usage', async () => { + const editor = await openEditorWithContentAndSetCursor( + 'javascript', + `ignore? encode=this? ignore?`, + `ignore? enco`.length + ); + await toggleUrlEncoding(editor); + expect(editor.document.getText()).to.equal(`ignore? encode%3Dthis%3F ignore?`); + await toggleUrlEncoding(editor); + expect(editor.document.getText()).to.equal(`ignore? encode=this? ignore?`); + }); + + it('multiple cursors - all selections use casing of first selection', async () => { + const editor = await openEditorWithContent( + 'javascript', + dedent` + encode? + encode? + encode? + ` + ); + for (const _iter of _.times(2)) { + await vscode.commands.executeCommand('editor.action.insertCursorBelow'); + } + // All lines should be toggled to the same new quote character + await toggleUrlEncoding(editor); + expect(editor.document.getText()).to.equal(dedent` + encode%3F + encode%3F + encode%3F + `); + await toggleUrlEncoding(editor); + expect(editor.document.getText()).to.equal(dedent` + encode? + encode? + encode? + `); + }); + + it('error when cursor is not inside of word', async () => { + const editor = await openEditorWithContentAndSetCursor('javascript', 'three spaces', 'three '.length); + await expect(toggleUrlEncoding(editor)).to.be.rejectedWith('Cursor must be located within a word!'); + }); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index b63afcf..bf5d135 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,3 +12,24 @@ export interface Match { type Json = string | number | boolean | null | { [key: string]: Json } | Json[]; export type JsonObjectOrArray = { [key: string]: Json } | Json[]; + +/** + * Options used for detecting "words" when no explicit highlighting is used + */ +export type CursorWordOptions = + | { + /** + * Characters beyond alphanumerics and underscore that should be used to detect "words" + */ + extraWordChars?: string[]; + /** + * If true, provided regex will be wrapped in ^ and $ + */ + matchFullLine?: boolean; + } + | { + /** + * If true, words will be parsed along whitespace as delimiter + */ + useWhitespaceDelimiter: true; + }; diff --git a/src/utils.ts b/src/utils.ts index 5978eb6..b99d944 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,9 @@ import { parse } from 'comment-json'; import _ from 'lodash'; +import { match } from 'ts-pattern'; import vscode from 'vscode'; -import { JsonObjectOrArray, Match } from './types'; +import { CursorWordOptions, JsonObjectOrArray, Match } from './types'; // Some error types used to drive user messages (see handleError below) export class UserError extends Error {} @@ -112,9 +113,9 @@ export function getNextElement(arr: T[], currentValue: T): T { export function getCursorWordAsSelection( editor: vscode.TextEditor, selection: vscode.Selection, - extraWordChars: string[] = [] + cursorWordOptions: CursorWordOptions = {} ): vscode.Selection { - const regex = getWordsRegex(extraWordChars, false); + const regex = getWordsRegex(cursorWordOptions); const lineText = editor.document.lineAt(selection.start.line).text; const matches: Match[] = []; @@ -143,7 +144,7 @@ export function getCursorWordAsSelection( * @returns true if text is a single word */ export function isWord(text: string, extraWordChars: string[]): boolean { - return getWordsRegex(extraWordChars, true).test(text); + return getWordsRegex({ extraWordChars, matchFullLine: true }).test(text); } /** @@ -230,24 +231,29 @@ export async function shrinkEditorSelections(editor: vscode.TextEditor, options: /** * Helper to get a regex for words, including the provided extra characters */ -function getWordsRegex(extraWordChars: string[], matchFullLine: boolean): RegExp { - // We're using a regex "character class" (i.e., brackets), so we only need to escape '^', '-', ']', and '\' - const escapedExtraWordChars = extraWordChars.map((char) => { - if (char.length !== 1) { - throw new UserError( - `All configured extra word characters must have length 1! The following is invalid: '${char}'` - ); - } else if (/[\^\-\]\\]/.test(char)) { - return '\\' + char; - } else { - return char; - } - }); - let regexStr = `[\\w${escapedExtraWordChars.join('')}]+`; - if (matchFullLine) { - regexStr = `^${regexStr}$`; - } - return new RegExp(regexStr, 'g'); +function getWordsRegex(cursorWordOptions: CursorWordOptions): RegExp { + return match(cursorWordOptions) + .with({ useWhitespaceDelimiter: true }, () => /\S+/g) + .otherwise((matched) => { + // Default to traditional "word" characters, with some customization options + const escapedExtraWordChars = (matched.extraWordChars ?? []).map((char) => { + if (char.length !== 1) { + throw new UserError( + `All configured extra word characters must have length 1! The following is invalid: '${char}'` + ); + } else if (/[\^\-\]\\]/.test(char)) { + return '\\' + char; + } else { + return char; + } + }); + // We're using a regex "character class" (i.e., brackets), so we only need to escape '^', '-', ']', and '\' + let regexStr = `[\\w${escapedExtraWordChars.join('')}]+`; + if (matched.matchFullLine) { + regexStr = `^${regexStr}$`; + } + return new RegExp(regexStr, 'g'); + }); } type CollectTxFunction = (el: T, idx: number) => R | undefined;