Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 17 additions & 35 deletions TablePro/Core/Services/Formatting/SQLFormatterService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,26 +40,17 @@ struct SQLFormatterService: SQLFormatterProtocol {
// MARK: - Cached Regex Patterns (CPU-3, CPU-9, CPU-10)

/// String literal extraction patterns — one per quote character
/// Handles both backslash escapes (\'') and SQL-standard doubled-quote escapes ('')
private static let stringLiteralRegexes: [String: NSRegularExpression] = {
var result: [String: NSRegularExpression] = [:]
for quoteChar in ["'", "\"", "`"] {
let escaped = NSRegularExpression.escapedPattern(for: quoteChar)
let pattern = "\(escaped)((?:\\\\\\\\\(quoteChar)|[^\(quoteChar)])*?)\(escaped)"
let pattern = "\(escaped)((?:\\\\\\\\\(quoteChar)|\(escaped)\(escaped)|[^\(quoteChar)])*)\(escaped)"
result[quoteChar] = regex(pattern)
}
return result
}()

/// Line comment pattern: --[^\n]*
private static let lineCommentRegex: NSRegularExpression = {
regex("--[^\\n]*")
}()

/// Block comment pattern: /* ... */
private static let blockCommentRegex: NSRegularExpression = {
regex("/\\*.*?\\*/", options: .dotMatchesLineSeparators)
}()

/// Line break keyword patterns — pre-compiled for all 16 keywords (CPU-9)
/// Sorted by keyword length (longest first) to handle multi-word keywords correctly
private static let lineBreakRegexes: [(keyword: String, regex: NSRegularExpression)] = {
Expand Down Expand Up @@ -207,7 +198,7 @@ struct SQLFormatterService: SQLFormatterProtocol {
result = restoreStringLiterals(result, literals: stringLiterals)

// Step 5: Add line breaks before major keywords
result = addLineBreaks(result)
result = addLineBreaks(result, options: options)

// Step 6: Add indentation based on nesting
if options.indentSize > 0 {
Expand Down Expand Up @@ -288,38 +279,28 @@ struct SQLFormatterService: SQLFormatterProtocol {

// MARK: - Comment Handling (Fix #6: UUID placeholders)

/// Extract comments with UUID-based placeholders (prevents collisions)
/// Combined pattern matching both line comments (--...) and block comments (/*...*/)
private static let combinedCommentRegex: NSRegularExpression = {
regex("--[^\\n]*|/\\*.*?\\*/", options: .dotMatchesLineSeparators)
}()

/// Extract all comments in a single pass, ordered by position in the source SQL.
/// This ensures __COMMENT_0__ is always the first comment, __COMMENT_1__ the second, etc.
private func extractComments(from sql: String) -> (String, [(placeholder: String, content: String)]) {
var result = sql
var comments: [(String, String)] = []
var counter = 0

// Extract line comments (-- ...) using cached regex
let lineMatches = Self.lineCommentRegex.matches(
let allMatches = Self.combinedCommentRegex.matches(
in: result,
range: NSRange(result.startIndex..., in: result)
)
for match in lineMatches.reversed() {
if let range = safeRange(from: match.range, in: result) {
let comment = String(result[range])
let placeholder = "__COMMENT_\(counter)__"
counter += 1
comments.insert((placeholder, comment), at: 0)
result.replaceSubrange(range, with: placeholder)
}
}

// Extract block comments (/* ... */) using cached regex
// Note: This doesn't handle nested block comments (SQL doesn't officially support them)
let blockMatches = Self.blockCommentRegex.matches(
in: result,
range: NSRange(result.startIndex..., in: result)
)
for match in blockMatches.reversed() {
// Process in reverse to maintain valid indices; assign counters by source position
for (reverseIndex, match) in allMatches.reversed().enumerated() {
if let range = safeRange(from: match.range, in: result) {
let comment = String(result[range])
let counter = allMatches.count - 1 - reverseIndex
let placeholder = "__COMMENT_\(counter)__"
counter += 1
comments.insert((placeholder, comment), at: 0)
result.replaceSubrange(range, with: placeholder)
}
Expand Down Expand Up @@ -363,15 +344,16 @@ struct SQLFormatterService: SQLFormatterProtocol {

// MARK: - Line Breaks

private func addLineBreaks(_ sql: String) -> String {
private func addLineBreaks(_ sql: String, options: SQLFormatterOptions) -> String {
var result = sql

// Use pre-compiled regex patterns for all line break keywords (CPU-9)
for (keyword, regex) in Self.lineBreakRegexes {
let replacement = options.uppercaseKeywords ? keyword.uppercased() : keyword
result = regex.stringByReplacingMatches(
in: result,
range: NSRange(result.startIndex..., in: result),
withTemplate: "\n\(keyword.uppercased())"
withTemplate: "\n\(replacement)"
)
}

Expand Down
16 changes: 16 additions & 0 deletions TablePro/Views/Editor/SQLEditorCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ final class SQLEditorCoordinator: TextViewCoordinator {
@ObservationIgnored var onAIOptimize: ((String) -> Void)?
@ObservationIgnored var onSaveAsFavorite: ((String) -> Void)?
@ObservationIgnored var onFormatSQL: (() -> Void)?
@ObservationIgnored var databaseType: DatabaseType?

/// Whether the editor text view is currently the first responder.
/// Used to guard cursor propagation — when the find panel highlights
Expand Down Expand Up @@ -172,6 +173,7 @@ final class SQLEditorCoordinator: TextViewCoordinator {
onAIExplain = nil
onAIOptimize = nil
onSaveAsFavorite = nil
onFormatSQL = nil
schemaProvider = nil
contextMenu = nil
vimEngine = nil
Expand All @@ -185,6 +187,17 @@ final class SQLEditorCoordinator: TextViewCoordinator {
cleanupMonitors()
}

func revive() {
guard didDestroy else { return }
didDestroy = false
if let controller, let textView = controller.textView {
EditorEventRouter.shared.register(self, textView: textView)
}
if contextMenu == nil, let controller {
installAIContextMenu(controller: controller)
}
}

// MARK: - AI Context Menu

private func installAIContextMenu(controller: TextViewController) {
Expand Down Expand Up @@ -212,6 +225,9 @@ final class SQLEditorCoordinator: TextViewCoordinator {

/// Called by EditorEventRouter when a right-click is detected in this editor's text view.
func showContextMenu(for event: NSEvent, in textView: TextView) {
if contextMenu == nil, let controller {
installAIContextMenu(controller: controller)
}
guard let menu = contextMenu else { return }
NSMenu.popUpContextMenu(menu, with: event, for: textView)
}
Expand Down
35 changes: 18 additions & 17 deletions TablePro/Views/Editor/SQLEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,18 @@ struct SQLEditorView: View {
@Environment(\.colorScheme) private var colorScheme

var body: some View {
Group {
// Keep callbacks fresh on every parent re-render
coordinator.onCloseTab = onCloseTab
coordinator.onExecuteQuery = onExecuteQuery
coordinator.onAIExplain = onAIExplain
coordinator.onAIOptimize = onAIOptimize
coordinator.onSaveAsFavorite = onSaveAsFavorite
coordinator.onFormatSQL = onFormatSQL
coordinator.schemaProvider = schemaProvider
coordinator.connectionAIPolicy = connectionAIPolicy
coordinator.databaseType = databaseType

return Group {
if editorReady {
SourceEditor(
$text,
Expand Down Expand Up @@ -97,33 +108,23 @@ struct SQLEditorView: View {
editorConfiguration = Self.makeConfiguration()
}
.onAppear {
if coordinator.isDestroyed {
coordinator.revive()
}
if completionAdapter == nil {
completionAdapter = SQLCompletionAdapter(schemaProvider: schemaProvider, databaseType: databaseType)
}
coordinator.schemaProvider = schemaProvider
coordinator.connectionAIPolicy = connectionAIPolicy
coordinator.onCloseTab = onCloseTab
coordinator.onExecuteQuery = onExecuteQuery
coordinator.onAIExplain = onAIExplain
coordinator.onAIOptimize = onAIOptimize
coordinator.onSaveAsFavorite = onSaveAsFavorite
coordinator.onFormatSQL = onFormatSQL
setupFavoritesObserver()
}
} else {
Color(nsColor: .textBackgroundColor)
.onAppear {
if coordinator.isDestroyed {
coordinator.revive()
}
if completionAdapter == nil {
completionAdapter = SQLCompletionAdapter(schemaProvider: schemaProvider, databaseType: databaseType)
}
coordinator.schemaProvider = schemaProvider
coordinator.connectionAIPolicy = connectionAIPolicy
coordinator.onCloseTab = onCloseTab
coordinator.onExecuteQuery = onExecuteQuery
coordinator.onAIExplain = onAIExplain
coordinator.onAIOptimize = onAIOptimize
coordinator.onSaveAsFavorite = onSaveAsFavorite
coordinator.onFormatSQL = onFormatSQL
setupFavoritesObserver()
editorReady = true
}
Expand Down
Loading