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
29 changes: 19 additions & 10 deletions apple/TablePro/Core/Services/Formatting/QueryFormatter.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Foundation
import TableProAutocomplete
import TableProPluginKit

struct QueryFormatResult {
Expand All @@ -13,28 +12,38 @@ protocol QueryFormatting {

struct SQLQueryFormatter: QueryFormatting {
private let dialect: DatabaseType
private let keywordCase: SQLKeywordCase
private let options: SQLFormatterOptions
private let formatter = SQLFormatterService()

init(dialect: DatabaseType, keywordCase: SQLKeywordCase = .default) {
init(dialect: DatabaseType, options: SQLFormatterOptions = .default) {
self.dialect = dialect
self.keywordCase = keywordCase
self.options = options
}

func format(_ text: String, cursorOffset: Int?) throws -> QueryFormatResult {
var options = SQLFormatterOptions.default
options.keywordCase = keywordCase.prefersUppercase ? .upper : .lower
let result = try formatter.format(text, dialect: dialect, cursorOffset: cursorOffset, options: options)
return QueryFormatResult(text: result.formattedSQL, cursorOffset: result.cursorOffset)
}
}

@MainActor
enum QueryFormatterFactory {
/// Nil for a language with no formatter of its own. The SQL formatter reads a Redis key `user:1` as
/// `user :1`, an etcd path as `/ config / name` and a SurrealQL record id `person:tobie` as two tokens,
/// so running it there changes what the command does.
static func make(for databaseType: DatabaseType?) -> QueryFormatting? {
make(for: databaseType) { AppSettingsManager.shared.formatterOptions }
}

// Throws when the linked folder's rules file can't be read, rather than format with Settings.
static func make(for databaseType: DatabaseType?, sourceFileURL: URL?) throws -> QueryFormatting? {
try make(for: databaseType) { try SQLFormatterRuleSource.options(forFileAt: sourceFileURL) }
}

// Nil for a language with no formatter of its own. The SQL formatter reads a Redis key `user:1` as
// `user :1`, an etcd path as `/ config / name` and a SurrealQL record id `person:tobie` as two tokens,
// so running it there changes what the command does.
private static func make(
for databaseType: DatabaseType?,
sqlOptions: () throws -> SQLFormatterOptions
) rethrows -> QueryFormatting? {
let dialect = databaseType ?? .mysql

// Elasticsearch, Typesense and Weaviate highlight as JavaScript for their JSON bodies, and the
Expand All @@ -45,7 +54,7 @@ enum QueryFormatterFactory {

switch PluginManager.shared.editorLanguage(for: dialect) {
case .sql:
return SQLQueryFormatter(dialect: dialect, keywordCase: AppSettingsManager.shared.editor.keywordCase)
return SQLQueryFormatter(dialect: dialect, options: try sqlOptions())
case .javascript:
return ConsoleRequestFormatter()
case .bash, .custom:
Expand Down
137 changes: 137 additions & 0 deletions apple/TablePro/Core/Services/Formatting/SQLFormatterRulesFile.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
//
// SQLFormatterRulesFile.swift
// TablePro
//

import Foundation

enum SQLFormatterRulesError: LocalizedError, Equatable {
case tooLarge
case notJSON
case invalidValue(key: String)
case unreadable(String)

var errorDescription: String? {
switch self {
case .tooLarge:
return String(
format: String(localized: "The file is larger than %@, too large for a rules file."),
ByteCountFormatter.string(fromByteCount: Int64(SQLFormatterRulesFile.maxSize), countStyle: .file)
)
case .notJSON:
return String(localized: "The file is not a JSON object.")
case .invalidValue(let key):
return String(format: String(localized: "The value of “%@” is not valid."), key)
case .unreadable(let reason):
return reason
}
}
}

struct SQLFormatterFolderRulesError: LocalizedError {
let fileURL: URL
let reason: SQLFormatterRulesError

var errorDescription: String? {
String(
format: String(localized: "The formatter rules in “%@” could not be read. %@"),
fileURL.path,
reason.localizedDescription
)
}
}

enum SQLFormatterRulesFile {
static let fileName = ".sql-formatter.json"
static let exportFileName = "sql-formatter.json"
static let maxSize = 64 * 1_024

static func encode(_ options: SQLFormatterOptions) throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(options)
}

static func decode(_ data: Data) throws -> SQLFormatterOptions {
guard data.count <= maxSize else { throw SQLFormatterRulesError.tooLarge }
do {
return try JSONDecoder().decode(SQLFormatterOptions.self, from: data)
} catch let error as DecodingError {
throw rulesError(for: error)
}
}

// A rules file comes with a repository and may link to a device or a pipe, so only a regular file is read.
static func load(from url: URL) throws -> SQLFormatterOptions {
do {
let target = url.resolvingSymlinksInPath()
let values = try target.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
guard values.isRegularFile == true else {
throw SQLFormatterRulesError.unreadable(String(localized: "The rules file is not a regular file."))
}
guard (values.fileSize ?? 0) <= maxSize else { throw SQLFormatterRulesError.tooLarge }
let handle = try FileHandle(forReadingFrom: target)
defer { try? handle.close() }
return try decode(try handle.read(upToCount: maxSize + 1) ?? Data())
} catch let error as SQLFormatterRulesError {
throw error
} catch {
throw SQLFormatterRulesError.unreadable(error.localizedDescription)
}
}

// Nearest rules file from the SQL file's folder up to its enabled linked folder's root, never above.
static func url(
forFileAt fileURL: URL,
in folders: [LinkedSQLFolder],
fileExists: (URL) -> Bool = { FileManager.default.fileExists(atPath: $0.path) }
) -> URL? {
let file = fileURL.resolvingSymlinksInPath().pathComponents
let roots = folders
.filter(\.isEnabled)
.map { $0.expandedURL.resolvingSymlinksInPath().pathComponents }
.filter { $0.count < file.count && Array(file.prefix($0.count)) == $0 }
guard let root = roots.max(by: { $0.count < $1.count }) else { return nil }

var directory = Array(file.dropLast())
while directory.count >= root.count {
let candidate = URL(fileURLWithPath: NSString.path(withComponents: directory + [fileName]))
if fileExists(candidate) { return candidate }
directory.removeLast()
}
return nil
}

private static func rulesError(for error: DecodingError) -> SQLFormatterRulesError {
let path: [CodingKey]
switch error {
case .typeMismatch(_, let context), .valueNotFound(_, let context), .dataCorrupted(let context):
path = context.codingPath
case .keyNotFound(_, let context):
path = context.codingPath
@unknown default:
path = []
}
guard let key = path.first?.stringValue else { return .notJSON }
return .invalidValue(key: key)
}
}

@MainActor
enum SQLFormatterRuleSource {
// A rules file in the linked folder wins over Settings, so every Mac formats that folder alike.
static func options(
forFileAt fileURL: URL?,
settings: @autoclosure () -> SQLFormatterOptions = AppSettingsManager.shared.formatterOptions,
folders: [LinkedSQLFolder] = LinkedSQLFolderStorage.shared.loadFolders()
) throws -> SQLFormatterOptions {
guard let fileURL, let rulesURL = SQLFormatterRulesFile.url(forFileAt: fileURL, in: folders) else {
return settings()
}
do {
return try SQLFormatterRulesFile.load(from: rulesURL)
} catch let error as SQLFormatterRulesError {
throw SQLFormatterFolderRulesError(fileURL: rulesURL, reason: error)
}
}
}
Loading
Loading