tree-sitter syntax highlighting, auto-indent, auto-close pairs, smart paste, format command

This commit is contained in:
jess 2026-04-06 13:49:58 -07:00
parent cca7d78cb3
commit 030b38a7a2
14 changed files with 1141 additions and 8 deletions

View File

@ -366,6 +366,18 @@
<string>mk</string> <string>mk</string>
</array> </array>
</dict> </dict>
<dict>
<key>CFBundleTypeName</key>
<string>Dockerfile</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>dockerfile</string>
</array>
</dict>
<dict> <dict>
<key>CFBundleTypeName</key> <key>CFBundleTypeName</key>
<string>Configuration</string> <string>Configuration</string>

View File

@ -13,6 +13,34 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
uuid = { version = "1", features = ["v4"] } 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] [build-dependencies]
cbindgen = "0.27" cbindgen = "0.27"

View File

@ -32,6 +32,8 @@ typedef struct SwiftlyDoc SwiftlyDoc;
char *swiftly_list_notes(void); char *swiftly_list_notes(void);
char *swiftly_highlight(const char *source, const char *lang);
void swiftly_free_string(char *s); void swiftly_free_string(char *s);
#endif /* SWIFTLY_H */ #endif /* SWIFTLY_H */

View File

@ -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]*$"))

View File

@ -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)

View File

@ -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

View File

@ -4,6 +4,7 @@ use std::path::Path;
use crate::document::SwiftlyDoc; use crate::document::SwiftlyDoc;
use crate::eval; use crate::eval;
use crate::highlight;
use crate::persist; use crate::persist;
fn cstr_to_str<'a>(ptr: *const c_char) -> Option<&'a str> { 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) 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] #[no_mangle]
pub extern "C" fn swiftly_free_string(s: *mut c_char) { pub extern "C" fn swiftly_free_string(s: *mut c_char) {
if s.is_null() { return; } if s.is_null() { return; }

252
core/src/highlight.rs Normal file
View File

@ -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<T>(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<LangDef> {
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<HighlightConfiguration> {
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<HighlightSpan> {
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<u8> = 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
}

View File

@ -1,6 +1,7 @@
pub mod doc; pub mod doc;
pub mod document; pub mod document;
pub mod eval; pub mod eval;
pub mod highlight;
pub mod interp; pub mod interp;
pub mod persist; pub mod persist;
pub mod ffi; pub mod ffi;

View File

@ -126,6 +126,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
findItem.tag = Int(NSTextFinder.Action.showFindInterface.rawValue) findItem.tag = Int(NSTextFinder.Action.showFindInterface.rawValue)
menu.addItem(findItem) 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 item.submenu = menu
return item return item
} }
@ -285,6 +292,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
return Array(Set(types)) return Array(Set(types))
}() }()
@objc private func formatDocument() {
NotificationCenter.default.post(name: .formatDocument, object: nil)
}
@objc private func openSettings() { @objc private func openSettings() {
SettingsWindowController.show() SettingsWindowController.show()
} }

View File

@ -66,7 +66,8 @@ enum FileFormat: String, CaseIterable {
switch self { switch self {
case .rust, .c, .cpp, .objc, .javascript, .typescript, .jsx, .tsx, case .rust, .c, .cpp, .objc, .javascript, .typescript, .jsx, .tsx,
.html, .css, .scss, .less, .python, .go, .ruby, .php, .lua, .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 return true
default: default:
return false return false
@ -75,6 +76,37 @@ enum FileFormat: String, CaseIterable {
var isMarkdown: Bool { self == .markdown } var isMarkdown: Bool { self == .markdown }
var isCSV: Bool { self == .csv } 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 { class AppState: ObservableObject {

View File

@ -30,4 +30,5 @@ extension Notification.Name {
static let toggleSidebar = Notification.Name("toggleSidebar") static let toggleSidebar = Notification.Name("toggleSidebar")
static let focusEditor = Notification.Name("focusEditor") static let focusEditor = Notification.Name("focusEditor")
static let focusTitle = Notification.Name("focusTitle") static let focusTitle = Notification.Name("focusTitle")
static let formatDocument = Notification.Name("formatDocument")
} }

View File

@ -1045,6 +1045,12 @@ struct EditorTextView: NSViewRepresentable {
tv.window?.makeFirstResponder(tv) tv.window?.makeFirstResponder(tv)
tv.setSelectedRange(NSRange(location: 0, length: 0)) 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( settingsObserver = NotificationCenter.default.addObserver(
forName: .settingsChanged, object: nil, queue: .main forName: .settingsChanged, object: nil, queue: .main
) { [weak self] _ in ) { [weak self] _ in
@ -1097,7 +1103,7 @@ struct EditorTextView: NSViewRepresentable {
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(NSResponder.insertNewline(_:)) { if commandSelector == #selector(NSResponder.insertNewline(_:)) {
textView.insertNewlineIgnoringFieldEditor(nil) insertNewlineWithAutoIndent(textView)
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.parent.onEvaluate() self?.parent.onEvaluate()
} }
@ -1112,6 +1118,149 @@ struct EditorTextView: NSViewRepresentable {
return false 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 { func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
var urlString: String? var urlString: String?
if let url = link as? URL { if let url = link as? URL {
@ -1412,7 +1561,7 @@ func applySyntaxHighlighting(to textStorage: NSTextStorage, format: FileFormat =
textStorage.setAttributes(baseAttrs, range: fullRange) textStorage.setAttributes(baseAttrs, range: fullRange)
if format.isCode { if format.isCode {
applyCodeFileHighlighting(to: textStorage, syn: syn, baseFont: baseFont) applyCodeFileHighlighting(to: textStorage, syn: syn, baseFont: baseFont, format: format)
return return
} }
@ -1464,21 +1613,88 @@ func applySyntaxHighlighting(to textStorage: NSTextStorage, format: FileFormat =
highlightAutolinks(textStorage: textStorage, palette: palette, fencedRanges: fencedRanges) 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 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 let nsText = text as NSString
var lineStart = 0 var lineStart = 0
while lineStart < nsText.length { while lineStart < nsText.length {
let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0)) let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0))
let line = nsText.substring(with: lineRange) let line = nsText.substring(with: lineRange)
highlightCodeLine(line, lineRange: lineRange, textStorage: textStorage, syn: syn) highlightCodeLine(line, lineRange: lineRange, textStorage: textStorage, syn: syn)
lineStart = NSMaxRange(lineRange) lineStart = NSMaxRange(lineRange)
} }
highlightBlockComments(textStorage: textStorage, syn: syn, baseFont: baseFont) 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 { private func highlightMarkdownLine(_ trimmed: String, line: String, lineRange: NSRange, textStorage: NSTextStorage, baseFont: NSFont, palette: CatppuccinPalette, isTableHeader: Bool = false) -> Bool {
if trimmed.hasPrefix("### ") { if trimmed.hasPrefix("### ") {
let hashRange = (textStorage.string as NSString).range(of: "###", range: lineRange) 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?) { override func paste(_ sender: Any?) {
let pb = NSPasteboard.general 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 }), if let imageType = imageTypes.first(where: { pb.data(forType: $0) != nil }),
let data = pb.data(forType: imageType) { let data = pb.data(forType: imageType) {
if let image = NSImage(data: data), let pngData = image.tiffRepresentation, if let image = NSImage(data: data), let pngData = image.tiffRepresentation,
@ -2357,6 +2574,48 @@ class LineNumberTextView: NSTextView {
} catch {} } 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) super.paste(sender)
} }

View File

@ -101,6 +101,36 @@ class RustBridge {
return parseNoteListJSON(json) 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) { func deleteNote(_ id: UUID) {
freeDocument(id) freeDocument(id)
let cacheDir = FileManager.default.homeDirectoryForCurrentUser let cacheDir = FileManager.default.homeDirectoryForCurrentUser