372 lines
14 KiB
Swift
372 lines
14 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
struct SQLCodeView: View {
|
|
let sql: String
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@State private var copied = false
|
|
|
|
private var formattedSQL: String {
|
|
SQLPrettyPrinter.format(sql)
|
|
}
|
|
|
|
private var highlightedSQL: AttributedString {
|
|
SQLSyntaxHighlighter.highlight(formattedSQL, colorScheme: colorScheme)
|
|
}
|
|
|
|
private var lineNumbers: String {
|
|
let count = max(formattedSQL.split(separator: "\n", omittingEmptySubsequences: false).count, 1)
|
|
return (1...count).map(String.init).joined(separator: "\n")
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 8) {
|
|
Label("格式化 SQL", systemImage: "chevron.left.forwardslash.chevron.right")
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Button {
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(formattedSQL, forType: .string)
|
|
copied = true
|
|
Task { @MainActor in
|
|
try? await Task.sleep(for: .seconds(1.5))
|
|
copied = false
|
|
}
|
|
} label: {
|
|
Label(copied ? "已复制" : "复制", systemImage: copied ? "checkmark" : "doc.on.doc")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.help("复制格式化后的 SQL")
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(.quaternary.opacity(0.34))
|
|
|
|
Divider()
|
|
|
|
ScrollView(.horizontal) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Text(lineNumbers)
|
|
.foregroundStyle(.tertiary)
|
|
.multilineTextAlignment(.trailing)
|
|
.textSelection(.disabled)
|
|
Divider()
|
|
Text(highlightedSQL)
|
|
.textSelection(.enabled)
|
|
}
|
|
.font(.system(size: 12.5, design: .monospaced))
|
|
.lineSpacing(3)
|
|
.padding(12)
|
|
.fixedSize(horizontal: true, vertical: true)
|
|
}
|
|
}
|
|
.background(Color(nsColor: .textBackgroundColor).opacity(colorScheme == .dark ? 0.72 : 0.88))
|
|
.clipShape(RoundedRectangle(cornerRadius: 9))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 9)
|
|
.stroke(Color(nsColor: .separatorColor).opacity(0.55), lineWidth: 1)
|
|
}
|
|
.onChange(of: sql) { _, _ in copied = false }
|
|
}
|
|
}
|
|
|
|
enum SQLPrettyPrinter {
|
|
private struct ParenthesisContext {
|
|
let isMultiline: Bool
|
|
let clause: String
|
|
let continuationIndent: Int
|
|
let closingIndent: Int
|
|
let parentIndent: Int
|
|
}
|
|
|
|
private static let tokenPattern = #"--[^\n]*|/\*[\s\S]*?\*/|'(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`|\[[^\]]*\]|[A-Za-z_][A-Za-z0-9_$]*|\d+(?:\.\d+)?|<>|!=|<=|>=|==|\|\||[-+*/%=<>,.;()]|[^\s]"#
|
|
|
|
private static let clausePhrases: Set<String> = [
|
|
"SELECT", "FROM", "WHERE", "GROUP BY", "HAVING", "ORDER BY",
|
|
"LIMIT", "OFFSET", "UNION", "UNION ALL", "EXCEPT", "INTERSECT",
|
|
"LEFT JOIN", "LEFT OUTER JOIN", "RIGHT JOIN", "RIGHT OUTER JOIN",
|
|
"INNER JOIN", "FULL JOIN", "FULL OUTER JOIN", "CROSS JOIN", "JOIN",
|
|
"VALUES", "SET"
|
|
]
|
|
|
|
static func format(_ sql: String) -> String {
|
|
let tokens = tokenize(sql)
|
|
guard !tokens.isEmpty else { return sql.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
|
|
var lines: [String] = []
|
|
var current = ""
|
|
var currentIndent = 0
|
|
var indent = 0
|
|
var continuationIndent = 0
|
|
var clause = ""
|
|
var parentheses: [ParenthesisContext] = []
|
|
var index = 0
|
|
|
|
func flush() {
|
|
let trimmed = current.trimmingCharacters(in: .whitespaces)
|
|
guard !trimmed.isEmpty else { return }
|
|
lines.append(String(repeating: " ", count: max(currentIndent, 0)) + trimmed)
|
|
current = ""
|
|
}
|
|
|
|
func newLine(_ requestedIndent: Int? = nil) {
|
|
flush()
|
|
currentIndent = max(requestedIndent ?? continuationIndent, 0)
|
|
}
|
|
|
|
func append(_ text: String, spaceBefore: Bool = true) {
|
|
if current.isEmpty {
|
|
current = text
|
|
} else if spaceBefore && !current.hasSuffix(" ") {
|
|
current += " " + text
|
|
} else {
|
|
current += text
|
|
}
|
|
}
|
|
|
|
while index < tokens.count {
|
|
let token = tokens[index]
|
|
let upper = token.uppercased()
|
|
let (phrase, consumed) = phrase(at: index, tokens: tokens)
|
|
let previous = index > 0 ? tokens[index - 1].uppercased() : ""
|
|
let next = index + consumed < tokens.count ? tokens[index + consumed].uppercased() : ""
|
|
|
|
if phrase == "WITH" {
|
|
newLine(indent)
|
|
append("WITH", spaceBefore: false)
|
|
continuationIndent = indent
|
|
clause = "WITH"
|
|
index += consumed
|
|
continue
|
|
}
|
|
|
|
if clausePhrases.contains(phrase) {
|
|
newLine(indent)
|
|
append(phrase, spaceBefore: false)
|
|
clause = phrase
|
|
continuationIndent = indent + 1
|
|
if phrase == "SELECT" {
|
|
newLine(continuationIndent)
|
|
}
|
|
index += consumed
|
|
continue
|
|
}
|
|
|
|
if phrase == "ON" {
|
|
newLine(indent + 1)
|
|
append("ON", spaceBefore: false)
|
|
clause = "ON"
|
|
continuationIndent = indent + 2
|
|
index += consumed
|
|
continue
|
|
}
|
|
|
|
if (phrase == "AND" || phrase == "OR")
|
|
&& ["WHERE", "HAVING", "ON"].contains(clause) {
|
|
newLine(indent + 1)
|
|
append(phrase, spaceBefore: false)
|
|
continuationIndent = indent + 2
|
|
index += consumed
|
|
continue
|
|
}
|
|
|
|
if phrase == "WHEN" || phrase == "ELSE" {
|
|
newLine(indent + 1)
|
|
append(phrase, spaceBefore: false)
|
|
continuationIndent = indent + 2
|
|
index += consumed
|
|
continue
|
|
}
|
|
|
|
if phrase == "END" {
|
|
newLine(indent)
|
|
append("END", spaceBefore: false)
|
|
continuationIndent = indent
|
|
index += consumed
|
|
continue
|
|
}
|
|
|
|
switch token {
|
|
case "(":
|
|
let multiline = next == "SELECT" || next == "WITH"
|
|
let needsSpace = multiline
|
|
? !current.isEmpty
|
|
: (!current.isEmpty
|
|
&& ![".", "("].contains(previous)
|
|
&& !isFunctionName(previous))
|
|
append("(", spaceBefore: needsSpace)
|
|
parentheses.append(ParenthesisContext(
|
|
isMultiline: multiline,
|
|
clause: clause,
|
|
continuationIndent: continuationIndent,
|
|
closingIndent: currentIndent,
|
|
parentIndent: indent
|
|
))
|
|
if multiline {
|
|
indent = currentIndent + 1
|
|
continuationIndent = indent
|
|
newLine(indent)
|
|
}
|
|
|
|
case ")":
|
|
let context = parentheses.popLast()
|
|
if context?.isMultiline == true {
|
|
indent = context?.parentIndent ?? max(indent - 1, 0)
|
|
newLine(context?.closingIndent ?? indent)
|
|
append(")", spaceBefore: false)
|
|
clause = context?.clause ?? clause
|
|
continuationIndent = context?.continuationIndent ?? indent
|
|
} else {
|
|
append(")", spaceBefore: false)
|
|
}
|
|
|
|
case ",":
|
|
append(",", spaceBefore: false)
|
|
let insideFunction = parentheses.last?.isMultiline == false
|
|
if !insideFunction && ["SELECT", "GROUP BY", "ORDER BY", "WITH"].contains(clause) {
|
|
newLine(continuationIndent)
|
|
}
|
|
|
|
case ".":
|
|
append(".", spaceBefore: false)
|
|
|
|
case ";":
|
|
append(";", spaceBefore: false)
|
|
newLine(indent)
|
|
|
|
case "=", "!=", "<>", "<", ">", "<=", ">=", "+", "-", "*", "/", "%", "||":
|
|
let compactWildcard = token == "*" && (previous == "(" || next == ")")
|
|
append(token, spaceBefore: !compactWildcard)
|
|
|
|
default:
|
|
if upper.hasPrefix("--") || upper.hasPrefix("/*") {
|
|
newLine(continuationIndent)
|
|
append(token, spaceBefore: false)
|
|
newLine(continuationIndent)
|
|
} else {
|
|
append(token, spaceBefore: previous != "." && previous != "(")
|
|
}
|
|
}
|
|
|
|
index += 1
|
|
}
|
|
|
|
flush()
|
|
return lines.joined(separator: "\n")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
private static func tokenize(_ sql: String) -> [String] {
|
|
guard let expression = try? NSRegularExpression(pattern: tokenPattern) else { return [sql] }
|
|
let range = NSRange(sql.startIndex..., in: sql)
|
|
return expression.matches(in: sql, range: range).map {
|
|
(sql as NSString).substring(with: $0.range)
|
|
}
|
|
}
|
|
|
|
private static func phrase(at index: Int, tokens: [String]) -> (String, Int) {
|
|
let word = tokens[index].uppercased()
|
|
func matches(_ values: [String]) -> Bool {
|
|
guard index + values.count <= tokens.count else { return false }
|
|
return zip(tokens[index..<(index + values.count)], values).allSatisfy {
|
|
$0.0.uppercased() == $0.1
|
|
}
|
|
}
|
|
|
|
let phrases = [
|
|
["LEFT", "OUTER", "JOIN"], ["RIGHT", "OUTER", "JOIN"],
|
|
["FULL", "OUTER", "JOIN"], ["GROUP", "BY"], ["ORDER", "BY"],
|
|
["PARTITION", "BY"], ["UNION", "ALL"], ["LEFT", "JOIN"],
|
|
["RIGHT", "JOIN"], ["INNER", "JOIN"], ["FULL", "JOIN"],
|
|
["CROSS", "JOIN"]
|
|
]
|
|
for values in phrases where matches(values) {
|
|
return (values.joined(separator: " "), values.count)
|
|
}
|
|
return (word, 1)
|
|
}
|
|
|
|
private static func isFunctionName(_ token: String) -> Bool {
|
|
guard let first = token.first else { return false }
|
|
return first.isLetter || first == "_"
|
|
}
|
|
}
|
|
|
|
private enum SQLSyntaxHighlighter {
|
|
private static let tokenPattern = #"--[^\n]*|/\*[\s\S]*?\*/|'(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`|\[[^\]]*\]|\b\d+(?:\.\d+)?\b|\b[A-Za-z_][A-Za-z0-9_$]*\b"#
|
|
|
|
private static let keywords: Set<String> = [
|
|
"ALL", "ALTER", "AND", "AS", "ASC", "BETWEEN", "BY", "CASE", "CAST",
|
|
"CREATE", "CROSS", "CURRENT", "DELETE", "DESC", "DISTINCT", "DROP", "ELSE",
|
|
"END", "EXCEPT", "EXISTS", "FOLLOWING", "FROM", "FULL", "GROUP", "HAVING",
|
|
"IN", "INNER", "INSERT", "INTERSECT", "INTERVAL", "INTO", "IS", "JOIN",
|
|
"LEFT", "LIKE", "LIMIT", "NOT", "NULL", "OFFSET", "ON", "OR", "ORDER",
|
|
"OUTER", "OVER", "PARTITION", "PRECEDING", "RANGE", "RECURSIVE", "RIGHT",
|
|
"ROW", "ROWS", "SELECT", "SET", "TABLE", "THEN", "UNION", "UPDATE",
|
|
"VALUES", "VIEW", "WHEN", "WHERE", "WITH"
|
|
]
|
|
|
|
private static let functions: Set<String> = [
|
|
"ABS", "AVG", "CAST", "COALESCE", "COUNT", "DATE", "DENSE_RANK", "IFNULL",
|
|
"LAG", "LEAD", "LOWER", "MAX", "MIN", "NULLIF", "RANK", "ROUND",
|
|
"ROW_NUMBER", "SUBSTR", "SUBSTRING", "SUM", "TRIM", "UPPER"
|
|
]
|
|
|
|
static func highlight(_ source: String, colorScheme: ColorScheme) -> AttributedString {
|
|
var attributed = AttributedString(source)
|
|
guard let expression = try? NSRegularExpression(pattern: tokenPattern) else {
|
|
return attributed
|
|
}
|
|
let nsSource = source as NSString
|
|
let fullRange = NSRange(source.startIndex..., in: source)
|
|
|
|
for match in expression.matches(in: source, range: fullRange) {
|
|
let token = nsSource.substring(with: match.range)
|
|
let upper = token.uppercased()
|
|
let color: Color?
|
|
|
|
if token.hasPrefix("--") || token.hasPrefix("/*") {
|
|
color = .secondary
|
|
} else if token.hasPrefix("'") {
|
|
color = colorScheme == .dark ? Color(red: 0.56, green: 0.82, blue: 0.58) : Color(red: 0.12, green: 0.50, blue: 0.20)
|
|
} else if token.hasPrefix("\"") || token.hasPrefix("`") || token.hasPrefix("[") {
|
|
color = .teal
|
|
} else if token.first?.isNumber == true {
|
|
color = .orange
|
|
} else if keywords.contains(upper) {
|
|
color = colorScheme == .dark ? Color(red: 0.80, green: 0.58, blue: 0.98) : Color(red: 0.49, green: 0.18, blue: 0.72)
|
|
} else if functions.contains(upper) || isFollowedByOpeningParenthesis(match.range, in: nsSource) {
|
|
color = colorScheme == .dark ? Color(red: 0.42, green: 0.72, blue: 1.0) : Color(red: 0.05, green: 0.38, blue: 0.72)
|
|
} else {
|
|
color = nil
|
|
}
|
|
|
|
guard let color,
|
|
let stringRange = Range(match.range, in: source),
|
|
let lower = AttributedString.Index(stringRange.lowerBound, within: attributed),
|
|
let upperBound = AttributedString.Index(stringRange.upperBound, within: attributed) else {
|
|
continue
|
|
}
|
|
attributed[lower..<upperBound].foregroundColor = color
|
|
}
|
|
return attributed
|
|
}
|
|
|
|
private static func isFollowedByOpeningParenthesis(_ range: NSRange, in source: NSString) -> Bool {
|
|
var offset = NSMaxRange(range)
|
|
while offset < source.length {
|
|
let scalar = source.character(at: offset)
|
|
if scalar == 32 || scalar == 9 || scalar == 10 || scalar == 13 {
|
|
offset += 1
|
|
continue
|
|
}
|
|
return scalar == 40
|
|
}
|
|
return false
|
|
}
|
|
}
|