diff --git a/Info.plist b/Info.plist
index eb43895..a23af44 100644
--- a/Info.plist
+++ b/Info.plist
@@ -28,5 +28,401 @@
NSSupportsSuddenTermination
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeName
+ Markdown
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ net.daringfireball.markdown
+
+ CFBundleTypeExtensions
+
+ md
+ markdown
+ mdown
+
+
+
+ CFBundleTypeName
+ CSV
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.comma-separated-values-text
+
+ CFBundleTypeExtensions
+
+ csv
+
+
+
+ CFBundleTypeName
+ JSON
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.json
+
+ CFBundleTypeExtensions
+
+ json
+
+
+
+ CFBundleTypeName
+ TOML
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ CFBundleTypeExtensions
+
+ toml
+
+
+
+ CFBundleTypeName
+ YAML
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ CFBundleTypeExtensions
+
+ yaml
+ yml
+
+
+
+ CFBundleTypeName
+ XML
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.xml
+ public.svg-image
+
+ CFBundleTypeExtensions
+
+ xml
+ svg
+
+
+
+ CFBundleTypeName
+ Rust Source
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ CFBundleTypeExtensions
+
+ rs
+
+
+
+ CFBundleTypeName
+ C/C++ Source
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.c-source
+ public.c-plus-plus-source
+ public.c-header
+
+ CFBundleTypeExtensions
+
+ c
+ cpp
+ cc
+ cxx
+ h
+ hpp
+ hxx
+
+
+
+ CFBundleTypeName
+ JavaScript/TypeScript
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ com.netscape.javascript-source
+
+ CFBundleTypeExtensions
+
+ js
+ jsx
+ ts
+ tsx
+
+
+
+ CFBundleTypeName
+ HTML
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.html
+
+ CFBundleTypeExtensions
+
+ html
+ htm
+
+
+
+ CFBundleTypeName
+ CSS
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ CFBundleTypeExtensions
+
+ css
+ scss
+ less
+
+
+
+ CFBundleTypeName
+ Python Source
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.python-script
+
+ CFBundleTypeExtensions
+
+ py
+
+
+
+ CFBundleTypeName
+ Go Source
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ CFBundleTypeExtensions
+
+ go
+
+
+
+ CFBundleTypeName
+ Ruby Source
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.ruby-script
+
+ CFBundleTypeExtensions
+
+ rb
+
+
+
+ CFBundleTypeName
+ PHP Source
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.php-script
+
+ CFBundleTypeExtensions
+
+ php
+
+
+
+ CFBundleTypeName
+ Lua Source
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ CFBundleTypeExtensions
+
+ lua
+
+
+
+ CFBundleTypeName
+ Shell Script
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.shell-script
+
+ CFBundleTypeExtensions
+
+ sh
+ bash
+ zsh
+ fish
+
+
+
+ CFBundleTypeName
+ Java/Kotlin Source
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ CFBundleTypeExtensions
+
+ java
+ kt
+ kts
+
+
+
+ CFBundleTypeName
+ Swift Source
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.swift-source
+
+ CFBundleTypeExtensions
+
+ swift
+
+
+
+ CFBundleTypeName
+ Zig Source
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ CFBundleTypeExtensions
+
+ zig
+
+
+
+ CFBundleTypeName
+ SQL
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ CFBundleTypeExtensions
+
+ sql
+
+
+
+ CFBundleTypeName
+ Makefile
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ CFBundleTypeExtensions
+
+ mk
+
+
+
+ CFBundleTypeName
+ Dockerfile
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ CFBundleTypeExtensions
+
+ dockerfile
+
+
+
+ CFBundleTypeName
+ Configuration
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ CFBundleTypeExtensions
+
+ ini
+ cfg
+ conf
+ env
+
+
+
+ CFBundleTypeName
+ Lock File
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ CFBundleTypeExtensions
+
+ lock
+
+
+
+ CFBundleTypeName
+ Plain Text
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.plain-text
+
+ CFBundleTypeExtensions
+
+ txt
+ text
+ log
+
+
+
diff --git a/core/Cargo.toml b/core/Cargo.toml
index a7849c2..e7197f2 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -13,6 +13,34 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
+tree-sitter = "0.24"
+tree-sitter-highlight = "0.24"
+tree-sitter-language = "0.1"
+tree-sitter-rust = "0.23"
+tree-sitter-c = "0.23"
+tree-sitter-cpp = "0.23"
+tree-sitter-javascript = "0.23"
+tree-sitter-typescript = "0.23"
+tree-sitter-python = "0.23"
+tree-sitter-go = "0.23"
+tree-sitter-ruby = "0.23"
+tree-sitter-bash = "0.23"
+tree-sitter-java = "0.23"
+tree-sitter-html = "0.23"
+tree-sitter-css = "0.23"
+tree-sitter-json = "0.24"
+tree-sitter-lua = "0.4"
+tree-sitter-php = "0.23"
+tree-sitter-toml = "0.20"
+tree-sitter-yaml = "0.6"
+tree-sitter-swift = "0.6"
+tree-sitter-zig = "1"
+tree-sitter-sql = "0.0.2"
+tree-sitter-md = "0.5"
+tree-sitter-make = "1"
+tree-sitter-dockerfile = "0.2"
+tree-sitter-kotlin = "0.3"
+
[build-dependencies]
cbindgen = "0.27"
diff --git a/core/include/swiftly.h b/core/include/swiftly.h
index 8cacafe..391552f 100644
--- a/core/include/swiftly.h
+++ b/core/include/swiftly.h
@@ -32,6 +32,8 @@ typedef struct SwiftlyDoc SwiftlyDoc;
char *swiftly_list_notes(void);
+ char *swiftly_highlight(const char *source, const char *lang);
+
void swiftly_free_string(char *s);
#endif /* SWIFTLY_H */
diff --git a/core/queries/dockerfile-highlights.scm b/core/queries/dockerfile-highlights.scm
new file mode 100644
index 0000000..a5d6514
--- /dev/null
+++ b/core/queries/dockerfile-highlights.scm
@@ -0,0 +1,58 @@
+[
+ "FROM"
+ "AS"
+ "RUN"
+ "CMD"
+ "LABEL"
+ "EXPOSE"
+ "ENV"
+ "ADD"
+ "COPY"
+ "ENTRYPOINT"
+ "VOLUME"
+ "USER"
+ "WORKDIR"
+ "ARG"
+ "ONBUILD"
+ "STOPSIGNAL"
+ "HEALTHCHECK"
+ "SHELL"
+ "MAINTAINER"
+ "CROSS_BUILD"
+ (heredoc_marker)
+ (heredoc_end)
+] @keyword
+
+[
+ ":"
+ "@"
+] @operator
+
+(comment) @comment
+
+
+(image_spec
+ (image_tag
+ ":" @punctuation.special)
+ (image_digest
+ "@" @punctuation.special))
+
+[
+ (double_quoted_string)
+ (single_quoted_string)
+ (json_string)
+ (heredoc_line)
+] @string
+
+(expansion
+ [
+ "$"
+ "{"
+ "}"
+ ] @punctuation.special
+) @none
+
+((variable) @constant
+ (#match? @constant "^[A-Z][A-Z_0-9]*$"))
+
+
diff --git a/core/queries/kotlin-highlights.scm b/core/queries/kotlin-highlights.scm
new file mode 100644
index 0000000..d2e15a6
--- /dev/null
+++ b/core/queries/kotlin-highlights.scm
@@ -0,0 +1,380 @@
+;; Based on the nvim-treesitter highlighting, which is under the Apache license.
+;; See https://github.com/nvim-treesitter/nvim-treesitter/blob/f8ab59861eed4a1c168505e3433462ed800f2bae/queries/kotlin/highlights.scm
+;;
+;; The only difference in this file is that queries using #lua-match?
+;; have been removed.
+
+;;; Identifiers
+
+(simple_identifier) @variable
+
+; `it` keyword inside lambdas
+; FIXME: This will highlight the keyword outside of lambdas since tree-sitter
+; does not allow us to check for arbitrary nestation
+((simple_identifier) @variable.builtin
+(#eq? @variable.builtin "it"))
+
+; `field` keyword inside property getter/setter
+; FIXME: This will highlight the keyword outside of getters and setters
+; since tree-sitter does not allow us to check for arbitrary nestation
+((simple_identifier) @variable.builtin
+(#eq? @variable.builtin "field"))
+
+; `this` this keyword inside classes
+(this_expression) @variable.builtin
+
+; `super` keyword inside classes
+(super_expression) @variable.builtin
+
+(class_parameter
+ (simple_identifier) @property)
+
+(class_body
+ (property_declaration
+ (variable_declaration
+ (simple_identifier) @property)))
+
+; id_1.id_2.id_3: `id_2` and `id_3` are assumed as object properties
+(_
+ (navigation_suffix
+ (simple_identifier) @property))
+
+(enum_entry
+ (simple_identifier) @constant)
+
+(type_identifier) @type
+
+((type_identifier) @type.builtin
+ (#any-of? @type.builtin
+ "Byte"
+ "Short"
+ "Int"
+ "Long"
+ "UByte"
+ "UShort"
+ "UInt"
+ "ULong"
+ "Float"
+ "Double"
+ "Boolean"
+ "Char"
+ "String"
+ "Array"
+ "ByteArray"
+ "ShortArray"
+ "IntArray"
+ "LongArray"
+ "UByteArray"
+ "UShortArray"
+ "UIntArray"
+ "ULongArray"
+ "FloatArray"
+ "DoubleArray"
+ "BooleanArray"
+ "CharArray"
+ "Map"
+ "Set"
+ "List"
+ "EmptyMap"
+ "EmptySet"
+ "EmptyList"
+ "MutableMap"
+ "MutableSet"
+ "MutableList"
+))
+
+(package_header
+ . (identifier)) @namespace
+
+(import_header
+ "import" @include)
+
+
+; TODO: Seperate labeled returns/breaks/continue/super/this
+; Must be implemented in the parser first
+(label) @label
+
+;;; Function definitions
+
+(function_declaration
+ . (simple_identifier) @function)
+
+(getter
+ ("get") @function.builtin)
+(setter
+ ("set") @function.builtin)
+
+(primary_constructor) @constructor
+(secondary_constructor
+ ("constructor") @constructor)
+
+(constructor_invocation
+ (user_type
+ (type_identifier) @constructor))
+
+(anonymous_initializer
+ ("init") @constructor)
+
+(parameter
+ (simple_identifier) @parameter)
+
+(parameter_with_optional_type
+ (simple_identifier) @parameter)
+
+; lambda parameters
+(lambda_literal
+ (lambda_parameters
+ (variable_declaration
+ (simple_identifier) @parameter)))
+
+;;; Function calls
+
+; function()
+(call_expression
+ . (simple_identifier) @function)
+
+; object.function() or object.property.function()
+(call_expression
+ (navigation_expression
+ (navigation_suffix
+ (simple_identifier) @function) . ))
+
+(call_expression
+ . (simple_identifier) @function.builtin
+ (#any-of? @function.builtin
+ "arrayOf"
+ "arrayOfNulls"
+ "byteArrayOf"
+ "shortArrayOf"
+ "intArrayOf"
+ "longArrayOf"
+ "ubyteArrayOf"
+ "ushortArrayOf"
+ "uintArrayOf"
+ "ulongArrayOf"
+ "floatArrayOf"
+ "doubleArrayOf"
+ "booleanArrayOf"
+ "charArrayOf"
+ "emptyArray"
+ "mapOf"
+ "setOf"
+ "listOf"
+ "emptyMap"
+ "emptySet"
+ "emptyList"
+ "mutableMapOf"
+ "mutableSetOf"
+ "mutableListOf"
+ "print"
+ "println"
+ "error"
+ "TODO"
+ "run"
+ "runCatching"
+ "repeat"
+ "lazy"
+ "lazyOf"
+ "enumValues"
+ "enumValueOf"
+ "assert"
+ "check"
+ "checkNotNull"
+ "require"
+ "requireNotNull"
+ "with"
+ "suspend"
+ "synchronized"
+))
+
+;;; Literals
+
+[
+ (line_comment)
+ (multiline_comment)
+ (shebang_line)
+] @comment
+
+(real_literal) @float
+[
+ (integer_literal)
+ (long_literal)
+ (hex_literal)
+ (bin_literal)
+ (unsigned_literal)
+] @number
+
+[
+ "null" ; should be highlighted the same as booleans
+ (boolean_literal)
+] @boolean
+
+(character_literal) @character
+
+(string_literal) @string
+
+(character_escape_seq) @string.escape
+
+; There are 3 ways to define a regex
+; - "[abc]?".toRegex()
+(call_expression
+ (navigation_expression
+ ((string_literal) @string.regex)
+ (navigation_suffix
+ ((simple_identifier) @_function
+ (#eq? @_function "toRegex")))))
+
+; - Regex("[abc]?")
+(call_expression
+ ((simple_identifier) @_function
+ (#eq? @_function "Regex"))
+ (call_suffix
+ (value_arguments
+ (value_argument
+ (string_literal) @string.regex))))
+
+; - Regex.fromLiteral("[abc]?")
+(call_expression
+ (navigation_expression
+ ((simple_identifier) @_class
+ (#eq? @_class "Regex"))
+ (navigation_suffix
+ ((simple_identifier) @_function
+ (#eq? @_function "fromLiteral"))))
+ (call_suffix
+ (value_arguments
+ (value_argument
+ (string_literal) @string.regex))))
+
+;;; Keywords
+
+(type_alias "typealias" @keyword)
+[
+ (class_modifier)
+ (member_modifier)
+ (function_modifier)
+ (property_modifier)
+ (platform_modifier)
+ (variance_modifier)
+ (parameter_modifier)
+ (visibility_modifier)
+ (reification_modifier)
+ (inheritance_modifier)
+]@keyword
+
+[
+ "val"
+ "var"
+ "enum"
+ "class"
+ "object"
+ "interface"
+; "typeof" ; NOTE: It is reserved for future use
+] @keyword
+
+("fun") @keyword.function
+
+(jump_expression) @keyword.return
+
+[
+ "if"
+ "else"
+ "when"
+] @conditional
+
+[
+ "for"
+ "do"
+ "while"
+] @repeat
+
+[
+ "try"
+ "catch"
+ "throw"
+ "finally"
+] @exception
+
+
+(annotation
+ "@" @attribute (use_site_target)? @attribute)
+(annotation
+ (user_type
+ (type_identifier) @attribute))
+(annotation
+ (constructor_invocation
+ (user_type
+ (type_identifier) @attribute)))
+
+(file_annotation
+ "@" @attribute "file" @attribute ":" @attribute)
+(file_annotation
+ (user_type
+ (type_identifier) @attribute))
+(file_annotation
+ (constructor_invocation
+ (user_type
+ (type_identifier) @attribute)))
+
+;;; Operators & Punctuation
+
+[
+ "!"
+ "!="
+ "!=="
+ "="
+ "=="
+ "==="
+ ">"
+ ">="
+ "<"
+ "<="
+ "||"
+ "&&"
+ "+"
+ "++"
+ "+="
+ "-"
+ "--"
+ "-="
+ "*"
+ "*="
+ "/"
+ "/="
+ "%"
+ "%="
+ "?."
+ "?:"
+ "!!"
+ "is"
+ "!is"
+ "in"
+ "!in"
+ "as"
+ "as?"
+ ".."
+ "->"
+] @operator
+
+[
+ "(" ")"
+ "[" "]"
+ "{" "}"
+] @punctuation.bracket
+
+[
+ "."
+ ","
+ ";"
+ ":"
+ "::"
+] @punctuation.delimiter
+
+; NOTE: `interpolated_identifier`s can be highlighted in any way
+(string_literal
+ "$" @punctuation.special
+ (interpolated_identifier) @none)
+(string_literal
+ "${" @punctuation.special
+ (interpolated_expression) @none
+ "}" @punctuation.special)
diff --git a/core/queries/sql-highlights.scm b/core/queries/sql-highlights.scm
new file mode 100644
index 0000000..5cc586b
--- /dev/null
+++ b/core/queries/sql-highlights.scm
@@ -0,0 +1,51 @@
+; (identifier) @variable FIXME this overrides function call pattern
+(string) @string
+(number) @number
+(comment) @comment
+
+(function_call
+ function: (identifier) @function)
+
+[
+ (NULL)
+ (TRUE)
+ (FALSE)
+] @constant.builtin
+
+[
+ "<"
+ "<="
+ "<>"
+ "="
+ ">"
+ ">="
+ "::"
+] @operator
+
+[
+ "("
+ ")"
+ "["
+ "]"
+] @punctuation.bracket
+
+[
+ (type)
+ (array_type)
+] @type
+
+
+[
+ "CREATE TABLE"
+ "CREATE TYPE"
+ "CREATE DOMAIN"
+ "CREATE"
+ "INDEX"
+ "UNIQUE"
+ "SELECT"
+ "WHERE"
+ "FROM"
+ "AS"
+ "GROUP BY"
+ "ORDER BY"
+] @keyword
diff --git a/core/src/ffi.rs b/core/src/ffi.rs
index f9c5ead..bf49c5c 100644
--- a/core/src/ffi.rs
+++ b/core/src/ffi.rs
@@ -4,6 +4,7 @@ use std::path::Path;
use crate::document::SwiftlyDoc;
use crate::eval;
+use crate::highlight;
use crate::persist;
fn cstr_to_str<'a>(ptr: *const c_char) -> Option<&'a str> {
@@ -136,6 +137,21 @@ pub extern "C" fn swiftly_list_notes() -> *mut c_char {
str_to_cstr(&json)
}
+#[no_mangle]
+pub extern "C" fn swiftly_highlight(source: *const c_char, lang: *const c_char) -> *mut c_char {
+ let source = match cstr_to_str(source) {
+ Some(s) => s,
+ None => return str_to_cstr("[]"),
+ };
+ let lang = match cstr_to_str(lang) {
+ Some(s) => s,
+ None => return str_to_cstr("[]"),
+ };
+ let spans = highlight::highlight_source(source, lang);
+ let json = serde_json::to_string(&spans).unwrap_or_else(|_| "[]".into());
+ str_to_cstr(&json)
+}
+
#[no_mangle]
pub extern "C" fn swiftly_free_string(s: *mut c_char) {
if s.is_null() { return; }
diff --git a/core/src/highlight.rs b/core/src/highlight.rs
new file mode 100644
index 0000000..4b109cb
--- /dev/null
+++ b/core/src/highlight.rs
@@ -0,0 +1,252 @@
+use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};
+use tree_sitter::Language;
+
+/// Convert an old-API tree-sitter Language (v0.19/v0.20) to current v0.24.
+/// Both are newtype wrappers around `*const TSLanguage`; the C ABI is identical.
+unsafe fn lang_compat(old: T) -> Language {
+ std::mem::transmute_copy(&old)
+}
+
+const HIGHLIGHT_NAMES: &[&str] = &[
+ "keyword",
+ "function",
+ "function.builtin",
+ "type",
+ "type.builtin",
+ "constructor",
+ "constant",
+ "constant.builtin",
+ "string",
+ "number",
+ "comment",
+ "variable",
+ "variable.builtin",
+ "variable.parameter",
+ "operator",
+ "punctuation",
+ "punctuation.bracket",
+ "punctuation.delimiter",
+ "property",
+ "tag",
+ "attribute",
+ "label",
+ "escape",
+ "embedded",
+];
+
+#[derive(serde::Serialize)]
+pub struct HighlightSpan {
+ pub start: usize,
+ pub end: usize,
+ pub kind: u8,
+}
+
+struct LangDef {
+ language: Language,
+ highlights: &'static str,
+ injections: &'static str,
+ locals: &'static str,
+}
+
+fn lang_def(lang_id: &str) -> Option {
+ let ld = match lang_id {
+ "rust" => LangDef {
+ language: tree_sitter_rust::LANGUAGE.into(),
+ highlights: tree_sitter_rust::HIGHLIGHTS_QUERY,
+ injections: tree_sitter_rust::INJECTIONS_QUERY,
+ locals: "",
+ },
+ "c" => LangDef {
+ language: tree_sitter_c::LANGUAGE.into(),
+ highlights: tree_sitter_c::HIGHLIGHT_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "cpp" => LangDef {
+ language: tree_sitter_cpp::LANGUAGE.into(),
+ highlights: tree_sitter_cpp::HIGHLIGHT_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "javascript" | "jsx" => LangDef {
+ language: tree_sitter_javascript::LANGUAGE.into(),
+ highlights: tree_sitter_javascript::HIGHLIGHT_QUERY,
+ injections: tree_sitter_javascript::INJECTIONS_QUERY,
+ locals: tree_sitter_javascript::LOCALS_QUERY,
+ },
+ "typescript" => LangDef {
+ language: tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
+ highlights: tree_sitter_typescript::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: tree_sitter_typescript::LOCALS_QUERY,
+ },
+ "tsx" => LangDef {
+ language: tree_sitter_typescript::LANGUAGE_TSX.into(),
+ highlights: tree_sitter_typescript::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: tree_sitter_typescript::LOCALS_QUERY,
+ },
+ "python" => LangDef {
+ language: tree_sitter_python::LANGUAGE.into(),
+ highlights: tree_sitter_python::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "go" => LangDef {
+ language: tree_sitter_go::LANGUAGE.into(),
+ highlights: tree_sitter_go::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "ruby" => LangDef {
+ language: tree_sitter_ruby::LANGUAGE.into(),
+ highlights: tree_sitter_ruby::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: tree_sitter_ruby::LOCALS_QUERY,
+ },
+ "bash" | "shell" => LangDef {
+ language: tree_sitter_bash::LANGUAGE.into(),
+ highlights: tree_sitter_bash::HIGHLIGHT_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "java" => LangDef {
+ language: tree_sitter_java::LANGUAGE.into(),
+ highlights: tree_sitter_java::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "html" => LangDef {
+ language: tree_sitter_html::LANGUAGE.into(),
+ highlights: tree_sitter_html::HIGHLIGHTS_QUERY,
+ injections: tree_sitter_html::INJECTIONS_QUERY,
+ locals: "",
+ },
+ "css" | "scss" | "less" => LangDef {
+ language: tree_sitter_css::LANGUAGE.into(),
+ highlights: tree_sitter_css::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "json" => LangDef {
+ language: tree_sitter_json::LANGUAGE.into(),
+ highlights: tree_sitter_json::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "lua" => LangDef {
+ language: tree_sitter_lua::LANGUAGE.into(),
+ highlights: tree_sitter_lua::HIGHLIGHTS_QUERY,
+ injections: tree_sitter_lua::INJECTIONS_QUERY,
+ locals: tree_sitter_lua::LOCALS_QUERY,
+ },
+ "php" => LangDef {
+ language: tree_sitter_php::LANGUAGE_PHP.into(),
+ highlights: tree_sitter_php::HIGHLIGHTS_QUERY,
+ injections: tree_sitter_php::INJECTIONS_QUERY,
+ locals: "",
+ },
+ "toml" => LangDef {
+ language: unsafe { lang_compat(tree_sitter_toml::language()) },
+ highlights: tree_sitter_toml::HIGHLIGHT_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "yaml" => LangDef {
+ language: unsafe { lang_compat(tree_sitter_yaml::language()) },
+ highlights: tree_sitter_yaml::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "swift" => LangDef {
+ language: tree_sitter_swift::LANGUAGE.into(),
+ highlights: tree_sitter_swift::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "zig" => LangDef {
+ language: tree_sitter_zig::LANGUAGE.into(),
+ highlights: tree_sitter_zig::HIGHLIGHTS_QUERY,
+ injections: tree_sitter_zig::INJECTIONS_QUERY,
+ locals: "",
+ },
+ "sql" => LangDef {
+ language: unsafe { lang_compat(tree_sitter_sql::language()) },
+ highlights: include_str!("../queries/sql-highlights.scm"),
+ injections: "",
+ locals: "",
+ },
+ "make" | "makefile" => LangDef {
+ language: tree_sitter_make::LANGUAGE.into(),
+ highlights: tree_sitter_make::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "dockerfile" => LangDef {
+ language: unsafe { lang_compat(tree_sitter_dockerfile::language()) },
+ highlights: include_str!("../queries/dockerfile-highlights.scm"),
+ injections: "",
+ locals: "",
+ },
+ "kotlin" => LangDef {
+ language: unsafe { lang_compat(tree_sitter_kotlin::language()) },
+ highlights: include_str!("../queries/kotlin-highlights.scm"),
+ injections: "",
+ locals: "",
+ },
+ _ => return None,
+ };
+ Some(ld)
+}
+
+fn make_config(def: LangDef, name: &str) -> Option {
+ let mut config = HighlightConfiguration::new(
+ def.language,
+ name,
+ def.highlights,
+ def.injections,
+ def.locals,
+ ).ok()?;
+ config.configure(HIGHLIGHT_NAMES);
+ Some(config)
+}
+
+pub fn highlight_source(source: &str, lang_id: &str) -> Vec {
+ let def = match lang_def(lang_id) {
+ Some(d) => d,
+ None => return Vec::new(),
+ };
+
+ let config = match make_config(def, lang_id) {
+ Some(c) => c,
+ None => return Vec::new(),
+ };
+
+ let mut highlighter = Highlighter::new();
+ let events = match highlighter.highlight(&config, source.as_bytes(), None, |_| None) {
+ Ok(e) => e,
+ Err(_) => return Vec::new(),
+ };
+
+ let mut spans = Vec::new();
+ let mut stack: Vec = Vec::new();
+
+ for event in events {
+ match event {
+ Ok(HighlightEvent::Source { start, end }) => {
+ if let Some(&kind) = stack.last() {
+ spans.push(HighlightSpan { start, end, kind });
+ }
+ }
+ Ok(HighlightEvent::HighlightStart(h)) => {
+ stack.push(h.0 as u8);
+ }
+ Ok(HighlightEvent::HighlightEnd) => {
+ stack.pop();
+ }
+ Err(_) => break,
+ }
+ }
+
+ spans
+}
diff --git a/core/src/lib.rs b/core/src/lib.rs
index 9d7f74c..dc076b0 100644
--- a/core/src/lib.rs
+++ b/core/src/lib.rs
@@ -1,6 +1,7 @@
pub mod doc;
pub mod document;
pub mod eval;
+pub mod highlight;
pub mod interp;
pub mod persist;
pub mod ffi;
diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift
index 5b9f79f..ae7cd4c 100644
--- a/src/AppDelegate.swift
+++ b/src/AppDelegate.swift
@@ -43,6 +43,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
)
}
+ func application(_ application: NSApplication, open urls: [URL]) {
+ guard let url = urls.first else { return }
+ appState.loadNoteFromFile(url)
+ }
+
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
@@ -121,6 +126,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
findItem.tag = Int(NSTextFinder.Action.showFindInterface.rawValue)
menu.addItem(findItem)
+ menu.addItem(.separator())
+
+ let formatItem = NSMenuItem(title: "Format Document", action: #selector(formatDocument), keyEquivalent: "F")
+ formatItem.keyEquivalentModifierMask = [.command, .shift]
+ formatItem.target = self
+ menu.addItem(formatItem)
+
item.submenu = menu
return item
}
@@ -169,7 +181,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func openNote() {
let panel = NSOpenPanel()
- panel.allowedContentTypes = [UTType(filenameExtension: "md")!, .plainText]
+ panel.allowedContentTypes = Self.supportedContentTypes
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
@@ -180,13 +192,21 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
@objc private func saveNote() {
- appState.saveNote()
+ if appState.currentFileURL != nil {
+ appState.saveNote()
+ } else {
+ saveNoteAs()
+ }
}
@objc private func saveNoteAs() {
let panel = NSSavePanel()
- panel.allowedContentTypes = [UTType(filenameExtension: "md")!]
+ panel.allowedContentTypes = Self.supportedContentTypes
panel.nameFieldStringValue = defaultFilename()
+ if let url = appState.currentFileURL {
+ panel.directoryURL = url.deletingLastPathComponent()
+ panel.nameFieldStringValue = url.lastPathComponent
+ }
panel.beginSheetModal(for: window) { [weak self] response in
guard response == .OK, let url = panel.url else { return }
self?.appState.saveNoteToFile(url)
@@ -194,6 +214,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
private func defaultFilename() -> String {
+ if let url = appState.currentFileURL {
+ return url.lastPathComponent
+ }
let firstLine = appState.documentText
.components(separatedBy: "\n").first?
.trimmingCharacters(in: .whitespaces) ?? ""
@@ -201,9 +224,76 @@ class AppDelegate: NSObject, NSApplicationDelegate {
of: "^#+\\s*", with: "", options: .regularExpression
)
let trimmed = stripped.trimmingCharacters(in: .whitespaces)
- guard !trimmed.isEmpty, trimmed != "Untitled" else { return "note.md" }
+ let ext = extensionForFormat(appState.currentFileFormat)
+ guard !trimmed.isEmpty, trimmed != "Untitled" else { return "note.\(ext)" }
let sanitized = trimmed.map { "/:\\\\".contains($0) ? "-" : String($0) }.joined()
- return sanitized.prefix(80) + ".md"
+ return sanitized.prefix(80) + ".\(ext)"
+ }
+
+ private func extensionForFormat(_ format: FileFormat) -> String {
+ switch format {
+ case .markdown: return "md"
+ case .csv: return "csv"
+ case .json: return "json"
+ case .toml: return "toml"
+ case .yaml: return "yaml"
+ case .xml: return "xml"
+ case .svg: return "svg"
+ case .rust: return "rs"
+ case .c: return "c"
+ case .cpp: return "cpp"
+ case .objc: return "m"
+ case .javascript: return "js"
+ case .typescript: return "ts"
+ case .jsx: return "jsx"
+ case .tsx: return "tsx"
+ case .html: return "html"
+ case .css: return "css"
+ case .scss: return "scss"
+ case .less: return "less"
+ case .python: return "py"
+ case .go: return "go"
+ case .ruby: return "rb"
+ case .php: return "php"
+ case .lua: return "lua"
+ case .shell: return "sh"
+ case .java: return "java"
+ case .kotlin: return "kt"
+ case .swift: return "swift"
+ case .zig: return "zig"
+ case .sql: return "sql"
+ case .makefile: return "mk"
+ case .dockerfile: return "Dockerfile"
+ case .config: return "conf"
+ case .lock: return "lock"
+ case .plainText, .unknown: return "txt"
+ }
+ }
+
+ private static let supportedContentTypes: [UTType] = {
+ let extensions = [
+ "md", "markdown", "mdown",
+ "csv", "json", "toml", "yaml", "yml", "xml", "svg",
+ "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx",
+ "js", "jsx", "ts", "tsx",
+ "html", "htm", "css", "scss", "less",
+ "py", "go", "rb", "php", "lua",
+ "sh", "bash", "zsh", "fish",
+ "java", "kt", "kts", "swift", "zig", "sql",
+ "mk", "ini", "cfg", "conf", "env",
+ "lock", "txt", "text", "log"
+ ]
+ var types: [UTType] = [.plainText]
+ for ext in extensions {
+ if let t = UTType(filenameExtension: ext) {
+ types.append(t)
+ }
+ }
+ return Array(Set(types))
+ }()
+
+ @objc private func formatDocument() {
+ NotificationCenter.default.post(name: .formatDocument, object: nil)
}
@objc private func openSettings() {
diff --git a/src/AppState.swift b/src/AppState.swift
index 9e7a350..0765ae6 100644
--- a/src/AppState.swift
+++ b/src/AppState.swift
@@ -1,6 +1,114 @@
import Foundation
import Combine
+enum FileFormat: String, CaseIterable {
+ case markdown, csv, json, toml, yaml, xml, svg
+ case rust, c, cpp, objc
+ case javascript, typescript, jsx, tsx
+ case html, css, scss, less
+ case python, go, ruby, php, lua
+ case shell, java, kotlin, swift, zig, sql
+ case makefile, dockerfile
+ case config, lock, plainText
+ case unknown
+
+ static func from(extension ext: String) -> FileFormat {
+ switch ext.lowercased() {
+ case "md", "markdown", "mdown": return .markdown
+ case "csv": return .csv
+ case "json": return .json
+ case "toml": return .toml
+ case "yaml", "yml": return .yaml
+ case "xml": return .xml
+ case "svg": return .svg
+ case "rs": return .rust
+ case "c": return .c
+ case "cpp", "cc", "cxx": return .cpp
+ case "h", "hpp", "hxx": return .cpp
+ case "m": return .objc
+ case "js": return .javascript
+ case "jsx": return .jsx
+ case "ts": return .typescript
+ case "tsx": return .tsx
+ case "html", "htm": return .html
+ case "css": return .css
+ case "scss": return .scss
+ case "less": return .less
+ case "py": return .python
+ case "go": return .go
+ case "rb": return .ruby
+ case "php": return .php
+ case "lua": return .lua
+ case "sh", "bash", "zsh", "fish": return .shell
+ case "java": return .java
+ case "kt", "kts": return .kotlin
+ case "swift": return .swift
+ case "zig": return .zig
+ case "sql": return .sql
+ case "mk": return .makefile
+ case "ini", "cfg", "conf", "env": return .config
+ case "lock": return .lock
+ case "txt", "text", "log": return .plainText
+ default: return .unknown
+ }
+ }
+
+ static func from(filename: String) -> FileFormat {
+ let lower = filename.lowercased()
+ if lower == "makefile" { return .makefile }
+ if lower == "dockerfile" { return .dockerfile }
+ let ext = (filename as NSString).pathExtension
+ if ext.isEmpty { return .unknown }
+ return from(extension: ext)
+ }
+
+ var isCode: Bool {
+ switch self {
+ case .rust, .c, .cpp, .objc, .javascript, .typescript, .jsx, .tsx,
+ .html, .css, .scss, .less, .python, .go, .ruby, .php, .lua,
+ .shell, .java, .kotlin, .swift, .zig, .sql, .makefile, .dockerfile,
+ .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 {
@Published var documentText: String = "" {
didSet {
@@ -16,6 +124,8 @@ class AppState: ObservableObject {
@Published var currentNoteID: UUID
@Published var selectedNoteIDs: Set = []
@Published var modified: Bool = false
+ @Published var currentFileURL: URL? = nil
+ @Published var currentFileFormat: FileFormat = .markdown
private let bridge = RustBridge.shared
private var autoSaveTimer: DispatchSourceTimer?
@@ -120,6 +230,8 @@ class AppState: ObservableObject {
documentText = ""
evalResults = [:]
modified = false
+ currentFileURL = nil
+ currentFileFormat = .markdown
refreshNoteList()
}
@@ -161,21 +273,46 @@ class AppState: ObservableObject {
}
func saveNote() {
- bridge.setText(currentNoteID, text: documentText)
+ let textToSave: String
+ if currentFileFormat.isCSV {
+ textToSave = markdownTableToCSV(documentText)
+ } else {
+ textToSave = documentText
+ }
+ bridge.setText(currentNoteID, text: textToSave)
+ if let url = currentFileURL {
+ try? textToSave.write(to: url, atomically: true, encoding: .utf8)
+ }
let _ = bridge.cacheSave(currentNoteID)
modified = false
refreshNoteList()
}
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
}
func loadNoteFromFile(_ url: URL) {
+ let format = FileFormat.from(filename: url.lastPathComponent)
if let (id, text) = bridge.loadNote(path: url.path) {
currentNoteID = id
- documentText = text
+ currentFileURL = url
+ currentFileFormat = format
+ if format.isCSV {
+ documentText = csvToMarkdownTable(text)
+ } else {
+ documentText = text
+ }
modified = false
let _ = bridge.cacheSave(id)
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) {
bridge.deleteNote(id)
if id == currentNoteID {
diff --git a/src/ContentView.swift b/src/ContentView.swift
index 76e5533..a9d9170 100644
--- a/src/ContentView.swift
+++ b/src/ContentView.swift
@@ -30,4 +30,5 @@ extension Notification.Name {
static let toggleSidebar = Notification.Name("toggleSidebar")
static let focusEditor = Notification.Name("focusEditor")
static let focusTitle = Notification.Name("focusTitle")
+ static let formatDocument = Notification.Name("formatDocument")
}
diff --git a/src/EditorView.swift b/src/EditorView.swift
index f58f4a4..94798e6 100644
--- a/src/EditorView.swift
+++ b/src/EditorView.swift
@@ -1138,6 +1138,7 @@ struct EditorView: View {
EditorTextView(
text: bodyBinding,
evalResults: offsetEvalResults(state.evalResults),
+ fileFormat: state.currentFileFormat,
onEvaluate: { state.evaluate() },
onBackspaceAtStart: {
NotificationCenter.default.post(name: .focusTitle, object: nil)
@@ -1158,6 +1159,7 @@ struct EditorView: View {
struct EditorTextView: NSViewRepresentable {
@Binding var text: String
var evalResults: [Int: EvalEntry]
+ var fileFormat: FileFormat = .markdown
var onEvaluate: () -> Void
var onBackspaceAtStart: (() -> Void)? = nil
@@ -1213,7 +1215,7 @@ struct EditorTextView: NSViewRepresentable {
if let ts = textView.textStorage {
ts.beginEditing()
- applySyntaxHighlighting(to: ts)
+ applySyntaxHighlighting(to: ts, format: fileFormat)
ts.endEditing()
}
textView.typingAttributes = [
@@ -1237,7 +1239,7 @@ struct EditorTextView: NSViewRepresentable {
textView.string = text
if let ts = textView.textStorage {
ts.beginEditing()
- applySyntaxHighlighting(to: ts)
+ applySyntaxHighlighting(to: ts, format: fileFormat)
ts.endEditing()
}
textView.selectedRanges = selectedRanges
@@ -1273,6 +1275,12 @@ struct EditorTextView: NSViewRepresentable {
tv.window?.makeFirstResponder(tv)
tv.setSelectedRange(NSRange(location: 0, length: 0))
}
+ NotificationCenter.default.addObserver(
+ forName: .formatDocument, object: nil, queue: .main
+ ) { [weak self] _ in
+ self?.formatCurrentDocument()
+ }
+
settingsObserver = NotificationCenter.default.addObserver(
forName: .settingsChanged, object: nil, queue: .main
) { [weak self] _ in
@@ -1286,7 +1294,7 @@ struct EditorTextView: NSViewRepresentable {
.foregroundColor: palette.text
]
ts.beginEditing()
- applySyntaxHighlighting(to: ts)
+ applySyntaxHighlighting(to: ts, format: parent.fileFormat)
ts.endEditing()
tv.needsDisplay = true
}
@@ -1307,7 +1315,7 @@ struct EditorTextView: NSViewRepresentable {
parent.text = tv.string
let sel = tv.selectedRanges
ts.beginEditing()
- applySyntaxHighlighting(to: ts)
+ applySyntaxHighlighting(to: ts, format: parent.fileFormat)
ts.endEditing()
tv.typingAttributes = [
.font: Theme.editorFont,
@@ -1325,7 +1333,7 @@ struct EditorTextView: NSViewRepresentable {
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
- textView.insertNewlineIgnoringFieldEditor(nil)
+ insertNewlineWithAutoIndent(textView)
DispatchQueue.main.async { [weak self] in
self?.parent.onEvaluate()
}
@@ -1340,6 +1348,155 @@ struct EditorTextView: NSViewRepresentable {
return false
}
+ func textView(_ textView: NSTextView, shouldChangeTextIn range: NSRange, replacementString text: String?) -> Bool {
+ guard let text = text, text.count == 1 else { return true }
+ let ch = text.first!
+ let hasSelection = range.length > 0
+
+ // Skip over matching closer when cursor is right before it
+ if !hasSelection {
+ let closerChars: Set = ["}", ")", "]", "\"", "'"]
+ if closerChars.contains(ch) {
+ let str = textView.string as NSString
+ if range.location < str.length {
+ let next = Character(UnicodeScalar(str.character(at: range.location))!)
+ if next == ch {
+ textView.setSelectedRange(NSRange(location: range.location + 1, length: 0))
+ return false
+ }
+ }
+ }
+ }
+
+ let 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 {
var urlString: String?
if let url = link as? URL {
@@ -1405,7 +1562,7 @@ struct EditorTextView: NSViewRepresentable {
let sel = tv.selectedRanges
ts.beginEditing()
ts.replaceCharacters(in: range, with: newMarkdown)
- applySyntaxHighlighting(to: ts)
+ applySyntaxHighlighting(to: ts, format: parent.fileFormat)
ts.endEditing()
tv.selectedRanges = sel
parent.text = tv.string
@@ -1626,7 +1783,7 @@ private let syntaxBooleans: Set = ["true", "false"]
private let syntaxOperatorChars = CharacterSet(charactersIn: "+-*/=^<>!(){}[]:,.&|%")
-func applySyntaxHighlighting(to textStorage: NSTextStorage) {
+func applySyntaxHighlighting(to textStorage: NSTextStorage, format: FileFormat = .markdown) {
let text = textStorage.string
let fullRange = NSRange(location: 0, length: (text as NSString).length)
let palette = Theme.current
@@ -1639,6 +1796,11 @@ func applySyntaxHighlighting(to textStorage: NSTextStorage) {
]
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 tableHeaderLines = findTableHeaderLines(textStorage: textStorage, fencedRanges: fencedRanges)
@@ -1687,6 +1849,95 @@ func applySyntaxHighlighting(to textStorage: NSTextStorage) {
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 {
if trimmed.hasPrefix("### ") {
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 nsText = text as NSString
let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular)
+ let syn = Theme.syntax
var fencedRanges: [NSRange] = []
var lineStart = 0
var openFence: Int? = nil
+ var fenceLang: String? = nil
+ var codeStart: Int = 0
while lineStart < nsText.length {
let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0))
@@ -2007,29 +2261,35 @@ private func highlightFencedCodeBlocks(textStorage: NSTextStorage, palette: Catp
if openFence == nil {
if trimmed.hasPrefix("```") {
openFence = lineRange.location
- // Mute the fence line
+ codeStart = NSMaxRange(lineRange)
+ let langId = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces)
+ fenceLang = langId.isEmpty ? nil : langId.lowercased()
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange)
textStorage.addAttribute(.font, value: monoFont, range: lineRange)
- // Language identifier after ```
- if trimmed.count > 3 {
- let langStart = (nsText as NSString).range(of: "```", range: lineRange)
- if langStart.location != NSNotFound {
- let after = langStart.location + langStart.length
- let langRange = NSRange(location: after, length: NSMaxRange(lineRange) - after)
- if langRange.length > 0 {
- textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: langRange)
- }
- }
- }
}
} else {
if trimmed == "```" {
- // Close fence
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange)
textStorage.addAttribute(.font, value: monoFont, range: lineRange)
let blockRange = NSRange(location: openFence!, length: NSMaxRange(lineRange) - openFence!)
fencedRanges.append(blockRange)
+
+ // Apply tree-sitter highlighting to the code content
+ let codeEnd = lineRange.location
+ if let lang = fenceLang, codeEnd > codeStart {
+ let codeRange = NSRange(location: codeStart, length: codeEnd - codeStart)
+ let code = nsText.substring(with: codeRange)
+ textStorage.addAttribute(.font, value: monoFont, range: codeRange)
+ let spans = RustBridge.shared.highlight(source: code, lang: lang)
+ if !spans.isEmpty {
+ applyTreeSitterHighlighting(spans: spans, textStorage: textStorage, syn: syn, baseFont: monoFont, offset: codeStart)
+ } else {
+ textStorage.addAttribute(.foregroundColor, value: palette.text, range: codeRange)
+ }
+ }
+
openFence = nil
+ fenceLang = nil
} else {
textStorage.addAttribute(.font, value: monoFont, range: lineRange)
textStorage.addAttribute(.foregroundColor, value: palette.text, range: lineRange)
@@ -2715,12 +2975,13 @@ class LineNumberTextView: NSTextView {
}
}
- // MARK: - Paste (image from clipboard)
+ // MARK: - Paste
override func paste(_ sender: Any?) {
let pb = NSPasteboard.general
- let imageTypes: [NSPasteboard.PasteboardType] = [.tiff, .png]
+ // Image paste
+ let imageTypes: [NSPasteboard.PasteboardType] = [.tiff, .png]
if let imageType = imageTypes.first(where: { pb.data(forType: $0) != nil }),
let data = pb.data(forType: imageType) {
if let image = NSImage(data: data), let pngData = image.tiffRepresentation,
@@ -2736,6 +2997,48 @@ class LineNumberTextView: NSTextView {
} catch {}
}
}
+
+ // Smart text paste with indent adjustment
+ if let text = pb.string(forType: .string) {
+ let lines = text.components(separatedBy: "\n")
+ if lines.count > 1 {
+ let str = string as NSString
+ let cursor = selectedRange().location
+ let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0))
+ let currentLine = str.substring(with: lineRange)
+ var currentIndent = ""
+ for c in currentLine {
+ if c == " " || c == "\t" { currentIndent.append(c) }
+ else { break }
+ }
+
+ var minIndent = Int.max
+ for line in lines where !line.trimmingCharacters(in: .whitespaces).isEmpty {
+ let spaces = line.prefix(while: { $0 == " " || $0 == "\t" }).count
+ minIndent = min(minIndent, spaces)
+ }
+ if minIndent == Int.max { minIndent = 0 }
+
+ var adjusted: [String] = []
+ for (i, line) in lines.enumerated() {
+ if line.trimmingCharacters(in: .whitespaces).isEmpty {
+ adjusted.append("")
+ } else {
+ let stripped = String(line.dropFirst(minIndent))
+ if i == 0 {
+ adjusted.append(stripped)
+ } else {
+ adjusted.append(currentIndent + stripped)
+ }
+ }
+ }
+
+ let result = adjusted.joined(separator: "\n")
+ insertText(result, replacementRange: selectedRange())
+ return
+ }
+ }
+
super.paste(sender)
}
diff --git a/src/RustBridge.swift b/src/RustBridge.swift
index 45e2b2e..7eff7da 100644
--- a/src/RustBridge.swift
+++ b/src/RustBridge.swift
@@ -112,6 +112,36 @@ class RustBridge {
return parseNoteListJSON(json)
}
+ struct HighlightSpan {
+ let start: Int
+ let end: Int
+ let kind: Int
+ }
+
+ func highlight(source: String, lang: String) -> [HighlightSpan] {
+ guard let cstr = source.withCString({ src in
+ lang.withCString({ lng in
+ swiftly_highlight(src, lng)
+ })
+ }) else { return [] }
+ let json = String(cString: cstr)
+ swiftly_free_string(cstr)
+ return parseHighlightJSON(json)
+ }
+
+ private func parseHighlightJSON(_ json: String) -> [HighlightSpan] {
+ guard let data = json.data(using: .utf8) else { return [] }
+ guard let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
+ var spans: [HighlightSpan] = []
+ for item in arr {
+ guard let start = item["start"] as? Int,
+ let end = item["end"] as? Int,
+ let kind = item["kind"] as? Int else { continue }
+ spans.append(HighlightSpan(start: start, end: end, kind: kind))
+ }
+ return spans
+ }
+
func deleteNote(_ id: UUID) {
freeDocument(id)
let cacheDir = FileManager.default.homeDirectoryForCurrentUser