/** * Regex pattern for markdown todo checkboxes (for toggling). */ const TODO_PATTERN = /^(\s*[-*+]\s+\[)([xX ]?)(\].*)$/; /** * Toggle the checkbox state at the specified line number. * Returns the modified content. */ export function toggleTodo(content: string, lineNumber: number): string { const lines = content.split('\n'); if (lineNumber < 0 || lineNumber >= lines.length) { return content; } const line = lines[lineNumber]; if (line === undefined) { return content; } const match = line.match(TODO_PATTERN); if (!match) { return content; } const checkboxContent = match[2]; const isChecked = checkboxContent === 'x' || checkboxContent === 'X'; const newCheckbox = isChecked ? ' ' : 'x'; lines[lineNumber] = `${match[1]}${newCheckbox}${match[3]}`; return lines.join('\n'); } /** * Remove the line at the specified line number. * Returns the modified content. */ export function removeTodoLine(content: string, lineNumber: number): string { const lines = content.split('\n'); if (lineNumber < 0 || lineNumber >= lines.length) { return content; } lines.splice(lineNumber, 1); return lines.join('\n'); } /** * Insert a todo line at the end of the content. * Handles files with/without trailing newlines. */ export function insertTodoAtEnd(content: string, todoLine: string): string { if (!content) { return todoLine; } // If content ends with newline(s), append directly if (content.endsWith('\n')) { return content + todoLine; } // Otherwise add a newline before the todo return content + '\n' + todoLine; } /** * Move a todo (and its children) from one position to another within the same content. * @param content - The file content * @param todoLineNumber - Line number of the todo to move * @param childLineNumbers - Line numbers of child todos (must be consecutive after todoLineNumber) * @param targetLineNumber - Line number to insert before (0-based). Lines are inserted at this position. */ export function moveTodoWithChildren( content: string, todoLineNumber: number, childLineNumbers: number[], targetLineNumber: number ): string { const lines = content.split('\n'); const allLineNumbers = [todoLineNumber, ...childLineNumbers].sort((a, b) => a - b); // Extract the lines to move const movedLines = allLineNumbers.map((ln) => lines[ln]!); // If moving to same position, no-op if (targetLineNumber === todoLineNumber) { return content; } // Remove lines from original positions (in reverse to preserve indices) for (let i = allLineNumbers.length - 1; i >= 0; i--) { lines.splice(allLineNumbers[i]!, 1); } // Adjust target position for removed lines let adjustedTarget = targetLineNumber; for (const ln of allLineNumbers) { if (ln < targetLineNumber) { adjustedTarget--; } } // Insert at adjusted target lines.splice(adjustedTarget, 0, ...movedLines); return lines.join('\n'); } /** * Move a todo (and its children) under a heading within the same file. * Inserts at the end of the heading's section. * @param content - The file content * @param todoLineNumber - Line number of the todo to move * @param childLineNumbers - Line numbers of child todos * @param headingLineNumber - Line number of the target heading * @param nextHeadingLineNumber - Optional line number of the next heading (section boundary) */ export function moveTodoUnderHeadingInFile( content: string, todoLineNumber: number, childLineNumbers: number[], headingLineNumber: number, nextHeadingLineNumber?: number ): string { // Determine insert position: end of section (before next heading) or after heading let targetLineNumber: number; if (nextHeadingLineNumber !== undefined) { targetLineNumber = nextHeadingLineNumber; } else { // No next heading — insert at end of file const lines = content.split('\n'); targetLineNumber = lines.length; } return moveTodoWithChildren(content, todoLineNumber, childLineNumbers, targetLineNumber); } /** * Adjust indentation of todo lines by a delta (positive = indent, negative = outdent). * Each level is 2 spaces. Will not outdent past zero. */ export function indentTodoLines(todoLines: string[], indentDelta: number): string[] { if (indentDelta === 0) return [...todoLines]; const indent = ' '; // 2 spaces per level return todoLines.map((line) => { if (indentDelta > 0) { return indent.repeat(indentDelta) + line; } else { // Outdent: remove leading spaces let result = line; for (let i = 0; i < Math.abs(indentDelta); i++) { if (result.startsWith(indent)) { result = result.slice(indent.length); } else if (result.startsWith('\t')) { result = result.slice(1); } } return result; } }); } /** * Insert a todo line under a specific heading. * @param content - The file content * @param headingLineNumber - The line number of the heading (0-based) * @param todoLine - The todo line to insert * @param nextHeadingLineNumber - Optional line number of the next heading (for section boundary) * @param addBlankLine - Whether to add a blank line between the heading and the todo */ export function insertTodoUnderHeading( content: string, headingLineNumber: number, todoLine: string, nextHeadingLineNumber?: number, addBlankLine = false ): string { const lines = content.split('\n'); // Determine where to insert let insertPosition: number; if (nextHeadingLineNumber !== undefined) { insertPosition = nextHeadingLineNumber; } else { insertPosition = headingLineNumber + 1; } if (addBlankLine) { // Check if there's already a blank line at the insert position const lineAfterHeading = lines[headingLineNumber + 1]; const alreadyHasBlank = lineAfterHeading !== undefined && lineAfterHeading.trim() === ''; if (alreadyHasBlank) { // Insert after the existing blank line lines.splice(headingLineNumber + 2, 0, todoLine); } else { // Insert blank line + todo lines.splice(insertPosition, 0, '', todoLine); } } else { lines.splice(insertPosition, 0, todoLine); } return lines.join('\n'); }