datatestool/macos/Sources/DataTestApp/SQLCodeView.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
}
}