diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index ae5b26e86..6b18e243a 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -153,6 +153,13 @@ final class SQLCompletionProvider { "CONSTRAINT", "ENGINE", "CHARSET", "COLLATE" ]) + case .alterTableColumn: + // After ALTER TABLE tablename DROP/MODIFY/CHANGE COLUMN - suggest column names + if let firstTable = context.tableReferences.first { + items = await schemaProvider.columnCompletionItems(for: firstTable.tableName) + } + items += filterKeywords(["COLUMN", "FIRST", "AFTER"]) + case .createTable: // Inside CREATE TABLE (...) - suggest constraints and data types items = filterKeywords([ diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index 21651d77a..abda8f808 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -26,10 +26,11 @@ enum SQLClauseType { case caseExpression // Inside CASE WHEN expression case inList // Inside IN (...) list case limit // After LIMIT/OFFSET - case alterTable // After ALTER TABLE tablename - case createTable // Inside CREATE TABLE definition - case columnDef // Typing column data type (after column name) - case unknown // Unknown or start of query + case alterTable // After ALTER TABLE tablename + case alterTableColumn // After DROP/MODIFY/CHANGE/RENAME COLUMN - need column names + case createTable // Inside CREATE TABLE definition + case columnDef // Typing column data type (after column name) + case unknown // Unknown or start of query } /// Represents a table reference with optional alias @@ -89,6 +90,88 @@ struct SQLContext { /// Analyzes SQL query to determine completion context final class SQLContextAnalyzer { + // MARK: - Cached Regex Patterns (Compiled Once at Class Load) + + /// Pre-compiled clause detection patterns for performance + /// ORDER MATTERS: More specific patterns must come before general ones + private static let clauseRegexes: [(regex: NSRegularExpression, clause: SQLClauseType)] = { + let patterns: [(String, SQLClauseType)] = [ + // DDL patterns (most specific first) + ("\\b(?:ADD|MODIFY|CHANGE)\\s+(?:COLUMN\\s+)?\\w+\\s+\\w*$", .columnDef), + ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+(?:DROP|MODIFY|CHANGE|RENAME)\\s+(?:COLUMN\\s+)?(?:[`\"']?\\w+[`\"']?)?\\s*$", .alterTableColumn), + ("\\bALTER\\s+TABLE\\s+[^;]*\\bAFTER\\s+\\w*$", .alterTableColumn), + ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+\\w*$", .alterTable), + ("\\bCREATE\\s+TABLE\\s+[^(]*\\([^)]*$", .createTable), + // Enhanced context patterns + ("\\bIN\\s*\\([^)]*$", .inList), + ("\\bCASE\\s+(?:WHEN\\s+[^;]*)?$", .caseExpression), + ("\\b(LIMIT|OFFSET)\\s+\\d*$", .limit), + // Standard clause patterns + ("\\bVALUES\\s*\\([^)]*$", .values), + ("\\bINSERT\\s+INTO\\s+\\w+\\s*\\([^)]*$", .insertColumns), + ("\\bINTO\\s+\\w*$", .into), + ("\\bSET\\s+[^;]*$", .set), + ("\\bHAVING\\s+[^;]*$", .having), + ("\\bORDER\\s+BY\\s+[^;]*$", .orderBy), + ("\\bGROUP\\s+BY\\s+[^;]*$", .groupBy), + ("\\b(AND|OR)\\s+\\w*$", .and), + ("\\bWHERE\\s+[^;]*$", .where_), + ("\\bON\\s+[^;]*$", .on), + // JOIN patterns + ("(?:LEFT|RIGHT|INNER|OUTER|FULL|CROSS)?\\s*(?:OUTER)?\\s*JOIN\\s+[`\"']?\\w+[`\"']?(?:\\s+(?:AS\\s+)?\\w+)?\\s*$", .join), + ("\\bJOIN\\s+[`\"']?\\w*[`\"']?\\s*$", .join), + // FROM patterns + ("\\bFROM\\s+[`\"']?\\w+[`\"']?(?:\\s+(?:AS\\s+)?\\w+)?\\s*$", .from), + ("\\bFROM\\s+\\w*$", .from), + // SELECT is most general + ("\\bSELECT\\s+[^;]*$", .select), + ] + return patterns.compactMap { pattern, clause in + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { + assertionFailure("Invalid SQL clause regex pattern: \(pattern)") + return nil + } + return (regex, clause) + } + }() + + /// Pre-compiled regex for removing strings and comments (force-unwrap safe: simple patterns) + private static let singleQuoteStringRegex: NSRegularExpression = { + if let regex = try? NSRegularExpression(pattern: "'[^']*'") { + return regex + } + assertionFailure("Failed to compile singleQuoteStringRegex - invalid pattern") + // Fallback to a regex that matches nothing + return try! NSRegularExpression(pattern: "(?!)") + }() + + private static let doubleQuoteStringRegex: NSRegularExpression = { + if let regex = try? NSRegularExpression(pattern: "\"[^\"]*\"") { + return regex + } + assertionFailure("Failed to compile doubleQuoteStringRegex - invalid pattern") + // Fallback to a regex that matches nothing + return try! NSRegularExpression(pattern: "(?!)") + }() + + private static let blockCommentRegex: NSRegularExpression = { + if let regex = try? NSRegularExpression(pattern: "/\\*[\\s\\S]*?\\*/") { + return regex + } + assertionFailure("Failed to compile blockCommentRegex - invalid pattern") + // Fallback to a regex that matches nothing + return try! NSRegularExpression(pattern: "(?!)") + }() + + private static let lineCommentRegex: NSRegularExpression = { + if let regex = try? NSRegularExpression(pattern: "--[^\n]*") { + return regex + } + assertionFailure("Failed to compile lineCommentRegex - invalid pattern") + // Fallback to a regex that matches nothing + return try! NSRegularExpression(pattern: "(?!)") + }() + // MARK: - Main Analysis /// Analyze the query at the given cursor position @@ -143,6 +226,14 @@ final class SQLContextAnalyzer { } } + // Extract ALTER TABLE table name and add to references + if let alterTableName = extractAlterTableName(from: currentStatement) { + let alterRef = TableReference(tableName: alterTableName, alias: nil) + if !tableReferences.contains(alterRef) { + tableReferences.append(alterRef) + } + } + // Calculate nesting level (subquery depth) let nestingLevel = calculateNestingLevel(in: textBeforeCursor) @@ -511,6 +602,26 @@ final class SQLContextAnalyzer { return references } + /// Pre-compiled regex for extracting table name from ALTER TABLE statements + private static let alterTableRegex: NSRegularExpression? = { + // Pattern: ALTER TABLE tablename (supports optional quoting and special characters) + let pattern = "(?i)\\bALTER\\s+TABLE\\s+[`\"']?([^`\"']+)[`\"']?" + return try? NSRegularExpression(pattern: pattern) + }() + + /// Extract table name from ALTER TABLE statement + private func extractAlterTableName(from query: String) -> String? { + guard let regex = Self.alterTableRegex else { return nil } + + let range = NSRange(query.startIndex..., in: query) + if let match = regex.firstMatch(in: query, range: range), + let tableRange = Range(match.range(at: 1), in: query) { + return String(query[tableRange]) + } + + return nil + } + /// Determine the clause type based on text before cursor private func determineClauseType(textBeforeCursor: String, dotPrefix: String?, currentFunction: String? = nil) -> SQLClauseType { // If we have a dot prefix, we're looking for columns @@ -528,46 +639,10 @@ final class SQLContextAnalyzer { // Remove string literals and comments for analysis let cleaned = removeStringsAndComments(from: upper) - // Find the last keyword to determine context - // ORDER MATTERS: More specific patterns must come before general ones - let clausePatterns: [(pattern: String, clause: SQLClauseType)] = [ - // DDL patterns (most specific first) - // After ADD/MODIFY COLUMN name - suggest data types - ("\\b(?:ADD|MODIFY|CHANGE)\\s+(?:COLUMN\\s+)?\\w+\\s+\\w*$", .columnDef), - // After ALTER TABLE tablename - suggest ADD, DROP, MODIFY, etc. - ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+\\w*$", .alterTable), - // Inside CREATE TABLE (...) - suggest column definitions - ("\\bCREATE\\s+TABLE\\s+[^(]*\\([^)]*$", .createTable), - - // New patterns for enhanced context - ("\\bIN\\s*\\([^)]*$", .inList), - ("\\bCASE\\s+(?:WHEN\\s+[^;]*)?$", .caseExpression), - ("\\b(LIMIT|OFFSET)\\s+\\d*$", .limit), - - // Existing patterns - ("\\bVALUES\\s*\\([^)]*$", .values), - ("\\bINSERT\\s+INTO\\s+\\w+\\s*\\([^)]*$", .insertColumns), - ("\\bINTO\\s+\\w*$", .into), - ("\\bSET\\s+[^;]*$", .set), - ("\\bHAVING\\s+[^;]*$", .having), - ("\\bORDER\\s+BY\\s+[^;]*$", .orderBy), - ("\\bGROUP\\s+BY\\s+[^;]*$", .groupBy), - ("\\b(AND|OR)\\s+\\w*$", .and), - ("\\bWHERE\\s+[^;]*$", .where_), - ("\\bON\\s+[^;]*$", .on), - // JOIN: match various JOIN types followed by table [alias] - must come before FROM - ("(?:LEFT|RIGHT|INNER|OUTER|FULL|CROSS)?\\s*(?:OUTER)?\\s*JOIN\\s+[`\"']?\\w+[`\"']?(?:\\s+(?:AS\\s+)?\\w+)?\\s*$", .join), - ("\\bJOIN\\s+[`\"']?\\w*[`\"']?\\s*$", .join), - // FROM: match "FROM table" or "FROM table " (with or without trailing space) - NOT followed by WHERE/ORDER/etc. - ("\\bFROM\\s+[`\"']?\\w+[`\"']?(?:\\s+(?:AS\\s+)?\\w+)?\\s*$", .from), - ("\\bFROM\\s+\\w*$", .from), - // SELECT comes last as it's the most general - ("\\bSELECT\\s+[^;]*$", .select), - ] - - for (pattern, clause) in clausePatterns { - if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), - regex.firstMatch(in: cleaned, range: NSRange(cleaned.startIndex..., in: cleaned)) != nil { + // Use pre-compiled regex patterns for performance + let range = NSRange(cleaned.startIndex..., in: cleaned) + for (regex, clause) in Self.clauseRegexes { + if regex.firstMatch(in: cleaned, range: range) != nil { return clause } } @@ -579,25 +654,30 @@ final class SQLContextAnalyzer { private func removeStringsAndComments(from text: String) -> String { var result = text - // Remove single-quoted strings - if let regex = try? NSRegularExpression(pattern: "'[^']*'") { - result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "''") - } + // Use pre-compiled regex patterns for performance + result = Self.singleQuoteStringRegex.stringByReplacingMatches( + in: result, + range: NSRange(result.startIndex..., in: result), + withTemplate: "''" + ) - // Remove double-quoted strings - if let regex = try? NSRegularExpression(pattern: "\"[^\"]*\"") { - result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "\"\"") - } + result = Self.doubleQuoteStringRegex.stringByReplacingMatches( + in: result, + range: NSRange(result.startIndex..., in: result), + withTemplate: "\"\"" + ) - // Remove block comments - if let regex = try? NSRegularExpression(pattern: "/\\*[\\s\\S]*?\\*/") { - result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "") - } + result = Self.blockCommentRegex.stringByReplacingMatches( + in: result, + range: NSRange(result.startIndex..., in: result), + withTemplate: "" + ) - // Remove line comments - if let regex = try? NSRegularExpression(pattern: "--[^\n]*") { - result = regex.stringByReplacingMatches(in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "") - } + result = Self.lineCommentRegex.stringByReplacingMatches( + in: result, + range: NSRange(result.startIndex..., in: result), + withTemplate: "" + ) return result } diff --git a/TablePro/Core/Utilities/SQLFileParser.swift b/TablePro/Core/Utilities/SQLFileParser.swift index f2ad23d7a..3afe14e50 100644 --- a/TablePro/Core/Utilities/SQLFileParser.swift +++ b/TablePro/Core/Utilities/SQLFileParser.swift @@ -247,9 +247,7 @@ final class SQLFileParser { } catch { // Log parsing errors - these should not fail silently print("ERROR: SQL file parsing failed: \(error.localizedDescription)") - if let fileError = error as? NSError { - print("Error details: domain=\(fileError.domain), code=\(fileError.code)") - } + print("Error details: \(error)") continuation.finish() } } diff --git a/TablePro/Views/Editor/EditorTextView.swift b/TablePro/Views/Editor/EditorTextView.swift index 32f1123ad..2d2864abb 100644 --- a/TablePro/Views/Editor/EditorTextView.swift +++ b/TablePro/Views/Editor/EditorTextView.swift @@ -24,6 +24,12 @@ final class EditorTextView: NSTextView { /// Callback when user clicks at a different position (to dismiss completion) var onClickOutsideCompletion: (() -> Void)? + /// Track the last cursor position for smart invalidation + private var lastCursorLine: Int = -1 + + /// Margin to expand invalidation rect to ensure borders/effects are redrawn + private let lineInvalidationMargin: CGFloat = 2 + // MARK: - Auto-Pairing Configuration private let bracketPairs: [Character: Character] = [ @@ -64,6 +70,20 @@ final class EditorTextView: NSTextView { name: NSTextView.didChangeSelectionNotification, object: self ) + // Observe text changes to invalidate line cache + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidChange(_:)), + name: NSText.didChangeNotification, + object: self + ) + } + + @objc private func textDidChange(_ notification: Notification) { + // Invalidate line cache when text changes + lineCache = nil + // Reset last cursor line to avoid stale line numbers from previous document state + lastCursorLine = -1 } deinit { @@ -71,8 +91,170 @@ final class EditorTextView: NSTextView { } @objc private func selectionDidChange(_ notification: Notification) { - // Trigger redraw for current line highlight and bracket matching - needsDisplay = true + // Smart invalidation: only redraw the affected line regions + // instead of the entire view + invalidateLineHighlightIfNeeded() + } + + /// Invalidate only the current and previous line regions for redraw + private func invalidateLineHighlightIfNeeded() { + guard let layoutManager = layoutManager, + let textContainer = textContainer else { + needsDisplay = true + return + } + + let cursorPos = selectedRange().location + + // Calculate current line by iterating line-by-line with NSString's line APIs + // (more efficient than manual per-character scanning, but still linear in the + // number of lines before the cursor) + let currentLine: Int + if string.isEmpty { + currentLine = 0 + } else { + let nsString = string as NSString + let length = nsString.length + + // Clamp cursor position to valid UTF-16 range + let clampedCursorPos = min(max(cursorPos, 0), length) + + var lineNumber = 0 + var index = 0 + + // Walk line by line until we reach or pass the cursor position + while index < clampedCursorPos { + var lineStart = 0 + var lineEnd = 0 + var contentsEnd = 0 + + nsString.getLineStart(&lineStart, + end: &lineEnd, + contentsEnd: &contentsEnd, + for: NSRange(location: index, length: 0)) + + // If we've reached the last line, stop + if lineEnd <= index { + break + } + + if lineEnd > clampedCursorPos { + break + } + + lineNumber += 1 + index = lineEnd + } + + currentLine = lineNumber + } + + // Skip if cursor is on the same line + if currentLine == lastCursorLine { + return + } + + // Invalidate the previous line rect + if lastCursorLine >= 0 { + if let rect = lineRectForLine(lastCursorLine, layoutManager: layoutManager, textContainer: textContainer) { + setNeedsDisplay(rect.insetBy(dx: -lineInvalidationMargin, dy: -lineInvalidationMargin)) + } + } + + // Invalidate the current line rect + if let rect = lineRectForLine(currentLine, layoutManager: layoutManager, textContainer: textContainer) { + setNeedsDisplay(rect.insetBy(dx: -lineInvalidationMargin, dy: -lineInvalidationMargin)) + } + + lastCursorLine = currentLine + } + + /// Simple cache for line lookups to avoid repeated O(n) scans for consecutive lines. + /// + /// NOTE: + /// - This cache is shared by both `invalidateLineHighlightIfNeeded()` (which typically + /// queries the current and previous cursor lines) and generic callers of + /// `lineRectForLine(_:,layoutManager:textContainer:)`, which may request any line. + /// - The cache only provides a benefit when the requested line is the same as, or + /// adjacent to, the last cached line (see the `abs(cache.lastLine - lineNumber) <= 1` + /// check in `lineRectForLine`). Calls for distant line numbers will effectively + /// overwrite the cache and may reduce its effectiveness for cursor-movement tracking. + /// - This limitation is intentional: the cache is an opportunistic optimization and + /// must not be relied upon for correctness or for guaranteeing fast lookups for + /// arbitrary line numbers. + private var lineCache: (lastLine: Int, charIndex: Int, searchRange: NSRange)? + + /// Get the rect for a specific line number using efficient NSString lineRange + private func lineRectForLine(_ lineNumber: Int, layoutManager: NSLayoutManager, textContainer: NSTextContainer) -> NSRect? { + guard layoutManager.numberOfGlyphs > 0 else { return nil } + + let text = string as NSString + guard text.length > 0 else { return nil } + + var charIndex = 0 + var searchRange = NSRange(location: 0, length: text.length) + var startLine = 0 + + // Use cache if we're looking for a nearby line AND cache is still valid for current text + if let cache = lineCache, + cache.searchRange.location < text.length, + NSMaxRange(cache.searchRange) <= text.length, + abs(cache.lastLine - lineNumber) <= 1 { + + if cache.lastLine == lineNumber { + // Exact cache hit - use cached position + charIndex = min(cache.charIndex, text.length - 1) + searchRange = cache.searchRange + startLine = lineNumber + } else if cache.lastLine + 1 == lineNumber { + // Start iteration from cached line to reach the next line + charIndex = min(cache.charIndex, text.length - 1) + searchRange = cache.searchRange + startLine = cache.lastLine + } + } + + // Iterate from cached position (or start) to the target line + for _ in startLine..