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
+
+ 
+ - 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
+
+ 
+ - 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;