diff --git a/package.json b/package.json index aafddc9..7a67a8e 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,12 @@ "type": "boolean", "default": true, "description": "Determines if auto-completion and IntelliSense suggestions will show" + }, + "ee.format.wrapAttributes": { + "type": "string", + "enum": ["auto", "force", "force-expand-multiline", "preserve"], + "default": "auto", + "description": "Controls how EE tag attributes are wrapped. 'auto' wraps when tag exceeds 80 characters, 'force' always wraps, 'force-expand-multiline' wraps when multiple attributes exist, 'preserve' keeps original formatting." } } }, diff --git a/src/extension.ts b/src/extension.ts index 27486ca..89241a7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,13 +8,14 @@ import CompletionProvider from './providers/CompletionProvider'; import ModifierCompletionProvider from './providers/ModifierCompletionProvider'; import ParametersProvider from './providers/ParameterProvider'; import GlobalVariableProvider from './providers/GlobalVariableProvider'; +import EEFormatterProvider from './providers/EEFormatterProvider'; /** * Activate - * + * * This method is called when your extension is activated - * - * @param context + * + * @param context */ export function activate(context: vscode.ExtensionContext) { CompletionProvider.register(context); @@ -23,6 +24,7 @@ export function activate(context: vscode.ExtensionContext) { GlobalVariableProvider.register(context); IndentRulesProvider.register(context); CommandsProvider.register(context); + EEFormatterProvider.register(context); } // this method is called when your extension is deactivated diff --git a/src/providers/EEFormatterProvider.ts b/src/providers/EEFormatterProvider.ts new file mode 100644 index 0000000..da7685d --- /dev/null +++ b/src/providers/EEFormatterProvider.ts @@ -0,0 +1,25 @@ +import * as vscode from 'vscode'; +import EEFormatterService from '../services/EEFormatterService'; + +export default class EEFormatterProvider { + + private static formatter: EEFormatterService; + + /** + * Register the EE formatter provider + * @param context VS Code extension context + */ + public static register(context: vscode.ExtensionContext) { + // Create formatter instance + this.formatter = new EEFormatterService(); + + // Register document formatting provider for HTML language + const disposable = vscode.languages.registerDocumentFormattingEditProvider( + 'html', + this.formatter + ); + + // Add to context subscriptions + context.subscriptions.push(disposable); + } +} diff --git a/src/services/EEFormatterService.ts b/src/services/EEFormatterService.ts new file mode 100644 index 0000000..39b6af9 --- /dev/null +++ b/src/services/EEFormatterService.ts @@ -0,0 +1,734 @@ +import * as vscode from 'vscode'; + +/** + * Interface for EE tag match results + */ +interface EETagMatch { + fullMatch: string; + content: string; + isClosing: boolean; + isPair: boolean; + lineSpan: number; + startPos: number; + endPos: number; +} + +/** + * Interface for closing tag information + */ +interface ClosingTagInfo { + lineIndex: number; + startPos: number; + endPos: number; + content: string; +} + +/** + * Interface for formatting result + */ +interface FormatResult { + formattedLines: string[]; + skipLines: number; +} + +/** + * ExpressionEngine Code Formatter Service + * + * Provides formatting capabilities for ExpressionEngine tags within HTML documents. + * Handles attribute wrapping, indentation, and nested tag structures. + * + * Key Features: + * - Brace counting algorithm for accurate EE tag detection + * - Assignment state tracking for proper attribute parsing + * - HTML structure parsing for correct indentation + * - Context-aware indentation calculation + * - Support for nested EE tags and complex HTML structures + */ +export default class EEFormatterService implements vscode.DocumentFormattingEditProvider { + + /** + * Provides document formatting edits for ExpressionEngine tags + * + * @param document - The document to format + * @param options - VS Code formatting options (indentation, etc.) + * @param token - Cancellation token for long-running operations + * @returns Promise resolving to array of text edits, or undefined if no changes needed + */ + public provideDocumentFormattingEdits( + document: vscode.TextDocument, + options: vscode.FormattingOptions, + token: vscode.CancellationToken + ): vscode.ProviderResult { + + const edits: vscode.TextEdit[] = []; + const text = document.getText(); + const lines = text.split('\n'); + + // Get EE formatting configuration + const config = vscode.workspace.getConfiguration('ee.format'); + const wrapAttributes = config.get('wrapAttributes', 'auto'); + + // Attempt to use VS Code formatting options for indentation + const indentChar = options.insertSpaces ? ' '.repeat(options.tabSize) : '\t'; + + let i = 0; + while (i < lines.length) { + // Check for cancellation + if (token.isCancellationRequested) { + return []; + } + + const line = lines[i]; + const eeTagMatch = this.findEETag(line); + + if (eeTagMatch) { + const result = this.formatEETag( + eeTagMatch, + lines, + i, + indentChar, + wrapAttributes + ); + + if (result.formattedLines.length > 1) { + // Check if this is a paired tag that spans multiple lines + const { tagName } = this.parseEETagAttributes(eeTagMatch.content); + const closingTagInfo = this.findClosingTag(tagName, lines, i); + + if (closingTagInfo) { + // Replace the entire paired tag section + const startLine = i; + const endLine = closingTagInfo.lineIndex; + + edits.push( + vscode.TextEdit.replace( + new vscode.Range( + document.positionAt(document.offsetAt(new vscode.Position(startLine, 0))), + document.positionAt(document.offsetAt(new vscode.Position(endLine + 1, 0))) + ), + result.formattedLines.join('\n') + '\n' + ) + ); + + i = endLine + 1; + } else { + // Single-line tag formatting + edits.push( + vscode.TextEdit.replace( + new vscode.Range( + document.positionAt(document.offsetAt(new vscode.Position(i, 0))), + document.positionAt(document.offsetAt(new vscode.Position(i + 1, 0))) + ), + result.formattedLines.join('\n') + '\n' + ) + ); + + i++; + } + } else { + i++; + } + } else { + i++; + } + } + + return edits; + } + + /** + * Finds ExpressionEngine tags in a line using brace counting + * + * @param line - The line to search for EE tags + * @returns EETagMatch object if found, null otherwise + */ + private findEETag(line: string): EETagMatch | null { + // Handle nested braces in EE tags + let braceCount = 0; + let start = -1; + let end = -1; + + for (let i = 0; i < line.length; i++) { + if (line[i] === '{') { + if (start === -1) { + start = i; + } + braceCount++; + } else if (line[i] === '}') { + braceCount--; + if (braceCount === 0 && start !== -1) { + end = i; + break; + } + } + } + + if (start !== -1 && end !== -1) { + const fullMatch = line.substring(start, end + 1); + const content = line.substring(start + 1, end); + + // Check if it's a tag that can have attributes (exp: tags) + if (content.includes(':') && !content.startsWith('/')) { + return { + fullMatch, + content, + isClosing: false, + isPair: true, + lineSpan: 1, + startPos: start, + endPos: end + 1 + }; + } + } + + return null; + } + + /** + * Formats an ExpressionEngine tag based on its type and attributes + * + * @param eeTagMatch - The matched EE tag information + * @param lines - All document lines for context + * @param currentLineIndex - Current line being processed + * @param indentChar - Character(s) used for indentation + * @param wrapAttributes - Configuration for attribute wrapping + * @returns FormatResult with formatted lines and skip count + */ + private formatEETag( + tagMatch: EETagMatch, + lines: string[], + startLineIndex: number, + indentChar: string, + wrapAttributes: string + ): { formattedLines: string[]; skipLines: number } { + + const line = lines[startLineIndex]; + const beforeTag = line.substring(0, tagMatch.startPos); + const afterTag = line.substring(tagMatch.endPos); + + // Calculate proper indentation from surrounding context + const contextIndent = this.calculateContextIndentation(lines, startLineIndex, indentChar); + + // Extract attributes from the tag content + const tagContent = tagMatch.content; + const { tagName, attributes } = this.parseEETagAttributes(tagContent); + + if (wrapAttributes === 'preserve') { + // Don't format if preserve is set + return { formattedLines: [line], skipLines: 0 }; + } + + // For exp: tags, always format if they have attributes + // For if tags, never format (keep as single line) + // For other tags, use the wrap attributes setting + const isExpTag = tagName.startsWith('exp:'); + const isIfTag = tagName === 'if' || tagName.startsWith('if:'); + const shouldWrap = isIfTag ? + false : // Never format if tags + (isExpTag ? + (attributes.length > 0) : // Always format exp tags with attributes + (wrapAttributes === 'force' || + (wrapAttributes === 'force-expand-multiline' && attributes.length > 0) || + (wrapAttributes === 'auto' && this.shouldWrapAttributes(tagContent)))); + + if (!shouldWrap || attributes.length === 0) { + return { formattedLines: [line], skipLines: 0 }; + } + + // Check if this is a paired tag that spans multiple lines + const closingTagInfo = this.findClosingTag(tagName, lines, startLineIndex); + + if (closingTagInfo) { + // Format paired tag with content indentation + const formattedLines = this.formatPairedEETag( + lines, + startLineIndex, + closingTagInfo, + indentChar, + tagName, + attributes, + beforeTag, + afterTag, + wrapAttributes + ); + + // Skip the lines that were processed + return { formattedLines, skipLines: closingTagInfo.lineIndex - startLineIndex }; + } else { + // Format single-line tag + const formattedLines = this.formatSingleLineEETag( + tagName, + attributes, + beforeTag, + afterTag, + indentChar + ); + return { formattedLines, skipLines: 0 }; + } + } + + /** + * Format a single-line EE tag + */ + private formatSingleLineEETag( + tagName: string, + attributes: string[], + beforeTag: string, + afterTag: string, + indentChar: string + ): string[] { + const formattedLines: string[] = []; + + // Opening tag with tag name + formattedLines.push(`${beforeTag}{${tagName}`); + + // Attributes on separate lines + for (const attribute of attributes) { + formattedLines.push(`${beforeTag}${indentChar}${attribute}`); + } + + // Closing brace + formattedLines.push(`${beforeTag}}${afterTag}`); + + return formattedLines; + } + + /** + * Format a paired EE tag with content indentation + */ + private formatPairedEETag( + lines: string[], + startLineIndex: number, + closingTagInfo: ClosingTagInfo, + indentChar: string, + tagName: string, + attributes: string[], + beforeTag: string, + afterTag: string, + wrapAttributes: string + ): string[] { + const formattedLines: string[] = []; + + // Use the original line's indentation for the opening tag + const openingIndent = beforeTag; + + // Opening tag with tag name + formattedLines.push(`${openingIndent}{${tagName}`); + + // Attributes on separate lines + for (const attribute of attributes) { + formattedLines.push(`${openingIndent}${indentChar}${attribute}`); + } + + // Closing brace of opening tag + formattedLines.push(`${openingIndent}}${afterTag}`); + + // Add content between tags, preserving relative indentation structure + // Also process any nested EE tags within the content + // Add content between tags with proper HTML structure parsing + // Also process any nested EE tags within the content + const contentLines = []; + const htmlStructure = this.parseHTMLStructure(lines, startLineIndex + 1, closingTagInfo.lineIndex); + + for (let i = startLineIndex + 1; i < closingTagInfo.lineIndex; i++) { + const contentLine = lines[i]; + if (contentLine.trim()) { + const trimmedContent = contentLine.trim(); + const indentLevel = htmlStructure.get(i) || 0; + + const baseIndent = openingIndent + indentChar; + const finalIndent = baseIndent + indentChar.repeat(indentLevel); + + contentLines.push(`${finalIndent}${trimmedContent}`); + } else { + // Preserve empty lines + contentLines.push(contentLine); + } + } + + // Process any nested EE tags in the content + const processedContentLines = this.processNestedEETags(contentLines, indentChar, wrapAttributes); + formattedLines.push(...processedContentLines); + + // Add closing tag with proper indentation + const closingLine = lines[closingTagInfo.lineIndex]; + const closingAfterTag = closingLine.substring(closingTagInfo.endPos); + formattedLines.push(`${openingIndent}{${closingTagInfo.content}}${closingAfterTag}`); + + return formattedLines; + } + + /** + * Find the closing tag for a paired EE tag + */ + private findClosingTag( + tagName: string, + lines: string[], + startLineIndex: number + ): ClosingTagInfo | null { + // Search for closing tag in subsequent lines + for (let i = startLineIndex + 1; i < lines.length; i++) { + const line = lines[i]; + const closingMatch = line.match(new RegExp(`(\\{[^}]*/${tagName}\\})`)); + + if (closingMatch) { + return { + lineIndex: i, + startPos: closingMatch.index!, + endPos: closingMatch.index! + closingMatch[0].length, + content: closingMatch[0].slice(1, -1) // Remove { and } + }; + } + } + + return null; + } + + /** + * Parses EE tag attributes while preserving quoted values and assignments + * + * @param tagContent - The content inside the EE tag braces + * @returns Object containing tag name and array of attributes + */ + private parseEETagAttributes(tagContent: string): { tagName: string; attributes: string[] } { + const attributes: string[] = []; + + // Split by spaces but preserve quoted values and attribute assignments + const parts = this.splitPreservingQuotesAndAssignments(tagContent); + + // First part is the tag name, rest are attributes + const tagName = parts[0] || ''; + + for (let i = 1; i < parts.length; i++) { + const trimmed = parts[i].trim(); + if (trimmed) { + attributes.push(trimmed); + } + } + + return { tagName, attributes }; + } + + /** + * Split string preserving quoted values and attribute assignments + * @param str String to split + * @returns Array of parts + */ + private splitPreservingQuotesAndAssignments(str: string): string[] { + const parts: string[] = []; + let current = ''; + let inQuotes = false; + let quoteChar = ''; + let inAssignment = false; + + for (let i = 0; i < str.length; i++) { + const char = str[i]; + + if (!inQuotes && (char === '"' || char === "'")) { + inQuotes = true; + quoteChar = char; + current += char; + } else if (inQuotes && char === quoteChar) { + inQuotes = false; + quoteChar = ''; + current += char; + // If we were in an assignment and just closed quotes, we're done + if (inAssignment) { + inAssignment = false; + } + } else if (!inQuotes && char === '=') { + // This is an assignment operator, keep it with current part + current += char; + inAssignment = true; + } else if (!inQuotes && char === ' ') { + if (inAssignment) { + // We're in an assignment, keep the space + current += char; + } else { + // Check if the next non-space character is = + const remaining = str.substring(i + 1).trim(); + if (remaining.startsWith('=')) { + // This space is before an assignment, keep it + current += char; + } else { + // Regular space separator + if (current.trim()) { + parts.push(current.trim()); + current = ''; + } + } + } + } else { + current += char; + } + } + + if (current.trim()) { + parts.push(current.trim()); + } + + return parts; + } + + /** + * Determine if attributes should be wrapped + * @param tagContent The tag content + * @returns True if attributes should be wrapped + */ + private shouldWrapAttributes(tagContent: string): boolean { + // Simple heuristic: wrap if tag is longer than 80 characters + return tagContent.length > 80; + } + + /** + * Calculate proper indentation based on surrounding context + * @param lines All lines in the document + * @param currentLineIndex Current line index + * @param indentChar Character(s) to use for indentation + * @returns Proper indentation string + */ + private calculateContextIndentation(lines: string[], currentLineIndex: number, indentChar: string): string { + // Look backwards to find the nearest opening block + for (let i = currentLineIndex - 1; i >= 0; i--) { + const line = lines[i]; + const trimmedLine = line.trim(); + + // Skip empty lines and comments + if (!trimmedLine || trimmedLine.startsWith('