diff --git a/.kiro/specs/document-outliner-with-lsp/tasks.md b/.kiro/specs/document-outliner-with-lsp/tasks.md index f4616db..f696bef 100644 --- a/.kiro/specs/document-outliner-with-lsp/tasks.md +++ b/.kiro/specs/document-outliner-with-lsp/tasks.md @@ -4,7 +4,7 @@ This implementation plan breaks down the feature into discrete, actionable codin ## Task List -- [ ] 1. Set up project structure and core interfaces +- [x] 1. Set up project structure and core interfaces - Create directory structure for VS Code extension (`extensions/doctk-outliner/`) - Create directory structure for language server (`src/doctk/lsp/`) @@ -13,46 +13,46 @@ This implementation plan breaks down the feature into discrete, actionable codin - Set up build configuration (tsconfig.json, package.json for extension) - _Requirements: 15, 20_ -- [ ] 2. Implement core document manipulation API +- [x] 2. Implement core document manipulation API - - [ ] 2.1 Create StructureOperations class with promote/demote operations + - [x] 2.1 Create StructureOperations class with promote/demote operations - Implement `promote()` method that decreases heading level - Implement `demote()` method that increases heading level - Add validation to ensure heading levels stay within 1-6 range - _Requirements: 3.2, 3.3, 20_ - - [ ] 2.2 Add move operations (move_up, move_down) + - [x] 2.2 Add move operations (move_up, move_down) - Implement `move_up()` to reorder sections among siblings - Implement `move_down()` to reorder sections among siblings - Handle edge cases (first/last sibling) - _Requirements: 3.4, 3.5, 20_ - - [ ] 2.3 Implement nest and unnest operations + - [x] 2.3 Implement nest and unnest operations - Implement `nest()` to move section under a new parent - Implement `unnest()` to move section up one level - Validate parent-child relationships - _Requirements: 2.2, 2.3, 20_ - - [ ] 2.4 Write unit tests for structure operations + - [x] 2.4 Write unit tests for structure operations - Test promote/demote with various heading levels - Test move operations with different sibling positions - Test nest/unnest with complex hierarchies - _Requirements: 20_ -- [ ] 3. Create ExtensionBridge for TypeScript-Python communication +- [x] 3. Create ExtensionBridge for TypeScript-Python communication - - [ ] 3.1 Implement JSON-RPC bridge in Python + - [x] 3.1 Implement JSON-RPC bridge in Python - Create `ExtensionBridge` class that accepts JSON-RPC requests - Implement stdin/stdout communication protocol - Add request/response handling with proper error serialization - _Requirements: 20_ - - [ ] 3.2 Implement TypeScript PythonBridge client + - [x] 3.2 Implement TypeScript PythonBridge client - Create `PythonBridge` class that spawns Python process - Implement JSON-RPC request/response handling @@ -60,16 +60,16 @@ This implementation plan breaks down the feature into discrete, actionable codin - Handle process lifecycle (start, stop, restart) - _Requirements: 18, 20_ - - [ ] 3.3 Write integration tests for bridge communication + - [x] 3.3 Write integration tests for bridge communication - Test request/response round-trip - Test error handling and serialization - Test process restart on failure - _Requirements: 18_ -- [ ] 4. Implement tree data provider for VS Code +- [x] 4. Implement tree data provider for VS Code - - [ ] 4.1 Create DocumentOutlineProvider class + - [x] 4.1 Create DocumentOutlineProvider class - Implement `TreeDataProvider` interface - Implement `getTreeItem()` to create tree items from nodes @@ -77,7 +77,7 @@ This implementation plan breaks down the feature into discrete, actionable codin - Implement `getParent()` for navigation - _Requirements: 1.1, 1.2, 1.3_ - - [ ] 4.2 Add document parsing to build tree structure + - [x] 4.2 Add document parsing to build tree structure - Parse Markdown document to extract headings - Build OutlineNode tree from heading hierarchy @@ -85,14 +85,14 @@ This implementation plan breaks down the feature into discrete, actionable codin - Track node ranges (line/column positions) - _Requirements: 1.1, 1.2, 1.3_ - - [ ] 4.3 Implement tree refresh and update mechanisms + - [x] 4.3 Implement tree refresh and update mechanisms - Add `refresh()` method to trigger tree re-render - Implement `updateFromDocument()` to sync with editor changes - Add debouncing to prevent excessive updates - _Requirements: 1.4, 16.1, 16.2, 16.3_ - - [ ] 4.4 Write unit tests for tree provider + - [x] 4.4 Write unit tests for tree provider - Test tree building from various Markdown structures - Test node ID generation and uniqueness diff --git a/extensions/doctk-outliner/language-configuration.json b/extensions/doctk-outliner/language-configuration.json new file mode 100644 index 0000000..c24c4ed --- /dev/null +++ b/extensions/doctk-outliner/language-configuration.json @@ -0,0 +1,24 @@ +{ + "comments": { + "lineComment": "#" + }, + "brackets": [ + ["[", "]"], + ["(", ")"], + ["{", "}"] + ], + "autoClosingPairs": [ + ["[", "]"], + ["(", ")"], + ["{", "}"], + ["\"", "\""], + ["'", "'"] + ], + "surroundingPairs": [ + ["[", "]"], + ["(", ")"], + ["{", "}"], + ["\"", "\""], + ["'", "'"] + ] +} diff --git a/extensions/doctk-outliner/package.json b/extensions/doctk-outliner/package.json new file mode 100644 index 0000000..987fa18 --- /dev/null +++ b/extensions/doctk-outliner/package.json @@ -0,0 +1,216 @@ +{ + "name": "doctk-outliner", + "displayName": "doctk Document Outliner", + "description": "Tree-based document outliner with LSP support for doctk", + "version": "0.1.0", + "publisher": "doctk", + "engines": { + "vscode": "^1.80.0" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onLanguage:markdown", + "onLanguage:doctk", + "onView:doctkOutline" + ], + "main": "./out/extension.js", + "contributes": { + "views": { + "explorer": [ + { + "id": "doctkOutline", + "name": "Document Outline", + "when": "resourceLangId == markdown" + } + ] + }, + "commands": [ + { + "command": "doctk.promote", + "title": "doctk: Promote Section", + "icon": "$(arrow-up)" + }, + { + "command": "doctk.demote", + "title": "doctk: Demote Section", + "icon": "$(arrow-down)" + }, + { + "command": "doctk.moveUp", + "title": "doctk: Move Section Up", + "icon": "$(chevron-up)" + }, + { + "command": "doctk.moveDown", + "title": "doctk: Move Section Down", + "icon": "$(chevron-down)" + }, + { + "command": "doctk.delete", + "title": "doctk: Delete Section", + "icon": "$(trash)" + }, + { + "command": "doctk.refresh", + "title": "doctk: Refresh Outline", + "icon": "$(refresh)" + } + ], + "menus": { + "view/title": [ + { + "command": "doctk.refresh", + "when": "view == doctkOutline", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "doctk.promote", + "when": "view == doctkOutline", + "group": "structure@1" + }, + { + "command": "doctk.demote", + "when": "view == doctkOutline", + "group": "structure@2" + }, + { + "command": "doctk.moveUp", + "when": "view == doctkOutline", + "group": "reorder@1" + }, + { + "command": "doctk.moveDown", + "when": "view == doctkOutline", + "group": "reorder@2" + }, + { + "command": "doctk.delete", + "when": "view == doctkOutline", + "group": "edit@1" + } + ] + }, + "keybindings": [ + { + "command": "doctk.promote", + "key": "ctrl+shift+up", + "mac": "cmd+shift+up", + "when": "focusedView == doctkOutline" + }, + { + "command": "doctk.demote", + "key": "ctrl+shift+down", + "mac": "cmd+shift+down", + "when": "focusedView == doctkOutline" + }, + { + "command": "doctk.moveUp", + "key": "alt+up", + "mac": "alt+up", + "when": "focusedView == doctkOutline" + }, + { + "command": "doctk.moveDown", + "key": "alt+down", + "mac": "alt+down", + "when": "focusedView == doctkOutline" + }, + { + "command": "doctk.delete", + "key": "delete", + "mac": "delete", + "when": "focusedView == doctkOutline" + } + ], + "languages": [ + { + "id": "doctk", + "extensions": [ + ".tk" + ], + "aliases": [ + "doctk", + "DocTK" + ], + "configuration": "./language-configuration.json" + } + ], + "configuration": { + "title": "doctk Outliner", + "properties": { + "doctk.outliner.autoRefresh": { + "type": "boolean", + "default": true, + "description": "Automatically refresh the outline when the document changes" + }, + "doctk.outliner.refreshDelay": { + "type": "number", + "default": 300, + "description": "Delay in milliseconds before refreshing the outline" + }, + "doctk.outliner.showContentPreview": { + "type": "boolean", + "default": false, + "description": "Show a preview of content in the outline" + }, + "doctk.outliner.maxPreviewLength": { + "type": "number", + "default": 50, + "description": "Maximum length of content preview" + }, + "doctk.lsp.enabled": { + "type": "boolean", + "default": true, + "description": "Enable the doctk language server" + }, + "doctk.lsp.trace": { + "type": "string", + "enum": [ + "off", + "messages", + "verbose" + ], + "default": "off", + "description": "Trace LSP communication for debugging" + }, + "doctk.lsp.maxCompletionItems": { + "type": "number", + "default": 50, + "description": "Maximum number of completion items to show" + }, + "doctk.performance.largeDocumentThreshold": { + "type": "number", + "default": 1000, + "description": "Number of headings to consider a document 'large'" + }, + "doctk.performance.enableVirtualization": { + "type": "boolean", + "default": true, + "description": "Enable virtualization for large documents" + } + } + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "lint": "eslint src --ext ts", + "test": "node ./out/test/runTest.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.80.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.45.0", + "typescript": "^5.1.0" + }, + "dependencies": { + "vscode-languageclient": "^9.0.0" + } +} diff --git a/extensions/doctk-outliner/src/extension.ts b/extensions/doctk-outliner/src/extension.ts new file mode 100644 index 0000000..500e456 --- /dev/null +++ b/extensions/doctk-outliner/src/extension.ts @@ -0,0 +1,198 @@ +/** + * VS Code extension entry point for doctk outliner. + */ + +import * as vscode from 'vscode'; +import { DocumentOutlineProvider } from './outlineProvider'; +import { PythonBridge } from './pythonBridge'; + +let outlineProvider: DocumentOutlineProvider; +let pythonBridge: PythonBridge; +let treeView: vscode.TreeView; + +/** + * Activate the extension. + * + * @param context - Extension context + */ +export async function activate(context: vscode.ExtensionContext) { + console.log('doctk outliner extension is now active'); + + // Initialize outline provider + outlineProvider = new DocumentOutlineProvider(); + + // Register tree data provider + treeView = vscode.window.createTreeView('doctkOutline', { + treeDataProvider: outlineProvider, + showCollapseAll: true, + }); + + // Initialize Python bridge + pythonBridge = new PythonBridge({ + cwd: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, + }); + + try { + await pythonBridge.start(); + console.log('Python bridge started successfully'); + } catch (error) { + console.error('Failed to start Python bridge:', error); + vscode.window.showErrorMessage('Failed to start doctk backend. Some features may not work.'); + } + + // Register commands + context.subscriptions.push( + vscode.commands.registerCommand('doctk.refresh', () => { + const editor = vscode.window.activeTextEditor; + if (editor && editor.document.languageId === 'markdown') { + outlineProvider.updateFromDocument(editor.document); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('doctk.navigateToNode', (node: any) => { + const editor = vscode.window.activeTextEditor; + if (editor && node.range) { + editor.selection = new vscode.Selection(node.range.start, node.range.end); + editor.revealRange(node.range, vscode.TextEditorRevealType.InCenter); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('doctk.promote', async (node: any) => { + await executeOperation('promote', node); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('doctk.demote', async (node: any) => { + await executeOperation('demote', node); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('doctk.moveUp', async (node: any) => { + await executeOperation('move_up', node); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('doctk.moveDown', async (node: any) => { + await executeOperation('move_down', node); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('doctk.delete', async (node: any) => { + // Delete operation would need to be implemented + vscode.window.showInformationMessage('Delete operation not yet implemented'); + }) + ); + + // Listen for active editor changes + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor((editor) => { + if (editor && editor.document.languageId === 'markdown') { + outlineProvider.updateFromDocument(editor.document); + } else { + outlineProvider.clear(); + } + }) + ); + + // Listen for document changes + context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument((event) => { + const editor = vscode.window.activeTextEditor; + if (editor && event.document === editor.document && event.document.languageId === 'markdown') { + outlineProvider.updateFromDocument(event.document); + } + }) + ); + + // Initialize with current editor if it's markdown + const editor = vscode.window.activeTextEditor; + if (editor && editor.document.languageId === 'markdown') { + outlineProvider.updateFromDocument(editor.document); + } + + // Register tree view + context.subscriptions.push(treeView); +} + +/** + * Execute an operation on a node. + * + * @param operation - Operation name + * @param node - Target node + */ +async function executeOperation(operation: string, node: any): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showErrorMessage('No active editor'); + return; + } + + const document = editor.document; + if (document.languageId !== 'markdown') { + vscode.window.showErrorMessage('Current document is not a Markdown file'); + return; + } + + try { + // Get document text + const documentText = document.getText(); + + // Call Python backend + let result; + switch (operation) { + case 'promote': + result = await pythonBridge.promote(documentText, node.id); + break; + case 'demote': + result = await pythonBridge.demote(documentText, node.id); + break; + case 'move_up': + result = await pythonBridge.moveUp(documentText, node.id); + break; + case 'move_down': + result = await pythonBridge.moveDown(documentText, node.id); + break; + default: + throw new Error(`Unknown operation: ${operation}`); + } + + if (result.success) { + // Apply changes to document + const edit = new vscode.WorkspaceEdit(); + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(documentText.length) + ); + edit.replace(document.uri, fullRange, result.document); + await vscode.workspace.applyEdit(edit); + + // Update tree view + outlineProvider.updateFromDocument(document); + } else { + vscode.window.showErrorMessage(`Operation failed: ${result.error}`); + } + } catch (error) { + vscode.window.showErrorMessage(`Error executing operation: ${error}`); + console.error('Operation error:', error); + } +} + +/** + * Deactivate the extension. + */ +export async function deactivate() { + console.log('doctk outliner extension is now deactivated'); + + // Stop Python bridge + if (pythonBridge) { + await pythonBridge.stop(); + } +} diff --git a/extensions/doctk-outliner/src/outlineProvider.ts b/extensions/doctk-outliner/src/outlineProvider.ts new file mode 100644 index 0000000..7520928 --- /dev/null +++ b/extensions/doctk-outliner/src/outlineProvider.ts @@ -0,0 +1,255 @@ +/** + * Document outline provider for VS Code tree view. + */ + +import * as vscode from 'vscode'; +import { OutlineNode, DocumentTree } from './types'; + +/** + * Provides a tree view of document structure. + * + * Implements VS Code's TreeDataProvider interface to display + * document headings in a hierarchical tree view. + */ +export class DocumentOutlineProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData: vscode.EventEmitter = + new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event = + this._onDidChangeTreeData.event; + + private documentTree: DocumentTree | null = null; + private document: vscode.TextDocument | null = null; + private debounceTimer: NodeJS.Timeout | null = null; + private readonly debounceDelay = 300; // milliseconds + + constructor() {} + + /** + * Get tree item representation for a node. + * + * @param element - The outline node + * @returns TreeItem for display in the tree view + */ + getTreeItem(element: OutlineNode): vscode.TreeItem { + const treeItem = new vscode.TreeItem( + element.label, + element.children.length > 0 + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.None + ); + + // Set description to show level + treeItem.description = `h${element.level}`; + + // Set icon based on level + treeItem.iconPath = new vscode.ThemeIcon(this.getIconForLevel(element.level)); + + // Set command to navigate to node location + treeItem.command = { + command: 'doctk.navigateToNode', + title: 'Navigate to Node', + arguments: [element], + }; + + // Store node ID in context value for commands + treeItem.contextValue = `outlineNode-${element.id}`; + + // Set tooltip with metadata + if (element.metadata) { + const lines = [ + `Level: ${element.level}`, + `ID: ${element.id}`, + `Range: ${element.range.start.line}:${element.range.start.character} - ${element.range.end.line}:${element.range.end.character}`, + ]; + if (element.metadata.hasContent) { + lines.push(`Content length: ${element.metadata.contentLength} chars`); + } + treeItem.tooltip = lines.join('\n'); + } + + return treeItem; + } + + /** + * Get children of a node. + * + * @param element - The parent node, or undefined for root + * @returns Array of child nodes + */ + getChildren(element?: OutlineNode): vscode.ProviderResult { + if (!this.documentTree) { + return []; + } + + if (element) { + return element.children; + } else { + // Return root-level children + return this.documentTree.root.children; + } + } + + /** + * Get parent of a node. + * + * @param element - The child node + * @returns Parent node or undefined + */ + getParent(element: OutlineNode): vscode.ProviderResult { + return element.parent; + } + + /** + * Refresh the tree view. + */ + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + /** + * Update the tree from the current document. + * + * @param document - The text document to parse + */ + updateFromDocument(document: vscode.TextDocument): void { + // Debounce updates to prevent excessive refreshes + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + + this.debounceTimer = setTimeout(() => { + this.document = document; + this.documentTree = this.parseDocument(document); + this.refresh(); + }, this.debounceDelay); + } + + /** + * Parse a document to build the outline tree. + * + * @param document - The text document to parse + * @returns DocumentTree structure + */ + private parseDocument(document: vscode.TextDocument): DocumentTree { + const root: OutlineNode = { + id: 'root', + label: 'Document', + level: 0, + range: new vscode.Range(0, 0, 0, 0), + children: [], + }; + + const nodeMap = new Map(); + const stack: OutlineNode[] = [root]; + let headingCounters = new Map(); + + // Parse document line by line + for (let i = 0; i < document.lineCount; i++) { + const line = document.lineAt(i); + const text = line.text; + + // Match Markdown heading pattern (# Heading) + const headingMatch = text.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + const level = headingMatch[1].length; + const label = headingMatch[2].trim(); + + // Generate unique ID + const count = headingCounters.get(level) || 0; + headingCounters.set(level, count + 1); + const id = `h${level}-${count}`; + + // Create node + const node: OutlineNode = { + id, + label, + level, + range: line.range, + children: [], + metadata: { + hasContent: false, + contentLength: 0, + lastModified: Date.now(), + }, + }; + + // Find parent in stack + // Pop stack until we find a node with level < current level + while (stack.length > 0 && stack[stack.length - 1].level >= level) { + stack.pop(); + } + + if (stack.length > 0) { + const parent = stack[stack.length - 1]; + node.parent = parent; + parent.children.push(node); + } + + // Push current node to stack + stack.push(node); + nodeMap.set(id, node); + } + } + + return { + root, + nodeMap, + version: 1, + }; + } + + /** + * Get icon name for heading level. + * + * @param level - Heading level (1-6) + * @returns Icon name + */ + private getIconForLevel(level: number): string { + const icons = [ + 'symbol-class', // h1 + 'symbol-method', // h2 + 'symbol-property', // h3 + 'symbol-field', // h4 + 'symbol-variable', // h5 + 'symbol-constant', // h6 + ]; + return icons[level - 1] || 'symbol-misc'; + } + + /** + * Get node by ID. + * + * @param nodeId - Node ID to find + * @returns OutlineNode or undefined + */ + getNodeById(nodeId: string): OutlineNode | undefined { + return this.documentTree?.nodeMap.get(nodeId); + } + + /** + * Get the current document tree. + * + * @returns DocumentTree or null + */ + getDocumentTree(): DocumentTree | null { + return this.documentTree; + } + + /** + * Get the current document. + * + * @returns TextDocument or null + */ + getDocument(): vscode.TextDocument | null { + return this.document; + } + + /** + * Clear the tree view. + */ + clear(): void { + this.documentTree = null; + this.document = null; + this.refresh(); + } +} diff --git a/extensions/doctk-outliner/src/pythonBridge.ts b/extensions/doctk-outliner/src/pythonBridge.ts new file mode 100644 index 0000000..2622895 --- /dev/null +++ b/extensions/doctk-outliner/src/pythonBridge.ts @@ -0,0 +1,359 @@ +/** + * Python bridge for executing document operations via JSON-RPC. + */ + +import { spawn, ChildProcess } from 'child_process'; +import * as path from 'path'; + +interface JsonRpcRequest { + jsonrpc: '2.0'; + id: number; + method: string; + params: Record; +} + +interface JsonRpcResponse { + jsonrpc: '2.0'; + id: number; + result?: any; + error?: { + code: number; + message: string; + }; +} + +interface PendingRequest { + resolve: (value: any) => void; + reject: (error: Error) => void; +} + +/** + * Options for configuring the Python bridge. + */ +export interface PythonBridgeOptions { + /** Path to the Python executable (defaults to 'uv') */ + pythonCommand?: string; + /** Working directory for the Python process */ + cwd?: string; + /** Timeout for requests in milliseconds (default: 10000) */ + timeout?: number; + /** Maximum number of restart attempts (default: 3) */ + maxRestarts?: number; +} + +/** + * Bridge to Python backend for executing document operations. + * + * Communicates via JSON-RPC over stdin/stdout with a Python process. + */ +export class PythonBridge { + private process: ChildProcess | null = null; + private nextId = 1; + private pendingRequests = new Map(); + private buffer = ''; + private options: Required; + private restartCount = 0; + private isStarting = false; + private isStopping = false; + + constructor(options: PythonBridgeOptions = {}) { + this.options = { + pythonCommand: options.pythonCommand || 'uv', + cwd: options.cwd || process.cwd(), + timeout: options.timeout || 10000, + maxRestarts: options.maxRestarts || 3, + }; + } + + /** + * Start the Python bridge process. + */ + async start(): Promise { + if (this.process || this.isStarting) { + return; + } + + this.isStarting = true; + + try { + // Spawn Python process with uv run + const args = + this.options.pythonCommand === 'uv' + ? ['run', 'python', '-m', 'doctk.lsp.bridge'] + : ['-m', 'doctk.lsp.bridge']; + + this.process = spawn(this.options.pythonCommand, args, { + cwd: this.options.cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + // Set up event handlers + this.setupEventHandlers(); + + // Wait for process to be ready + await this.waitForReady(); + } finally { + this.isStarting = false; + } + } + + /** + * Stop the Python bridge process. + */ + async stop(): Promise { + if (!this.process || this.isStopping) { + return; + } + + this.isStopping = true; + + try { + // Reject all pending requests + for (const [id, pending] of this.pendingRequests) { + pending.reject(new Error('Bridge stopped')); + } + this.pendingRequests.clear(); + + // Kill the process + if (this.process) { + this.process.kill(); + this.process = null; + } + } finally { + this.isStopping = false; + } + } + + /** + * Restart the Python bridge process. + */ + async restart(): Promise { + await this.stop(); + await this.start(); + } + + /** + * Call a method on the Python backend. + * + * @param method - Method name + * @param params - Method parameters + * @returns Promise that resolves with the result + */ + async call(method: string, params: Record = {}): Promise { + if (!this.process) { + throw new Error('Bridge not started'); + } + + const id = this.nextId++; + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id, + method, + params, + }; + + return new Promise((resolve, reject) => { + // Set up timeout + const timeoutId = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`Request timed out after ${this.options.timeout}ms`)); + }, this.options.timeout); + + // Store pending request + this.pendingRequests.set(id, { + resolve: (value: any) => { + clearTimeout(timeoutId); + resolve(value); + }, + reject: (error: Error) => { + clearTimeout(timeoutId); + reject(error); + }, + }); + + // Send request + const requestLine = JSON.stringify(request) + '\n'; + this.process!.stdin!.write(requestLine); + }); + } + + /** + * Execute a promote operation. + */ + async promote(document: string, nodeId: string): Promise { + return this.call('promote', { document, node_id: nodeId }); + } + + /** + * Execute a demote operation. + */ + async demote(document: string, nodeId: string): Promise { + return this.call('demote', { document, node_id: nodeId }); + } + + /** + * Execute a move_up operation. + */ + async moveUp(document: string, nodeId: string): Promise { + return this.call('move_up', { document, node_id: nodeId }); + } + + /** + * Execute a move_down operation. + */ + async moveDown(document: string, nodeId: string): Promise { + return this.call('move_down', { document, node_id: nodeId }); + } + + /** + * Execute a nest operation. + */ + async nest(document: string, nodeId: string, parentId: string): Promise { + return this.call('nest', { document, node_id: nodeId, parent_id: parentId }); + } + + /** + * Execute an unnest operation. + */ + async unnest(document: string, nodeId: string): Promise { + return this.call('unnest', { document, node_id: nodeId }); + } + + /** + * Validate a promote operation. + */ + async validatePromote(document: string, nodeId: string): Promise { + return this.call('validate_promote', { document, node_id: nodeId }); + } + + /** + * Validate a demote operation. + */ + async validateDemote(document: string, nodeId: string): Promise { + return this.call('validate_demote', { document, node_id: nodeId }); + } + + /** + * Set up event handlers for the Python process. + */ + private setupEventHandlers(): void { + if (!this.process) { + return; + } + + // Handle stdout data (responses) + this.process.stdout!.on('data', (data: Buffer) => { + this.handleStdout(data.toString()); + }); + + // Handle stderr data (errors) + this.process.stderr!.on('data', (data: Buffer) => { + console.error('Python bridge error:', data.toString()); + }); + + // Handle process exit + this.process.on('exit', (code: number | null, signal: string | null) => { + console.log(`Python bridge exited with code ${code}, signal ${signal}`); + this.handleProcessExit(code, signal); + }); + + // Handle process error + this.process.on('error', (error: Error) => { + console.error('Python bridge process error:', error); + this.handleProcessError(error); + }); + } + + /** + * Handle stdout data from the Python process. + */ + private handleStdout(data: string): void { + // Add data to buffer + this.buffer += data; + + // Process complete lines + const lines = this.buffer.split('\n'); + this.buffer = lines.pop() || ''; // Keep incomplete line in buffer + + for (const line of lines) { + if (line.trim()) { + try { + const response: JsonRpcResponse = JSON.parse(line); + this.handleResponse(response); + } catch (error) { + console.error('Failed to parse response:', error, line); + } + } + } + } + + /** + * Handle a JSON-RPC response. + */ + private handleResponse(response: JsonRpcResponse): void { + const pending = this.pendingRequests.get(response.id); + if (!pending) { + console.warn('Received response for unknown request:', response.id); + return; + } + + this.pendingRequests.delete(response.id); + + if (response.error) { + pending.reject(new Error(response.error.message)); + } else { + pending.resolve(response.result); + } + } + + /** + * Handle process exit. + */ + private async handleProcessExit(code: number | null, signal: string | null): Promise { + // Reject all pending requests + for (const [id, pending] of this.pendingRequests) { + pending.reject(new Error(`Process exited with code ${code}, signal ${signal}`)); + } + this.pendingRequests.clear(); + + // Attempt restart if not stopping + if (!this.isStopping && this.restartCount < this.options.maxRestarts) { + console.log(`Attempting to restart Python bridge (attempt ${this.restartCount + 1})`); + this.restartCount++; + + try { + await this.restart(); + this.restartCount = 0; // Reset counter on successful restart + } catch (error) { + console.error('Failed to restart Python bridge:', error); + } + } + } + + /** + * Handle process error. + */ + private handleProcessError(error: Error): void { + // Reject all pending requests + for (const [id, pending] of this.pendingRequests) { + pending.reject(error); + } + this.pendingRequests.clear(); + } + + /** + * Wait for the process to be ready. + */ + private async waitForReady(): Promise { + // For now, just wait a bit for the process to start + // In the future, we could send a ping request to verify + return new Promise((resolve) => setTimeout(resolve, 100)); + } + + /** + * Check if the bridge is running. + */ + isRunning(): boolean { + return this.process !== null && !this.process.killed; + } +} diff --git a/extensions/doctk-outliner/src/types.ts b/extensions/doctk-outliner/src/types.ts new file mode 100644 index 0000000..e709ef7 --- /dev/null +++ b/extensions/doctk-outliner/src/types.ts @@ -0,0 +1,132 @@ +/** + * Type definitions for the doctk outliner extension. + */ + +import { Range } from 'vscode'; + +/** + * Metadata associated with an outline node. + */ +export interface NodeMetadata { + /** Whether the node has body content */ + hasContent: boolean; + /** Character count of content */ + contentLength: number; + /** Timestamp of last modification */ + lastModified: number; +} + +/** + * Represents a node in the document outline tree. + */ +export interface OutlineNode { + /** Unique identifier for the node (e.g., "h1-0", "h2-3") */ + id: string; + /** Heading text */ + label: string; + /** Heading level (1-6) */ + level: number; + /** Position in document */ + range: Range; + /** Child nodes */ + children: OutlineNode[]; + /** Parent reference (optional) */ + parent?: OutlineNode; + /** Additional metadata */ + metadata?: NodeMetadata; +} + +/** + * Represents the complete document tree structure. + */ +export interface DocumentTree { + /** Root node of the tree */ + root: OutlineNode; + /** Map for fast lookup by ID */ + nodeMap: Map; + /** Version number for change tracking */ + version: number; +} + +/** + * Operation types supported by the outliner. + */ +export type OperationType = + | 'promote' + | 'demote' + | 'move_up' + | 'move_down' + | 'nest' + | 'unnest' + | 'delete'; + +/** + * Represents an operation to be performed on a node. + */ +export interface Operation { + /** Type of operation */ + type: OperationType; + /** Target node for the operation */ + targetNode: OutlineNode; + /** Optional parameters for the operation */ + params?: Record; +} + +/** + * Result of executing an operation. + */ +export interface OperationResult { + /** Whether the operation succeeded */ + success: boolean; + /** Modified document text (if successful) */ + document?: string; + /** Ranges that were modified */ + modifiedRanges?: Range[]; + /** Error message (if failed) */ + error?: string; +} + +/** + * Configuration for the doctk outliner. + */ +export interface DoctkConfiguration { + outliner: { + /** Whether to automatically refresh the tree */ + autoRefresh: boolean; + /** Delay before refreshing (milliseconds) */ + refreshDelay: number; + /** Whether to show content preview */ + showContentPreview: boolean; + /** Maximum length of preview text */ + maxPreviewLength: number; + }; + + keybindings: { + /** Keybinding for promote operation */ + promote: string; + /** Keybinding for demote operation */ + demote: string; + /** Keybinding for move up operation */ + moveUp: string; + /** Keybinding for move down operation */ + moveDown: string; + /** Keybinding for delete operation */ + delete: string; + }; + + lsp: { + /** Whether LSP is enabled */ + enabled: boolean; + /** Trace level for LSP communication */ + trace: 'off' | 'messages' | 'verbose'; + /** Maximum number of completion items to show */ + maxCompletionItems: number; + }; + + performance: { + /** Threshold for considering a document "large" (number of headings) */ + largeDocumentThreshold: number; + /** Whether to enable virtualization for large documents */ + enableVirtualization: boolean; + }; +} diff --git a/extensions/doctk-outliner/tsconfig.json b/extensions/doctk-outliner/tsconfig.json new file mode 100644 index 0000000..eb93dab --- /dev/null +++ b/extensions/doctk-outliner/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./out", + "rootDir": "./src", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "out", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/src/doctk/lsp/__init__.py b/src/doctk/lsp/__init__.py new file mode 100644 index 0000000..9cd1955 --- /dev/null +++ b/src/doctk/lsp/__init__.py @@ -0,0 +1,20 @@ +"""Language Server Protocol support for doctk.""" + +from doctk.lsp.bridge import ExtensionBridge +from doctk.lsp.operations import DocumentTreeBuilder, StructureOperations +from doctk.lsp.protocols import ( + DocumentInterface, + DocumentOperation, + OperationResult, + ValidationResult, +) + +__all__ = [ + "DocumentInterface", + "DocumentOperation", + "DocumentTreeBuilder", + "ExtensionBridge", + "OperationResult", + "StructureOperations", + "ValidationResult", +] diff --git a/src/doctk/lsp/bridge.py b/src/doctk/lsp/bridge.py new file mode 100644 index 0000000..f2b5758 --- /dev/null +++ b/src/doctk/lsp/bridge.py @@ -0,0 +1,336 @@ +"""Extension bridge for TypeScript-Python communication via JSON-RPC.""" + +from __future__ import annotations + +import json +import sys +from typing import Any + +from doctk.core import Document +from doctk.lsp.operations import StructureOperations +from doctk.lsp.protocols import OperationResult + + +class ExtensionBridge: + """ + Bridge between VS Code extension and doctk core. + + Provides a JSON-RPC interface over stdin/stdout for executing + document operations from the TypeScript extension. + """ + + def __init__(self): + """Initialize the extension bridge.""" + self.operations = StructureOperations() + + def handle_request(self, request: dict[str, Any]) -> dict[str, Any]: + """ + Handle a JSON-RPC request. + + Args: + request: JSON-RPC request dictionary with: + - jsonrpc: "2.0" + - id: Request ID + - method: Method name + - params: Method parameters + + Returns: + JSON-RPC response dictionary + """ + jsonrpc_version = request.get("jsonrpc") + request_id = request.get("id") + method = request.get("method") + params = request.get("params", {}) + + # Validate JSON-RPC version + if jsonrpc_version != "2.0": + return self._error_response( + request_id, -32600, "Invalid Request: JSON-RPC version must be 2.0" + ) + + # Validate method + if not method: + return self._error_response(request_id, -32600, "Invalid Request: Missing method") + + # Route to appropriate handler + try: + result = self._execute_method(method, params) + return self._success_response(request_id, result) + except Exception as e: + return self._error_response(request_id, -32603, f"Internal error: {str(e)}") + + def _execute_method(self, method: str, params: dict[str, Any]) -> Any: + """ + Execute a method with the given parameters. + + Args: + method: Method name + params: Method parameters + + Returns: + Method result + + Raises: + ValueError: If method is unknown + """ + # Map of method names to handlers + method_map = { + "promote": self._handle_promote, + "demote": self._handle_demote, + "move_up": self._handle_move_up, + "move_down": self._handle_move_down, + "nest": self._handle_nest, + "unnest": self._handle_unnest, + "validate_promote": self._handle_validate_promote, + "validate_demote": self._handle_validate_demote, + "validate_move_up": self._handle_validate_move_up, + "validate_move_down": self._handle_validate_move_down, + "validate_nest": self._handle_validate_nest, + "validate_unnest": self._handle_validate_unnest, + } + + handler = method_map.get(method) + if not handler: + raise ValueError(f"Unknown method: {method}") + + return handler(params) + + def _handle_promote(self, params: dict[str, Any]) -> dict[str, Any]: + """Handle promote operation.""" + document_text = params.get("document") + node_id = params.get("node_id") + + if not document_text or not node_id: + raise ValueError("Missing required parameters: document, node_id") + + doc = Document.from_string(document_text) + new_doc, result = self.operations.promote(doc, node_id) + + return self._operation_result_to_dict(result) + + def _handle_demote(self, params: dict[str, Any]) -> dict[str, Any]: + """Handle demote operation.""" + document_text = params.get("document") + node_id = params.get("node_id") + + if not document_text or not node_id: + raise ValueError("Missing required parameters: document, node_id") + + doc = Document.from_string(document_text) + new_doc, result = self.operations.demote(doc, node_id) + + return self._operation_result_to_dict(result) + + def _handle_move_up(self, params: dict[str, Any]) -> dict[str, Any]: + """Handle move_up operation.""" + document_text = params.get("document") + node_id = params.get("node_id") + + if not document_text or not node_id: + raise ValueError("Missing required parameters: document, node_id") + + doc = Document.from_string(document_text) + new_doc, result = self.operations.move_up(doc, node_id) + + return self._operation_result_to_dict(result) + + def _handle_move_down(self, params: dict[str, Any]) -> dict[str, Any]: + """Handle move_down operation.""" + document_text = params.get("document") + node_id = params.get("node_id") + + if not document_text or not node_id: + raise ValueError("Missing required parameters: document, node_id") + + doc = Document.from_string(document_text) + new_doc, result = self.operations.move_down(doc, node_id) + + return self._operation_result_to_dict(result) + + def _handle_nest(self, params: dict[str, Any]) -> dict[str, Any]: + """Handle nest operation.""" + document_text = params.get("document") + node_id = params.get("node_id") + parent_id = params.get("parent_id") + + if not document_text or not node_id or not parent_id: + raise ValueError("Missing required parameters: document, node_id, parent_id") + + doc = Document.from_string(document_text) + new_doc, result = self.operations.nest(doc, node_id, parent_id) + + return self._operation_result_to_dict(result) + + def _handle_unnest(self, params: dict[str, Any]) -> dict[str, Any]: + """Handle unnest operation.""" + document_text = params.get("document") + node_id = params.get("node_id") + + if not document_text or not node_id: + raise ValueError("Missing required parameters: document, node_id") + + doc = Document.from_string(document_text) + new_doc, result = self.operations.unnest(doc, node_id) + + return self._operation_result_to_dict(result) + + def _handle_validate_promote(self, params: dict[str, Any]) -> dict[str, Any]: + """Handle validate_promote operation.""" + document_text = params.get("document") + node_id = params.get("node_id") + + if not document_text or not node_id: + raise ValueError("Missing required parameters: document, node_id") + + doc = Document.from_string(document_text) + result = self.operations.validate_promote(doc, node_id) + + return {"valid": result.valid, "error": result.error} + + def _handle_validate_demote(self, params: dict[str, Any]) -> dict[str, Any]: + """Handle validate_demote operation.""" + document_text = params.get("document") + node_id = params.get("node_id") + + if not document_text or not node_id: + raise ValueError("Missing required parameters: document, node_id") + + doc = Document.from_string(document_text) + result = self.operations.validate_demote(doc, node_id) + + return {"valid": result.valid, "error": result.error} + + def _handle_validate_move_up(self, params: dict[str, Any]) -> dict[str, Any]: + """Handle validate_move_up operation.""" + document_text = params.get("document") + node_id = params.get("node_id") + + if not document_text or not node_id: + raise ValueError("Missing required parameters: document, node_id") + + doc = Document.from_string(document_text) + result = self.operations.validate_move_up(doc, node_id) + + return {"valid": result.valid, "error": result.error} + + def _handle_validate_move_down(self, params: dict[str, Any]) -> dict[str, Any]: + """Handle validate_move_down operation.""" + document_text = params.get("document") + node_id = params.get("node_id") + + if not document_text or not node_id: + raise ValueError("Missing required parameters: document, node_id") + + doc = Document.from_string(document_text) + result = self.operations.validate_move_down(doc, node_id) + + return {"valid": result.valid, "error": result.error} + + def _handle_validate_nest(self, params: dict[str, Any]) -> dict[str, Any]: + """Handle validate_nest operation.""" + document_text = params.get("document") + node_id = params.get("node_id") + parent_id = params.get("parent_id") + + if not document_text or not node_id or not parent_id: + raise ValueError("Missing required parameters: document, node_id, parent_id") + + doc = Document.from_string(document_text) + result = self.operations.validate_nest(doc, node_id, parent_id) + + return {"valid": result.valid, "error": result.error} + + def _handle_validate_unnest(self, params: dict[str, Any]) -> dict[str, Any]: + """Handle validate_unnest operation.""" + document_text = params.get("document") + node_id = params.get("node_id") + + if not document_text or not node_id: + raise ValueError("Missing required parameters: document, node_id") + + doc = Document.from_string(document_text) + result = self.operations.validate_unnest(doc, node_id) + + return {"valid": result.valid, "error": result.error} + + def _operation_result_to_dict(self, result: OperationResult) -> dict[str, Any]: + """ + Convert OperationResult to dictionary for JSON serialization. + + Args: + result: OperationResult to convert + + Returns: + Dictionary representation + """ + return { + "success": result.success, + "document": result.document, + "modified_ranges": result.modified_ranges, + "error": result.error, + } + + def _success_response(self, request_id: Any, result: Any) -> dict[str, Any]: + """ + Create a JSON-RPC success response. + + Args: + request_id: Request ID + result: Result data + + Returns: + JSON-RPC success response + """ + return {"jsonrpc": "2.0", "id": request_id, "result": result} + + def _error_response(self, request_id: Any, code: int, message: str) -> dict[str, Any]: + """ + Create a JSON-RPC error response. + + Args: + request_id: Request ID + code: Error code + message: Error message + + Returns: + JSON-RPC error response + """ + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": code, "message": message}, + } + + def run(self): + """ + Run the bridge, reading requests from stdin and writing responses to stdout. + + This is the main loop for the JSON-RPC bridge. + """ + for line in sys.stdin: + try: + request = json.loads(line.strip()) + response = self.handle_request(request) + print(json.dumps(response), flush=True) + except json.JSONDecodeError as e: + # Send parse error response + error_response = self._error_response( + None, -32700, f"Parse error: {str(e)}" + ) + print(json.dumps(error_response), flush=True) + except Exception as e: + # Send internal error response + error_response = self._error_response( + None, -32603, f"Internal error: {str(e)}" + ) + print(json.dumps(error_response), flush=True) + + +def main(): + """Main entry point for the extension bridge.""" + bridge = ExtensionBridge() + bridge.run() + + +if __name__ == "__main__": + main() diff --git a/src/doctk/lsp/operations.py b/src/doctk/lsp/operations.py new file mode 100644 index 0000000..40c67b4 --- /dev/null +++ b/src/doctk/lsp/operations.py @@ -0,0 +1,644 @@ +"""Document structure operations for the LSP integration layer.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from doctk.core import Document, Heading, Node + +if TYPE_CHECKING: + from doctk.lsp.protocols import OperationResult, ValidationResult + + +class DocumentTreeBuilder: + """Builds a tree representation of a document with node IDs.""" + + def __init__(self, document: Document[Node]): + """ + Initialize the tree builder. + + Args: + document: The document to build a tree from + """ + self.document = document + self.node_map: dict[str, Node] = {} + self.parent_map: dict[str, str] = {} + self._build_node_map() + + def _build_node_map(self) -> None: + """Build a map of node IDs to nodes.""" + heading_counter: dict[int, int] = {} + + for node in self.document.nodes: + if isinstance(node, Heading): + level = node.level + heading_counter[level] = heading_counter.get(level, 0) + 1 + node_id = f"h{level}-{heading_counter[level] - 1}" + self.node_map[node_id] = node + + def find_node(self, node_id: str) -> Node | None: + """ + Find a node by its ID. + + Args: + node_id: The ID of the node to find + + Returns: + The node if found, None otherwise + """ + return self.node_map.get(node_id) + + def get_node_index(self, node_id: str) -> int | None: + """ + Get the index of a node in the document. + + Args: + node_id: The ID of the node to find + + Returns: + The index of the node, or None if not found + """ + node = self.find_node(node_id) + if node is None: + return None + + try: + return self.document.nodes.index(node) + except ValueError: + return None + + +class StructureOperations: + """High-level operations for document structure manipulation.""" + + @staticmethod + def promote(document: Document[Node], node_id: str) -> tuple[Document[Node], OperationResult]: + """ + Decrease heading level by one (e.g., h3 -> h2). + + Args: + document: The document to operate on + node_id: The ID of the node to promote + + Returns: + Tuple of (modified document, operation result) + """ + from doctk.lsp.protocols import OperationResult + + tree_builder = DocumentTreeBuilder(document) + node = tree_builder.find_node(node_id) + + if node is None: + return document, OperationResult( + success=False, error=f"Node not found: {node_id}" + ) + + if not isinstance(node, Heading): + return document, OperationResult( + success=False, error=f"Node {node_id} is not a heading" + ) + + # Validate: already at minimum level? + if node.level <= 1: + return document, OperationResult( + success=True, + document=document.to_string(), + error=None, + ) + + # Get the index of the node + node_index = tree_builder.get_node_index(node_id) + if node_index is None: + return document, OperationResult( + success=False, error=f"Could not find index for node: {node_id}" + ) + + # Create new promoted node + promoted_node = node.promote() + + # Create new document with updated node + new_nodes = list(document.nodes) + new_nodes[node_index] = promoted_node + new_document = Document(new_nodes) + + return new_document, OperationResult( + success=True, document=new_document.to_string() + ) + + @staticmethod + def demote(document: Document[Node], node_id: str) -> tuple[Document[Node], OperationResult]: + """ + Increase heading level by one (e.g., h2 -> h3). + + Args: + document: The document to operate on + node_id: The ID of the node to demote + + Returns: + Tuple of (modified document, operation result) + """ + from doctk.lsp.protocols import OperationResult + + tree_builder = DocumentTreeBuilder(document) + node = tree_builder.find_node(node_id) + + if node is None: + return document, OperationResult( + success=False, error=f"Node not found: {node_id}" + ) + + if not isinstance(node, Heading): + return document, OperationResult( + success=False, error=f"Node {node_id} is not a heading" + ) + + # Validate: already at maximum level? + if node.level >= 6: + return document, OperationResult( + success=True, + document=document.to_string(), + error=None, + ) + + # Get the index of the node + node_index = tree_builder.get_node_index(node_id) + if node_index is None: + return document, OperationResult( + success=False, error=f"Could not find index for node: {node_id}" + ) + + # Create new demoted node + demoted_node = node.demote() + + # Create new document with updated node + new_nodes = list(document.nodes) + new_nodes[node_index] = demoted_node + new_document = Document(new_nodes) + + return new_document, OperationResult( + success=True, document=new_document.to_string() + ) + + @staticmethod + def validate_promote(document: Document[Node], node_id: str) -> ValidationResult: + """ + Validate that a promote operation can be executed. + + Args: + document: The document to validate against + node_id: The ID of the node to promote + + Returns: + ValidationResult indicating whether the operation is valid + """ + from doctk.lsp.protocols import ValidationResult + + tree_builder = DocumentTreeBuilder(document) + node = tree_builder.find_node(node_id) + + if node is None: + return ValidationResult(valid=False, error=f"Node not found: {node_id}") + + if not isinstance(node, Heading): + return ValidationResult(valid=False, error=f"Node {node_id} is not a heading") + + # Promote is always valid (at level 1 it's identity) + return ValidationResult(valid=True) + + @staticmethod + def validate_demote(document: Document[Node], node_id: str) -> ValidationResult: + """ + Validate that a demote operation can be executed. + + Args: + document: The document to validate against + node_id: The ID of the node to demote + + Returns: + ValidationResult indicating whether the operation is valid + """ + from doctk.lsp.protocols import ValidationResult + + tree_builder = DocumentTreeBuilder(document) + node = tree_builder.find_node(node_id) + + if node is None: + return ValidationResult(valid=False, error=f"Node not found: {node_id}") + + if not isinstance(node, Heading): + return ValidationResult(valid=False, error=f"Node {node_id} is not a heading") + + # Demote is always valid (at level 6 it's identity) + return ValidationResult(valid=True) + + @staticmethod + def move_up(document: Document[Node], node_id: str) -> tuple[Document[Node], OperationResult]: + """ + Move a node up in the sibling order. + + Args: + document: The document to operate on + node_id: The ID of the node to move up + + Returns: + Tuple of (modified document, operation result) + """ + from doctk.lsp.protocols import OperationResult + + tree_builder = DocumentTreeBuilder(document) + node = tree_builder.find_node(node_id) + + if node is None: + return document, OperationResult( + success=False, error=f"Node not found: {node_id}" + ) + + if not isinstance(node, Heading): + return document, OperationResult( + success=False, error=f"Node {node_id} is not a heading" + ) + + # Get the index of the node + node_index = tree_builder.get_node_index(node_id) + if node_index is None: + return document, OperationResult( + success=False, error=f"Could not find index for node: {node_id}" + ) + + # Check if already at the top (first sibling of its level) + if node_index == 0: + return document, OperationResult( + success=True, + document=document.to_string(), + error=None, + ) + + # Find the previous sibling (same level or higher) + prev_index = node_index - 1 + while prev_index >= 0: + prev_node = document.nodes[prev_index] + if isinstance(prev_node, Heading): + # Found a heading - check if it's a valid swap target + if prev_node.level <= node.level: + break + prev_index -= 1 + + # If we can't find a valid previous sibling, stay in place + if prev_index < 0: + return document, OperationResult( + success=True, + document=document.to_string(), + error=None, + ) + + # Swap the nodes + new_nodes = list(document.nodes) + new_nodes[prev_index], new_nodes[node_index] = ( + new_nodes[node_index], + new_nodes[prev_index], + ) + new_document = Document(new_nodes) + + return new_document, OperationResult( + success=True, document=new_document.to_string() + ) + + @staticmethod + def move_down( + document: Document[Node], node_id: str + ) -> tuple[Document[Node], OperationResult]: + """ + Move a node down in the sibling order. + + Args: + document: The document to operate on + node_id: The ID of the node to move down + + Returns: + Tuple of (modified document, operation result) + """ + from doctk.lsp.protocols import OperationResult + + tree_builder = DocumentTreeBuilder(document) + node = tree_builder.find_node(node_id) + + if node is None: + return document, OperationResult( + success=False, error=f"Node not found: {node_id}" + ) + + if not isinstance(node, Heading): + return document, OperationResult( + success=False, error=f"Node {node_id} is not a heading" + ) + + # Get the index of the node + node_index = tree_builder.get_node_index(node_id) + if node_index is None: + return document, OperationResult( + success=False, error=f"Could not find index for node: {node_id}" + ) + + # Check if already at the bottom (last sibling of its level) + if node_index >= len(document.nodes) - 1: + return document, OperationResult( + success=True, + document=document.to_string(), + error=None, + ) + + # Find the next sibling (same level or higher) + next_index = node_index + 1 + while next_index < len(document.nodes): + next_node = document.nodes[next_index] + if isinstance(next_node, Heading): + # Found a heading - check if it's a valid swap target + if next_node.level <= node.level: + break + next_index += 1 + + # If we can't find a valid next sibling, stay in place + if next_index >= len(document.nodes): + return document, OperationResult( + success=True, + document=document.to_string(), + error=None, + ) + + # Swap the nodes + new_nodes = list(document.nodes) + new_nodes[next_index], new_nodes[node_index] = ( + new_nodes[node_index], + new_nodes[next_index], + ) + new_document = Document(new_nodes) + + return new_document, OperationResult( + success=True, document=new_document.to_string() + ) + + @staticmethod + def validate_move_up(document: Document[Node], node_id: str) -> ValidationResult: + """ + Validate that a move_up operation can be executed. + + Args: + document: The document to validate against + node_id: The ID of the node to move up + + Returns: + ValidationResult indicating whether the operation is valid + """ + from doctk.lsp.protocols import ValidationResult + + tree_builder = DocumentTreeBuilder(document) + node = tree_builder.find_node(node_id) + + if node is None: + return ValidationResult(valid=False, error=f"Node not found: {node_id}") + + if not isinstance(node, Heading): + return ValidationResult(valid=False, error=f"Node {node_id} is not a heading") + + # Move up is always valid (stays in place if already at top) + return ValidationResult(valid=True) + + @staticmethod + def validate_move_down(document: Document[Node], node_id: str) -> ValidationResult: + """ + Validate that a move_down operation can be executed. + + Args: + document: The document to validate against + node_id: The ID of the node to move down + + Returns: + ValidationResult indicating whether the operation is valid + """ + from doctk.lsp.protocols import ValidationResult + + tree_builder = DocumentTreeBuilder(document) + node = tree_builder.find_node(node_id) + + if node is None: + return ValidationResult(valid=False, error=f"Node not found: {node_id}") + + if not isinstance(node, Heading): + return ValidationResult(valid=False, error=f"Node {node_id} is not a heading") + + # Move down is always valid (stays in place if already at bottom) + return ValidationResult(valid=True) + + @staticmethod + def nest( + document: Document[Node], node_id: str, parent_id: str + ) -> tuple[Document[Node], OperationResult]: + """ + Nest a node under a new parent (make it a child of the parent). + + This operation moves the node to immediately after the parent and + adjusts its level to be parent.level + 1. + + Args: + document: The document to operate on + node_id: The ID of the node to nest + parent_id: The ID of the parent node + + Returns: + Tuple of (modified document, operation result) + """ + from doctk.lsp.protocols import OperationResult + + tree_builder = DocumentTreeBuilder(document) + node = tree_builder.find_node(node_id) + parent = tree_builder.find_node(parent_id) + + if node is None: + return document, OperationResult( + success=False, error=f"Node not found: {node_id}" + ) + + if parent is None: + return document, OperationResult( + success=False, error=f"Parent node not found: {parent_id}" + ) + + if not isinstance(node, Heading): + return document, OperationResult( + success=False, error=f"Node {node_id} is not a heading" + ) + + if not isinstance(parent, Heading): + return document, OperationResult( + success=False, error=f"Parent node {parent_id} is not a heading" + ) + + # Get indices + node_index = tree_builder.get_node_index(node_id) + parent_index = tree_builder.get_node_index(parent_id) + + if node_index is None or parent_index is None: + return document, OperationResult( + success=False, error="Could not find node indices" + ) + + # Calculate new level for the node + new_level = min(6, parent.level + 1) + + # Create new node with adjusted level + nested_node = Heading( + level=new_level, + text=node.text, + children=node.children, + metadata=node.metadata, + ) + + # Create new node list with the node moved after parent + new_nodes = list(document.nodes) + + # Remove node from current position + new_nodes.pop(node_index) + + # Adjust parent index if node was before parent + if node_index < parent_index: + parent_index -= 1 + + # Insert node after parent + new_nodes.insert(parent_index + 1, nested_node) + + new_document = Document(new_nodes) + + return new_document, OperationResult( + success=True, document=new_document.to_string() + ) + + @staticmethod + def unnest(document: Document[Node], node_id: str) -> tuple[Document[Node], OperationResult]: + """ + Move a node up one level in the hierarchy (decrease level by 1). + + This is essentially a promote operation that represents "unnesting" + from a parent section. + + Args: + document: The document to operate on + node_id: The ID of the node to unnest + + Returns: + Tuple of (modified document, operation result) + """ + from doctk.lsp.protocols import OperationResult + + tree_builder = DocumentTreeBuilder(document) + node = tree_builder.find_node(node_id) + + if node is None: + return document, OperationResult( + success=False, error=f"Node not found: {node_id}" + ) + + if not isinstance(node, Heading): + return document, OperationResult( + success=False, error=f"Node {node_id} is not a heading" + ) + + # Validate: already at minimum level? + if node.level <= 1: + return document, OperationResult( + success=True, + document=document.to_string(), + error=None, + ) + + # Get the index of the node + node_index = tree_builder.get_node_index(node_id) + if node_index is None: + return document, OperationResult( + success=False, error=f"Could not find index for node: {node_id}" + ) + + # Create new unnested node (promote by one level) + unnested_node = node.promote() + + # Create new document with updated node + new_nodes = list(document.nodes) + new_nodes[node_index] = unnested_node + new_document = Document(new_nodes) + + return new_document, OperationResult( + success=True, document=new_document.to_string() + ) + + @staticmethod + def validate_nest( + document: Document[Node], node_id: str, parent_id: str + ) -> ValidationResult: + """ + Validate that a nest operation can be executed. + + Args: + document: The document to validate against + node_id: The ID of the node to nest + parent_id: The ID of the parent node + + Returns: + ValidationResult indicating whether the operation is valid + """ + from doctk.lsp.protocols import ValidationResult + + tree_builder = DocumentTreeBuilder(document) + node = tree_builder.find_node(node_id) + parent = tree_builder.find_node(parent_id) + + if node is None: + return ValidationResult(valid=False, error=f"Node not found: {node_id}") + + if parent is None: + return ValidationResult(valid=False, error=f"Parent node not found: {parent_id}") + + if not isinstance(node, Heading): + return ValidationResult(valid=False, error=f"Node {node_id} is not a heading") + + if not isinstance(parent, Heading): + return ValidationResult( + valid=False, error=f"Parent node {parent_id} is not a heading" + ) + + # Can't nest a node under itself + if node_id == parent_id: + return ValidationResult(valid=False, error="Cannot nest a node under itself") + + # Nest is valid as long as the result wouldn't exceed level 6 + new_level = parent.level + 1 + if new_level > 6: + return ValidationResult( + valid=False, error="Cannot nest: would exceed maximum heading level (6)" + ) + + return ValidationResult(valid=True) + + @staticmethod + def validate_unnest(document: Document[Node], node_id: str) -> ValidationResult: + """ + Validate that an unnest operation can be executed. + + Args: + document: The document to validate against + node_id: The ID of the node to unnest + + Returns: + ValidationResult indicating whether the operation is valid + """ + from doctk.lsp.protocols import ValidationResult + + tree_builder = DocumentTreeBuilder(document) + node = tree_builder.find_node(node_id) + + if node is None: + return ValidationResult(valid=False, error=f"Node not found: {node_id}") + + if not isinstance(node, Heading): + return ValidationResult(valid=False, error=f"Node {node_id} is not a heading") + + # Unnest is always valid (at level 1 it's identity) + return ValidationResult(valid=True) diff --git a/src/doctk/lsp/protocols.py b/src/doctk/lsp/protocols.py new file mode 100644 index 0000000..58c6905 --- /dev/null +++ b/src/doctk/lsp/protocols.py @@ -0,0 +1,128 @@ +"""Protocol definitions for doctk LSP integration.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Protocol + +if TYPE_CHECKING: + from doctk.core import Document, Node + + +@dataclass +class ValidationResult: + """Result of validating an operation.""" + + valid: bool + error: str | None = None + + +@dataclass +class OperationResult: + """Result of executing a document operation.""" + + success: bool + document: str | None = None + modified_ranges: list[tuple[int, int, int, int]] | None = None + error: str | None = None + + +class DocumentOperation(Protocol): + """Protocol for all document operations.""" + + def execute(self, doc: Document, target: Node) -> Document: + """ + Execute the operation on the document. + + Args: + doc: The document to operate on + target: The target node for the operation + + Returns: + The modified document + """ + ... + + def validate(self, doc: Document, target: Node) -> ValidationResult: + """ + Validate that the operation can be executed. + + Args: + doc: The document to validate against + target: The target node for the operation + + Returns: + ValidationResult indicating whether the operation is valid + """ + ... + + +class DocumentInterface(ABC): + """Abstract interface for document manipulation UIs.""" + + @abstractmethod + def display_tree(self, tree: Any) -> None: + """ + Display document structure as a tree. + + Args: + tree: The document tree to display + """ + pass + + @abstractmethod + def get_user_selection(self) -> Any | None: + """ + Get currently selected node(s). + + Returns: + The selected node or None if no selection + """ + pass + + @abstractmethod + def apply_operation(self, operation: Any) -> OperationResult: + """ + Apply an operation and update the display. + + Args: + operation: The operation to apply + + Returns: + Result of the operation + """ + pass + + @abstractmethod + def show_error(self, message: str) -> None: + """ + Display error message to user. + + Args: + message: The error message to display + """ + pass + + +@dataclass +class ParameterInfo: + """Information about an operation parameter.""" + + name: str + type: str + required: bool + description: str + default: Any | None = None + + +@dataclass +class OperationMetadata: + """Metadata about a doctk operation.""" + + name: str + description: str + parameters: list[ParameterInfo] = field(default_factory=list) + return_type: str = "Document" + examples: list[str] = field(default_factory=list) + category: str = "general" diff --git a/tests/unit/test_bridge.py b/tests/unit/test_bridge.py new file mode 100644 index 0000000..43965f4 --- /dev/null +++ b/tests/unit/test_bridge.py @@ -0,0 +1,381 @@ +"""Tests for the ExtensionBridge JSON-RPC interface.""" + + + +from doctk.lsp.bridge import ExtensionBridge + + +class TestExtensionBridge: + """Tests for ExtensionBridge class.""" + + def setup_method(self): + """Set up test fixtures.""" + self.bridge = ExtensionBridge() + + def test_handle_promote_request(self): + """Test handling a promote operation request.""" + doc_text = "# Title\n\n## Section\n" + request = { + "jsonrpc": "2.0", + "id": 1, + "method": "promote", + "params": {"document": doc_text, "node_id": "h2-0"}, + } + + response = self.bridge.handle_request(request) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 1 + assert "result" in response + assert response["result"]["success"] is True + assert "# Title" in response["result"]["document"] + assert "# Section" in response["result"]["document"] # h2 -> h1 + + def test_handle_demote_request(self): + """Test handling a demote operation request.""" + doc_text = "# Title\n" + request = { + "jsonrpc": "2.0", + "id": 2, + "method": "demote", + "params": {"document": doc_text, "node_id": "h1-0"}, + } + + response = self.bridge.handle_request(request) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 2 + assert "result" in response + assert response["result"]["success"] is True + assert "## Title" in response["result"]["document"] # h1 -> h2 + + def test_handle_move_up_request(self): + """Test handling a move_up operation request.""" + doc_text = "# First\n\n# Second\n" + request = { + "jsonrpc": "2.0", + "id": 3, + "method": "move_up", + "params": {"document": doc_text, "node_id": "h1-1"}, + } + + response = self.bridge.handle_request(request) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 3 + assert "result" in response + assert response["result"]["success"] is True + # Second should now be first + lines = response["result"]["document"].strip().split("\n") + assert "Second" in lines[0] + assert "First" in lines[2] # Skip blank line + + def test_handle_move_down_request(self): + """Test handling a move_down operation request.""" + doc_text = "# First\n\n# Second\n" + request = { + "jsonrpc": "2.0", + "id": 4, + "method": "move_down", + "params": {"document": doc_text, "node_id": "h1-0"}, + } + + response = self.bridge.handle_request(request) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 4 + assert "result" in response + assert response["result"]["success"] is True + + def test_handle_nest_request(self): + """Test handling a nest operation request.""" + doc_text = "# Parent\n\n# Child\n" + request = { + "jsonrpc": "2.0", + "id": 5, + "method": "nest", + "params": {"document": doc_text, "node_id": "h1-1", "parent_id": "h1-0"}, + } + + response = self.bridge.handle_request(request) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 5 + assert "result" in response + assert response["result"]["success"] is True + # Child should now be h2 (nested under parent) + assert "## Child" in response["result"]["document"] + + def test_handle_unnest_request(self): + """Test handling an unnest operation request.""" + doc_text = "## Nested\n" + request = { + "jsonrpc": "2.0", + "id": 6, + "method": "unnest", + "params": {"document": doc_text, "node_id": "h2-0"}, + } + + response = self.bridge.handle_request(request) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 6 + assert "result" in response + assert response["result"]["success"] is True + assert "# Nested" in response["result"]["document"] # h2 -> h1 + + def test_handle_validate_promote_request(self): + """Test handling a validate_promote request.""" + doc_text = "## Section\n" + request = { + "jsonrpc": "2.0", + "id": 7, + "method": "validate_promote", + "params": {"document": doc_text, "node_id": "h2-0"}, + } + + response = self.bridge.handle_request(request) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 7 + assert "result" in response + assert response["result"]["valid"] is True + assert response["result"]["error"] is None + + def test_handle_validate_nest_request(self): + """Test handling a validate_nest request.""" + doc_text = "# Parent\n\n# Child\n" + request = { + "jsonrpc": "2.0", + "id": 8, + "method": "validate_nest", + "params": {"document": doc_text, "node_id": "h1-1", "parent_id": "h1-0"}, + } + + response = self.bridge.handle_request(request) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 8 + assert "result" in response + assert response["result"]["valid"] is True + + def test_invalid_jsonrpc_version(self): + """Test error response for invalid JSON-RPC version.""" + request = {"jsonrpc": "1.0", "id": 1, "method": "promote", "params": {}} + + response = self.bridge.handle_request(request) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 1 + assert "error" in response + assert response["error"]["code"] == -32600 + assert "version" in response["error"]["message"].lower() + + def test_missing_method(self): + """Test error response for missing method.""" + request = {"jsonrpc": "2.0", "id": 1, "params": {}} + + response = self.bridge.handle_request(request) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 1 + assert "error" in response + assert response["error"]["code"] == -32600 + + def test_unknown_method(self): + """Test error response for unknown method.""" + request = {"jsonrpc": "2.0", "id": 1, "method": "invalid_method", "params": {}} + + response = self.bridge.handle_request(request) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 1 + assert "error" in response + assert response["error"]["code"] == -32603 + assert "unknown method" in response["error"]["message"].lower() + + def test_missing_required_parameters(self): + """Test error response for missing required parameters.""" + request = { + "jsonrpc": "2.0", + "id": 1, + "method": "promote", + "params": {}, # Missing document and node_id + } + + response = self.bridge.handle_request(request) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 1 + assert "error" in response + assert response["error"]["code"] == -32603 + + def test_operation_error_handling(self): + """Test error handling when operation fails.""" + doc_text = "# Title\n" + request = { + "jsonrpc": "2.0", + "id": 1, + "method": "promote", + "params": {"document": doc_text, "node_id": "h99-0"}, # Invalid node ID + } + + response = self.bridge.handle_request(request) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == 1 + assert "result" in response + assert response["result"]["success"] is False + assert "not found" in response["result"]["error"].lower() + + +class TestExtensionBridgeIntegration: + """Integration tests for ExtensionBridge with complex scenarios.""" + + def setup_method(self): + """Set up test fixtures.""" + self.bridge = ExtensionBridge() + + def test_complex_document_promote_chain(self): + """Test multiple promote operations on a complex document.""" + doc_text = """# Title + +## Section 1 + +### Subsection 1.1 + +## Section 2 + +### Subsection 2.1 +""" + # Promote first subsection + request1 = { + "jsonrpc": "2.0", + "id": 1, + "method": "promote", + "params": {"document": doc_text, "node_id": "h3-0"}, + } + response1 = self.bridge.handle_request(request1) + assert response1["result"]["success"] is True + + # Now promote the promoted section again + new_doc = response1["result"]["document"] + request2 = { + "jsonrpc": "2.0", + "id": 2, + "method": "promote", + "params": {"document": new_doc, "node_id": "h2-1"}, + } + response2 = self.bridge.handle_request(request2) + assert response2["result"]["success"] is True + assert "# Subsection 1.1" in response2["result"]["document"] + + def test_nest_and_unnest_roundtrip(self): + """Test that nest and unnest are inverses.""" + doc_text = "# Parent\n\n# Child\n" + + # Nest child under parent + nest_request = { + "jsonrpc": "2.0", + "id": 1, + "method": "nest", + "params": {"document": doc_text, "node_id": "h1-1", "parent_id": "h1-0"}, + } + nest_response = self.bridge.handle_request(nest_request) + assert nest_response["result"]["success"] is True + nested_doc = nest_response["result"]["document"] + + # Unnest the child + unnest_request = { + "jsonrpc": "2.0", + "id": 2, + "method": "unnest", + "params": {"document": nested_doc, "node_id": "h2-0"}, + } + unnest_response = self.bridge.handle_request(unnest_request) + assert unnest_response["result"]["success"] is True + + # Should be back to approximately original structure + # (Child is now h1 again) + assert "# Child" in unnest_response["result"]["document"] + + def test_move_operations_reordering(self): + """Test move operations for reordering sections.""" + doc_text = "# First\n\n# Second\n\n# Third\n" + + # Move third section up + request1 = { + "jsonrpc": "2.0", + "id": 1, + "method": "move_up", + "params": {"document": doc_text, "node_id": "h1-2"}, + } + response1 = self.bridge.handle_request(request1) + assert response1["result"]["success"] is True + + # Move it up again + doc2 = response1["result"]["document"] + request2 = { + "jsonrpc": "2.0", + "id": 2, + "method": "move_up", + "params": {"document": doc2, "node_id": "h1-1"}, + } + response2 = self.bridge.handle_request(request2) + assert response2["result"]["success"] is True + + # Third should now be first + lines = [ + line for line in response2["result"]["document"].split("\n") if line.strip() + ] + assert "Third" in lines[0] + + def test_validation_before_operation(self): + """Test validation workflow before executing operation.""" + doc_text = "###### Deepest\n" # h6 + + # Validate demote (should be valid but identity) + validate_request = { + "jsonrpc": "2.0", + "id": 1, + "method": "validate_demote", + "params": {"document": doc_text, "node_id": "h6-0"}, + } + validate_response = self.bridge.handle_request(validate_request) + assert validate_response["result"]["valid"] is True + + # Execute demote + demote_request = { + "jsonrpc": "2.0", + "id": 2, + "method": "demote", + "params": {"document": doc_text, "node_id": "h6-0"}, + } + demote_response = self.bridge.handle_request(demote_request) + assert demote_response["result"]["success"] is True + # Should stay at h6 + assert "###### Deepest" in demote_response["result"]["document"] + + def test_error_recovery(self): + """Test that bridge continues working after errors.""" + doc_text = "# Title\n" + + # Send invalid request + invalid_request = { + "jsonrpc": "2.0", + "id": 1, + "method": "promote", + "params": {"document": doc_text, "node_id": "h99-0"}, + } + invalid_response = self.bridge.handle_request(invalid_request) + assert invalid_response["result"]["success"] is False + + # Send valid request - should still work + valid_request = { + "jsonrpc": "2.0", + "id": 2, + "method": "demote", + "params": {"document": doc_text, "node_id": "h1-0"}, + } + valid_response = self.bridge.handle_request(valid_request) + assert valid_response["result"]["success"] is True diff --git a/tests/unit/test_lsp_operations.py b/tests/unit/test_lsp_operations.py new file mode 100644 index 0000000..35165f8 --- /dev/null +++ b/tests/unit/test_lsp_operations.py @@ -0,0 +1,480 @@ +"""Tests for LSP structure operations.""" + + +from doctk.core import Document, Heading, Paragraph +from doctk.lsp.operations import DocumentTreeBuilder, StructureOperations + + +class TestDocumentTreeBuilder: + """Tests for DocumentTreeBuilder class.""" + + def test_build_node_map_with_headings(self): + """Test that DocumentTreeBuilder builds a correct node map.""" + doc = Document( + nodes=[ + Heading(level=1, text="Title"), + Heading(level=2, text="Section 1"), + Heading(level=2, text="Section 2"), + Heading(level=3, text="Subsection"), + ] + ) + builder = DocumentTreeBuilder(doc) + + assert len(builder.node_map) == 4 + assert "h1-0" in builder.node_map + assert "h2-0" in builder.node_map + assert "h2-1" in builder.node_map + assert "h3-0" in builder.node_map + + def test_find_node_by_id(self): + """Test finding a node by ID.""" + doc = Document( + nodes=[ + Heading(level=1, text="Title"), + Heading(level=2, text="Section"), + ] + ) + builder = DocumentTreeBuilder(doc) + + node = builder.find_node("h2-0") + assert node is not None + assert isinstance(node, Heading) + assert node.text == "Section" + + def test_find_node_returns_none_for_invalid_id(self): + """Test that find_node returns None for invalid ID.""" + doc = Document(nodes=[Heading(level=1, text="Title")]) + builder = DocumentTreeBuilder(doc) + + node = builder.find_node("h99-0") + assert node is None + + def test_get_node_index(self): + """Test getting node index in document.""" + doc = Document( + nodes=[ + Heading(level=1, text="Title"), + Heading(level=2, text="Section"), + Paragraph(content="Text"), + ] + ) + builder = DocumentTreeBuilder(doc) + + index = builder.get_node_index("h2-0") + assert index == 1 + + +class TestPromote: + """Tests for promote operation.""" + + def test_promote_h2_to_h1(self): + """Test promoting h2 to h1.""" + doc = Document(nodes=[Heading(level=2, text="Section")]) + + new_doc, result = StructureOperations.promote(doc, "h2-0") + + assert result.success is True + assert len(new_doc.nodes) == 1 + assert new_doc.nodes[0].level == 1 + assert new_doc.nodes[0].text == "Section" + + def test_promote_h1_stays_h1(self): + """Test that promoting h1 stays at h1 (identity).""" + doc = Document(nodes=[Heading(level=1, text="Title")]) + + new_doc, result = StructureOperations.promote(doc, "h1-0") + + assert result.success is True + assert len(new_doc.nodes) == 1 + assert new_doc.nodes[0].level == 1 + + def test_promote_invalid_node_id(self): + """Test promoting with invalid node ID.""" + doc = Document(nodes=[Heading(level=2, text="Section")]) + + new_doc, result = StructureOperations.promote(doc, "h99-0") + + assert result.success is False + assert "not found" in result.error.lower() + + def test_promote_non_heading_fails(self): + """Test that promoting non-heading nodes fails gracefully.""" + doc = Document( + nodes=[ + Heading(level=1, text="Title"), + Paragraph(content="Text"), + ] + ) + + # Attempting to promote a paragraph (which wouldn't have an ID) + # would fail during node_map building, so this tests error handling + new_doc, result = StructureOperations.promote(doc, "p-0") + + assert result.success is False + + def test_promote_immutability(self): + """Test that promote doesn't mutate the original document.""" + original_heading = Heading(level=2, text="Section") + doc = Document(nodes=[original_heading]) + + new_doc, _result = StructureOperations.promote(doc, "h2-0") + + # Original should be unchanged + assert original_heading.level == 2 + assert doc.nodes[0].level == 2 + + # New document should have promoted heading + assert new_doc.nodes[0].level == 1 + + def test_validate_promote_success(self): + """Test validating a valid promote operation.""" + doc = Document(nodes=[Heading(level=2, text="Section")]) + + validation = StructureOperations.validate_promote(doc, "h2-0") + + assert validation.valid is True + assert validation.error is None + + +class TestDemote: + """Tests for demote operation.""" + + def test_demote_h1_to_h2(self): + """Test demoting h1 to h2.""" + doc = Document(nodes=[Heading(level=1, text="Title")]) + + new_doc, result = StructureOperations.demote(doc, "h1-0") + + assert result.success is True + assert len(new_doc.nodes) == 1 + assert new_doc.nodes[0].level == 2 + assert new_doc.nodes[0].text == "Title" + + def test_demote_h6_stays_h6(self): + """Test that demoting h6 stays at h6 (identity).""" + doc = Document(nodes=[Heading(level=6, text="Deepest")]) + + new_doc, result = StructureOperations.demote(doc, "h6-0") + + assert result.success is True + assert len(new_doc.nodes) == 1 + assert new_doc.nodes[0].level == 6 + + def test_demote_invalid_node_id(self): + """Test demoting with invalid node ID.""" + doc = Document(nodes=[Heading(level=2, text="Section")]) + + new_doc, result = StructureOperations.demote(doc, "h99-0") + + assert result.success is False + assert "not found" in result.error.lower() + + def test_validate_demote_success(self): + """Test validating a valid demote operation.""" + doc = Document(nodes=[Heading(level=2, text="Section")]) + + validation = StructureOperations.validate_demote(doc, "h2-0") + + assert validation.valid is True + assert validation.error is None + + +class TestMoveUp: + """Tests for move_up operation.""" + + def test_move_up_swaps_with_previous(self): + """Test moving a heading up swaps it with previous sibling.""" + doc = Document( + nodes=[ + Heading(level=2, text="Section 1"), + Heading(level=2, text="Section 2"), + ] + ) + + new_doc, result = StructureOperations.move_up(doc, "h2-1") + + assert result.success is True + assert new_doc.nodes[0].text == "Section 2" + assert new_doc.nodes[1].text == "Section 1" + + def test_move_up_at_top_stays_in_place(self): + """Test moving up the first element stays in place.""" + doc = Document( + nodes=[ + Heading(level=1, text="First"), + Heading(level=1, text="Second"), + ] + ) + + new_doc, result = StructureOperations.move_up(doc, "h1-0") + + assert result.success is True + assert new_doc.nodes[0].text == "First" + + def test_move_up_invalid_node_id(self): + """Test move_up with invalid node ID.""" + doc = Document(nodes=[Heading(level=1, text="Title")]) + + new_doc, result = StructureOperations.move_up(doc, "h99-0") + + assert result.success is False + + def test_validate_move_up_success(self): + """Test validating move_up operation.""" + doc = Document( + nodes=[ + Heading(level=1, text="First"), + Heading(level=1, text="Second"), + ] + ) + + validation = StructureOperations.validate_move_up(doc, "h1-1") + + assert validation.valid is True + + +class TestMoveDown: + """Tests for move_down operation.""" + + def test_move_down_swaps_with_next(self): + """Test moving a heading down swaps it with next sibling.""" + doc = Document( + nodes=[ + Heading(level=2, text="Section 1"), + Heading(level=2, text="Section 2"), + ] + ) + + new_doc, result = StructureOperations.move_down(doc, "h2-0") + + assert result.success is True + assert new_doc.nodes[0].text == "Section 2" + assert new_doc.nodes[1].text == "Section 1" + + def test_move_down_at_bottom_stays_in_place(self): + """Test moving down the last element stays in place.""" + doc = Document( + nodes=[ + Heading(level=1, text="First"), + Heading(level=1, text="Last"), + ] + ) + + new_doc, result = StructureOperations.move_down(doc, "h1-1") + + assert result.success is True + assert new_doc.nodes[1].text == "Last" + + def test_validate_move_down_success(self): + """Test validating move_down operation.""" + doc = Document( + nodes=[ + Heading(level=1, text="First"), + Heading(level=1, text="Second"), + ] + ) + + validation = StructureOperations.validate_move_down(doc, "h1-0") + + assert validation.valid is True + + +class TestNest: + """Tests for nest operation.""" + + def test_nest_under_parent(self): + """Test nesting a heading under a parent.""" + doc = Document( + nodes=[ + Heading(level=1, text="Parent"), + Heading(level=1, text="Child"), + ] + ) + + new_doc, result = StructureOperations.nest(doc, "h1-1", "h1-0") + + assert result.success is True + # Child should now be h2 (parent.level + 1) + assert new_doc.nodes[1].level == 2 + assert new_doc.nodes[1].text == "Child" + # Child should be moved after parent + assert new_doc.nodes[0].text == "Parent" + + def test_nest_adjusts_level(self): + """Test that nest adjusts the level correctly.""" + doc = Document( + nodes=[ + Heading(level=2, text="Parent"), + Heading(level=1, text="Child"), + ] + ) + + new_doc, result = StructureOperations.nest(doc, "h1-0", "h2-0") + + assert result.success is True + # Child should become h3 (parent.level + 1) + assert new_doc.nodes[1].level == 3 + + def test_nest_respects_max_level(self): + """Test that nest respects maximum heading level of 6.""" + doc = Document( + nodes=[ + Heading(level=6, text="Parent"), + Heading(level=1, text="Child"), + ] + ) + + new_doc, result = StructureOperations.nest(doc, "h1-0", "h6-0") + + assert result.success is True + # Child should be capped at level 6 + assert new_doc.nodes[1].level == 6 + + def test_nest_invalid_parent_id(self): + """Test nest with invalid parent ID.""" + doc = Document( + nodes=[ + Heading(level=1, text="Parent"), + Heading(level=1, text="Child"), + ] + ) + + new_doc, result = StructureOperations.nest(doc, "h1-1", "h99-0") + + assert result.success is False + assert "not found" in result.error.lower() + + def test_validate_nest_under_self_fails(self): + """Test that nesting a node under itself fails validation.""" + doc = Document(nodes=[Heading(level=1, text="Parent")]) + + validation = StructureOperations.validate_nest(doc, "h1-0", "h1-0") + + assert validation.valid is False + assert "under itself" in validation.error.lower() + + def test_validate_nest_exceeding_max_level_fails(self): + """Test validation fails when nest would exceed level 6.""" + doc = Document( + nodes=[ + Heading(level=6, text="Parent"), + Heading(level=1, text="Child"), + ] + ) + + # Note: This actually doesn't fail in validate because we cap at level 6 + # Let's test the validation logic + validation = StructureOperations.validate_nest(doc, "h1-0", "h6-0") + + # Actually should fail because parent.level + 1 would be 7 + assert validation.valid is False + assert "exceed" in validation.error.lower() + + +class TestUnnest: + """Tests for unnest operation.""" + + def test_unnest_decreases_level(self): + """Test that unnest decreases heading level by 1.""" + doc = Document(nodes=[Heading(level=3, text="Nested")]) + + new_doc, result = StructureOperations.unnest(doc, "h3-0") + + assert result.success is True + assert new_doc.nodes[0].level == 2 + assert new_doc.nodes[0].text == "Nested" + + def test_unnest_h1_stays_h1(self): + """Test that unnesting h1 stays at h1 (identity).""" + doc = Document(nodes=[Heading(level=1, text="Top")]) + + new_doc, result = StructureOperations.unnest(doc, "h1-0") + + assert result.success is True + assert new_doc.nodes[0].level == 1 + + def test_unnest_invalid_node_id(self): + """Test unnest with invalid node ID.""" + doc = Document(nodes=[Heading(level=2, text="Section")]) + + new_doc, result = StructureOperations.unnest(doc, "h99-0") + + assert result.success is False + + def test_validate_unnest_success(self): + """Test validating unnest operation.""" + doc = Document(nodes=[Heading(level=3, text="Section")]) + + validation = StructureOperations.validate_unnest(doc, "h3-0") + + assert validation.valid is True + + +class TestOperationImmutability: + """Tests to ensure all operations maintain immutability.""" + + def test_all_operations_dont_mutate_original(self): + """Test that all operations don't mutate the original document.""" + original_heading = Heading(level=2, text="Original") + doc = Document(nodes=[original_heading]) + + # Test promote + new_doc, _ = StructureOperations.promote(doc, "h2-0") + assert original_heading.level == 2 + assert doc.nodes[0].level == 2 + assert new_doc.nodes[0].level == 1 + + # Test demote + new_doc, _ = StructureOperations.demote(doc, "h2-0") + assert original_heading.level == 2 + assert doc.nodes[0].level == 2 + assert new_doc.nodes[0].level == 3 + + # Test unnest + new_doc, _ = StructureOperations.unnest(doc, "h2-0") + assert original_heading.level == 2 + assert doc.nodes[0].level == 2 + assert new_doc.nodes[0].level == 1 + + +class TestComplexScenarios: + """Tests for complex document structures.""" + + def test_operations_on_mixed_document(self): + """Test operations on a document with mixed node types.""" + doc = Document( + nodes=[ + Heading(level=1, text="Title"), + Paragraph(content="Introduction"), + Heading(level=2, text="Section 1"), + Paragraph(content="Content"), + Heading(level=2, text="Section 2"), + ] + ) + + # Promote second h2 + new_doc, result = StructureOperations.promote(doc, "h2-1") + + assert result.success is True + # Find the promoted heading (should be at index 4, level 1) + heading_count = sum(1 for n in new_doc.nodes if isinstance(n, Heading)) + assert heading_count == 3 + + def test_move_with_intervening_paragraphs(self): + """Test move operations with non-heading nodes between headings.""" + doc = Document( + nodes=[ + Heading(level=1, text="First"), + Paragraph(content="Text 1"), + Heading(level=1, text="Second"), + Paragraph(content="Text 2"), + ] + ) + + new_doc, result = StructureOperations.move_down(doc, "h1-0") + + assert result.success is True + # Verify the headings were swapped + headings = [n for n in new_doc.nodes if isinstance(n, Heading)] + assert headings[0].text == "Second" + assert headings[1].text == "First" diff --git a/tests/unit/test_outline_integration.py b/tests/unit/test_outline_integration.py new file mode 100644 index 0000000..03d1400 --- /dev/null +++ b/tests/unit/test_outline_integration.py @@ -0,0 +1,379 @@ +"""Tests for outline tree building and integration.""" + + +from doctk.core import Document, Heading, Paragraph +from doctk.lsp.operations import DocumentTreeBuilder + + +class TestOutlineTreeBuilding: + """Tests for building outline trees from documents.""" + + def test_simple_heading_structure(self): + """Test building tree from simple heading structure.""" + doc = Document( + nodes=[ + Heading(level=1, text="Title"), + Heading(level=2, text="Section 1"), + Heading(level=2, text="Section 2"), + ] + ) + + builder = DocumentTreeBuilder(doc) + + # Check node map + assert len(builder.node_map) == 3 + assert "h1-0" in builder.node_map + assert "h2-0" in builder.node_map + assert "h2-1" in builder.node_map + + # Check node content + h1 = builder.find_node("h1-0") + assert h1 is not None + assert h1.text == "Title" + assert h1.level == 1 + + h2_0 = builder.find_node("h2-0") + assert h2_0 is not None + assert h2_0.text == "Section 1" + + def test_nested_heading_structure(self): + """Test building tree from nested heading structure.""" + doc = Document( + nodes=[ + Heading(level=1, text="Chapter 1"), + Heading(level=2, text="Section 1.1"), + Heading(level=3, text="Subsection 1.1.1"), + Heading(level=2, text="Section 1.2"), + ] + ) + + builder = DocumentTreeBuilder(doc) + + # Check all nodes are mapped + assert len(builder.node_map) == 4 + assert "h1-0" in builder.node_map + assert "h2-0" in builder.node_map + assert "h3-0" in builder.node_map + assert "h2-1" in builder.node_map + + def test_heading_id_generation(self): + """Test that heading IDs are unique and sequential.""" + doc = Document( + nodes=[ + Heading(level=2, text="First h2"), + Heading(level=2, text="Second h2"), + Heading(level=2, text="Third h2"), + Heading(level=3, text="First h3"), + Heading(level=3, text="Second h3"), + ] + ) + + builder = DocumentTreeBuilder(doc) + + # Check h2 IDs are sequential + assert "h2-0" in builder.node_map + assert "h2-1" in builder.node_map + assert "h2-2" in builder.node_map + + # Check h3 IDs are sequential + assert "h3-0" in builder.node_map + assert "h3-1" in builder.node_map + + # Verify content + assert builder.find_node("h2-0").text == "First h2" + assert builder.find_node("h2-1").text == "Second h2" + assert builder.find_node("h2-2").text == "Third h2" + + def test_mixed_content_with_paragraphs(self): + """Test that paragraphs don't interfere with heading indexing.""" + doc = Document( + nodes=[ + Heading(level=1, text="Title"), + Paragraph(content="Some intro text"), + Heading(level=2, text="Section"), + Paragraph(content="Section content"), + Heading(level=2, text="Another Section"), + ] + ) + + builder = DocumentTreeBuilder(doc) + + # Only headings should be in the map + assert len(builder.node_map) == 3 + assert "h1-0" in builder.node_map + assert "h2-0" in builder.node_map + assert "h2-1" in builder.node_map + + def test_get_node_index(self): + """Test getting node index in document.""" + doc = Document( + nodes=[ + Heading(level=1, text="Title"), + Paragraph(content="Text"), + Heading(level=2, text="Section"), + ] + ) + + builder = DocumentTreeBuilder(doc) + + # h1-0 should be at index 0 + assert builder.get_node_index("h1-0") == 0 + + # h2-0 should be at index 2 (after paragraph) + assert builder.get_node_index("h2-0") == 2 + + def test_empty_document(self): + """Test handling empty document.""" + doc = Document(nodes=[]) + builder = DocumentTreeBuilder(doc) + + assert len(builder.node_map) == 0 + assert builder.find_node("h1-0") is None + + def test_document_with_no_headings(self): + """Test document with only paragraphs.""" + doc = Document( + nodes=[ + Paragraph(content="First paragraph"), + Paragraph(content="Second paragraph"), + ] + ) + + builder = DocumentTreeBuilder(doc) + + assert len(builder.node_map) == 0 + + def test_all_heading_levels(self): + """Test document with all heading levels 1-6.""" + doc = Document( + nodes=[ + Heading(level=1, text="H1"), + Heading(level=2, text="H2"), + Heading(level=3, text="H3"), + Heading(level=4, text="H4"), + Heading(level=5, text="H5"), + Heading(level=6, text="H6"), + ] + ) + + builder = DocumentTreeBuilder(doc) + + assert len(builder.node_map) == 6 + for level in range(1, 7): + node_id = f"h{level}-0" + assert node_id in builder.node_map + node = builder.find_node(node_id) + assert node.level == level + assert node.text == f"H{level}" + + +class TestOutlineTreeFromMarkdown: + """Tests for building outline trees from Markdown strings.""" + + def test_parse_simple_markdown(self): + """Test parsing simple Markdown document.""" + markdown = """# Title + +## Section 1 + +Some content here. + +## Section 2 + +More content. +""" + doc = Document.from_string(markdown) + builder = DocumentTreeBuilder(doc) + + # Should have 1 h1 and 2 h2 headings + assert "h1-0" in builder.node_map + assert "h2-0" in builder.node_map + assert "h2-1" in builder.node_map + + h1 = builder.find_node("h1-0") + assert h1.text == "Title" + + def test_parse_nested_markdown(self): + """Test parsing nested Markdown structure.""" + markdown = """# Chapter 1 + +## Section 1.1 + +### Subsection 1.1.1 + +#### Deep heading + +## Section 1.2 + +# Chapter 2 +""" + doc = Document.from_string(markdown) + builder = DocumentTreeBuilder(doc) + + # Check all levels are present + assert "h1-0" in builder.node_map # Chapter 1 + assert "h2-0" in builder.node_map # Section 1.1 + assert "h3-0" in builder.node_map # Subsection 1.1.1 + assert "h4-0" in builder.node_map # Deep heading + assert "h2-1" in builder.node_map # Section 1.2 + assert "h1-1" in builder.node_map # Chapter 2 + + def test_parse_markdown_with_code_blocks(self): + """Test that code blocks don't interfere with heading parsing.""" + markdown = """# Title + +Some code: + +```markdown +# This is not a heading +## It's inside a code block +``` + +## Real Section +""" + doc = Document.from_string(markdown) + builder = DocumentTreeBuilder(doc) + + # Should only have 2 headings (Title and Real Section) + # Note: This depends on the Markdown parser implementation + # The parser should correctly ignore headings in code blocks + assert "h1-0" in builder.node_map + + +class TestOutlineOperationsIntegration: + """Tests for operations working with the outline tree.""" + + def test_promote_updates_node_id(self): + """Test that promoting a heading changes its level in the tree.""" + doc = Document( + nodes=[ + Heading(level=1, text="Title"), + Heading(level=2, text="Section"), + ] + ) + + from doctk.lsp.operations import StructureOperations + + # Promote h2 to h1 + new_doc, result = StructureOperations.promote(doc, "h2-0") + assert result.success + + # Build new tree + new_builder = DocumentTreeBuilder(new_doc) + + # Should now have 2 h1 nodes + assert "h1-0" in new_builder.node_map + assert "h1-1" in new_builder.node_map + + # The promoted node should be h1 + promoted = new_builder.find_node("h1-1") + assert promoted is not None + assert promoted.level == 1 + assert promoted.text == "Section" + + def test_demote_updates_node_id(self): + """Test that demoting a heading changes its level in the tree.""" + doc = Document( + nodes=[ + Heading(level=1, text="Title"), + ] + ) + + from doctk.lsp.operations import StructureOperations + + # Demote h1 to h2 + new_doc, result = StructureOperations.demote(doc, "h1-0") + assert result.success + + # Build new tree + new_builder = DocumentTreeBuilder(new_doc) + + # Should now have h2 instead of h1 + assert "h2-0" in new_builder.node_map + assert "h1-0" not in new_builder.node_map + + demoted = new_builder.find_node("h2-0") + assert demoted.level == 2 + assert demoted.text == "Title" + + def test_move_operations_preserve_structure(self): + """Test that move operations preserve the tree structure.""" + doc = Document( + nodes=[ + Heading(level=1, text="First"), + Heading(level=1, text="Second"), + Heading(level=1, text="Third"), + ] + ) + + from doctk.lsp.operations import StructureOperations + + # Move third up + new_doc, result = StructureOperations.move_up(doc, "h1-2") + assert result.success + + # Build new tree + new_builder = DocumentTreeBuilder(new_doc) + + # Should still have 3 h1 nodes + assert len([k for k in new_builder.node_map.keys() if k.startswith("h1-")]) == 3 + + # Order should be: Third, Second, First (counting from rebuild) + # Note: IDs are regenerated based on order in document + nodes = [new_builder.find_node(f"h1-{i}") for i in range(3)] + assert nodes[0].text == "First" + assert nodes[1].text == "Third" # Moved up + assert nodes[2].text == "Second" # Moved down + + +class TestOutlineEdgeCases: + """Tests for edge cases in outline tree building.""" + + def test_multiple_h1_headings(self): + """Test document with multiple top-level headings.""" + doc = Document( + nodes=[ + Heading(level=1, text="Chapter 1"), + Heading(level=1, text="Chapter 2"), + Heading(level=1, text="Chapter 3"), + ] + ) + + builder = DocumentTreeBuilder(doc) + + assert len(builder.node_map) == 3 + assert "h1-0" in builder.node_map + assert "h1-1" in builder.node_map + assert "h1-2" in builder.node_map + + def test_skipped_heading_levels(self): + """Test document with skipped heading levels (h1 -> h3).""" + doc = Document( + nodes=[ + Heading(level=1, text="Title"), + Heading(level=3, text="Subsection"), # Skips h2 + ] + ) + + builder = DocumentTreeBuilder(doc) + + # Both should be in the map + assert "h1-0" in builder.node_map + assert "h3-0" in builder.node_map + + def test_reverse_level_order(self): + """Test document with reverse level order (h3 before h1).""" + doc = Document( + nodes=[ + Heading(level=3, text="Deep"), + Heading(level=2, text="Mid"), + Heading(level=1, text="Top"), + ] + ) + + builder = DocumentTreeBuilder(doc) + + # All should be mapped with correct IDs + assert "h3-0" in builder.node_map + assert "h2-0" in builder.node_map + assert "h1-0" in builder.node_map