diff --git a/CHANGELOG.md b/CHANGELOG.md index b36d76f0a..d956c8d2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Drop Schema for PostgreSQL, SQL Server and SurrealDB. - Quick Switcher can search tables and views across every open connection. - SSL settings on mobile for MySQL, PostgreSQL and Redis: a mode picker plus CA, client certificate and client key. Import a PEM file, paste one, or use a PKCS#12 file. (#2083) +- The editor underlines a structural mistake as you type: a closing bracket with no opener, or an unterminated comment. On MongoDB it also reports what the query parser rejects, such as an unknown collection method. A half-written statement is never flagged. (#2095) +- PostgreSQL enum values are suggested when you compare against an enum column, so `WHERE status = ` offers the labels the type declares. (#2095) - Legacy UUID Encoding on a MongoDB connection, so binary UUIDs written by the Java, C# or Python drivers read as UUIDs instead of hex. Filters, edits and MQL exports write the same bytes back. (#2086) - Autocomplete for MongoDB queries. Typing `db.` lists collections, `db.users.` lists the driver methods, and inside a query you get field names plus the operators that are valid in that spot: query operators in a filter, update operators in an update, stage names in a pipeline, and expression operators inside a stage. (#2095) - PostgreSQL autocomplete now knows the operators, including `::`, the JSON ones (`->`, `->>`, `#>`, `@>`, `?`, `?|`, `?&`), array and range containment, regex matching and full-text search. Each one shows what it does and which types it works on. Typing `::` offers the type names. (#2095) diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index 0c12dc998..64c45dcde 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -223,6 +223,7 @@ final class SQLCompletionProvider { } case .on: + items += await allowedValueItems(for: context) // HP-3: ON clause — prioritize columns from joined tables items += await columnItems(for: context.tableReferences) for ref in context.tableReferences { @@ -257,6 +258,7 @@ final class SQLCompletionProvider { case .where_, .and, .having: // HP-8: Columns, operators, logical keywords + clause transitions + items += await allowedValueItems(for: context) items += await columnItems(for: context.tableReferences) items += SQLKeywords.operatorItems() items += dialectOperatorItems() @@ -460,6 +462,25 @@ final class SQLCompletionProvider { .map { SQLCompletionItem.favorite(keyword: $0.key, name: $0.value.name, query: $0.value.query) } } + /// Values a compared column is restricted to, offered as quoted literals ahead of everything + /// else. Nothing is offered for an ordinary column. + private func allowedValueItems(for context: SQLContext) async -> [SQLCompletionItem] { + guard let column = context.comparisonColumn, let schemaProvider else { return [] } + + let values = await schemaProvider.allowedValues(forColumn: column, in: context.tableReferences) + return values.map { value in + var item = SQLCompletionItem( + label: "'\(value)'", + kind: .keyword, + insertText: "'\(value)'", + detail: column, + filterText: value.lowercased() + ) + item.sortPriority = 10 + return item + } + } + /// Operators the connected dialect declares, with their documented meaning. private func dialectOperatorItems() -> [SQLCompletionItem] { guard let descriptor = cachedDialect else { return [] } diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index 13bfff129..9cb99791e 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -93,6 +93,7 @@ struct SQLContext { let currentFunction: String? // If inside function args, the function name let isAfterComma: Bool // True if immediately after a comma let expectsObjectName: Bool // Cursor is in the table-operand slot of FROM/JOIN/INTO + let comparisonColumn: String? // Column being compared against, when the cursor is on the value side init( clauseType: SQLClauseType, @@ -106,7 +107,8 @@ struct SQLContext { nestingLevel: Int = 0, currentFunction: String? = nil, isAfterComma: Bool = false, - expectsObjectName: Bool = false + expectsObjectName: Bool = false, + comparisonColumn: String? = nil ) { self.clauseType = clauseType self.prefix = prefix @@ -120,6 +122,7 @@ struct SQLContext { self.currentFunction = currentFunction self.isAfterComma = isAfterComma self.expectsObjectName = expectsObjectName + self.comparisonColumn = comparisonColumn } func replacingTableReferences(_ references: [TableReference]) -> SQLContext { @@ -135,7 +138,8 @@ struct SQLContext { nestingLevel: nestingLevel, currentFunction: currentFunction, isAfterComma: isAfterComma, - expectsObjectName: expectsObjectName + expectsObjectName: expectsObjectName, + comparisonColumn: comparisonColumn ) } } @@ -354,6 +358,7 @@ final class SQLContextAnalyzer { ) let isCastTarget = endsWithCastOperator(nsBeforeCursor, before: prefixStart) + let comparisonColumn = comparisonTarget(in: nsBeforeCursor, before: prefixStart) return SQLContext( clauseType: isCastTarget ? .castTarget : resolution.clause, @@ -367,7 +372,8 @@ final class SQLContextAnalyzer { nestingLevel: nestingLevel, currentFunction: currentFunction, isAfterComma: isAfterComma, - expectsObjectName: resolution.expectsObjectName + expectsObjectName: resolution.expectsObjectName, + comparisonColumn: comparisonColumn ) } @@ -681,6 +687,58 @@ final class SQLContextAnalyzer { return false } + /// The column a value is being compared against, when the cursor sits on the value side of a + /// comparison. Drives value completion for columns with a known set of allowed values. + private func comparisonTarget(in text: NSString, before prefixStart: Int) -> String? { + var index = skippingWhitespaceBackwards(in: text, from: min(prefixStart, text.length)) + guard let operatorStart = comparisonOperatorStart(in: text, endingAt: index) else { return nil } + + index = skippingWhitespaceBackwards(in: text, from: operatorStart) + let identifierEnd = index + while index > 0, SQLTokenBoundary.isIdentifierChar(text.character(at: index - 1)) { + index -= 1 + } + guard index < identifierEnd else { return nil } + + let identifier = text.substring(with: NSRange(location: index, length: identifierEnd - index)) + guard !Self.comparisonStopWords.contains(identifier.uppercased()) else { return nil } + return identifier + } + + private func skippingWhitespaceBackwards(in text: NSString, from start: Int) -> Int { + var index = start + while index > 0 { + let character = text.character(at: index - 1) + guard character == Self.space || character == Self.tab + || character == Self.newline || character == Self.cr else { break } + index -= 1 + } + return index + } + + private func comparisonOperatorStart(in text: NSString, endingAt end: Int) -> Int? { + for symbol in Self.comparisonOperators { + let length = (symbol as NSString).length + guard end >= length else { continue } + let range = NSRange(location: end - length, length: length) + guard text.substring(with: range).caseInsensitiveCompare(symbol) == .orderedSame else { continue } + + let start = end - length + let isWordOperator = symbol.first?.isLetter == true + if isWordOperator, start > 0, SQLTokenBoundary.isIdentifierChar(text.character(at: start - 1)) { + continue + } + return start + } + return nil + } + + private static let comparisonOperators: [String] = ["<>", "!=", "<=", ">=", "=", "<", ">", "IN", "LIKE", "ILIKE"] + + private static let comparisonStopWords: Set = [ + "AND", "OR", "NOT", "WHERE", "ON", "HAVING", "SELECT", "SET", "WHEN", "THEN", "ELSE", "BY" + ] + /// True when the token being typed directly follows a PostgreSQL `::` cast operator. private func endsWithCastOperator(_ text: NSString, before prefixStart: Int) -> Bool { guard prefixStart >= 2, prefixStart <= text.length else { return false } diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index f639175ad..6506fb7a7 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -409,6 +409,29 @@ actor SQLSchemaProvider { return paths } + /// Values a column is restricted to, when the database declares them (a PostgreSQL enum type, + /// a MongoDB `$jsonSchema` enum). Returns nothing for an ordinary column. + /// Reads only what the column cache already holds. Completion runs on every keystroke, so it + /// must never trigger a schema fetch; the eager column preload is what fills this cache. + func allowedValues(forColumn column: String, in references: [TableReference]) -> [String] { + let name = column.lowercased() + let candidates = references.isEmpty + ? tables.map { (table: $0.name, schema: String?.none) } + : references.map { (table: $0.tableName, schema: $0.schema) } + + for candidate in candidates { + let key = [candidate.schema?.lowercased(), candidate.table.lowercased()] + .compactMap(\.self) + .joined(separator: ".") + guard let columns = columnCache[key] else { continue } + if let match = columns.first(where: { $0.name.lowercased() == name }), + let values = match.allowedValues, !values.isEmpty { + return values + } + } + return [] + } + /// Get completion items for all columns of tables in scope func allColumnsInScope(for references: [TableReference]) async -> [SQLCompletionItem] { // swiftlint:disable:next large_tuple diff --git a/TablePro/Core/Diagnostics/MongoDiagnosticsProducer.swift b/TablePro/Core/Diagnostics/MongoDiagnosticsProducer.swift new file mode 100644 index 000000000..366e3e22f --- /dev/null +++ b/TablePro/Core/Diagnostics/MongoDiagnosticsProducer.swift @@ -0,0 +1,55 @@ +import Foundation +import TableProPluginKit + +struct MongoDiagnosticsProducer: QueryDiagnosticsProducing { + private static let maximumLength = 100_000 + + func diagnostics(for text: String) -> [QueryDiagnostic] { + let source = text as NSString + guard source.length > 0, source.length <= Self.maximumLength else { return [] } + + let structure = QueryBracketScanner.scan(source, allowsLineComments: false) + + if let range = structure.unmatchedClose { + return [QueryDiagnostic(range: range, message: String(localized: "No matching opening bracket"))] + } + if let range = structure.unterminatedComment { + return [QueryDiagnostic(range: range, message: String(localized: "Unterminated comment"))] + } + + guard !structure.hasUnclosedOpener, !structure.hasUnterminatedString else { return [] } + + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + do { + _ = try MongoShellParser.parse(trimmed) + return [] + } catch let error as MongoShellParseError { + guard let message = error.errorDescription else { return [] } + return [QueryDiagnostic(range: range(for: error, in: source), message: message)] + } catch { + return [] + } + } + + private func range(for error: MongoShellParseError, in source: NSString) -> NSRange { + if case .unsupportedMethod(let method) = error { + let name = method.trimmingCharacters(in: CharacterSet(charactersIn: ".()")) + let found = source.range(of: name) + if found.location != NSNotFound { return found } + } + return firstStatementLine(in: source) + } + + private func firstStatementLine(in source: NSString) -> NSRange { + let full = NSRange(location: 0, length: source.length) + let line = source.lineRange(for: NSRange(location: 0, length: 0)) + let trimmedLength = source.substring(with: line) + .trimmingCharacters(in: .whitespacesAndNewlines) + .utf16 + .count + guard trimmedLength > 0 else { return full } + return NSRange(location: line.location, length: min(trimmedLength, source.length - line.location)) + } +} diff --git a/TablePro/Core/Diagnostics/QueryBracketScanner.swift b/TablePro/Core/Diagnostics/QueryBracketScanner.swift new file mode 100644 index 000000000..db5453c91 --- /dev/null +++ b/TablePro/Core/Diagnostics/QueryBracketScanner.swift @@ -0,0 +1,126 @@ +import Foundation + +/// Structural scan shared by the diagnostic producers. Reports only what more typing cannot fix: +/// a closer with no opener, and an unterminated block comment. A missing closer is treated as +/// "still typing" and never reported, which is what keeps the editor quiet while you work. +enum QueryBracketScanner { + struct Result { + let unmatchedClose: NSRange? + let unterminatedComment: NSRange? + let hasUnclosedOpener: Bool + let hasUnterminatedString: Bool + } + + private static let openParen = UInt16(UnicodeScalar("(").value) + private static let closeParen = UInt16(UnicodeScalar(")").value) + private static let openBrace = UInt16(UnicodeScalar("{").value) + private static let closeBrace = UInt16(UnicodeScalar("}").value) + private static let openBracket = UInt16(UnicodeScalar("[").value) + private static let closeBracket = UInt16(UnicodeScalar("]").value) + private static let backslash = UInt16(UnicodeScalar("\\").value) + private static let slash = UInt16(UnicodeScalar("/").value) + private static let star = UInt16(UnicodeScalar("*").value) + private static let dash = UInt16(UnicodeScalar("-").value) + private static let newline = UInt16(UnicodeScalar("\n").value) + private static let singleQuote = UInt16(UnicodeScalar("'").value) + private static let doubleQuote = UInt16(UnicodeScalar("\"").value) + private static let backtick = UInt16(UnicodeScalar("`").value) + + static func scan(_ text: NSString, allowsLineComments: Bool) -> Result { + var stack: [UInt16] = [] + var unmatchedClose: NSRange? + var commentStart: Int? + var stringDelimiter: UInt16 = 0 + var isInBlockComment = false + var isInLineComment = false + var index = 0 + + while index < text.length { + let character = text.character(at: index) + + if isInLineComment { + if character == newline { isInLineComment = false } + index += 1 + continue + } + + if isInBlockComment { + if character == star, index + 1 < text.length, text.character(at: index + 1) == slash { + isInBlockComment = false + commentStart = nil + index += 2 + continue + } + index += 1 + continue + } + + if stringDelimiter != 0 { + if character == backslash { + index += 2 + continue + } + if character == stringDelimiter { stringDelimiter = 0 } + index += 1 + continue + } + + if character == slash, index + 1 < text.length { + let next = text.character(at: index + 1) + if next == star { + isInBlockComment = true + commentStart = index + index += 2 + continue + } + if next == slash, allowsLineComments { + isInLineComment = true + index += 2 + continue + } + } + + if character == dash, index + 1 < text.length, text.character(at: index + 1) == dash { + isInLineComment = true + index += 2 + continue + } + + if character == singleQuote || character == doubleQuote || character == backtick { + stringDelimiter = character + index += 1 + continue + } + + switch character { + case openParen, openBrace, openBracket: + stack.append(character) + case closeParen, closeBrace, closeBracket: + if stack.last == opener(for: character) { + stack.removeLast() + } else if unmatchedClose == nil { + unmatchedClose = NSRange(location: index, length: 1) + } + default: + break + } + + index += 1 + } + + return Result( + unmatchedClose: unmatchedClose, + unterminatedComment: isInBlockComment ? commentStart.map { NSRange(location: $0, length: 2) } : nil, + hasUnclosedOpener: !stack.isEmpty, + hasUnterminatedString: stringDelimiter != 0 + ) + } + + private static func opener(for closer: UInt16) -> UInt16 { + switch closer { + case closeParen: return openParen + case closeBrace: return openBrace + default: return openBracket + } + } +} diff --git a/TablePro/Core/Diagnostics/QueryDiagnostic.swift b/TablePro/Core/Diagnostics/QueryDiagnostic.swift new file mode 100644 index 000000000..1d361a8ac --- /dev/null +++ b/TablePro/Core/Diagnostics/QueryDiagnostic.swift @@ -0,0 +1,43 @@ +import Foundation +import TableProPluginKit + +struct QueryDiagnostic: Equatable, Identifiable { + enum Severity: String { + case error + case warning + } + + let id: UUID + let range: NSRange + let message: String + let severity: Severity + + init(range: NSRange, message: String, severity: Severity = .error) { + self.id = UUID() + self.range = range + self.message = message + self.severity = severity + } + + static func == (lhs: QueryDiagnostic, rhs: QueryDiagnostic) -> Bool { + lhs.range == rhs.range && lhs.message == rhs.message && lhs.severity == rhs.severity + } +} + +protocol QueryDiagnosticsProducing: Sendable { + func diagnostics(for text: String) -> [QueryDiagnostic] +} + +@MainActor +enum QueryDiagnosticsFactory { + static func make(for databaseType: DatabaseType?) -> QueryDiagnosticsProducing { + let dialect = databaseType ?? .mysql + + switch PluginManager.shared.editorLanguage(for: dialect) { + case .javascript: + return MongoDiagnosticsProducer() + default: + return SQLDiagnosticsProducer() + } + } +} diff --git a/TablePro/Core/Diagnostics/SQLDiagnosticsProducer.swift b/TablePro/Core/Diagnostics/SQLDiagnosticsProducer.swift new file mode 100644 index 000000000..1a5f527e2 --- /dev/null +++ b/TablePro/Core/Diagnostics/SQLDiagnosticsProducer.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Reports only structural problems more typing cannot fix. TablePro has no SQL grammar, and a +/// half-written statement must never be flagged, so an unclosed bracket is deliberately silent. +struct SQLDiagnosticsProducer: QueryDiagnosticsProducing { + private static let maximumLength = 100_000 + + func diagnostics(for text: String) -> [QueryDiagnostic] { + let source = text as NSString + guard source.length > 0, source.length <= Self.maximumLength else { return [] } + + let structure = QueryBracketScanner.scan(source, allowsLineComments: false) + var results: [QueryDiagnostic] = [] + + if let range = structure.unmatchedClose { + results.append(QueryDiagnostic(range: range, message: String(localized: "No matching opening bracket"))) + } + if let range = structure.unterminatedComment { + results.append(QueryDiagnostic(range: range, message: String(localized: "Unterminated comment"))) + } + + return results + } +} diff --git a/TablePro/Views/Editor/QueryDiagnosticsController.swift b/TablePro/Views/Editor/QueryDiagnosticsController.swift new file mode 100644 index 000000000..b02df0b25 --- /dev/null +++ b/TablePro/Views/Editor/QueryDiagnosticsController.swift @@ -0,0 +1,95 @@ +// +// QueryDiagnosticsController.swift +// TablePro +// +// Runs the language's diagnostic producer on a debounce and renders the result as underlines +// through CodeEditTextView's EmphasisManager, which owns its own drawing layer. +// + +import AppKit +import CodeEditSourceEditor +import CodeEditTextView +import os + +@MainActor +final class QueryDiagnosticsController { + static let emphasisGroup = "com.TablePro.queryDiagnostics" + + private var producer: QueryDiagnosticsProducing + private var pendingTask: Task? + private(set) var diagnostics: [QueryDiagnostic] = [] + + private let debounceNanoseconds: UInt64 = 500_000_000 + + private static let logger = Logger(subsystem: "com.TablePro", category: "QueryDiagnostics") + + init(databaseType: DatabaseType?) { + self.producer = QueryDiagnosticsFactory.make(for: databaseType) + } + + func configure(databaseType: DatabaseType?) { + producer = QueryDiagnosticsFactory.make(for: databaseType) + diagnostics = [] + } + + func scheduleRefresh(for controller: TextViewController?) { + pendingTask?.cancel() + + guard let controller else { + clear(in: nil) + return + } + + pendingTask = Task { [weak self, weak controller] in + guard let self else { return } + do { + try await Task.sleep(nanoseconds: self.debounceNanoseconds) + } catch { + return + } + guard !Task.isCancelled, let controller else { return } + self.refresh(for: controller) + } + } + + func refresh(for controller: TextViewController) { + let text = controller.textView.string + let produced = producer.diagnostics(for: text) + guard produced != diagnostics else { return } + + diagnostics = produced + apply(produced, in: controller) + } + + func clear(in controller: TextViewController?) { + pendingTask?.cancel() + pendingTask = nil + diagnostics = [] + controller?.textView.emphasisManager?.removeEmphases(for: Self.emphasisGroup) + } + + func diagnostic(at offset: Int) -> QueryDiagnostic? { + diagnostics.first { NSLocationInRange(offset, $0.range) } + } + + private func apply(_ produced: [QueryDiagnostic], in controller: TextViewController) { + guard let manager = controller.textView.emphasisManager else { return } + + let length = (controller.textView.string as NSString).length + let emphases = produced.compactMap { diagnostic -> Emphasis? in + guard diagnostic.range.location >= 0, + NSMaxRange(diagnostic.range) <= length else { return nil } + return Emphasis(range: diagnostic.range, style: .underline(color: color(for: diagnostic.severity))) + } + + manager.replaceEmphases(emphases, for: Self.emphasisGroup) + Self.logger.debug("diagnostics rendered count=\(emphases.count)") + } + + private func color(for severity: QueryDiagnostic.Severity) -> NSColor { + switch severity { + case .error: return .systemRed + case .warning: return .systemOrange + } + } +} diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 8c277fde2..10b0874c3 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -26,6 +26,9 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { private static let languageServiceLengthLimit = EditorHighlighting.maxHighlightableCharacters @ObservationIgnored weak var controller: TextViewController? + @ObservationIgnored private lazy var diagnosticsController = QueryDiagnosticsController( + databaseType: databaseType + ) /// Shared schema provider for inline AI suggestions (avoids duplicate schema fetches) @ObservationIgnored var schemaProvider: SQLSchemaProvider? /// Connection-level AI policy for inline suggestions @@ -144,6 +147,8 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { installAIContextMenu(controller: controller) installInlineSuggestionManager(controller: controller) + diagnosticsController.configure(databaseType: databaseType) + diagnosticsController.scheduleRefresh(for: controller) installVimModeIfEnabled(controller: controller) installEditorSettingsObserver(controller: controller) @@ -196,6 +201,10 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { } uppercaseKeywordIfNeeded(textView: textView, range: range, string: string) + + if !isLargeDocument { + diagnosticsController.scheduleRefresh(for: controller) + } } func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { diff --git a/TableProTests/Core/Diagnostics/QueryDiagnosticsTests.swift b/TableProTests/Core/Diagnostics/QueryDiagnosticsTests.swift new file mode 100644 index 000000000..c1da5b85f --- /dev/null +++ b/TableProTests/Core/Diagnostics/QueryDiagnosticsTests.swift @@ -0,0 +1,110 @@ +// +// QueryDiagnosticsTests.swift +// TableProTests +// +// The property that matters most here is restraint: a half-typed statement must never be +// flagged. Only problems more typing cannot fix are reported. +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Query Diagnostics") +struct QueryDiagnosticsTests { + private let sql = SQLDiagnosticsProducer() + private let mql = MongoDiagnosticsProducer() + + // MARK: - Restraint + + @Test("a half-typed SQL statement is not flagged") + func testPartialSqlIsQuiet() { + #expect(sql.diagnostics(for: "SELECT * FROM users WHERE (id =").isEmpty) + } + + @Test("a half-typed MQL statement is not flagged") + func testPartialMqlIsQuiet() { + #expect(mql.diagnostics(for: "db.users.find({").isEmpty) + } + + @Test("an unterminated string is not flagged while typing") + func testUnterminatedStringIsQuiet() { + #expect(sql.diagnostics(for: "SELECT * FROM users WHERE name = 'ali").isEmpty) + #expect(mql.diagnostics(for: "db.users.find({name: \"ali").isEmpty) + } + + @Test("empty input produces nothing") + func testEmptyInput() { + #expect(sql.diagnostics(for: "").isEmpty) + #expect(mql.diagnostics(for: " \n ").isEmpty) + } + + @Test("valid statements produce nothing") + func testValidStatements() { + #expect(sql.diagnostics(for: "SELECT * FROM users WHERE id = 1;").isEmpty) + #expect(mql.diagnostics(for: "db.users.find({\"a\": 1})").isEmpty) + #expect(mql.diagnostics(for: "db.orders.aggregate([{\"$match\": {}}]).limit(5)").isEmpty) + } + + // MARK: - Real problems + + @Test("a closing bracket with no opener is reported") + func testUnmatchedCloseReported() { + let results = sql.diagnostics(for: "SELECT * FROM users)") + #expect(results.count == 1) + #expect(results.first?.range == NSRange(location: 19, length: 1)) + #expect(results.first?.severity == .error) + } + + @Test("a mismatched bracket kind is reported") + func testMismatchedBracketReported() { + #expect(!mql.diagnostics(for: "db.users.find([})").isEmpty) + } + + @Test("an unterminated block comment is reported") + func testUnterminatedCommentReported() { + let results = sql.diagnostics(for: "SELECT 1 /* open") + #expect(results.count == 1) + #expect(results.first?.message == "Unterminated comment") + } + + @Test("an unsupported MQL method is reported on the method name") + func testUnsupportedMethodRange() { + let query = "db.users.frobnicate({})" + let results = mql.diagnostics(for: query) + #expect(results.count == 1) + + guard let range = results.first?.range else { return } + #expect((query as NSString).substring(with: range) == "frobnicate") + } + + @Test("a query that does not start with db is reported") + func testNonDbQueryReported() { + #expect(!mql.diagnostics(for: "SELECT * FROM users").isEmpty) + } + + // MARK: - Structure is respected + + @Test("brackets inside a string literal do not count") + func testBracketsInStringIgnored() { + #expect(sql.diagnostics(for: "SELECT ')' FROM users").isEmpty) + #expect(mql.diagnostics(for: "db.users.find({\"a\": \"}\"})").isEmpty) + } + + @Test("brackets inside a SQL comment do not count") + func testBracketsInCommentIgnored() { + #expect(sql.diagnostics(for: "SELECT 1 -- )\n").isEmpty) + #expect(sql.diagnostics(for: "SELECT 1 /* ) */").isEmpty) + } + + @Test("a SQL line comment does not start on a double slash") + func testSqlDoesNotTreatDoubleSlashAsComment() { + #expect(sql.diagnostics(for: "SELECT 1 // )").count == 1) + } + + @Test("MQL has no comment syntax, so a double slash is not a comment") + func testMqlHasNoLineComments() { + #expect(!mql.diagnostics(for: "db.users.find({}) // )").isEmpty) + } +} diff --git a/docs/features/autocomplete.mdx b/docs/features/autocomplete.mdx index 74cdddd6e..98ab8d163 100644 --- a/docs/features/autocomplete.mdx +++ b/docs/features/autocomplete.mdx @@ -153,6 +153,18 @@ The sample is cached per collection and cleared when you switch database or refr Completion is suppressed inside comments, and braces or brackets inside a string literal do not count as opening a document. +### Enum Values + +Comparing against a column whose type declares a fixed set of values suggests those values, quoted and ready to accept. On PostgreSQL that covers enum types: + +```sql +WHERE status = | -- 'pending', 'active', 'archived' +WHERE status IN (| -- same list +WHERE mood <> 'ha| -- 'happy' +``` + +The values come from the column cache the editor already holds, so nothing extra is queried while you type. A column with no declared value set suggests nothing. + ### Favorite Keywords Favorites you've assigned a keyword to (DB-stored or linked-file `@keyword` frontmatter) appear in the popup as a top-priority match. Type the keyword, accept the suggestion, and the favorite's full SQL replaces the keyword inline. A `;;` in the favorite's SQL sets where the cursor lands after expansion. See [Favorites](/features/favorites#cursor-placement) for how to assign keywords and place the marker. diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index 3cb5776ac..8f3c013c3 100644 --- a/docs/features/sql-editor.mdx +++ b/docs/features/sql-editor.mdx @@ -10,6 +10,22 @@ Write and run SQL with tree-sitter syntax highlighting, schema-aware autocomplet SQL Editor +## Inline Diagnostics + +The editor underlines a structural mistake in red while you type, and clears it as soon as you fix it. It reports only what more typing cannot fix: + +| Reported | Not reported | +|----------|--------------| +| A closing bracket with no opener | A bracket you have not closed yet | +| A bracket that closes the wrong kind | A string you are still typing | +| An unterminated block comment | An incomplete statement | + +A half-written statement is never flagged, which is the usual complaint about editors that check as you type. Brackets inside a string literal or a comment are ignored. + +On MongoDB connections the query parser runs too, so an unknown collection method or a query that does not start with `db.` is underlined with the reason. The method name itself is underlined where the parser names one. + +Checking runs 500ms after you stop typing, and is skipped on very large documents. + ## Writing Queries Separate statements with semicolons. Place the cursor in any statement and press `Cmd+Enter` to run just that one.