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
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//
// SQLHighlightTextView.swift
// TableProMobile
//

import SwiftUI
import UIKit

struct SQLHighlightTextView: UIViewRepresentable {
@Binding var text: String

private static let font = UIFont.monospacedSystemFont(ofSize: 15, weight: .regular)

func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.font = Self.font
textView.autocorrectionType = .no
textView.autocapitalizationType = .none
textView.smartQuotesType = .no
textView.smartDashesType = .no
textView.smartInsertDeleteType = .no
textView.keyboardType = .asciiCapable
textView.textColor = .label
textView.backgroundColor = .clear
textView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
textView.textStorage.delegate = context.coordinator
return textView
}

func updateUIView(_ textView: UITextView, context: Context) {
if textView.text != text {
context.coordinator.isUpdating = true
textView.text = text
let length = (text as NSString).length
if length > 0 {
SQLSyntaxHighlighter.highlight(textView.textStorage, in: NSRange(location: 0, length: length))
}
context.coordinator.isUpdating = false
}
}

func makeCoordinator() -> Coordinator { Coordinator(self) }

class Coordinator: NSObject, UITextViewDelegate, NSTextStorageDelegate {
var parent: SQLHighlightTextView
var isUpdating = false

init(_ parent: SQLHighlightTextView) {
self.parent = parent
}

func textViewDidChange(_ textView: UITextView) {
guard !isUpdating else { return }
parent.text = textView.text
}

func textStorage(
_ textStorage: NSTextStorage,
didProcessEditing editedMask: NSTextStorage.EditActions,
range editedRange: NSRange,
changeInLength delta: Int
) {
guard editedMask.contains(.editedCharacters), !isUpdating else { return }
// Defer to avoid re-entrant editing during processEditing
DispatchQueue.main.async {
SQLSyntaxHighlighter.highlight(textStorage, in: editedRange)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//
// SQLSyntaxHighlighter.swift
// TableProMobile
//

import UIKit

enum SQLSyntaxHighlighter {
private static let maxHighlightLength = 10_000

private static let defaultFont = UIFont.monospacedSystemFont(ofSize: 15, weight: .regular)

private static let keywordPattern: NSRegularExpression = {
let keywords = [
"SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER",
"TABLE", "INDEX", "VIEW", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "CROSS", "FULL",
"ON", "AS", "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "UNION", "ALL",
"SET", "INTO", "VALUES", "AND", "OR", "NOT", "NULL", "IS", "IN", "LIKE", "BETWEEN",
"EXISTS", "DISTINCT", "ASC", "DESC", "BEGIN", "COMMIT", "ROLLBACK", "TRANSACTION",
"CASE", "WHEN", "THEN", "ELSE", "END", "TRUE", "FALSE", "IF", "ELSE",
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "CONSTRAINT", "DEFAULT", "CHECK",
"UNIQUE", "ADD", "COLUMN", "RENAME", "TO", "DATABASE", "SCHEMA", "USE",
"GRANT", "REVOKE", "WITH", "RECURSIVE", "FETCH", "NEXT", "ROWS", "ONLY"
]
let pattern = "\\b(" + keywords.joined(separator: "|") + ")\\b"
// swiftlint:disable:next force_try
return try! NSRegularExpression(pattern: pattern, options: .caseInsensitive)
}()

private static let functionPattern: NSRegularExpression = {
let functions = [
"COUNT", "SUM", "AVG", "MIN", "MAX", "COALESCE", "IFNULL", "NULLIF",
"UPPER", "LOWER", "TRIM", "LTRIM", "RTRIM", "LENGTH", "SUBSTRING", "SUBSTR",
"CONCAT", "REPLACE", "REVERSE", "NOW", "CURRENT_TIMESTAMP", "CURRENT_DATE",
"CAST", "CONVERT", "ROUND", "CEIL", "FLOOR", "ABS", "MOD",
"DATE", "TIME", "YEAR", "MONTH", "DAY", "HOUR", "MINUTE", "SECOND",
"GROUP_CONCAT", "STRING_AGG", "ARRAY_AGG", "JSON_EXTRACT", "JSON_VALUE"
]
let pattern = "\\b(" + functions.joined(separator: "|") + ")\\s*(?=\\()"
// swiftlint:disable:next force_try
return try! NSRegularExpression(pattern: pattern, options: .caseInsensitive)
}()

private static let numberPattern: NSRegularExpression = {
// swiftlint:disable:next force_try
try! NSRegularExpression(pattern: "\\b\\d+\\.?\\d*\\b", options: [])
}()

private static let singleLineCommentPattern: NSRegularExpression = {
// swiftlint:disable:next force_try
try! NSRegularExpression(pattern: "--[^\\n]*", options: [])
}()

private static let blockCommentPattern: NSRegularExpression = {
// swiftlint:disable:next force_try
try! NSRegularExpression(pattern: "/\\*[\\s\\S]*?\\*/", options: [])
}()

private static let stringPattern: NSRegularExpression = {
// swiftlint:disable:next force_try
try! NSRegularExpression(pattern: "'(?:[^']|'')*'", options: [])
}()

static func highlight(_ textStorage: NSTextStorage, in editedRange: NSRange) {
let fullLength = textStorage.length
guard fullLength > 0 else { return }

let cappedLength = min(fullLength, maxHighlightLength)
let nsString = textStorage.string as NSString

let highlightRange: NSRange
if editedRange.location == 0 && editedRange.length >= cappedLength {
highlightRange = NSRange(location: 0, length: cappedLength)
} else {
let lineStart = nsString.lineRange(for: NSRange(location: editedRange.location, length: 0)).location
let editEnd = min(NSMaxRange(editedRange), cappedLength)
let lineEnd = NSMaxRange(nsString.lineRange(for: NSRange(location: max(editEnd - 1, 0), length: 0)))
highlightRange = NSRange(location: lineStart, length: min(lineEnd - lineStart, cappedLength - lineStart))
}

guard highlightRange.length > 0 else { return }

let text = nsString.substring(with: highlightRange) as NSString

textStorage.beginEditing()

let defaultAttrs: [NSAttributedString.Key: Any] = [
.foregroundColor: UIColor.label,
.font: defaultFont
]
textStorage.setAttributes(defaultAttrs, range: highlightRange)

var protectedRanges: [NSRange] = []

blockCommentPattern.enumerateMatches(in: text as String, range: NSRange(location: 0, length: text.length)) { match, _, _ in
guard let matchRange = match?.range else { return }
let absolute = NSRange(location: highlightRange.location + matchRange.location, length: matchRange.length)
textStorage.addAttribute(.foregroundColor, value: UIColor.systemGray, range: absolute)
protectedRanges.append(matchRange)
}

singleLineCommentPattern.enumerateMatches(in: text as String, range: NSRange(location: 0, length: text.length)) { match, _, _ in
guard let matchRange = match?.range else { return }
if protectedRanges.contains(where: { NSIntersectionRange($0, matchRange).length > 0 }) { return }
let absolute = NSRange(location: highlightRange.location + matchRange.location, length: matchRange.length)
textStorage.addAttribute(.foregroundColor, value: UIColor.systemGray, range: absolute)
protectedRanges.append(matchRange)
}

stringPattern.enumerateMatches(in: text as String, range: NSRange(location: 0, length: text.length)) { match, _, _ in
guard let matchRange = match?.range else { return }
if protectedRanges.contains(where: { NSIntersectionRange($0, matchRange).length > 0 }) { return }
let absolute = NSRange(location: highlightRange.location + matchRange.location, length: matchRange.length)
textStorage.addAttribute(.foregroundColor, value: UIColor.systemRed, range: absolute)
protectedRanges.append(matchRange)
}

func isProtected(_ range: NSRange) -> Bool {
protectedRanges.contains { NSIntersectionRange($0, range).length > 0 }
}

keywordPattern.enumerateMatches(in: text as String, range: NSRange(location: 0, length: text.length)) { match, _, _ in
guard let matchRange = match?.range, !isProtected(matchRange) else { return }
let absolute = NSRange(location: highlightRange.location + matchRange.location, length: matchRange.length)
textStorage.addAttribute(.foregroundColor, value: UIColor.systemBlue, range: absolute)
}

functionPattern.enumerateMatches(in: text as String, range: NSRange(location: 0, length: text.length)) { match, _, _ in
guard let matchRange = match?.range, !isProtected(matchRange) else { return }
let absolute = NSRange(location: highlightRange.location + matchRange.location, length: matchRange.length)
textStorage.addAttribute(.foregroundColor, value: UIColor.systemPurple, range: absolute)
}

numberPattern.enumerateMatches(in: text as String, range: NSRange(location: 0, length: text.length)) { match, _, _ in
guard let matchRange = match?.range, !isProtected(matchRange) else { return }
let absolute = NSRange(location: highlightRange.location + matchRange.location, length: matchRange.length)
textStorage.addAttribute(.foregroundColor, value: UIColor.systemOrange, range: absolute)
}

textStorage.endEditing()
}
}
14 changes: 2 additions & 12 deletions TableProMobile/TableProMobile/Views/QueryEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ struct QueryEditorView: View {
let historyStorage: QueryHistoryStorage
@State private var showHistory = false
@State private var showClearHistoryConfirmation = false
@FocusState private var editorFocused: Bool

var body: some View {
VStack(spacing: 0) {
editorSection
Expand All @@ -45,16 +43,8 @@ struct QueryEditorView: View {

private var editorSection: some View {
VStack(spacing: 0) {
TextEditor(text: $query)
.font(.system(.body, design: .monospaced))
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.keyboardType(.asciiCapable)
.scrollContentBackground(.hidden)
SQLHighlightTextView(text: $query)
.frame(minHeight: 80, maxHeight: result != nil || appError != nil ? 120 : 250)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.focused($editorFocused)

if executionTime != nil || result != nil {
HStack {
Expand Down Expand Up @@ -342,7 +332,7 @@ struct QueryEditorView: View {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }

editorFocused = false
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
isExecuting = true
defer { isExecuting = false }
appError = nil
Expand Down
Loading