diff --git a/README.md b/README.md index 72c917e..b913d3d 100644 --- a/README.md +++ b/README.md @@ -7,37 +7,43 @@ as multiple selections. Current string functions available: -1. camelize -1. capitalize -1. classify -1. chop - split into groups provided n # of characters -1. clean -1. clean diacritics -1. dasherize -1. decapitalize -1. humanize -1. reverse -1. screaming snake -1. sentence -1. slugify -1. snake -1. underscore -1. swap case -1. titleize -1. titleize (AP Style) -1. titleize (Chicago Style) +1. camelize - converts hyphenated strings to camelCase +1. capitalize - capitalizes the first character of each selection +1. classify - converts underscored text to PascalCase +1. chop - splits into groups provided n # of characters +1. clean - collapses multiple spaces into one +1. clean diacritics - removes diacritic marks from characters +1. dasherize - converts camelCase to kebab-case +1. decapitalize - lowercases the first character of each selection +1. humanize - converts text to human-readable form +1. reverse - reverses the characters in the selection +1. screaming snake - converts text to SCREAMING_SNAKE_CASE +1. sentence - transforms text to sentence case +1. slugify - converts text to a URL-friendly slug +1. snake - converts text to snake_case +1. swap case - inverts the case of each character +1. titleize - capitalizes the first letter of each word +1. titleize (AP Style) - capitalizes titles according to AP style +1. titleize (Chicago Style) - capitalizes titles according to Chicago style 1. truncate - trims string to n # of characters and appends ellipsis 1. prune - truncate but keeps ellipsis within character count provided -1. repeat - repeat selection n #of times -1. convert between unicode and readable characters. +1. repeat - repeat selection n # of times +1. random case - randomly changes the case of characters +1. swap quotes - swaps between single and double quotes +1. utf8ToChar - converts Unicode escapes to characters +1. charToUtf8 - converts characters to Unicode escapes Number related functions: -1. increment all numbers in selection -1. decrement all numbers in selection -1. duplicate selection and increment all number -1. duplicate selection and decrement all number -1. sequence all numbers in selection from first number +1. increment - increases all numbers in the selection by 1 +1. decrement - decreases all numbers in the selection by 1 +1. duplicate and increment - duplicates selection and increments all numbers +1. duplicate and decrement - duplicates selection and decrements all numbers +1. sequence - replaces numbers with a sequence starting from the first number + +Additional utility commands: + +1. repeat last action - repeats the last string manipulation command that was executed ## Use @@ -49,7 +55,7 @@ To use these commands, press ⌘+p and enter any of the commands above while tex Introducing String Manipulation Labs -We’re excited to announce the launch of String Manipulation Labs—a collection of (really just one at this moment) experimental features designed to enhance and expand the capabilities of the String Manipulation extension. Labs features are disabled by default to ensure a stable experience with the core functionalities. +We're excited to announce the launch of String Manipulation Labs—a collection of (really just one at this moment) experimental features designed to enhance and expand the capabilities of the String Manipulation extension. Labs features are disabled by default to ensure a stable experience with the core functionalities. ### 🚀 How to Enable Labs Features diff --git a/src/commands.ts b/src/commands.ts deleted file mode 100644 index aa39330..0000000 --- a/src/commands.ts +++ /dev/null @@ -1,249 +0,0 @@ -import * as vscode from "vscode"; -import * as underscore from "underscore.string"; -import { swapQuotes } from "./commands/swap_quotes"; -const apStyleTitleCase = require("ap-style-title-case"); -const chicagoStyleTitleCase = require("chicago-capitalize"); -const slugify = require("@sindresorhus/slugify"); - -interface MultiSelectData { - offset?: number; -} - -const defaultFunction = (commandName: string, option?: any) => (str: string) => - (underscore as any)[commandName](str, option); - -const sequence = (str: string, multiselectData: MultiSelectData = {}) => { - return str.replace(/-?\d+/g, (n) => { - const isFirst = typeof multiselectData.offset !== "number"; - multiselectData.offset = isFirst - ? Number(n) - : (multiselectData.offset || 0) + 1; - return String(multiselectData.offset); - }); -}; - -const increment = (str: string) => - str.replace(/-?\d+/g, (n) => String(Number(n) + 1)); - -const decrement = (str: string) => - str.replace(/-?\d+/g, (n) => String(Number(n) - 1)); - -const randomCase = (input: string): string => { - let result = ""; - for (const char of input) { - if (Math.random() < 0.5) { - result += char.toLowerCase(); - } else { - result += char.toUpperCase(); - } - } - return result; -}; - -const snake = (str: string) => - underscore - .underscored(str) - .replace(/([A-Z])[^A-Z]/g, " $1") - .replace(/[^a-z0-9]+/gi, " ") - .trim() - .replace(/\s/gi, "_"); - -export type StringFunction = ( - str: string, - multiselectData?: MultiSelectData -) => string; -export type CommandFunction = - | StringFunction - | ((...args: any[]) => StringFunction); - -const commandNameFunctionMap: { [key: string]: CommandFunction } = { - titleize: defaultFunction("titleize"), - chop: (n: number) => defaultFunction("chop", n), - classify: defaultFunction("classify"), - clean: defaultFunction("clean"), - cleanDiacritics: defaultFunction("cleanDiacritics"), - underscored: snake, - dasherize: defaultFunction("dasherize"), - humanize: defaultFunction("humanize"), - reverse: defaultFunction("reverse"), - decapitalize: defaultFunction("decapitalize"), - capitalize: defaultFunction("capitalize"), - sentence: defaultFunction("capitalize", true), - camelize: (str: string) => - underscore.camelize(/[a-z]/.test(str) ? str : str.toLowerCase()), - slugify: slugify, - swapCase: defaultFunction("swapCase"), - snake, - screamingSnake: (str: string) => snake(str).toUpperCase(), - titleizeApStyle: apStyleTitleCase, - titleizeChicagoStyle: chicagoStyleTitleCase, - truncate: (n: number) => defaultFunction("truncate", n), - prune: (n: number) => (str: string) => str.slice(0, n - 3).trim() + "...", - repeat: (n: number) => defaultFunction("repeat", n), - increment, - decrement, - duplicateAndIncrement: () => "", - duplicateAndDecrement: () => "", - sequence, - utf8ToChar: (str: string) => - str - .match(/\\u[\dA-Fa-f]{4}/g) - ?.map((x) => x.slice(2)) - .map((x) => String.fromCharCode(parseInt(x, 16))) - .join("") || "", - charToUtf8: (str: string) => - str - .split("") - .map((x) => `\\u${x.charCodeAt(0).toString(16).padStart(4, "0")}`) - .join(""), - randomCase, - swapQuotes, -}; - -const numberFunctionNames = [ - "increment", - "decrement", - "sequence", - "duplicateAndIncrement", - "duplicateAndDecrement", -]; - -export const functionNamesWithArgument = [ - "chop", - "truncate", - "prune", - "repeat", -]; - -export const stringFunction = async ( - commandName: string, - context: vscode.ExtensionContext, - shouldApply = true -): Promise<{ replacedSelections: string[] } | undefined> => { - const editor = vscode.window.activeTextEditor; - if (!editor) { - return; - } - - const selectionMap: { - [key: number]: { selection: vscode.Selection; replaced: string }; - } = {}; - - let multiselectData: MultiSelectData = {}; - - let stringFunc: (str: string) => string; - - let replacedSelections = []; - - if (functionNamesWithArgument.includes(commandName)) { - const valueStr = await vscode.window.showInputBox(); - if (valueStr === undefined) { - return; - } - const value = Number(valueStr); - if (isNaN(value)) { - vscode.window.showErrorMessage("Invalid number"); - return; - } - stringFunc = (commandNameFunctionMap[commandName] as Function)(value); - } else if (numberFunctionNames.includes(commandName)) { - stringFunc = (str: string) => - (commandNameFunctionMap[commandName] as Function)(str, multiselectData); - } else { - stringFunc = commandNameFunctionMap[commandName] as StringFunction; - } - - if ( - commandName === "duplicateAndIncrement" || - commandName === "duplicateAndDecrement" - ) { - for (const [index, selection] of editor.selections.entries()) { - const text = editor.document.getText(selection); - - const operation = - commandName === "duplicateAndIncrement" ? increment : decrement; - const replaced = text + operation(text); - - replacedSelections.push(replaced); - selectionMap[index] = { selection, replaced }; - } - } else { - for (const [index, selection] of editor.selections.entries()) { - const text = editor.document.getText(selection); - const textParts = text.split("\n"); - const replaced = textParts.map((part) => stringFunc(part)).join("\n"); - replacedSelections.push(replaced); - selectionMap[index] = { selection, replaced }; - } - } - - if (shouldApply) { - await editor.edit((builder) => { - Object.values(selectionMap).forEach(({ selection, replaced }) => { - builder.replace(selection, replaced); - }); - }); - - // Set the selection to the duplicated part for duplicateAndIncrement and duplicateAndDecrement - if ( - commandName === "duplicateAndIncrement" || - commandName === "duplicateAndDecrement" - ) { - const newSelections = editor.selections.map((selection, index) => { - const originalSelection = selectionMap[index].selection; - const originalText = editor.document.getText(originalSelection); - - // Calculate the start position of the duplicated text - const startPos = originalSelection.end; - - // Calculate the end position based on the original text length - let endLine = startPos.line; - let endChar = startPos.character + originalText.length; - - // Handle multi-line selections - const lines = originalText.split("\n"); - if (lines.length > 1) { - endLine = startPos.line + lines.length - 1; - // If multi-line, the end character should be the length of the last line - endChar = lines[lines.length - 1].length; - } - - const endPos = new vscode.Position(endLine, endChar); - return new vscode.Selection(startPos, endPos); - }); - - editor.selections = newSelections; - } - - context.globalState.update("lastAction", commandName); - } - - return await Promise.resolve({ replacedSelections }); -}; - -export function activate(context: vscode.ExtensionContext) { - context.globalState.setKeysForSync(["lastAction"]); - - context.subscriptions.push( - vscode.commands.registerCommand( - "string-manipulation.repeatLastAction", - () => { - const lastAction = context.globalState.get("lastAction"); - if (lastAction) { - return stringFunction(lastAction, context); - } - } - ) - ); - - Object.keys(commandNameFunctionMap).forEach((commandName) => { - context.subscriptions.push( - vscode.commands.registerCommand( - `string-manipulation.${commandName}`, - () => stringFunction(commandName, context) - ) - ); - }); -} - -export { commandNameFunctionMap }; diff --git a/src/commands/default-functions.ts b/src/commands/default-functions.ts new file mode 100644 index 0000000..d97ad55 --- /dev/null +++ b/src/commands/default-functions.ts @@ -0,0 +1,43 @@ +import * as underscore from "underscore.string"; +import { CommandFunction } from "./types"; + +export const defaultFunction = + (commandName: string, option?: any) => (str: string) => + (underscore as any)[commandName](str, option); + +export const snake = (str: string) => + underscore + .underscored(str) + .replace(/([A-Z])[^A-Z]/g, " $1") + .replace(/[^a-z0-9]+/gi, " ") + .trim() + .replace(/\s/gi, "_"); + +export const screamingSnake: CommandFunction = (str: string) => + snake(str).toUpperCase(); + +export const camelize: CommandFunction = (str: string) => + underscore.camelize(/[a-z]/.test(str) ? str : str.toLowerCase()); + +// Default functions +export const titleize: CommandFunction = defaultFunction("titleize"); +export const classify: CommandFunction = defaultFunction("classify"); +export const clean: CommandFunction = defaultFunction("clean"); +export const cleanDiacritics: CommandFunction = + defaultFunction("cleanDiacritics"); +export const dasherize: CommandFunction = defaultFunction("dasherize"); +export const humanize: CommandFunction = defaultFunction("humanize"); +export const reverse: CommandFunction = defaultFunction("reverse"); +export const decapitalize: CommandFunction = defaultFunction("decapitalize"); +export const capitalize: CommandFunction = defaultFunction("capitalize"); +export const sentence: CommandFunction = defaultFunction("capitalize", true); +export const swapCase: CommandFunction = defaultFunction("swapCase"); + +// Functions with arguments +export const chop: CommandFunction = (n: number) => defaultFunction("chop", n); +export const truncate: CommandFunction = (n: number) => + defaultFunction("truncate", n); +export const prune: CommandFunction = (n: number) => (str: string) => + str.slice(0, n - 3).trim() + "..."; +export const repeat: CommandFunction = (n: number) => + defaultFunction("repeat", n); diff --git a/src/commands/increment-decrement.ts b/src/commands/increment-decrement.ts new file mode 100644 index 0000000..0bfe76c --- /dev/null +++ b/src/commands/increment-decrement.ts @@ -0,0 +1,71 @@ +import * as vscode from "vscode"; +import { CommandFunction } from "./types"; + +export const increment: CommandFunction = (str: string) => + str.replace(/-?\d+/g, (n) => String(Number(n) + 1)); + +export const decrement: CommandFunction = (str: string) => + str.replace(/-?\d+/g, (n) => String(Number(n) - 1)); + +// These functions are placeholders as the actual implementation is in the stringFunction +// They're kept here for type consistency in the command registry +export const duplicateAndIncrement: CommandFunction = () => ""; +export const duplicateAndDecrement: CommandFunction = () => ""; + +// Helper function to handle duplicate and increment/decrement operations +export function handleDuplicateAndIncrementDecrement( + editor: vscode.TextEditor, + selections: readonly vscode.Selection[], + operation: (str: string) => string +): { + selectionMap: { + [key: number]: { selection: vscode.Selection; replaced: string }; + }; + replacedSelections: string[]; +} { + const selectionMap: { + [key: number]: { selection: vscode.Selection; replaced: string }; + } = {}; + const replacedSelections: string[] = []; + + for (const [index, selection] of selections.entries()) { + const text = editor.document.getText(selection); + const replaced = text + operation(text); + + replacedSelections.push(replaced); + selectionMap[index] = { selection, replaced }; + } + + return { selectionMap, replacedSelections }; +} + +// Helper function to update selections after duplicate operations +export function updateSelectionsAfterDuplicate( + editor: vscode.TextEditor, + selectionMap: { + [key: number]: { selection: vscode.Selection; replaced: string }; + } +): vscode.Selection[] { + return editor.selections.map((selection, index) => { + const originalSelection = selectionMap[index].selection; + const originalText = editor.document.getText(originalSelection); + + // Calculate the start position of the duplicated text + const startPos = originalSelection.end; + + // Calculate the end position based on the original text length + let endLine = startPos.line; + let endChar = startPos.character + originalText.length; + + // Handle multi-line selections + const lines = originalText.split("\n"); + if (lines.length > 1) { + endLine = startPos.line + lines.length - 1; + // If multi-line, the end character should be the length of the last line + endChar = lines[lines.length - 1].length; + } + + const endPos = new vscode.Position(endLine, endChar); + return new vscode.Selection(startPos, endPos); + }); +} diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..afe8968 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,177 @@ +import * as vscode from "vscode"; +import { + CommandRegistry, + functionNamesWithArgument, + numberFunctionNames, + CommandFunction, +} from "./types"; +import * as defaultFunctions from "./default-functions"; +import { + increment, + decrement, + duplicateAndIncrement, + duplicateAndDecrement, + handleDuplicateAndIncrementDecrement, + updateSelectionsAfterDuplicate, +} from "./increment-decrement"; +import { sequence } from "./sequence"; +import { randomCase } from "./random-case"; +import { utf8ToChar, charToUtf8 } from "./utf8-conversion"; +import { titleizeApStyle, titleizeChicagoStyle } from "./title-case"; +import { slugify } from "./slugify"; +import { swapQuotes } from "./swap_quotes"; + +// Combine all commands into a single registry +export const commandNameFunctionMap: CommandRegistry = { + // Default functions + titleize: defaultFunctions.titleize, + chop: defaultFunctions.chop, + classify: defaultFunctions.classify, + clean: defaultFunctions.clean, + cleanDiacritics: defaultFunctions.cleanDiacritics, + underscored: defaultFunctions.snake, + dasherize: defaultFunctions.dasherize, + humanize: defaultFunctions.humanize, + reverse: defaultFunctions.reverse, + decapitalize: defaultFunctions.decapitalize, + capitalize: defaultFunctions.capitalize, + sentence: defaultFunctions.sentence, + camelize: defaultFunctions.camelize, + swapCase: defaultFunctions.swapCase, + snake: defaultFunctions.snake, + screamingSnake: defaultFunctions.screamingSnake, + truncate: defaultFunctions.truncate, + prune: defaultFunctions.prune, + repeat: defaultFunctions.repeat, + + // Specialized functions + increment, + decrement, + duplicateAndIncrement, + duplicateAndDecrement, + sequence, + utf8ToChar, + charToUtf8, + randomCase, + titleizeApStyle, + titleizeChicagoStyle, + slugify, + swapQuotes, +}; + +// Re-export types and constants +export { + functionNamesWithArgument, + numberFunctionNames, + CommandFunction, +} from "./types"; + +// Main string function that applies the transformations +export const stringFunction = async ( + commandName: string, + context: vscode.ExtensionContext, + shouldApply = true +): Promise<{ replacedSelections: string[] } | undefined> => { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + + let selectionMap: { + [key: number]: { selection: vscode.Selection; replaced: string }; + } = {}; + + let multiselectData = {}; + + let stringFunc: (str: string) => string; + + let replacedSelections: string[] = []; + + if (functionNamesWithArgument.includes(commandName)) { + const valueStr = await vscode.window.showInputBox(); + if (valueStr === undefined) { + return; + } + const value = Number(valueStr); + if (isNaN(value)) { + vscode.window.showErrorMessage("Invalid number"); + return; + } + stringFunc = (commandNameFunctionMap[commandName] as Function)(value); + } else if (numberFunctionNames.includes(commandName)) { + stringFunc = (str: string) => + (commandNameFunctionMap[commandName] as Function)(str, multiselectData); + } else { + stringFunc = commandNameFunctionMap[commandName] as (str: string) => string; + } + + if ( + commandName === "duplicateAndIncrement" || + commandName === "duplicateAndDecrement" + ) { + const operation = + commandName === "duplicateAndIncrement" ? increment : decrement; + + const result = handleDuplicateAndIncrementDecrement( + editor, + editor.selections, + operation as (str: string) => string + ); + selectionMap = result.selectionMap; + replacedSelections = result.replacedSelections; + } else { + for (const [index, selection] of editor.selections.entries()) { + const text = editor.document.getText(selection); + const textParts = text.split("\n"); + const replaced = textParts.map((part) => stringFunc(part)).join("\n"); + replacedSelections.push(replaced); + selectionMap[index] = { selection, replaced }; + } + } + + if (shouldApply) { + await editor.edit((builder) => { + Object.values(selectionMap).forEach(({ selection, replaced }) => { + builder.replace(selection, replaced); + }); + }); + + // Set the selection to the duplicated part for duplicateAndIncrement and duplicateAndDecrement + if ( + commandName === "duplicateAndIncrement" || + commandName === "duplicateAndDecrement" + ) { + editor.selections = updateSelectionsAfterDuplicate(editor, selectionMap); + } + + context.globalState.update("lastAction", commandName); + } + + return await Promise.resolve({ replacedSelections }); +}; + +// Activation function +export function activate(context: vscode.ExtensionContext) { + context.globalState.setKeysForSync(["lastAction"]); + + context.subscriptions.push( + vscode.commands.registerCommand( + "string-manipulation.repeatLastAction", + () => { + const lastAction = context.globalState.get("lastAction"); + if (lastAction) { + return stringFunction(lastAction, context); + } + } + ) + ); + + Object.keys(commandNameFunctionMap).forEach((commandName) => { + context.subscriptions.push( + vscode.commands.registerCommand( + `string-manipulation.${commandName}`, + () => stringFunction(commandName, context) + ) + ); + }); +} diff --git a/src/commands/random-case.ts b/src/commands/random-case.ts new file mode 100644 index 0000000..ad3ea59 --- /dev/null +++ b/src/commands/random-case.ts @@ -0,0 +1,13 @@ +import { CommandFunction } from "./types"; + +export const randomCase: CommandFunction = (input: string): string => { + let result = ""; + for (const char of input) { + if (Math.random() < 0.5) { + result += char.toLowerCase(); + } else { + result += char.toUpperCase(); + } + } + return result; +}; diff --git a/src/commands/sequence.ts b/src/commands/sequence.ts new file mode 100644 index 0000000..8ac1f53 --- /dev/null +++ b/src/commands/sequence.ts @@ -0,0 +1,14 @@ +import { CommandFunction, MultiSelectData } from "./types"; + +export const sequence: CommandFunction = ( + str: string, + multiselectData: MultiSelectData = {} +) => { + return str.replace(/-?\d+/g, (n) => { + const isFirst = typeof multiselectData.offset !== "number"; + multiselectData.offset = isFirst + ? Number(n) + : (multiselectData.offset || 0) + 1; + return String(multiselectData.offset); + }); +}; diff --git a/src/commands/slugify.ts b/src/commands/slugify.ts new file mode 100644 index 0000000..2e9b1c3 --- /dev/null +++ b/src/commands/slugify.ts @@ -0,0 +1,4 @@ +import { CommandFunction } from "./types"; +const slugifyLib = require("@sindresorhus/slugify"); + +export const slugify: CommandFunction = slugifyLib; diff --git a/src/commands/title-case.ts b/src/commands/title-case.ts new file mode 100644 index 0000000..25843d1 --- /dev/null +++ b/src/commands/title-case.ts @@ -0,0 +1,6 @@ +import { CommandFunction } from "./types"; +const apStyleTitleCase = require("ap-style-title-case"); +const chicagoStyleTitleCase = require("chicago-capitalize"); + +export const titleizeApStyle: CommandFunction = apStyleTitleCase; +export const titleizeChicagoStyle: CommandFunction = chicagoStyleTitleCase; diff --git a/src/commands/types.ts b/src/commands/types.ts new file mode 100644 index 0000000..d84cab1 --- /dev/null +++ b/src/commands/types.ts @@ -0,0 +1,33 @@ +import * as vscode from "vscode"; + +export interface MultiSelectData { + offset?: number; +} + +export type StringFunction = ( + str: string, + multiselectData?: MultiSelectData +) => string; + +export type CommandFunction = + | StringFunction + | ((...args: any[]) => StringFunction); + +export interface CommandRegistry { + [key: string]: CommandFunction; +} + +export const numberFunctionNames = [ + "increment", + "decrement", + "sequence", + "duplicateAndIncrement", + "duplicateAndDecrement", +]; + +export const functionNamesWithArgument = [ + "chop", + "truncate", + "prune", + "repeat", +]; diff --git a/src/commands/utf8-conversion.ts b/src/commands/utf8-conversion.ts new file mode 100644 index 0000000..16d97ac --- /dev/null +++ b/src/commands/utf8-conversion.ts @@ -0,0 +1,14 @@ +import { CommandFunction } from "./types"; + +export const utf8ToChar: CommandFunction = (str: string) => + str + .match(/\\u[\dA-Fa-f]{4}/g) + ?.map((x) => x.slice(2)) + .map((x) => String.fromCharCode(parseInt(x, 16))) + .join("") || ""; + +export const charToUtf8: CommandFunction = (str: string) => + str + .split("") + .map((x) => `\\u${x.charCodeAt(0).toString(16).padStart(4, "0")}`) + .join(""); diff --git a/src/extension.ts b/src/extension.ts index d35001d..b4c7d2c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; import { StringManipulationSidebar } from "./sidebar"; -import { activate as stringManipulationActivate } from "./commands"; +import { activate as stringManipulationActivate } from "./commands/index"; export function activate(context: vscode.ExtensionContext) { stringManipulationActivate(context); diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 997a255..e8fa5b1 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -2,7 +2,7 @@ import * as assert from "assert"; import { afterEach, beforeEach, suite, test } from "mocha"; import * as path from "path"; import * as vscode from "vscode"; -import { CommandFunction, commandNameFunctionMap } from "../commands"; +import { CommandFunction, commandNameFunctionMap } from "../commands/index"; type StringTransformationTest = [ funcName: string,