From 030b38a7a2946385c7c87f8d2946e1c0117d0e33 Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 13:49:58 -0700 Subject: [PATCH] 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