import { parseIndentLevel } from './todo-parser'; /** * 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 beginning of the content. */ export function insertTodoAtBeginning(content: string, todoLine: string): string { if (!content) { return todoLine; } return todoLine + '\n' + content; } /** * 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 (or block of lines) under a specific heading. * @param content - The file content * @param headingLineNumber - The line number of the heading (0-based) * @param todoLine - The todo line(s) to insert (single string or array of strings) * @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 | string[], nextHeadingLineNumber?: number, addBlankLine = false ): string { const lines = content.split('\n'); const todoLines = Array.isArray(todoLine) ? todoLine : [todoLine]; // 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, ...todoLines); } else { // Insert blank line + todos lines.splice(insertPosition, 0, '', ...todoLines); } } else { lines.splice(insertPosition, 0, ...todoLines); } return lines.join('\n'); } /** * Collect all line numbers that belong to a todo's block (by indentation). * A line is part of the block if its indentation is greater than the todo's. * Stops at blank line or line with same/lesser indentation. */ export function collectTodoBlockLines(content: string, todoLineNumber: number): number[] { const lines = content.split('\n'); const todoLine = lines[todoLineNumber]; if (!todoLine) return []; const todoIndent = parseIndentLevel(todoLine); const blockLines: number[] = []; for (let i = todoLineNumber + 1; i < lines.length; i++) { const line = lines[i]!; // Stop at blank line if (line.trim() === '') { break; } // Stop at same or lesser indentation if (parseIndentLevel(line) <= todoIndent) { break; } blockLines.push(i); } return blockLines; } /** * Remove a todo and all its block lines (children, nested content, etc.). */ export function removeTodoBlock(content: string, todoLineNumber: number): string { const blockLines = collectTodoBlockLines(content, todoLineNumber); const allLineNumbers = [todoLineNumber, ...blockLines].sort((a, b) => a - b); const lines = content.split('\n'); // Remove in reverse to preserve indices for (let i = allLineNumbers.length - 1; i >= 0; i--) { lines.splice(allLineNumbers[i]!, 1); } return lines.join('\n'); } /** * Move a todo block to a new position with optional indentation adjustment. * Auto-detects all lines in the block by indentation. * @param indentDelta - Positive = indent, negative = outdent */ export function moveTodoBlock( content: string, todoLineNumber: number, targetLineNumber: number, indentDelta = 0 ): string { const blockLines = collectTodoBlockLines(content, todoLineNumber); const allLineNumbers = [todoLineNumber, ...blockLines].sort((a, b) => a - b); const lines = content.split('\n'); // Extract lines to move let movedLines = allLineNumbers.map((ln) => lines[ln]!); // Apply indentation if needed if (indentDelta !== 0) { movedLines = indentTodoLines(movedLines, indentDelta); } // Remove original lines (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'); }