From 23cdf0f8ee9e413a631b81c792af26d2a59c06e7 Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 13:22:35 -0700 Subject: [PATCH 1/8] register all supported file types in Info.plist CFBundleDocumentTypes --- Info.plist | 384 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 384 insertions(+) diff --git a/Info.plist b/Info.plist index eb43895..72a0fcf 100644 --- a/Info.plist +++ b/Info.plist @@ -28,5 +28,389 @@ NSSupportsSuddenTermination + CFBundleDocumentTypes + + + CFBundleTypeName + Markdown + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + net.daringfireball.markdown + + CFBundleTypeExtensions + + md + markdown + mdown + + + + CFBundleTypeName + CSV + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.comma-separated-values-text + + CFBundleTypeExtensions + + csv + + + + CFBundleTypeName + JSON + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.json + + CFBundleTypeExtensions + + json + + + + CFBundleTypeName + TOML + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + toml + + + + CFBundleTypeName + YAML + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + yaml + yml + + + + CFBundleTypeName + XML + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.xml + public.svg-image + + CFBundleTypeExtensions + + xml + svg + + + + CFBundleTypeName + Rust Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + rs + + + + CFBundleTypeName + C/C++ Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.c-source + public.c-plus-plus-source + public.c-header + + CFBundleTypeExtensions + + c + cpp + cc + cxx + h + hpp + hxx + + + + CFBundleTypeName + JavaScript/TypeScript + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + com.netscape.javascript-source + + CFBundleTypeExtensions + + js + jsx + ts + tsx + + + + CFBundleTypeName + HTML + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.html + + CFBundleTypeExtensions + + html + htm + + + + CFBundleTypeName + CSS + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + css + scss + less + + + + CFBundleTypeName + Python Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.python-script + + CFBundleTypeExtensions + + py + + + + CFBundleTypeName + Go Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + go + + + + CFBundleTypeName + Ruby Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.ruby-script + + CFBundleTypeExtensions + + rb + + + + CFBundleTypeName + PHP Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.php-script + + CFBundleTypeExtensions + + php + + + + CFBundleTypeName + Lua Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + lua + + + + CFBundleTypeName + Shell Script + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.shell-script + + CFBundleTypeExtensions + + sh + bash + zsh + fish + + + + CFBundleTypeName + Java/Kotlin Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + java + kt + kts + + + + CFBundleTypeName + Swift Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.swift-source + + CFBundleTypeExtensions + + swift + + + + CFBundleTypeName + Zig Source + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + zig + + + + CFBundleTypeName + SQL + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + sql + + + + CFBundleTypeName + Makefile + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + mk + + + + CFBundleTypeName + Configuration + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + ini + cfg + conf + env + + + + CFBundleTypeName + Lock File + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + + lock + + + + CFBundleTypeName + Plain Text + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.plain-text + + CFBundleTypeExtensions + + txt + text + log + + + From 93d00f428234005b7ff4011827f50c8b0759fa32 Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 13:23:18 -0700 Subject: [PATCH 2/8] add FileFormat enum and file tracking to AppState --- src/AppState.swift | 87 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/AppState.swift b/src/AppState.swift index 5086fd8..12ae056 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -1,6 +1,82 @@ import Foundation import Combine +enum FileFormat: String, CaseIterable { + case markdown, csv, json, toml, yaml, xml, svg + case rust, c, cpp, objc + case javascript, typescript, jsx, tsx + case html, css, scss, less + case python, go, ruby, php, lua + case shell, java, kotlin, swift, zig, sql + case makefile, dockerfile + case config, lock, plainText + case unknown + + static func from(extension ext: String) -> FileFormat { + switch ext.lowercased() { + case "md", "markdown", "mdown": return .markdown + case "csv": return .csv + case "json": return .json + case "toml": return .toml + case "yaml", "yml": return .yaml + case "xml": return .xml + case "svg": return .svg + case "rs": return .rust + case "c": return .c + case "cpp", "cc", "cxx": return .cpp + case "h", "hpp", "hxx": return .cpp + case "m": return .objc + case "js": return .javascript + case "jsx": return .jsx + case "ts": return .typescript + case "tsx": return .tsx + case "html", "htm": return .html + case "css": return .css + case "scss": return .scss + case "less": return .less + case "py": return .python + case "go": return .go + case "rb": return .ruby + case "php": return .php + case "lua": return .lua + case "sh", "bash", "zsh", "fish": return .shell + case "java": return .java + case "kt", "kts": return .kotlin + case "swift": return .swift + case "zig": return .zig + case "sql": return .sql + case "mk": return .makefile + case "ini", "cfg", "conf", "env": return .config + case "lock": return .lock + case "txt", "text", "log": return .plainText + default: return .unknown + } + } + + static func from(filename: String) -> FileFormat { + let lower = filename.lowercased() + if lower == "makefile" { return .makefile } + if lower == "dockerfile" { return .dockerfile } + let ext = (filename as NSString).pathExtension + if ext.isEmpty { return .unknown } + return from(extension: ext) + } + + var isCode: Bool { + switch self { + case .rust, .c, .cpp, .objc, .javascript, .typescript, .jsx, .tsx, + .html, .css, .scss, .less, .python, .go, .ruby, .php, .lua, + .shell, .java, .kotlin, .swift, .zig, .sql, .makefile, .dockerfile: + return true + default: + return false + } + } + + var isMarkdown: Bool { self == .markdown } + var isCSV: Bool { self == .csv } +} + class AppState: ObservableObject { @Published var documentText: String = "" { didSet { @@ -16,6 +92,8 @@ class AppState: ObservableObject { @Published var currentNoteID: UUID @Published var selectedNoteIDs: Set = [] @Published var modified: Bool = false + @Published var currentFileURL: URL? = nil + @Published var currentFileFormat: FileFormat = .markdown private let bridge = RustBridge.shared private var autoSaveTimer: DispatchSourceTimer? @@ -120,6 +198,8 @@ class AppState: ObservableObject { documentText = "" evalResults = [:] modified = false + currentFileURL = nil + currentFileFormat = .markdown refreshNoteList() } @@ -162,6 +242,9 @@ class AppState: ObservableObject { func saveNote() { bridge.setText(currentNoteID, text: documentText) + if let url = currentFileURL { + let _ = bridge.saveNote(currentNoteID, path: url.path) + } let _ = bridge.cacheSave(currentNoteID) modified = false refreshNoteList() @@ -169,6 +252,8 @@ class AppState: ObservableObject { func saveNoteToFile(_ url: URL) { let _ = bridge.saveNote(currentNoteID, path: url.path) + currentFileURL = url + currentFileFormat = FileFormat.from(filename: url.lastPathComponent) modified = false } @@ -176,6 +261,8 @@ class AppState: ObservableObject { if let (id, text) = bridge.loadNote(path: url.path) { currentNoteID = id documentText = text + currentFileURL = url + currentFileFormat = FileFormat.from(filename: url.lastPathComponent) modified = false let _ = bridge.cacheSave(id) evaluate() From 3d20668edc293996548867d827e4ed9bb3af0d76 Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 13:23:57 -0700 Subject: [PATCH 3/8] implement file open from Finder, save preserves format, save-as with format choice --- src/AppDelegate.swift | 89 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 5 deletions(-) diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index 5b9f79f..06650f3 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -43,6 +43,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) } + func application(_ application: NSApplication, open urls: [URL]) { + guard let url = urls.first else { return } + appState.loadNoteFromFile(url) + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } @@ -169,7 +174,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { @objc private func openNote() { let panel = NSOpenPanel() - panel.allowedContentTypes = [UTType(filenameExtension: "md")!, .plainText] + panel.allowedContentTypes = Self.supportedContentTypes panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false @@ -180,13 +185,21 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @objc private func saveNote() { - appState.saveNote() + if appState.currentFileURL != nil { + appState.saveNote() + } else { + saveNoteAs() + } } @objc private func saveNoteAs() { let panel = NSSavePanel() - panel.allowedContentTypes = [UTType(filenameExtension: "md")!] + panel.allowedContentTypes = Self.supportedContentTypes panel.nameFieldStringValue = defaultFilename() + if let url = appState.currentFileURL { + panel.directoryURL = url.deletingLastPathComponent() + panel.nameFieldStringValue = url.lastPathComponent + } panel.beginSheetModal(for: window) { [weak self] response in guard response == .OK, let url = panel.url else { return } self?.appState.saveNoteToFile(url) @@ -194,6 +207,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { } private func defaultFilename() -> String { + if let url = appState.currentFileURL { + return url.lastPathComponent + } let firstLine = appState.documentText .components(separatedBy: "\n").first? .trimmingCharacters(in: .whitespaces) ?? "" @@ -201,11 +217,74 @@ class AppDelegate: NSObject, NSApplicationDelegate { of: "^#+\\s*", with: "", options: .regularExpression ) let trimmed = stripped.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty, trimmed != "Untitled" else { return "note.md" } + let ext = extensionForFormat(appState.currentFileFormat) + guard !trimmed.isEmpty, trimmed != "Untitled" else { return "note.\(ext)" } let sanitized = trimmed.map { "/:\\\\".contains($0) ? "-" : String($0) }.joined() - return sanitized.prefix(80) + ".md" + return sanitized.prefix(80) + ".\(ext)" } + private func extensionForFormat(_ format: FileFormat) -> String { + switch format { + case .markdown: return "md" + case .csv: return "csv" + case .json: return "json" + case .toml: return "toml" + case .yaml: return "yaml" + case .xml: return "xml" + case .svg: return "svg" + case .rust: return "rs" + case .c: return "c" + case .cpp: return "cpp" + case .objc: return "m" + case .javascript: return "js" + case .typescript: return "ts" + case .jsx: return "jsx" + case .tsx: return "tsx" + case .html: return "html" + case .css: return "css" + case .scss: return "scss" + case .less: return "less" + case .python: return "py" + case .go: return "go" + case .ruby: return "rb" + case .php: return "php" + case .lua: return "lua" + case .shell: return "sh" + case .java: return "java" + case .kotlin: return "kt" + case .swift: return "swift" + case .zig: return "zig" + case .sql: return "sql" + case .makefile: return "mk" + case .dockerfile: return "Dockerfile" + case .config: return "conf" + case .lock: return "lock" + case .plainText, .unknown: return "txt" + } + } + + private static let supportedContentTypes: [UTType] = { + let extensions = [ + "md", "markdown", "mdown", + "csv", "json", "toml", "yaml", "yml", "xml", "svg", + "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", + "js", "jsx", "ts", "tsx", + "html", "htm", "css", "scss", "less", + "py", "go", "rb", "php", "lua", + "sh", "bash", "zsh", "fish", + "java", "kt", "kts", "swift", "zig", "sql", + "mk", "ini", "cfg", "conf", "env", + "lock", "txt", "text", "log" + ] + var types: [UTType] = [.plainText] + for ext in extensions { + if let t = UTType(filenameExtension: ext) { + types.append(t) + } + } + return Array(Set(types)) + }() + @objc private func openSettings() { SettingsWindowController.show() } From 977874cd22bf148682807b6d7de9e8f003ea8d79 Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 13:25:31 -0700 Subject: [PATCH 4/8] format-aware highlighting: code files get code mode, others fall back to Cordial/markdown --- src/EditorView.swift | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/EditorView.swift b/src/EditorView.swift index eb0fa6f..d7db798 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -908,6 +908,7 @@ struct EditorView: View { EditorTextView( text: bodyBinding, evalResults: offsetEvalResults(state.evalResults), + fileFormat: state.currentFileFormat, onEvaluate: { state.evaluate() }, onBackspaceAtStart: { NotificationCenter.default.post(name: .focusTitle, object: nil) @@ -928,6 +929,7 @@ struct EditorView: View { struct EditorTextView: NSViewRepresentable { @Binding var text: String var evalResults: [Int: String] + var fileFormat: FileFormat = .markdown var onEvaluate: () -> Void var onBackspaceAtStart: (() -> Void)? = nil @@ -983,7 +985,7 @@ struct EditorTextView: NSViewRepresentable { if let ts = textView.textStorage { ts.beginEditing() - applySyntaxHighlighting(to: ts) + applySyntaxHighlighting(to: ts, format: fileFormat) ts.endEditing() } textView.typingAttributes = [ @@ -1007,7 +1009,7 @@ struct EditorTextView: NSViewRepresentable { textView.string = text if let ts = textView.textStorage { ts.beginEditing() - applySyntaxHighlighting(to: ts) + applySyntaxHighlighting(to: ts, format: fileFormat) ts.endEditing() } textView.selectedRanges = selectedRanges @@ -1056,7 +1058,7 @@ struct EditorTextView: NSViewRepresentable { .foregroundColor: palette.text ] ts.beginEditing() - applySyntaxHighlighting(to: ts) + applySyntaxHighlighting(to: ts, format: parent.fileFormat) ts.endEditing() tv.needsDisplay = true } @@ -1077,7 +1079,7 @@ struct EditorTextView: NSViewRepresentable { parent.text = tv.string let sel = tv.selectedRanges ts.beginEditing() - applySyntaxHighlighting(to: ts) + applySyntaxHighlighting(to: ts, format: parent.fileFormat) ts.endEditing() tv.typingAttributes = [ .font: Theme.editorFont, @@ -1175,7 +1177,7 @@ struct EditorTextView: NSViewRepresentable { let sel = tv.selectedRanges ts.beginEditing() ts.replaceCharacters(in: range, with: newMarkdown) - applySyntaxHighlighting(to: ts) + applySyntaxHighlighting(to: ts, format: parent.fileFormat) ts.endEditing() tv.selectedRanges = sel parent.text = tv.string @@ -1396,7 +1398,7 @@ private let syntaxBooleans: Set = ["true", "false"] private let syntaxOperatorChars = CharacterSet(charactersIn: "+-*/=^<>!(){}[]:,.&|%") -func applySyntaxHighlighting(to textStorage: NSTextStorage) { +func applySyntaxHighlighting(to textStorage: NSTextStorage, format: FileFormat = .markdown) { let text = textStorage.string let fullRange = NSRange(location: 0, length: (text as NSString).length) let palette = Theme.current @@ -1409,6 +1411,11 @@ func applySyntaxHighlighting(to textStorage: NSTextStorage) { ] textStorage.setAttributes(baseAttrs, range: fullRange) + if format.isCode { + applyCodeFileHighlighting(to: textStorage, syn: syn, baseFont: baseFont) + return + } + let fencedRanges = highlightFencedCodeBlocks(textStorage: textStorage, palette: palette, baseFont: baseFont) let tableHeaderLines = findTableHeaderLines(textStorage: textStorage, fencedRanges: fencedRanges) @@ -1457,6 +1464,21 @@ func applySyntaxHighlighting(to textStorage: NSTextStorage) { highlightAutolinks(textStorage: textStorage, palette: palette, fencedRanges: fencedRanges) } +private func applyCodeFileHighlighting(to textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont) { + let text = textStorage.string + let nsText = text as NSString + var lineStart = 0 + + while lineStart < nsText.length { + let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0)) + let line = nsText.substring(with: lineRange) + highlightCodeLine(line, lineRange: lineRange, textStorage: textStorage, syn: syn) + lineStart = NSMaxRange(lineRange) + } + + highlightBlockComments(textStorage: textStorage, syn: syn, baseFont: baseFont) +} + private func highlightMarkdownLine(_ trimmed: String, line: String, lineRange: NSRange, textStorage: NSTextStorage, baseFont: NSFont, palette: CatppuccinPalette, isTableHeader: Bool = false) -> Bool { if trimmed.hasPrefix("### ") { let hashRange = (textStorage.string as NSString).range(of: "###", range: lineRange) From cca7d78cb3f978dddf93dfcbe84f9de69d3e3f0d Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 13:26:16 -0700 Subject: [PATCH 5/8] CSV files load as editable markdown tables, save converts back to CSV --- src/AppState.swift | 133 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 6 deletions(-) diff --git a/src/AppState.swift b/src/AppState.swift index 12ae056..c676824 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -241,9 +241,15 @@ class AppState: ObservableObject { } func saveNote() { - bridge.setText(currentNoteID, text: documentText) + let textToSave: String + if currentFileFormat.isCSV { + textToSave = markdownTableToCSV(documentText) + } else { + textToSave = documentText + } + bridge.setText(currentNoteID, text: textToSave) if let url = currentFileURL { - let _ = bridge.saveNote(currentNoteID, path: url.path) + try? textToSave.write(to: url, atomically: true, encoding: .utf8) } let _ = bridge.cacheSave(currentNoteID) modified = false @@ -251,18 +257,30 @@ class AppState: ObservableObject { } func saveNoteToFile(_ url: URL) { - let _ = bridge.saveNote(currentNoteID, path: url.path) + let format = FileFormat.from(filename: url.lastPathComponent) + let textToSave: String + if format.isCSV { + textToSave = markdownTableToCSV(documentText) + } else { + textToSave = documentText + } + try? textToSave.write(to: url, atomically: true, encoding: .utf8) currentFileURL = url - currentFileFormat = FileFormat.from(filename: url.lastPathComponent) + currentFileFormat = format modified = false } func loadNoteFromFile(_ url: URL) { + let format = FileFormat.from(filename: url.lastPathComponent) if let (id, text) = bridge.loadNote(path: url.path) { currentNoteID = id - documentText = text currentFileURL = url - currentFileFormat = FileFormat.from(filename: url.lastPathComponent) + currentFileFormat = format + if format.isCSV { + documentText = csvToMarkdownTable(text) + } else { + documentText = text + } modified = false let _ = bridge.cacheSave(id) evaluate() @@ -270,6 +288,109 @@ class AppState: ObservableObject { } } + // MARK: - CSV conversion + + private func csvToMarkdownTable(_ csv: String) -> String { + let rows = parseCSVRows(csv) + guard let header = rows.first, !header.isEmpty else { return csv } + + var lines: [String] = [] + lines.append("| " + header.joined(separator: " | ") + " |") + lines.append("| " + header.map { _ in "---" }.joined(separator: " | ") + " |") + for row in rows.dropFirst() { + var cells = row + while cells.count < header.count { cells.append("") } + lines.append("| " + cells.prefix(header.count).joined(separator: " | ") + " |") + } + return lines.joined(separator: "\n") + } + + private func markdownTableToCSV(_ markdown: String) -> String { + let lines = markdown.components(separatedBy: "\n").filter { !$0.isEmpty } + var csvRows: [String] = [] + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("|") else { continue } + if isTableSeparatorLine(trimmed) { continue } + let cells = extractTableCells(trimmed) + csvRows.append(cells.map { escapeCSVField($0) }.joined(separator: ",")) + } + + return csvRows.joined(separator: "\n") + "\n" + } + + private func parseCSVRows(_ csv: String) -> [[String]] { + var rows: [[String]] = [] + var current: [String] = [] + var field = "" + var inQuotes = false + let chars = Array(csv) + var i = 0 + + while i < chars.count { + let ch = chars[i] + if inQuotes { + if ch == "\"" { + if i + 1 < chars.count && chars[i + 1] == "\"" { + field.append("\"") + i += 2 + continue + } + inQuotes = false + } else { + field.append(ch) + } + } else { + if ch == "\"" { + inQuotes = true + } else if ch == "," { + current.append(field.trimmingCharacters(in: .whitespaces)) + field = "" + } else if ch == "\n" || ch == "\r" { + current.append(field.trimmingCharacters(in: .whitespaces)) + field = "" + if !current.isEmpty { + rows.append(current) + } + current = [] + if ch == "\r" && i + 1 < chars.count && chars[i + 1] == "\n" { + i += 1 + } + } else { + field.append(ch) + } + } + i += 1 + } + + if !field.isEmpty || !current.isEmpty { + current.append(field.trimmingCharacters(in: .whitespaces)) + rows.append(current) + } + + return rows + } + + private func isTableSeparatorLine(_ line: String) -> Bool { + let stripped = line.replacingOccurrences(of: " ", with: "") + return stripped.allSatisfy { "|:-".contains($0) } && stripped.contains("-") + } + + private func extractTableCells(_ line: String) -> [String] { + var trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("|") { trimmed = String(trimmed.dropFirst()) } + if trimmed.hasSuffix("|") { trimmed = String(trimmed.dropLast()) } + return trimmed.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) } + } + + private func escapeCSVField(_ field: String) -> String { + if field.contains(",") || field.contains("\"") || field.contains("\n") { + return "\"" + field.replacingOccurrences(of: "\"", with: "\"\"") + "\"" + } + return field + } + func deleteNote(_ id: UUID) { bridge.deleteNote(id) if id == currentNoteID { From 030b38a7a2946385c7c87f8d2946e1c0117d0e33 Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 13:49:58 -0700 Subject: [PATCH 6/8] tree-sitter syntax highlighting, auto-indent, auto-close pairs, smart paste, format command --- Info.plist | 12 + core/Cargo.toml | 28 ++ core/include/swiftly.h | 2 + core/queries/dockerfile-highlights.scm | 58 ++++ core/queries/kotlin-highlights.scm | 380 +++++++++++++++++++++++++ core/queries/sql-highlights.scm | 51 ++++ core/src/ffi.rs | 16 ++ core/src/highlight.rs | 252 ++++++++++++++++ core/src/lib.rs | 1 + src/AppDelegate.swift | 11 + src/AppState.swift | 34 ++- src/ContentView.swift | 1 + src/EditorView.swift | 273 +++++++++++++++++- src/RustBridge.swift | 30 ++ 14 files changed, 1141 insertions(+), 8 deletions(-) create mode 100644 core/queries/dockerfile-highlights.scm create mode 100644 core/queries/kotlin-highlights.scm create mode 100644 core/queries/sql-highlights.scm create mode 100644 core/src/highlight.rs diff --git a/Info.plist b/Info.plist index 72a0fcf..a23af44 100644 --- a/Info.plist +++ b/Info.plist @@ -366,6 +366,18 @@ mk + + CFBundleTypeName + Dockerfile + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions + + dockerfile + + CFBundleTypeName Configuration diff --git a/core/Cargo.toml b/core/Cargo.toml index a7849c2..e7197f2 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -13,6 +13,34 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4"] } +tree-sitter = "0.24" +tree-sitter-highlight = "0.24" +tree-sitter-language = "0.1" +tree-sitter-rust = "0.23" +tree-sitter-c = "0.23" +tree-sitter-cpp = "0.23" +tree-sitter-javascript = "0.23" +tree-sitter-typescript = "0.23" +tree-sitter-python = "0.23" +tree-sitter-go = "0.23" +tree-sitter-ruby = "0.23" +tree-sitter-bash = "0.23" +tree-sitter-java = "0.23" +tree-sitter-html = "0.23" +tree-sitter-css = "0.23" +tree-sitter-json = "0.24" +tree-sitter-lua = "0.4" +tree-sitter-php = "0.23" +tree-sitter-toml = "0.20" +tree-sitter-yaml = "0.6" +tree-sitter-swift = "0.6" +tree-sitter-zig = "1" +tree-sitter-sql = "0.0.2" +tree-sitter-md = "0.5" +tree-sitter-make = "1" +tree-sitter-dockerfile = "0.2" +tree-sitter-kotlin = "0.3" + [build-dependencies] cbindgen = "0.27" diff --git a/core/include/swiftly.h b/core/include/swiftly.h index 8cacafe..391552f 100644 --- a/core/include/swiftly.h +++ b/core/include/swiftly.h @@ -32,6 +32,8 @@ typedef struct SwiftlyDoc SwiftlyDoc; char *swiftly_list_notes(void); + char *swiftly_highlight(const char *source, const char *lang); + void swiftly_free_string(char *s); #endif /* SWIFTLY_H */ diff --git a/core/queries/dockerfile-highlights.scm b/core/queries/dockerfile-highlights.scm new file mode 100644 index 0000000..a5d6514 --- /dev/null +++ b/core/queries/dockerfile-highlights.scm @@ -0,0 +1,58 @@ +[ + "FROM" + "AS" + "RUN" + "CMD" + "LABEL" + "EXPOSE" + "ENV" + "ADD" + "COPY" + "ENTRYPOINT" + "VOLUME" + "USER" + "WORKDIR" + "ARG" + "ONBUILD" + "STOPSIGNAL" + "HEALTHCHECK" + "SHELL" + "MAINTAINER" + "CROSS_BUILD" + (heredoc_marker) + (heredoc_end) +] @keyword + +[ + ":" + "@" +] @operator + +(comment) @comment + + +(image_spec + (image_tag + ":" @punctuation.special) + (image_digest + "@" @punctuation.special)) + +[ + (double_quoted_string) + (single_quoted_string) + (json_string) + (heredoc_line) +] @string + +(expansion + [ + "$" + "{" + "}" + ] @punctuation.special +) @none + +((variable) @constant + (#match? @constant "^[A-Z][A-Z_0-9]*$")) + + diff --git a/core/queries/kotlin-highlights.scm b/core/queries/kotlin-highlights.scm new file mode 100644 index 0000000..d2e15a6 --- /dev/null +++ b/core/queries/kotlin-highlights.scm @@ -0,0 +1,380 @@ +;; Based on the nvim-treesitter highlighting, which is under the Apache license. +;; See https://github.com/nvim-treesitter/nvim-treesitter/blob/f8ab59861eed4a1c168505e3433462ed800f2bae/queries/kotlin/highlights.scm +;; +;; The only difference in this file is that queries using #lua-match? +;; have been removed. + +;;; Identifiers + +(simple_identifier) @variable + +; `it` keyword inside lambdas +; FIXME: This will highlight the keyword outside of lambdas since tree-sitter +; does not allow us to check for arbitrary nestation +((simple_identifier) @variable.builtin +(#eq? @variable.builtin "it")) + +; `field` keyword inside property getter/setter +; FIXME: This will highlight the keyword outside of getters and setters +; since tree-sitter does not allow us to check for arbitrary nestation +((simple_identifier) @variable.builtin +(#eq? @variable.builtin "field")) + +; `this` this keyword inside classes +(this_expression) @variable.builtin + +; `super` keyword inside classes +(super_expression) @variable.builtin + +(class_parameter + (simple_identifier) @property) + +(class_body + (property_declaration + (variable_declaration + (simple_identifier) @property))) + +; id_1.id_2.id_3: `id_2` and `id_3` are assumed as object properties +(_ + (navigation_suffix + (simple_identifier) @property)) + +(enum_entry + (simple_identifier) @constant) + +(type_identifier) @type + +((type_identifier) @type.builtin + (#any-of? @type.builtin + "Byte" + "Short" + "Int" + "Long" + "UByte" + "UShort" + "UInt" + "ULong" + "Float" + "Double" + "Boolean" + "Char" + "String" + "Array" + "ByteArray" + "ShortArray" + "IntArray" + "LongArray" + "UByteArray" + "UShortArray" + "UIntArray" + "ULongArray" + "FloatArray" + "DoubleArray" + "BooleanArray" + "CharArray" + "Map" + "Set" + "List" + "EmptyMap" + "EmptySet" + "EmptyList" + "MutableMap" + "MutableSet" + "MutableList" +)) + +(package_header + . (identifier)) @namespace + +(import_header + "import" @include) + + +; TODO: Seperate labeled returns/breaks/continue/super/this +; Must be implemented in the parser first +(label) @label + +;;; Function definitions + +(function_declaration + . (simple_identifier) @function) + +(getter + ("get") @function.builtin) +(setter + ("set") @function.builtin) + +(primary_constructor) @constructor +(secondary_constructor + ("constructor") @constructor) + +(constructor_invocation + (user_type + (type_identifier) @constructor)) + +(anonymous_initializer + ("init") @constructor) + +(parameter + (simple_identifier) @parameter) + +(parameter_with_optional_type + (simple_identifier) @parameter) + +; lambda parameters +(lambda_literal + (lambda_parameters + (variable_declaration + (simple_identifier) @parameter))) + +;;; Function calls + +; function() +(call_expression + . (simple_identifier) @function) + +; object.function() or object.property.function() +(call_expression + (navigation_expression + (navigation_suffix + (simple_identifier) @function) . )) + +(call_expression + . (simple_identifier) @function.builtin + (#any-of? @function.builtin + "arrayOf" + "arrayOfNulls" + "byteArrayOf" + "shortArrayOf" + "intArrayOf" + "longArrayOf" + "ubyteArrayOf" + "ushortArrayOf" + "uintArrayOf" + "ulongArrayOf" + "floatArrayOf" + "doubleArrayOf" + "booleanArrayOf" + "charArrayOf" + "emptyArray" + "mapOf" + "setOf" + "listOf" + "emptyMap" + "emptySet" + "emptyList" + "mutableMapOf" + "mutableSetOf" + "mutableListOf" + "print" + "println" + "error" + "TODO" + "run" + "runCatching" + "repeat" + "lazy" + "lazyOf" + "enumValues" + "enumValueOf" + "assert" + "check" + "checkNotNull" + "require" + "requireNotNull" + "with" + "suspend" + "synchronized" +)) + +;;; Literals + +[ + (line_comment) + (multiline_comment) + (shebang_line) +] @comment + +(real_literal) @float +[ + (integer_literal) + (long_literal) + (hex_literal) + (bin_literal) + (unsigned_literal) +] @number + +[ + "null" ; should be highlighted the same as booleans + (boolean_literal) +] @boolean + +(character_literal) @character + +(string_literal) @string + +(character_escape_seq) @string.escape + +; There are 3 ways to define a regex +; - "[abc]?".toRegex() +(call_expression + (navigation_expression + ((string_literal) @string.regex) + (navigation_suffix + ((simple_identifier) @_function + (#eq? @_function "toRegex"))))) + +; - Regex("[abc]?") +(call_expression + ((simple_identifier) @_function + (#eq? @_function "Regex")) + (call_suffix + (value_arguments + (value_argument + (string_literal) @string.regex)))) + +; - Regex.fromLiteral("[abc]?") +(call_expression + (navigation_expression + ((simple_identifier) @_class + (#eq? @_class "Regex")) + (navigation_suffix + ((simple_identifier) @_function + (#eq? @_function "fromLiteral")))) + (call_suffix + (value_arguments + (value_argument + (string_literal) @string.regex)))) + +;;; Keywords + +(type_alias "typealias" @keyword) +[ + (class_modifier) + (member_modifier) + (function_modifier) + (property_modifier) + (platform_modifier) + (variance_modifier) + (parameter_modifier) + (visibility_modifier) + (reification_modifier) + (inheritance_modifier) +]@keyword + +[ + "val" + "var" + "enum" + "class" + "object" + "interface" +; "typeof" ; NOTE: It is reserved for future use +] @keyword + +("fun") @keyword.function + +(jump_expression) @keyword.return + +[ + "if" + "else" + "when" +] @conditional + +[ + "for" + "do" + "while" +] @repeat + +[ + "try" + "catch" + "throw" + "finally" +] @exception + + +(annotation + "@" @attribute (use_site_target)? @attribute) +(annotation + (user_type + (type_identifier) @attribute)) +(annotation + (constructor_invocation + (user_type + (type_identifier) @attribute))) + +(file_annotation + "@" @attribute "file" @attribute ":" @attribute) +(file_annotation + (user_type + (type_identifier) @attribute)) +(file_annotation + (constructor_invocation + (user_type + (type_identifier) @attribute))) + +;;; Operators & Punctuation + +[ + "!" + "!=" + "!==" + "=" + "==" + "===" + ">" + ">=" + "<" + "<=" + "||" + "&&" + "+" + "++" + "+=" + "-" + "--" + "-=" + "*" + "*=" + "/" + "/=" + "%" + "%=" + "?." + "?:" + "!!" + "is" + "!is" + "in" + "!in" + "as" + "as?" + ".." + "->" +] @operator + +[ + "(" ")" + "[" "]" + "{" "}" +] @punctuation.bracket + +[ + "." + "," + ";" + ":" + "::" +] @punctuation.delimiter + +; NOTE: `interpolated_identifier`s can be highlighted in any way +(string_literal + "$" @punctuation.special + (interpolated_identifier) @none) +(string_literal + "${" @punctuation.special + (interpolated_expression) @none + "}" @punctuation.special) diff --git a/core/queries/sql-highlights.scm b/core/queries/sql-highlights.scm new file mode 100644 index 0000000..5cc586b --- /dev/null +++ b/core/queries/sql-highlights.scm @@ -0,0 +1,51 @@ +; (identifier) @variable FIXME this overrides function call pattern +(string) @string +(number) @number +(comment) @comment + +(function_call + function: (identifier) @function) + +[ + (NULL) + (TRUE) + (FALSE) +] @constant.builtin + +[ + "<" + "<=" + "<>" + "=" + ">" + ">=" + "::" +] @operator + +[ + "(" + ")" + "[" + "]" +] @punctuation.bracket + +[ + (type) + (array_type) +] @type + + +[ + "CREATE TABLE" + "CREATE TYPE" + "CREATE DOMAIN" + "CREATE" + "INDEX" + "UNIQUE" + "SELECT" + "WHERE" + "FROM" + "AS" + "GROUP BY" + "ORDER BY" +] @keyword diff --git a/core/src/ffi.rs b/core/src/ffi.rs index f9c5ead..bf49c5c 100644 --- a/core/src/ffi.rs +++ b/core/src/ffi.rs @@ -4,6 +4,7 @@ use std::path::Path; use crate::document::SwiftlyDoc; use crate::eval; +use crate::highlight; use crate::persist; fn cstr_to_str<'a>(ptr: *const c_char) -> Option<&'a str> { @@ -136,6 +137,21 @@ pub extern "C" fn swiftly_list_notes() -> *mut c_char { str_to_cstr(&json) } +#[no_mangle] +pub extern "C" fn swiftly_highlight(source: *const c_char, lang: *const c_char) -> *mut c_char { + let source = match cstr_to_str(source) { + Some(s) => s, + None => return str_to_cstr("[]"), + }; + let lang = match cstr_to_str(lang) { + Some(s) => s, + None => return str_to_cstr("[]"), + }; + let spans = highlight::highlight_source(source, lang); + let json = serde_json::to_string(&spans).unwrap_or_else(|_| "[]".into()); + str_to_cstr(&json) +} + #[no_mangle] pub extern "C" fn swiftly_free_string(s: *mut c_char) { if s.is_null() { return; } diff --git a/core/src/highlight.rs b/core/src/highlight.rs new file mode 100644 index 0000000..4b109cb --- /dev/null +++ b/core/src/highlight.rs @@ -0,0 +1,252 @@ +use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter}; +use tree_sitter::Language; + +/// Convert an old-API tree-sitter Language (v0.19/v0.20) to current v0.24. +/// Both are newtype wrappers around `*const TSLanguage`; the C ABI is identical. +unsafe fn lang_compat(old: T) -> Language { + std::mem::transmute_copy(&old) +} + +const HIGHLIGHT_NAMES: &[&str] = &[ + "keyword", + "function", + "function.builtin", + "type", + "type.builtin", + "constructor", + "constant", + "constant.builtin", + "string", + "number", + "comment", + "variable", + "variable.builtin", + "variable.parameter", + "operator", + "punctuation", + "punctuation.bracket", + "punctuation.delimiter", + "property", + "tag", + "attribute", + "label", + "escape", + "embedded", +]; + +#[derive(serde::Serialize)] +pub struct HighlightSpan { + pub start: usize, + pub end: usize, + pub kind: u8, +} + +struct LangDef { + language: Language, + highlights: &'static str, + injections: &'static str, + locals: &'static str, +} + +fn lang_def(lang_id: &str) -> Option { + let ld = match lang_id { + "rust" => LangDef { + language: tree_sitter_rust::LANGUAGE.into(), + highlights: tree_sitter_rust::HIGHLIGHTS_QUERY, + injections: tree_sitter_rust::INJECTIONS_QUERY, + locals: "", + }, + "c" => LangDef { + language: tree_sitter_c::LANGUAGE.into(), + highlights: tree_sitter_c::HIGHLIGHT_QUERY, + injections: "", + locals: "", + }, + "cpp" => LangDef { + language: tree_sitter_cpp::LANGUAGE.into(), + highlights: tree_sitter_cpp::HIGHLIGHT_QUERY, + injections: "", + locals: "", + }, + "javascript" | "jsx" => LangDef { + language: tree_sitter_javascript::LANGUAGE.into(), + highlights: tree_sitter_javascript::HIGHLIGHT_QUERY, + injections: tree_sitter_javascript::INJECTIONS_QUERY, + locals: tree_sitter_javascript::LOCALS_QUERY, + }, + "typescript" => LangDef { + language: tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + highlights: tree_sitter_typescript::HIGHLIGHTS_QUERY, + injections: "", + locals: tree_sitter_typescript::LOCALS_QUERY, + }, + "tsx" => LangDef { + language: tree_sitter_typescript::LANGUAGE_TSX.into(), + highlights: tree_sitter_typescript::HIGHLIGHTS_QUERY, + injections: "", + locals: tree_sitter_typescript::LOCALS_QUERY, + }, + "python" => LangDef { + language: tree_sitter_python::LANGUAGE.into(), + highlights: tree_sitter_python::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + "go" => LangDef { + language: tree_sitter_go::LANGUAGE.into(), + highlights: tree_sitter_go::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + "ruby" => LangDef { + language: tree_sitter_ruby::LANGUAGE.into(), + highlights: tree_sitter_ruby::HIGHLIGHTS_QUERY, + injections: "", + locals: tree_sitter_ruby::LOCALS_QUERY, + }, + "bash" | "shell" => LangDef { + language: tree_sitter_bash::LANGUAGE.into(), + highlights: tree_sitter_bash::HIGHLIGHT_QUERY, + injections: "", + locals: "", + }, + "java" => LangDef { + language: tree_sitter_java::LANGUAGE.into(), + highlights: tree_sitter_java::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + "html" => LangDef { + language: tree_sitter_html::LANGUAGE.into(), + highlights: tree_sitter_html::HIGHLIGHTS_QUERY, + injections: tree_sitter_html::INJECTIONS_QUERY, + locals: "", + }, + "css" | "scss" | "less" => LangDef { + language: tree_sitter_css::LANGUAGE.into(), + highlights: tree_sitter_css::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + "json" => LangDef { + language: tree_sitter_json::LANGUAGE.into(), + highlights: tree_sitter_json::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + "lua" => LangDef { + language: tree_sitter_lua::LANGUAGE.into(), + highlights: tree_sitter_lua::HIGHLIGHTS_QUERY, + injections: tree_sitter_lua::INJECTIONS_QUERY, + locals: tree_sitter_lua::LOCALS_QUERY, + }, + "php" => LangDef { + language: tree_sitter_php::LANGUAGE_PHP.into(), + highlights: tree_sitter_php::HIGHLIGHTS_QUERY, + injections: tree_sitter_php::INJECTIONS_QUERY, + locals: "", + }, + "toml" => LangDef { + language: unsafe { lang_compat(tree_sitter_toml::language()) }, + highlights: tree_sitter_toml::HIGHLIGHT_QUERY, + injections: "", + locals: "", + }, + "yaml" => LangDef { + language: unsafe { lang_compat(tree_sitter_yaml::language()) }, + highlights: tree_sitter_yaml::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + "swift" => LangDef { + language: tree_sitter_swift::LANGUAGE.into(), + highlights: tree_sitter_swift::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + "zig" => LangDef { + language: tree_sitter_zig::LANGUAGE.into(), + highlights: tree_sitter_zig::HIGHLIGHTS_QUERY, + injections: tree_sitter_zig::INJECTIONS_QUERY, + locals: "", + }, + "sql" => LangDef { + language: unsafe { lang_compat(tree_sitter_sql::language()) }, + highlights: include_str!("../queries/sql-highlights.scm"), + injections: "", + locals: "", + }, + "make" | "makefile" => LangDef { + language: tree_sitter_make::LANGUAGE.into(), + highlights: tree_sitter_make::HIGHLIGHTS_QUERY, + injections: "", + locals: "", + }, + "dockerfile" => LangDef { + language: unsafe { lang_compat(tree_sitter_dockerfile::language()) }, + highlights: include_str!("../queries/dockerfile-highlights.scm"), + injections: "", + locals: "", + }, + "kotlin" => LangDef { + language: unsafe { lang_compat(tree_sitter_kotlin::language()) }, + highlights: include_str!("../queries/kotlin-highlights.scm"), + injections: "", + locals: "", + }, + _ => return None, + }; + Some(ld) +} + +fn make_config(def: LangDef, name: &str) -> Option { + let mut config = HighlightConfiguration::new( + def.language, + name, + def.highlights, + def.injections, + def.locals, + ).ok()?; + config.configure(HIGHLIGHT_NAMES); + Some(config) +} + +pub fn highlight_source(source: &str, lang_id: &str) -> Vec { + let def = match lang_def(lang_id) { + Some(d) => d, + None => return Vec::new(), + }; + + let config = match make_config(def, lang_id) { + Some(c) => c, + None => return Vec::new(), + }; + + let mut highlighter = Highlighter::new(); + let events = match highlighter.highlight(&config, source.as_bytes(), None, |_| None) { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + + let mut spans = Vec::new(); + let mut stack: Vec = Vec::new(); + + for event in events { + match event { + Ok(HighlightEvent::Source { start, end }) => { + if let Some(&kind) = stack.last() { + spans.push(HighlightSpan { start, end, kind }); + } + } + Ok(HighlightEvent::HighlightStart(h)) => { + stack.push(h.0 as u8); + } + Ok(HighlightEvent::HighlightEnd) => { + stack.pop(); + } + Err(_) => break, + } + } + + spans +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 9d7f74c..dc076b0 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,6 +1,7 @@ pub mod doc; pub mod document; pub mod eval; +pub mod highlight; pub mod interp; pub mod persist; pub mod ffi; diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index 06650f3..ae7cd4c 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -126,6 +126,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { findItem.tag = Int(NSTextFinder.Action.showFindInterface.rawValue) menu.addItem(findItem) + menu.addItem(.separator()) + + let formatItem = NSMenuItem(title: "Format Document", action: #selector(formatDocument), keyEquivalent: "F") + formatItem.keyEquivalentModifierMask = [.command, .shift] + formatItem.target = self + menu.addItem(formatItem) + item.submenu = menu return item } @@ -285,6 +292,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { return Array(Set(types)) }() + @objc private func formatDocument() { + NotificationCenter.default.post(name: .formatDocument, object: nil) + } + @objc private func openSettings() { SettingsWindowController.show() } diff --git a/src/AppState.swift b/src/AppState.swift index c676824..553a8ea 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -66,7 +66,8 @@ enum FileFormat: String, CaseIterable { switch self { case .rust, .c, .cpp, .objc, .javascript, .typescript, .jsx, .tsx, .html, .css, .scss, .less, .python, .go, .ruby, .php, .lua, - .shell, .java, .kotlin, .swift, .zig, .sql, .makefile, .dockerfile: + .shell, .java, .kotlin, .swift, .zig, .sql, .makefile, .dockerfile, + .json, .toml, .yaml, .xml, .svg: return true default: return false @@ -75,6 +76,37 @@ enum FileFormat: String, CaseIterable { var isMarkdown: Bool { self == .markdown } var isCSV: Bool { self == .csv } + + var treeSitterLang: String? { + switch self { + case .rust: return "rust" + case .c: return "c" + case .cpp: return "cpp" + case .javascript: return "javascript" + case .jsx: return "jsx" + case .typescript: return "typescript" + case .tsx: return "tsx" + case .python: return "python" + case .go: return "go" + case .ruby: return "ruby" + case .php: return "php" + case .lua: return "lua" + case .shell: return "bash" + case .java: return "java" + case .kotlin: return "kotlin" + case .swift: return "swift" + case .zig: return "zig" + case .sql: return "sql" + case .html: return "html" + case .css, .scss, .less: return "css" + case .json: return "json" + case .toml: return "toml" + case .yaml: return "yaml" + case .makefile: return "make" + case .dockerfile: return "dockerfile" + default: return nil + } + } } class AppState: ObservableObject { diff --git a/src/ContentView.swift b/src/ContentView.swift index 76e5533..a9d9170 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -30,4 +30,5 @@ extension Notification.Name { static let toggleSidebar = Notification.Name("toggleSidebar") static let focusEditor = Notification.Name("focusEditor") static let focusTitle = Notification.Name("focusTitle") + static let formatDocument = Notification.Name("formatDocument") } diff --git a/src/EditorView.swift b/src/EditorView.swift index d7db798..7f46982 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -1045,6 +1045,12 @@ struct EditorTextView: NSViewRepresentable { tv.window?.makeFirstResponder(tv) tv.setSelectedRange(NSRange(location: 0, length: 0)) } + NotificationCenter.default.addObserver( + forName: .formatDocument, object: nil, queue: .main + ) { [weak self] _ in + self?.formatCurrentDocument() + } + settingsObserver = NotificationCenter.default.addObserver( forName: .settingsChanged, object: nil, queue: .main ) { [weak self] _ in @@ -1097,7 +1103,7 @@ struct EditorTextView: NSViewRepresentable { func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { if commandSelector == #selector(NSResponder.insertNewline(_:)) { - textView.insertNewlineIgnoringFieldEditor(nil) + insertNewlineWithAutoIndent(textView) DispatchQueue.main.async { [weak self] in self?.parent.onEvaluate() } @@ -1112,6 +1118,149 @@ struct EditorTextView: NSViewRepresentable { return false } + func textView(_ textView: NSTextView, shouldChangeTextIn range: NSRange, replacementString text: String?) -> Bool { + guard let text = text, text.count == 1 else { return true } + let ch = text.first! + + let closers: [Character: Character] = ["}": "{", ")": "(", "]": "["] + if let opener = closers[ch] { + let str = textView.string as NSString + if range.location < str.length { + let next = str.character(at: range.location) + if next == ch.asciiValue.map({ UInt16($0) }) ?? 0 { + textView.setSelectedRange(NSRange(location: range.location + 1, length: 0)) + return false + } + } + } + + let pairs: [Character: String] = ["{": "}", "(": ")", "[": "]"] + if let close = pairs[ch] { + textView.insertText(String(ch) + close, replacementRange: range) + textView.setSelectedRange(NSRange(location: range.location + 1, length: 0)) + return false + } + + if ch == "\"" || ch == "'" { + let str = textView.string as NSString + if range.location < str.length { + let next = str.character(at: range.location) + if next == ch.asciiValue.map({ UInt16($0) }) ?? 0 { + textView.setSelectedRange(NSRange(location: range.location + 1, length: 0)) + return false + } + } + textView.insertText(String(ch) + String(ch), replacementRange: range) + textView.setSelectedRange(NSRange(location: range.location + 1, length: 0)) + return false + } + + return true + } + + private func formatCurrentDocument() { + guard let tv = textView else { return } + let format = parent.fileFormat + let text = tv.string + + var formatted: String? + switch format { + case .json: + formatted = formatJSON(text) + default: + if format.isCode { + formatted = normalizeIndentation(text) + } + } + + if let result = formatted, result != text { + let sel = tv.selectedRanges + tv.string = result + parent.text = result + if let ts = tv.textStorage { + ts.beginEditing() + applySyntaxHighlighting(to: ts, format: format) + ts.endEditing() + } + tv.selectedRanges = sel + tv.needsDisplay = true + } + } + + private func formatJSON(_ text: String) -> String? { + guard let data = text.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data), + let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]), + let result = String(data: pretty, encoding: .utf8) else { return nil } + return result + } + + private func normalizeIndentation(_ text: String) -> String { + let lines = text.components(separatedBy: "\n") + var result: [String] = [] + var depth = 0 + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + result.append("") + continue + } + + let closesFirst = trimmed.hasPrefix("}") || trimmed.hasPrefix(")") || trimmed.hasPrefix("]") + if closesFirst && depth > 0 { depth -= 1 } + + let indent = String(repeating: " ", count: depth) + result.append(indent + trimmed) + + let opens = trimmed.filter { "{([".contains($0) }.count + let closes = trimmed.filter { "})]".contains($0) }.count + depth += opens - closes + if closesFirst { depth += 1; depth -= 1 } + if depth < 0 { depth = 0 } + } + + return result.joined(separator: "\n") + } + + private func insertNewlineWithAutoIndent(_ textView: NSTextView) { + let str = textView.string as NSString + let cursor = textView.selectedRange().location + let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0)) + let currentLine = str.substring(with: lineRange) + + var indent = "" + for c in currentLine { + if c == " " || c == "\t" { indent.append(c) } + else { break } + } + + let trimmed = currentLine.trimmingCharacters(in: .whitespacesAndNewlines) + let shouldIndent = trimmed.hasSuffix("{") || trimmed.hasSuffix(":") || + trimmed.hasSuffix("do") || trimmed.hasSuffix("then") || + trimmed.hasSuffix("(") || trimmed.hasSuffix("[") + + if shouldIndent { + indent += " " + } + + let cursorBeforeLineEnd = cursor < NSMaxRange(lineRange) - 1 + let charBeforeCursor: Character? = cursor > 0 ? Character(UnicodeScalar(str.character(at: cursor - 1))!) : nil + let charAtCursor: Character? = cursor < str.length ? Character(UnicodeScalar(str.character(at: cursor))!) : nil + + // Between matching pairs: insert extra newline + if let before = charBeforeCursor, let after = charAtCursor, + (before == "{" && after == "}") || (before == "(" && after == ")") || (before == "[" && after == "]") { + let baseIndent = String(indent.dropLast(4).isEmpty ? "" : indent.dropLast(4)) + let insertion = "\n" + indent + "\n" + baseIndent + textView.insertText(insertion, replacementRange: textView.selectedRange()) + textView.setSelectedRange(NSRange(location: cursor + 1 + indent.count, length: 0)) + return + } + + textView.insertText("\n" + indent, replacementRange: textView.selectedRange()) + } + func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { var urlString: String? if let url = link as? URL { @@ -1412,7 +1561,7 @@ func applySyntaxHighlighting(to textStorage: NSTextStorage, format: FileFormat = textStorage.setAttributes(baseAttrs, range: fullRange) if format.isCode { - applyCodeFileHighlighting(to: textStorage, syn: syn, baseFont: baseFont) + applyCodeFileHighlighting(to: textStorage, syn: syn, baseFont: baseFont, format: format) return } @@ -1464,21 +1613,88 @@ func applySyntaxHighlighting(to textStorage: NSTextStorage, format: FileFormat = highlightAutolinks(textStorage: textStorage, palette: palette, fencedRanges: fencedRanges) } -private func applyCodeFileHighlighting(to textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont) { +private func applyCodeFileHighlighting(to textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont, format: FileFormat = .unknown) { let text = textStorage.string + + if let lang = format.treeSitterLang { + let spans = RustBridge.shared.highlight(source: text, lang: lang) + if !spans.isEmpty { + applyTreeSitterHighlighting(spans: spans, textStorage: textStorage, syn: syn, baseFont: baseFont) + return + } + } + let nsText = text as NSString var lineStart = 0 - while lineStart < nsText.length { let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0)) let line = nsText.substring(with: lineRange) highlightCodeLine(line, lineRange: lineRange, textStorage: textStorage, syn: syn) lineStart = NSMaxRange(lineRange) } - highlightBlockComments(textStorage: textStorage, syn: syn, baseFont: baseFont) } +private func applyTreeSitterHighlighting(spans: [RustBridge.HighlightSpan], textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont) { + let palette = Theme.current + let italicFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .italicFontMask) + let nsText = textStorage.string as NSString + let textLen = nsText.length + + for span in spans { + guard span.start < textLen && span.end <= textLen && span.start < span.end else { continue } + + let byteStart = span.start + let byteEnd = span.end + let str = textStorage.string + let bytes = str.utf8 + let startIdx = bytes.index(bytes.startIndex, offsetBy: byteStart, limitedBy: bytes.endIndex) ?? bytes.endIndex + let endIdx = bytes.index(bytes.startIndex, offsetBy: byteEnd, limitedBy: bytes.endIndex) ?? bytes.endIndex + let charStart = str.distance(from: str.startIndex, to: String.Index(startIdx, within: str) ?? str.endIndex) + let charEnd = str.distance(from: str.startIndex, to: String.Index(endIdx, within: str) ?? str.endIndex) + + guard charStart < textLen && charEnd <= textLen && charStart < charEnd else { continue } + let range = NSRange(location: charStart, length: charEnd - charStart) + + let color: NSColor + var font: NSFont? = nil + + // kind indices match HIGHLIGHT_NAMES in highlight.rs + switch span.kind { + case 0: color = syn.keyword // keyword + case 1: color = syn.function // function + case 2: color = syn.function // function.builtin + case 3: color = syn.type // type + case 4: color = syn.type // type.builtin + case 5: color = syn.type // constructor + case 6: color = palette.peach // constant + case 7: color = palette.peach // constant.builtin + case 8: color = syn.string // string + case 9: color = syn.number // number + case 10: color = syn.comment; font = italicFont // comment + case 11: color = palette.text // variable + case 12: color = palette.red // variable.builtin + case 13: color = palette.maroon // variable.parameter + case 14: color = syn.operator // operator + case 15: color = palette.overlay2 // punctuation + case 16: color = palette.overlay2 // punctuation.bracket + case 17: color = palette.overlay2 // punctuation.delimiter + case 18: color = palette.teal // property + case 19: color = palette.red // tag (HTML) + case 20: color = palette.yellow // attribute + case 21: color = palette.sapphire // label + case 22: color = palette.pink // escape + case 23: color = palette.teal // embedded + default: continue + } + + textStorage.addAttribute(.foregroundColor, value: color, range: range) + if let f = font { + textStorage.addAttribute(.font, value: f, range: range) + } + } +} + private func highlightMarkdownLine(_ trimmed: String, line: String, lineRange: NSRange, textStorage: NSTextStorage, baseFont: NSFont, palette: CatppuccinPalette, isTableHeader: Bool = false) -> Bool { if trimmed.hasPrefix("### ") { let hashRange = (textStorage.string as NSString).range(of: "###", range: lineRange) @@ -2336,12 +2552,13 @@ class LineNumberTextView: NSTextView { } } - // MARK: - Paste (image from clipboard) + // MARK: - Paste override func paste(_ sender: Any?) { let pb = NSPasteboard.general - let imageTypes: [NSPasteboard.PasteboardType] = [.tiff, .png] + // Image paste + let imageTypes: [NSPasteboard.PasteboardType] = [.tiff, .png] if let imageType = imageTypes.first(where: { pb.data(forType: $0) != nil }), let data = pb.data(forType: imageType) { if let image = NSImage(data: data), let pngData = image.tiffRepresentation, @@ -2357,6 +2574,48 @@ class LineNumberTextView: NSTextView { } catch {} } } + + // Smart text paste with indent adjustment + if let text = pb.string(forType: .string) { + let lines = text.components(separatedBy: "\n") + if lines.count > 1 { + let str = string as NSString + let cursor = selectedRange().location + let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0)) + let currentLine = str.substring(with: lineRange) + var currentIndent = "" + for c in currentLine { + if c == " " || c == "\t" { currentIndent.append(c) } + else { break } + } + + var minIndent = Int.max + for line in lines where !line.trimmingCharacters(in: .whitespaces).isEmpty { + let spaces = line.prefix(while: { $0 == " " || $0 == "\t" }).count + minIndent = min(minIndent, spaces) + } + if minIndent == Int.max { minIndent = 0 } + + var adjusted: [String] = [] + for (i, line) in lines.enumerated() { + if line.trimmingCharacters(in: .whitespaces).isEmpty { + adjusted.append("") + } else { + let stripped = String(line.dropFirst(minIndent)) + if i == 0 { + adjusted.append(stripped) + } else { + adjusted.append(currentIndent + stripped) + } + } + } + + let result = adjusted.joined(separator: "\n") + insertText(result, replacementRange: selectedRange()) + return + } + } + super.paste(sender) } diff --git a/src/RustBridge.swift b/src/RustBridge.swift index 0ae2840..17c163a 100644 --- a/src/RustBridge.swift +++ b/src/RustBridge.swift @@ -101,6 +101,36 @@ class RustBridge { return parseNoteListJSON(json) } + struct HighlightSpan { + let start: Int + let end: Int + let kind: Int + } + + func highlight(source: String, lang: String) -> [HighlightSpan] { + guard let cstr = source.withCString({ src in + lang.withCString({ lng in + swiftly_highlight(src, lng) + }) + }) else { return [] } + let json = String(cString: cstr) + swiftly_free_string(cstr) + return parseHighlightJSON(json) + } + + private func parseHighlightJSON(_ json: String) -> [HighlightSpan] { + guard let data = json.data(using: .utf8) else { return [] } + guard let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] } + var spans: [HighlightSpan] = [] + for item in arr { + guard let start = item["start"] as? Int, + let end = item["end"] as? Int, + let kind = item["kind"] as? Int else { continue } + spans.append(HighlightSpan(start: start, end: end, kind: kind)) + } + return spans + } + func deleteNote(_ id: UUID) { freeDocument(id) let cacheDir = FileManager.default.homeDirectoryForCurrentUser From 5342ddbe5f42c7b1ae410eaf569d848de1b0deac Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 13:51:17 -0700 Subject: [PATCH 7/8] fix auto-close pairs wrapping, normalize-indentation logic, cleanup --- src/EditorView.swift | 66 ++++++++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/src/EditorView.swift b/src/EditorView.swift index 7f46982..9c9e37f 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -1121,37 +1121,45 @@ struct EditorTextView: NSViewRepresentable { func textView(_ textView: NSTextView, shouldChangeTextIn range: NSRange, replacementString text: String?) -> Bool { guard let text = text, text.count == 1 else { return true } let ch = text.first! + let hasSelection = range.length > 0 - let closers: [Character: Character] = ["}": "{", ")": "(", "]": "["] - if let opener = closers[ch] { - let str = textView.string as NSString - if range.location < str.length { - let next = str.character(at: range.location) - if next == ch.asciiValue.map({ UInt16($0) }) ?? 0 { - textView.setSelectedRange(NSRange(location: range.location + 1, length: 0)) - return false + // Skip over matching closer when cursor is right before it + if !hasSelection { + let closerChars: Set = ["}", ")", "]", "\"", "'"] + if closerChars.contains(ch) { + let str = textView.string as NSString + if range.location < str.length { + let next = Character(UnicodeScalar(str.character(at: range.location))!) + if next == ch { + textView.setSelectedRange(NSRange(location: range.location + 1, length: 0)) + return false + } } } } - let pairs: [Character: String] = ["{": "}", "(": ")", "[": "]"] - if let close = pairs[ch] { - textView.insertText(String(ch) + close, replacementRange: range) - textView.setSelectedRange(NSRange(location: range.location + 1, length: 0)) + let pairClosers: [Character: Character] = ["{": "}", "(": ")", "[": "]"] + if let close = pairClosers[ch] { + if hasSelection { + let selected = (textView.string as NSString).substring(with: range) + textView.insertText(String(ch) + selected + String(close), replacementRange: range) + textView.setSelectedRange(NSRange(location: range.location + 1, length: selected.count)) + } else { + textView.insertText(String(ch) + String(close), replacementRange: range) + textView.setSelectedRange(NSRange(location: range.location + 1, length: 0)) + } return false } if ch == "\"" || ch == "'" { - let str = textView.string as NSString - if range.location < str.length { - let next = str.character(at: range.location) - if next == ch.asciiValue.map({ UInt16($0) }) ?? 0 { - textView.setSelectedRange(NSRange(location: range.location + 1, length: 0)) - return false - } + if hasSelection { + let selected = (textView.string as NSString).substring(with: range) + textView.insertText(String(ch) + selected + String(ch), replacementRange: range) + textView.setSelectedRange(NSRange(location: range.location + 1, length: selected.count)) + } else { + textView.insertText(String(ch) + String(ch), replacementRange: range) + textView.setSelectedRange(NSRange(location: range.location + 1, length: 0)) } - textView.insertText(String(ch) + String(ch), replacementRange: range) - textView.setSelectedRange(NSRange(location: range.location + 1, length: 0)) return false } @@ -1207,17 +1215,16 @@ struct EditorTextView: NSViewRepresentable { continue } - let closesFirst = trimmed.hasPrefix("}") || trimmed.hasPrefix(")") || trimmed.hasPrefix("]") - if closesFirst && depth > 0 { depth -= 1 } + let opens = trimmed.filter { "{([".contains($0) }.count + let closes = trimmed.filter { "})]".contains($0) }.count + let delta = opens - closes + + if delta < 0 { depth = max(0, depth + delta) } let indent = String(repeating: " ", count: depth) result.append(indent + trimmed) - let opens = trimmed.filter { "{([".contains($0) }.count - let closes = trimmed.filter { "})]".contains($0) }.count - depth += opens - closes - if closesFirst { depth += 1; depth -= 1 } - if depth < 0 { depth = 0 } + if delta > 0 { depth += delta } } return result.joined(separator: "\n") @@ -1244,14 +1251,13 @@ struct EditorTextView: NSViewRepresentable { indent += " " } - let cursorBeforeLineEnd = cursor < NSMaxRange(lineRange) - 1 let charBeforeCursor: Character? = cursor > 0 ? Character(UnicodeScalar(str.character(at: cursor - 1))!) : nil let charAtCursor: Character? = cursor < str.length ? Character(UnicodeScalar(str.character(at: cursor))!) : nil // Between matching pairs: insert extra newline if let before = charBeforeCursor, let after = charAtCursor, (before == "{" && after == "}") || (before == "(" && after == ")") || (before == "[" && after == "]") { - let baseIndent = String(indent.dropLast(4).isEmpty ? "" : indent.dropLast(4)) + let baseIndent = indent.count >= 4 ? String(indent.dropLast(4)) : "" let insertion = "\n" + indent + "\n" + baseIndent textView.insertText(insertion, replacementRange: textView.selectedRange()) textView.setSelectedRange(NSRange(location: cursor + 1 + indent.count, length: 0)) From 0466d7ca569f147989f002b3648159ac3f869dae Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 13:53:45 -0700 Subject: [PATCH 8/8] tree-sitter highlighting in fenced code blocks within markdown --- src/EditorView.swift | 120 ++++++++++++++++++++++++------------------- 1 file changed, 68 insertions(+), 52 deletions(-) diff --git a/src/EditorView.swift b/src/EditorView.swift index 9c9e37f..8b0cb0c 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -1641,56 +1641,63 @@ private func applyCodeFileHighlighting(to textStorage: NSTextStorage, syn: Theme highlightBlockComments(textStorage: textStorage, syn: syn, baseFont: baseFont) } -private func applyTreeSitterHighlighting(spans: [RustBridge.HighlightSpan], textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont) { +private func applyTreeSitterHighlighting(spans: [RustBridge.HighlightSpan], textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont, offset: Int = 0) { let palette = Theme.current let italicFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .italicFontMask) - let nsText = textStorage.string as NSString - let textLen = nsText.length + let textLen = (textStorage.string as NSString).length + + // When offset > 0, spans are byte-relative to a substring starting at `offset` chars. + // Extract that substring and convert bytes to chars within it. + let sourceStr: String + if offset > 0 { + sourceStr = (textStorage.string as NSString).substring(from: offset) + } else { + sourceStr = textStorage.string + } + let sourceBytes = Array(sourceStr.utf8) for span in spans { - guard span.start < textLen && span.end <= textLen && span.start < span.end else { continue } + guard span.start < sourceBytes.count && span.end <= sourceBytes.count && span.start < span.end else { continue } - let byteStart = span.start - let byteEnd = span.end - let str = textStorage.string - let bytes = str.utf8 - let startIdx = bytes.index(bytes.startIndex, offsetBy: byteStart, limitedBy: bytes.endIndex) ?? bytes.endIndex - let endIdx = bytes.index(bytes.startIndex, offsetBy: byteEnd, limitedBy: bytes.endIndex) ?? bytes.endIndex - let charStart = str.distance(from: str.startIndex, to: String.Index(startIdx, within: str) ?? str.endIndex) - let charEnd = str.distance(from: str.startIndex, to: String.Index(endIdx, within: str) ?? str.endIndex) + // Convert byte offsets to character offsets within sourceStr + let prefix = sourceStr.utf8.prefix(span.start) + let charStart = sourceStr.distance(from: sourceStr.startIndex, to: sourceStr.utf8.index(sourceStr.utf8.startIndex, offsetBy: prefix.count)) + let endPrefix = sourceStr.utf8.prefix(span.end) + let charEnd = sourceStr.distance(from: sourceStr.startIndex, to: sourceStr.utf8.index(sourceStr.utf8.startIndex, offsetBy: endPrefix.count)) - guard charStart < textLen && charEnd <= textLen && charStart < charEnd else { continue } - let range = NSRange(location: charStart, length: charEnd - charStart) + let absStart = offset + charStart + let absEnd = offset + charEnd + guard absStart < textLen && absEnd <= textLen && absStart < absEnd else { continue } + let range = NSRange(location: absStart, length: absEnd - absStart) let color: NSColor var font: NSFont? = nil - // kind indices match HIGHLIGHT_NAMES in highlight.rs switch span.kind { - case 0: color = syn.keyword // keyword - case 1: color = syn.function // function - case 2: color = syn.function // function.builtin - case 3: color = syn.type // type - case 4: color = syn.type // type.builtin - case 5: color = syn.type // constructor - case 6: color = palette.peach // constant - case 7: color = palette.peach // constant.builtin - case 8: color = syn.string // string - case 9: color = syn.number // number - case 10: color = syn.comment; font = italicFont // comment - case 11: color = palette.text // variable - case 12: color = palette.red // variable.builtin - case 13: color = palette.maroon // variable.parameter - case 14: color = syn.operator // operator - case 15: color = palette.overlay2 // punctuation - case 16: color = palette.overlay2 // punctuation.bracket - case 17: color = palette.overlay2 // punctuation.delimiter - case 18: color = palette.teal // property - case 19: color = palette.red // tag (HTML) - case 20: color = palette.yellow // attribute - case 21: color = palette.sapphire // label - case 22: color = palette.pink // escape - case 23: color = palette.teal // embedded + case 0: color = syn.keyword + case 1: color = syn.function + case 2: color = syn.function + case 3: color = syn.type + case 4: color = syn.type + case 5: color = syn.type + case 6: color = palette.peach + case 7: color = palette.peach + case 8: color = syn.string + case 9: color = syn.number + case 10: color = syn.comment; font = italicFont + case 11: color = palette.text + case 12: color = palette.red + case 13: color = palette.maroon + case 14: color = syn.operator + case 15: color = palette.overlay2 + case 16: color = palette.overlay2 + case 17: color = palette.overlay2 + case 18: color = palette.teal + case 19: color = palette.red + case 20: color = palette.yellow + case 21: color = palette.sapphire + case 22: color = palette.pink + case 23: color = palette.teal default: continue } @@ -2004,9 +2011,12 @@ private func highlightFencedCodeBlocks(textStorage: NSTextStorage, palette: Catp let text = textStorage.string let nsText = text as NSString let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular) + let syn = Theme.syntax var fencedRanges: [NSRange] = [] var lineStart = 0 var openFence: Int? = nil + var fenceLang: String? = nil + var codeStart: Int = 0 while lineStart < nsText.length { let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0)) @@ -2016,29 +2026,35 @@ private func highlightFencedCodeBlocks(textStorage: NSTextStorage, palette: Catp if openFence == nil { if trimmed.hasPrefix("```") { openFence = lineRange.location - // Mute the fence line + codeStart = NSMaxRange(lineRange) + let langId = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces) + fenceLang = langId.isEmpty ? nil : langId.lowercased() textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange) textStorage.addAttribute(.font, value: monoFont, range: lineRange) - // Language identifier after ``` - if trimmed.count > 3 { - let langStart = (nsText as NSString).range(of: "```", range: lineRange) - if langStart.location != NSNotFound { - let after = langStart.location + langStart.length - let langRange = NSRange(location: after, length: NSMaxRange(lineRange) - after) - if langRange.length > 0 { - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: langRange) - } - } - } } } else { if trimmed == "```" { - // Close fence textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange) textStorage.addAttribute(.font, value: monoFont, range: lineRange) let blockRange = NSRange(location: openFence!, length: NSMaxRange(lineRange) - openFence!) fencedRanges.append(blockRange) + + // Apply tree-sitter highlighting to the code content + let codeEnd = lineRange.location + if let lang = fenceLang, codeEnd > codeStart { + let codeRange = NSRange(location: codeStart, length: codeEnd - codeStart) + let code = nsText.substring(with: codeRange) + textStorage.addAttribute(.font, value: monoFont, range: codeRange) + let spans = RustBridge.shared.highlight(source: code, lang: lang) + if !spans.isEmpty { + applyTreeSitterHighlighting(spans: spans, textStorage: textStorage, syn: syn, baseFont: monoFont, offset: codeStart) + } else { + textStorage.addAttribute(.foregroundColor, value: palette.text, range: codeRange) + } + } + openFence = nil + fenceLang = nil } else { textStorage.addAttribute(.font, value: monoFont, range: lineRange) textStorage.addAttribute(.foregroundColor, value: palette.text, range: lineRange)