merge file handling, tree-sitter, and editor intelligence

This commit is contained in:
jess 2026-04-06 14:04:33 -07:00
commit df35ed406e
14 changed files with 1878 additions and 30 deletions

View File

@ -28,5 +28,401 @@
<false/> <false/>
<key>NSSupportsSuddenTermination</key> <key>NSSupportsSuddenTermination</key>
<false/> <false/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Markdown</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>net.daringfireball.markdown</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>md</string>
<string>markdown</string>
<string>mdown</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>CSV</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.comma-separated-values-text</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>csv</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>JSON</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.json</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>json</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>TOML</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>toml</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>YAML</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>yaml</string>
<string>yml</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>XML</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.xml</string>
<string>public.svg-image</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>xml</string>
<string>svg</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Rust Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>rs</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>C/C++ Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.c-source</string>
<string>public.c-plus-plus-source</string>
<string>public.c-header</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>c</string>
<string>cpp</string>
<string>cc</string>
<string>cxx</string>
<string>h</string>
<string>hpp</string>
<string>hxx</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>JavaScript/TypeScript</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.netscape.javascript-source</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>js</string>
<string>jsx</string>
<string>ts</string>
<string>tsx</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>HTML</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.html</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>html</string>
<string>htm</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>CSS</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>css</string>
<string>scss</string>
<string>less</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Python Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.python-script</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>py</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Go Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>go</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Ruby Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.ruby-script</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>rb</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>PHP Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.php-script</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>php</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Lua Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>lua</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Shell Script</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.shell-script</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>sh</string>
<string>bash</string>
<string>zsh</string>
<string>fish</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Java/Kotlin Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>java</string>
<string>kt</string>
<string>kts</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Swift Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.swift-source</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>swift</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Zig Source</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>zig</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>SQL</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>sql</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Makefile</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>mk</string>
</array>
</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>
<key>CFBundleTypeName</key>
<string>Configuration</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>ini</string>
<string>cfg</string>
<string>conf</string>
<string>env</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Lock File</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>lock</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Plain Text</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.plain-text</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>txt</string>
<string>text</string>
<string>log</string>
</array>
</dict>
</array>
</dict> </dict>
</plist> </plist>

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

@ -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 { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true return true
} }
@ -121,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
} }
@ -169,7 +181,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func openNote() { @objc private func openNote() {
let panel = NSOpenPanel() let panel = NSOpenPanel()
panel.allowedContentTypes = [UTType(filenameExtension: "md")!, .plainText] panel.allowedContentTypes = Self.supportedContentTypes
panel.canChooseFiles = true panel.canChooseFiles = true
panel.canChooseDirectories = false panel.canChooseDirectories = false
panel.allowsMultipleSelection = false panel.allowsMultipleSelection = false
@ -180,13 +192,21 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
@objc private func saveNote() { @objc private func saveNote() {
appState.saveNote() if appState.currentFileURL != nil {
appState.saveNote()
} else {
saveNoteAs()
}
} }
@objc private func saveNoteAs() { @objc private func saveNoteAs() {
let panel = NSSavePanel() let panel = NSSavePanel()
panel.allowedContentTypes = [UTType(filenameExtension: "md")!] panel.allowedContentTypes = Self.supportedContentTypes
panel.nameFieldStringValue = defaultFilename() panel.nameFieldStringValue = defaultFilename()
if let url = appState.currentFileURL {
panel.directoryURL = url.deletingLastPathComponent()
panel.nameFieldStringValue = url.lastPathComponent
}
panel.beginSheetModal(for: window) { [weak self] response in panel.beginSheetModal(for: window) { [weak self] response in
guard response == .OK, let url = panel.url else { return } guard response == .OK, let url = panel.url else { return }
self?.appState.saveNoteToFile(url) self?.appState.saveNoteToFile(url)
@ -194,6 +214,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
private func defaultFilename() -> String { private func defaultFilename() -> String {
if let url = appState.currentFileURL {
return url.lastPathComponent
}
let firstLine = appState.documentText let firstLine = appState.documentText
.components(separatedBy: "\n").first? .components(separatedBy: "\n").first?
.trimmingCharacters(in: .whitespaces) ?? "" .trimmingCharacters(in: .whitespaces) ?? ""
@ -201,9 +224,76 @@ class AppDelegate: NSObject, NSApplicationDelegate {
of: "^#+\\s*", with: "", options: .regularExpression of: "^#+\\s*", with: "", options: .regularExpression
) )
let trimmed = stripped.trimmingCharacters(in: .whitespaces) 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() 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 formatDocument() {
NotificationCenter.default.post(name: .formatDocument, object: nil)
} }
@objc private func openSettings() { @objc private func openSettings() {

View File

@ -1,6 +1,114 @@
import Foundation import Foundation
import Combine 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,
.json, .toml, .yaml, .xml, .svg:
return true
default:
return false
}
}
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 { class AppState: ObservableObject {
@Published var documentText: String = "" { @Published var documentText: String = "" {
didSet { didSet {
@ -16,6 +124,8 @@ class AppState: ObservableObject {
@Published var currentNoteID: UUID @Published var currentNoteID: UUID
@Published var selectedNoteIDs: Set<UUID> = [] @Published var selectedNoteIDs: Set<UUID> = []
@Published var modified: Bool = false @Published var modified: Bool = false
@Published var currentFileURL: URL? = nil
@Published var currentFileFormat: FileFormat = .markdown
private let bridge = RustBridge.shared private let bridge = RustBridge.shared
private var autoSaveTimer: DispatchSourceTimer? private var autoSaveTimer: DispatchSourceTimer?
@ -120,6 +230,8 @@ class AppState: ObservableObject {
documentText = "" documentText = ""
evalResults = [:] evalResults = [:]
modified = false modified = false
currentFileURL = nil
currentFileFormat = .markdown
refreshNoteList() refreshNoteList()
} }
@ -161,21 +273,46 @@ class AppState: ObservableObject {
} }
func saveNote() { 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 {
try? textToSave.write(to: url, atomically: true, encoding: .utf8)
}
let _ = bridge.cacheSave(currentNoteID) let _ = bridge.cacheSave(currentNoteID)
modified = false modified = false
refreshNoteList() refreshNoteList()
} }
func saveNoteToFile(_ url: URL) { 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 = format
modified = false modified = false
} }
func loadNoteFromFile(_ url: URL) { func loadNoteFromFile(_ url: URL) {
let format = FileFormat.from(filename: url.lastPathComponent)
if let (id, text) = bridge.loadNote(path: url.path) { if let (id, text) = bridge.loadNote(path: url.path) {
currentNoteID = id currentNoteID = id
documentText = text currentFileURL = url
currentFileFormat = format
if format.isCSV {
documentText = csvToMarkdownTable(text)
} else {
documentText = text
}
modified = false modified = false
let _ = bridge.cacheSave(id) let _ = bridge.cacheSave(id)
evaluate() evaluate()
@ -183,6 +320,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) { func deleteNote(_ id: UUID) {
bridge.deleteNote(id) bridge.deleteNote(id)
if id == currentNoteID { if id == currentNoteID {

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

@ -1138,6 +1138,7 @@ struct EditorView: View {
EditorTextView( EditorTextView(
text: bodyBinding, text: bodyBinding,
evalResults: offsetEvalResults(state.evalResults), evalResults: offsetEvalResults(state.evalResults),
fileFormat: state.currentFileFormat,
onEvaluate: { state.evaluate() }, onEvaluate: { state.evaluate() },
onBackspaceAtStart: { onBackspaceAtStart: {
NotificationCenter.default.post(name: .focusTitle, object: nil) NotificationCenter.default.post(name: .focusTitle, object: nil)
@ -1158,6 +1159,7 @@ struct EditorView: View {
struct EditorTextView: NSViewRepresentable { struct EditorTextView: NSViewRepresentable {
@Binding var text: String @Binding var text: String
var evalResults: [Int: EvalEntry] var evalResults: [Int: EvalEntry]
var fileFormat: FileFormat = .markdown
var onEvaluate: () -> Void var onEvaluate: () -> Void
var onBackspaceAtStart: (() -> Void)? = nil var onBackspaceAtStart: (() -> Void)? = nil
@ -1213,7 +1215,7 @@ struct EditorTextView: NSViewRepresentable {
if let ts = textView.textStorage { if let ts = textView.textStorage {
ts.beginEditing() ts.beginEditing()
applySyntaxHighlighting(to: ts) applySyntaxHighlighting(to: ts, format: fileFormat)
ts.endEditing() ts.endEditing()
} }
textView.typingAttributes = [ textView.typingAttributes = [
@ -1237,7 +1239,7 @@ struct EditorTextView: NSViewRepresentable {
textView.string = text textView.string = text
if let ts = textView.textStorage { if let ts = textView.textStorage {
ts.beginEditing() ts.beginEditing()
applySyntaxHighlighting(to: ts) applySyntaxHighlighting(to: ts, format: fileFormat)
ts.endEditing() ts.endEditing()
} }
textView.selectedRanges = selectedRanges textView.selectedRanges = selectedRanges
@ -1273,6 +1275,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
@ -1286,7 +1294,7 @@ struct EditorTextView: NSViewRepresentable {
.foregroundColor: palette.text .foregroundColor: palette.text
] ]
ts.beginEditing() ts.beginEditing()
applySyntaxHighlighting(to: ts) applySyntaxHighlighting(to: ts, format: parent.fileFormat)
ts.endEditing() ts.endEditing()
tv.needsDisplay = true tv.needsDisplay = true
} }
@ -1307,7 +1315,7 @@ struct EditorTextView: NSViewRepresentable {
parent.text = tv.string parent.text = tv.string
let sel = tv.selectedRanges let sel = tv.selectedRanges
ts.beginEditing() ts.beginEditing()
applySyntaxHighlighting(to: ts) applySyntaxHighlighting(to: ts, format: parent.fileFormat)
ts.endEditing() ts.endEditing()
tv.typingAttributes = [ tv.typingAttributes = [
.font: Theme.editorFont, .font: Theme.editorFont,
@ -1325,7 +1333,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()
} }
@ -1340,6 +1348,155 @@ 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 hasSelection = range.length > 0
// Skip over matching closer when cursor is right before it
if !hasSelection {
let closerChars: Set<Character> = ["}", ")", "]", "\"", "'"]
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 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 == "'" {
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))
}
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 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)
if delta > 0 { depth += delta }
}
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 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 = 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))
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 {
@ -1405,7 +1562,7 @@ struct EditorTextView: NSViewRepresentable {
let sel = tv.selectedRanges let sel = tv.selectedRanges
ts.beginEditing() ts.beginEditing()
ts.replaceCharacters(in: range, with: newMarkdown) ts.replaceCharacters(in: range, with: newMarkdown)
applySyntaxHighlighting(to: ts) applySyntaxHighlighting(to: ts, format: parent.fileFormat)
ts.endEditing() ts.endEditing()
tv.selectedRanges = sel tv.selectedRanges = sel
parent.text = tv.string parent.text = tv.string
@ -1626,7 +1783,7 @@ private let syntaxBooleans: Set<String> = ["true", "false"]
private let syntaxOperatorChars = CharacterSet(charactersIn: "+-*/=^<>!(){}[]:,.&|%") private let syntaxOperatorChars = CharacterSet(charactersIn: "+-*/=^<>!(){}[]:,.&|%")
func applySyntaxHighlighting(to textStorage: NSTextStorage) { func applySyntaxHighlighting(to textStorage: NSTextStorage, format: FileFormat = .markdown) {
let text = textStorage.string let text = textStorage.string
let fullRange = NSRange(location: 0, length: (text as NSString).length) let fullRange = NSRange(location: 0, length: (text as NSString).length)
let palette = Theme.current let palette = Theme.current
@ -1639,6 +1796,11 @@ func applySyntaxHighlighting(to textStorage: NSTextStorage) {
] ]
textStorage.setAttributes(baseAttrs, range: fullRange) textStorage.setAttributes(baseAttrs, range: fullRange)
if format.isCode {
applyCodeFileHighlighting(to: textStorage, syn: syn, baseFont: baseFont, format: format)
return
}
let fencedRanges = highlightFencedCodeBlocks(textStorage: textStorage, palette: palette, baseFont: baseFont) let fencedRanges = highlightFencedCodeBlocks(textStorage: textStorage, palette: palette, baseFont: baseFont)
let tableHeaderLines = findTableHeaderLines(textStorage: textStorage, fencedRanges: fencedRanges) let tableHeaderLines = findTableHeaderLines(textStorage: textStorage, fencedRanges: fencedRanges)
@ -1687,6 +1849,95 @@ func applySyntaxHighlighting(to textStorage: NSTextStorage) {
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, 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, offset: Int = 0) {
let palette = Theme.current
let italicFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .italicFontMask)
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 < sourceBytes.count && span.end <= sourceBytes.count && span.start < span.end else { continue }
// 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))
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
switch span.kind {
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
}
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)
@ -1995,9 +2246,12 @@ private func highlightFencedCodeBlocks(textStorage: NSTextStorage, palette: Catp
let text = textStorage.string let text = textStorage.string
let nsText = text as NSString let nsText = text as NSString
let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular) let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular)
let syn = Theme.syntax
var fencedRanges: [NSRange] = [] var fencedRanges: [NSRange] = []
var lineStart = 0 var lineStart = 0
var openFence: Int? = nil var openFence: Int? = nil
var fenceLang: String? = nil
var codeStart: Int = 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))
@ -2007,29 +2261,35 @@ private func highlightFencedCodeBlocks(textStorage: NSTextStorage, palette: Catp
if openFence == nil { if openFence == nil {
if trimmed.hasPrefix("```") { if trimmed.hasPrefix("```") {
openFence = lineRange.location 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(.foregroundColor, value: palette.overlay0, range: lineRange)
textStorage.addAttribute(.font, value: monoFont, 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 { } else {
if trimmed == "```" { if trimmed == "```" {
// Close fence
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange) textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange)
textStorage.addAttribute(.font, value: monoFont, range: lineRange) textStorage.addAttribute(.font, value: monoFont, range: lineRange)
let blockRange = NSRange(location: openFence!, length: NSMaxRange(lineRange) - openFence!) let blockRange = NSRange(location: openFence!, length: NSMaxRange(lineRange) - openFence!)
fencedRanges.append(blockRange) 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 openFence = nil
fenceLang = nil
} else { } else {
textStorage.addAttribute(.font, value: monoFont, range: lineRange) textStorage.addAttribute(.font, value: monoFont, range: lineRange)
textStorage.addAttribute(.foregroundColor, value: palette.text, range: lineRange) textStorage.addAttribute(.foregroundColor, value: palette.text, range: lineRange)
@ -2715,12 +2975,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,
@ -2736,6 +2997,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

@ -112,6 +112,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