diff --git a/Info.plist b/Info.plist
index 72a0fcf..a23af44 100644
--- a/Info.plist
+++ b/Info.plist
@@ -366,6 +366,18 @@
mk
+
+ CFBundleTypeName
+ Dockerfile
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ CFBundleTypeExtensions
+
+ dockerfile
+
+
CFBundleTypeName
Configuration
diff --git a/core/Cargo.toml b/core/Cargo.toml
index a7849c2..e7197f2 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -13,6 +13,34 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
+tree-sitter = "0.24"
+tree-sitter-highlight = "0.24"
+tree-sitter-language = "0.1"
+tree-sitter-rust = "0.23"
+tree-sitter-c = "0.23"
+tree-sitter-cpp = "0.23"
+tree-sitter-javascript = "0.23"
+tree-sitter-typescript = "0.23"
+tree-sitter-python = "0.23"
+tree-sitter-go = "0.23"
+tree-sitter-ruby = "0.23"
+tree-sitter-bash = "0.23"
+tree-sitter-java = "0.23"
+tree-sitter-html = "0.23"
+tree-sitter-css = "0.23"
+tree-sitter-json = "0.24"
+tree-sitter-lua = "0.4"
+tree-sitter-php = "0.23"
+tree-sitter-toml = "0.20"
+tree-sitter-yaml = "0.6"
+tree-sitter-swift = "0.6"
+tree-sitter-zig = "1"
+tree-sitter-sql = "0.0.2"
+tree-sitter-md = "0.5"
+tree-sitter-make = "1"
+tree-sitter-dockerfile = "0.2"
+tree-sitter-kotlin = "0.3"
+
[build-dependencies]
cbindgen = "0.27"
diff --git a/core/include/swiftly.h b/core/include/swiftly.h
index 8cacafe..391552f 100644
--- a/core/include/swiftly.h
+++ b/core/include/swiftly.h
@@ -32,6 +32,8 @@ typedef struct SwiftlyDoc SwiftlyDoc;
char *swiftly_list_notes(void);
+ char *swiftly_highlight(const char *source, const char *lang);
+
void swiftly_free_string(char *s);
#endif /* SWIFTLY_H */
diff --git a/core/queries/dockerfile-highlights.scm b/core/queries/dockerfile-highlights.scm
new file mode 100644
index 0000000..a5d6514
--- /dev/null
+++ b/core/queries/dockerfile-highlights.scm
@@ -0,0 +1,58 @@
+[
+ "FROM"
+ "AS"
+ "RUN"
+ "CMD"
+ "LABEL"
+ "EXPOSE"
+ "ENV"
+ "ADD"
+ "COPY"
+ "ENTRYPOINT"
+ "VOLUME"
+ "USER"
+ "WORKDIR"
+ "ARG"
+ "ONBUILD"
+ "STOPSIGNAL"
+ "HEALTHCHECK"
+ "SHELL"
+ "MAINTAINER"
+ "CROSS_BUILD"
+ (heredoc_marker)
+ (heredoc_end)
+] @keyword
+
+[
+ ":"
+ "@"
+] @operator
+
+(comment) @comment
+
+
+(image_spec
+ (image_tag
+ ":" @punctuation.special)
+ (image_digest
+ "@" @punctuation.special))
+
+[
+ (double_quoted_string)
+ (single_quoted_string)
+ (json_string)
+ (heredoc_line)
+] @string
+
+(expansion
+ [
+ "$"
+ "{"
+ "}"
+ ] @punctuation.special
+) @none
+
+((variable) @constant
+ (#match? @constant "^[A-Z][A-Z_0-9]*$"))
+
+
diff --git a/core/queries/kotlin-highlights.scm b/core/queries/kotlin-highlights.scm
new file mode 100644
index 0000000..d2e15a6
--- /dev/null
+++ b/core/queries/kotlin-highlights.scm
@@ -0,0 +1,380 @@
+;; Based on the nvim-treesitter highlighting, which is under the Apache license.
+;; See https://github.com/nvim-treesitter/nvim-treesitter/blob/f8ab59861eed4a1c168505e3433462ed800f2bae/queries/kotlin/highlights.scm
+;;
+;; The only difference in this file is that queries using #lua-match?
+;; have been removed.
+
+;;; Identifiers
+
+(simple_identifier) @variable
+
+; `it` keyword inside lambdas
+; FIXME: This will highlight the keyword outside of lambdas since tree-sitter
+; does not allow us to check for arbitrary nestation
+((simple_identifier) @variable.builtin
+(#eq? @variable.builtin "it"))
+
+; `field` keyword inside property getter/setter
+; FIXME: This will highlight the keyword outside of getters and setters
+; since tree-sitter does not allow us to check for arbitrary nestation
+((simple_identifier) @variable.builtin
+(#eq? @variable.builtin "field"))
+
+; `this` this keyword inside classes
+(this_expression) @variable.builtin
+
+; `super` keyword inside classes
+(super_expression) @variable.builtin
+
+(class_parameter
+ (simple_identifier) @property)
+
+(class_body
+ (property_declaration
+ (variable_declaration
+ (simple_identifier) @property)))
+
+; id_1.id_2.id_3: `id_2` and `id_3` are assumed as object properties
+(_
+ (navigation_suffix
+ (simple_identifier) @property))
+
+(enum_entry
+ (simple_identifier) @constant)
+
+(type_identifier) @type
+
+((type_identifier) @type.builtin
+ (#any-of? @type.builtin
+ "Byte"
+ "Short"
+ "Int"
+ "Long"
+ "UByte"
+ "UShort"
+ "UInt"
+ "ULong"
+ "Float"
+ "Double"
+ "Boolean"
+ "Char"
+ "String"
+ "Array"
+ "ByteArray"
+ "ShortArray"
+ "IntArray"
+ "LongArray"
+ "UByteArray"
+ "UShortArray"
+ "UIntArray"
+ "ULongArray"
+ "FloatArray"
+ "DoubleArray"
+ "BooleanArray"
+ "CharArray"
+ "Map"
+ "Set"
+ "List"
+ "EmptyMap"
+ "EmptySet"
+ "EmptyList"
+ "MutableMap"
+ "MutableSet"
+ "MutableList"
+))
+
+(package_header
+ . (identifier)) @namespace
+
+(import_header
+ "import" @include)
+
+
+; TODO: Seperate labeled returns/breaks/continue/super/this
+; Must be implemented in the parser first
+(label) @label
+
+;;; Function definitions
+
+(function_declaration
+ . (simple_identifier) @function)
+
+(getter
+ ("get") @function.builtin)
+(setter
+ ("set") @function.builtin)
+
+(primary_constructor) @constructor
+(secondary_constructor
+ ("constructor") @constructor)
+
+(constructor_invocation
+ (user_type
+ (type_identifier) @constructor))
+
+(anonymous_initializer
+ ("init") @constructor)
+
+(parameter
+ (simple_identifier) @parameter)
+
+(parameter_with_optional_type
+ (simple_identifier) @parameter)
+
+; lambda parameters
+(lambda_literal
+ (lambda_parameters
+ (variable_declaration
+ (simple_identifier) @parameter)))
+
+;;; Function calls
+
+; function()
+(call_expression
+ . (simple_identifier) @function)
+
+; object.function() or object.property.function()
+(call_expression
+ (navigation_expression
+ (navigation_suffix
+ (simple_identifier) @function) . ))
+
+(call_expression
+ . (simple_identifier) @function.builtin
+ (#any-of? @function.builtin
+ "arrayOf"
+ "arrayOfNulls"
+ "byteArrayOf"
+ "shortArrayOf"
+ "intArrayOf"
+ "longArrayOf"
+ "ubyteArrayOf"
+ "ushortArrayOf"
+ "uintArrayOf"
+ "ulongArrayOf"
+ "floatArrayOf"
+ "doubleArrayOf"
+ "booleanArrayOf"
+ "charArrayOf"
+ "emptyArray"
+ "mapOf"
+ "setOf"
+ "listOf"
+ "emptyMap"
+ "emptySet"
+ "emptyList"
+ "mutableMapOf"
+ "mutableSetOf"
+ "mutableListOf"
+ "print"
+ "println"
+ "error"
+ "TODO"
+ "run"
+ "runCatching"
+ "repeat"
+ "lazy"
+ "lazyOf"
+ "enumValues"
+ "enumValueOf"
+ "assert"
+ "check"
+ "checkNotNull"
+ "require"
+ "requireNotNull"
+ "with"
+ "suspend"
+ "synchronized"
+))
+
+;;; Literals
+
+[
+ (line_comment)
+ (multiline_comment)
+ (shebang_line)
+] @comment
+
+(real_literal) @float
+[
+ (integer_literal)
+ (long_literal)
+ (hex_literal)
+ (bin_literal)
+ (unsigned_literal)
+] @number
+
+[
+ "null" ; should be highlighted the same as booleans
+ (boolean_literal)
+] @boolean
+
+(character_literal) @character
+
+(string_literal) @string
+
+(character_escape_seq) @string.escape
+
+; There are 3 ways to define a regex
+; - "[abc]?".toRegex()
+(call_expression
+ (navigation_expression
+ ((string_literal) @string.regex)
+ (navigation_suffix
+ ((simple_identifier) @_function
+ (#eq? @_function "toRegex")))))
+
+; - Regex("[abc]?")
+(call_expression
+ ((simple_identifier) @_function
+ (#eq? @_function "Regex"))
+ (call_suffix
+ (value_arguments
+ (value_argument
+ (string_literal) @string.regex))))
+
+; - Regex.fromLiteral("[abc]?")
+(call_expression
+ (navigation_expression
+ ((simple_identifier) @_class
+ (#eq? @_class "Regex"))
+ (navigation_suffix
+ ((simple_identifier) @_function
+ (#eq? @_function "fromLiteral"))))
+ (call_suffix
+ (value_arguments
+ (value_argument
+ (string_literal) @string.regex))))
+
+;;; Keywords
+
+(type_alias "typealias" @keyword)
+[
+ (class_modifier)
+ (member_modifier)
+ (function_modifier)
+ (property_modifier)
+ (platform_modifier)
+ (variance_modifier)
+ (parameter_modifier)
+ (visibility_modifier)
+ (reification_modifier)
+ (inheritance_modifier)
+]@keyword
+
+[
+ "val"
+ "var"
+ "enum"
+ "class"
+ "object"
+ "interface"
+; "typeof" ; NOTE: It is reserved for future use
+] @keyword
+
+("fun") @keyword.function
+
+(jump_expression) @keyword.return
+
+[
+ "if"
+ "else"
+ "when"
+] @conditional
+
+[
+ "for"
+ "do"
+ "while"
+] @repeat
+
+[
+ "try"
+ "catch"
+ "throw"
+ "finally"
+] @exception
+
+
+(annotation
+ "@" @attribute (use_site_target)? @attribute)
+(annotation
+ (user_type
+ (type_identifier) @attribute))
+(annotation
+ (constructor_invocation
+ (user_type
+ (type_identifier) @attribute)))
+
+(file_annotation
+ "@" @attribute "file" @attribute ":" @attribute)
+(file_annotation
+ (user_type
+ (type_identifier) @attribute))
+(file_annotation
+ (constructor_invocation
+ (user_type
+ (type_identifier) @attribute)))
+
+;;; Operators & Punctuation
+
+[
+ "!"
+ "!="
+ "!=="
+ "="
+ "=="
+ "==="
+ ">"
+ ">="
+ "<"
+ "<="
+ "||"
+ "&&"
+ "+"
+ "++"
+ "+="
+ "-"
+ "--"
+ "-="
+ "*"
+ "*="
+ "/"
+ "/="
+ "%"
+ "%="
+ "?."
+ "?:"
+ "!!"
+ "is"
+ "!is"
+ "in"
+ "!in"
+ "as"
+ "as?"
+ ".."
+ "->"
+] @operator
+
+[
+ "(" ")"
+ "[" "]"
+ "{" "}"
+] @punctuation.bracket
+
+[
+ "."
+ ","
+ ";"
+ ":"
+ "::"
+] @punctuation.delimiter
+
+; NOTE: `interpolated_identifier`s can be highlighted in any way
+(string_literal
+ "$" @punctuation.special
+ (interpolated_identifier) @none)
+(string_literal
+ "${" @punctuation.special
+ (interpolated_expression) @none
+ "}" @punctuation.special)
diff --git a/core/queries/sql-highlights.scm b/core/queries/sql-highlights.scm
new file mode 100644
index 0000000..5cc586b
--- /dev/null
+++ b/core/queries/sql-highlights.scm
@@ -0,0 +1,51 @@
+; (identifier) @variable FIXME this overrides function call pattern
+(string) @string
+(number) @number
+(comment) @comment
+
+(function_call
+ function: (identifier) @function)
+
+[
+ (NULL)
+ (TRUE)
+ (FALSE)
+] @constant.builtin
+
+[
+ "<"
+ "<="
+ "<>"
+ "="
+ ">"
+ ">="
+ "::"
+] @operator
+
+[
+ "("
+ ")"
+ "["
+ "]"
+] @punctuation.bracket
+
+[
+ (type)
+ (array_type)
+] @type
+
+
+[
+ "CREATE TABLE"
+ "CREATE TYPE"
+ "CREATE DOMAIN"
+ "CREATE"
+ "INDEX"
+ "UNIQUE"
+ "SELECT"
+ "WHERE"
+ "FROM"
+ "AS"
+ "GROUP BY"
+ "ORDER BY"
+] @keyword
diff --git a/core/src/ffi.rs b/core/src/ffi.rs
index f9c5ead..bf49c5c 100644
--- a/core/src/ffi.rs
+++ b/core/src/ffi.rs
@@ -4,6 +4,7 @@ use std::path::Path;
use crate::document::SwiftlyDoc;
use crate::eval;
+use crate::highlight;
use crate::persist;
fn cstr_to_str<'a>(ptr: *const c_char) -> Option<&'a str> {
@@ -136,6 +137,21 @@ pub extern "C" fn swiftly_list_notes() -> *mut c_char {
str_to_cstr(&json)
}
+#[no_mangle]
+pub extern "C" fn swiftly_highlight(source: *const c_char, lang: *const c_char) -> *mut c_char {
+ let source = match cstr_to_str(source) {
+ Some(s) => s,
+ None => return str_to_cstr("[]"),
+ };
+ let lang = match cstr_to_str(lang) {
+ Some(s) => s,
+ None => return str_to_cstr("[]"),
+ };
+ let spans = highlight::highlight_source(source, lang);
+ let json = serde_json::to_string(&spans).unwrap_or_else(|_| "[]".into());
+ str_to_cstr(&json)
+}
+
#[no_mangle]
pub extern "C" fn swiftly_free_string(s: *mut c_char) {
if s.is_null() { return; }
diff --git a/core/src/highlight.rs b/core/src/highlight.rs
new file mode 100644
index 0000000..4b109cb
--- /dev/null
+++ b/core/src/highlight.rs
@@ -0,0 +1,252 @@
+use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};
+use tree_sitter::Language;
+
+/// Convert an old-API tree-sitter Language (v0.19/v0.20) to current v0.24.
+/// Both are newtype wrappers around `*const TSLanguage`; the C ABI is identical.
+unsafe fn lang_compat(old: T) -> Language {
+ std::mem::transmute_copy(&old)
+}
+
+const HIGHLIGHT_NAMES: &[&str] = &[
+ "keyword",
+ "function",
+ "function.builtin",
+ "type",
+ "type.builtin",
+ "constructor",
+ "constant",
+ "constant.builtin",
+ "string",
+ "number",
+ "comment",
+ "variable",
+ "variable.builtin",
+ "variable.parameter",
+ "operator",
+ "punctuation",
+ "punctuation.bracket",
+ "punctuation.delimiter",
+ "property",
+ "tag",
+ "attribute",
+ "label",
+ "escape",
+ "embedded",
+];
+
+#[derive(serde::Serialize)]
+pub struct HighlightSpan {
+ pub start: usize,
+ pub end: usize,
+ pub kind: u8,
+}
+
+struct LangDef {
+ language: Language,
+ highlights: &'static str,
+ injections: &'static str,
+ locals: &'static str,
+}
+
+fn lang_def(lang_id: &str) -> Option {
+ let ld = match lang_id {
+ "rust" => LangDef {
+ language: tree_sitter_rust::LANGUAGE.into(),
+ highlights: tree_sitter_rust::HIGHLIGHTS_QUERY,
+ injections: tree_sitter_rust::INJECTIONS_QUERY,
+ locals: "",
+ },
+ "c" => LangDef {
+ language: tree_sitter_c::LANGUAGE.into(),
+ highlights: tree_sitter_c::HIGHLIGHT_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "cpp" => LangDef {
+ language: tree_sitter_cpp::LANGUAGE.into(),
+ highlights: tree_sitter_cpp::HIGHLIGHT_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "javascript" | "jsx" => LangDef {
+ language: tree_sitter_javascript::LANGUAGE.into(),
+ highlights: tree_sitter_javascript::HIGHLIGHT_QUERY,
+ injections: tree_sitter_javascript::INJECTIONS_QUERY,
+ locals: tree_sitter_javascript::LOCALS_QUERY,
+ },
+ "typescript" => LangDef {
+ language: tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
+ highlights: tree_sitter_typescript::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: tree_sitter_typescript::LOCALS_QUERY,
+ },
+ "tsx" => LangDef {
+ language: tree_sitter_typescript::LANGUAGE_TSX.into(),
+ highlights: tree_sitter_typescript::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: tree_sitter_typescript::LOCALS_QUERY,
+ },
+ "python" => LangDef {
+ language: tree_sitter_python::LANGUAGE.into(),
+ highlights: tree_sitter_python::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "go" => LangDef {
+ language: tree_sitter_go::LANGUAGE.into(),
+ highlights: tree_sitter_go::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "ruby" => LangDef {
+ language: tree_sitter_ruby::LANGUAGE.into(),
+ highlights: tree_sitter_ruby::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: tree_sitter_ruby::LOCALS_QUERY,
+ },
+ "bash" | "shell" => LangDef {
+ language: tree_sitter_bash::LANGUAGE.into(),
+ highlights: tree_sitter_bash::HIGHLIGHT_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "java" => LangDef {
+ language: tree_sitter_java::LANGUAGE.into(),
+ highlights: tree_sitter_java::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "html" => LangDef {
+ language: tree_sitter_html::LANGUAGE.into(),
+ highlights: tree_sitter_html::HIGHLIGHTS_QUERY,
+ injections: tree_sitter_html::INJECTIONS_QUERY,
+ locals: "",
+ },
+ "css" | "scss" | "less" => LangDef {
+ language: tree_sitter_css::LANGUAGE.into(),
+ highlights: tree_sitter_css::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "json" => LangDef {
+ language: tree_sitter_json::LANGUAGE.into(),
+ highlights: tree_sitter_json::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "lua" => LangDef {
+ language: tree_sitter_lua::LANGUAGE.into(),
+ highlights: tree_sitter_lua::HIGHLIGHTS_QUERY,
+ injections: tree_sitter_lua::INJECTIONS_QUERY,
+ locals: tree_sitter_lua::LOCALS_QUERY,
+ },
+ "php" => LangDef {
+ language: tree_sitter_php::LANGUAGE_PHP.into(),
+ highlights: tree_sitter_php::HIGHLIGHTS_QUERY,
+ injections: tree_sitter_php::INJECTIONS_QUERY,
+ locals: "",
+ },
+ "toml" => LangDef {
+ language: unsafe { lang_compat(tree_sitter_toml::language()) },
+ highlights: tree_sitter_toml::HIGHLIGHT_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "yaml" => LangDef {
+ language: unsafe { lang_compat(tree_sitter_yaml::language()) },
+ highlights: tree_sitter_yaml::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "swift" => LangDef {
+ language: tree_sitter_swift::LANGUAGE.into(),
+ highlights: tree_sitter_swift::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "zig" => LangDef {
+ language: tree_sitter_zig::LANGUAGE.into(),
+ highlights: tree_sitter_zig::HIGHLIGHTS_QUERY,
+ injections: tree_sitter_zig::INJECTIONS_QUERY,
+ locals: "",
+ },
+ "sql" => LangDef {
+ language: unsafe { lang_compat(tree_sitter_sql::language()) },
+ highlights: include_str!("../queries/sql-highlights.scm"),
+ injections: "",
+ locals: "",
+ },
+ "make" | "makefile" => LangDef {
+ language: tree_sitter_make::LANGUAGE.into(),
+ highlights: tree_sitter_make::HIGHLIGHTS_QUERY,
+ injections: "",
+ locals: "",
+ },
+ "dockerfile" => LangDef {
+ language: unsafe { lang_compat(tree_sitter_dockerfile::language()) },
+ highlights: include_str!("../queries/dockerfile-highlights.scm"),
+ injections: "",
+ locals: "",
+ },
+ "kotlin" => LangDef {
+ language: unsafe { lang_compat(tree_sitter_kotlin::language()) },
+ highlights: include_str!("../queries/kotlin-highlights.scm"),
+ injections: "",
+ locals: "",
+ },
+ _ => return None,
+ };
+ Some(ld)
+}
+
+fn make_config(def: LangDef, name: &str) -> Option {
+ let mut config = HighlightConfiguration::new(
+ def.language,
+ name,
+ def.highlights,
+ def.injections,
+ def.locals,
+ ).ok()?;
+ config.configure(HIGHLIGHT_NAMES);
+ Some(config)
+}
+
+pub fn highlight_source(source: &str, lang_id: &str) -> Vec {
+ let def = match lang_def(lang_id) {
+ Some(d) => d,
+ None => return Vec::new(),
+ };
+
+ let config = match make_config(def, lang_id) {
+ Some(c) => c,
+ None => return Vec::new(),
+ };
+
+ let mut highlighter = Highlighter::new();
+ let events = match highlighter.highlight(&config, source.as_bytes(), None, |_| None) {
+ Ok(e) => e,
+ Err(_) => return Vec::new(),
+ };
+
+ let mut spans = Vec::new();
+ let mut stack: Vec = Vec::new();
+
+ for event in events {
+ match event {
+ Ok(HighlightEvent::Source { start, end }) => {
+ if let Some(&kind) = stack.last() {
+ spans.push(HighlightSpan { start, end, kind });
+ }
+ }
+ Ok(HighlightEvent::HighlightStart(h)) => {
+ stack.push(h.0 as u8);
+ }
+ Ok(HighlightEvent::HighlightEnd) => {
+ stack.pop();
+ }
+ Err(_) => break,
+ }
+ }
+
+ spans
+}
diff --git a/core/src/lib.rs b/core/src/lib.rs
index 9d7f74c..dc076b0 100644
--- a/core/src/lib.rs
+++ b/core/src/lib.rs
@@ -1,6 +1,7 @@
pub mod doc;
pub mod document;
pub mod eval;
+pub mod highlight;
pub mod interp;
pub mod persist;
pub mod ffi;
diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift
index 06650f3..ae7cd4c 100644
--- a/src/AppDelegate.swift
+++ b/src/AppDelegate.swift
@@ -126,6 +126,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
findItem.tag = Int(NSTextFinder.Action.showFindInterface.rawValue)
menu.addItem(findItem)
+ menu.addItem(.separator())
+
+ let formatItem = NSMenuItem(title: "Format Document", action: #selector(formatDocument), keyEquivalent: "F")
+ formatItem.keyEquivalentModifierMask = [.command, .shift]
+ formatItem.target = self
+ menu.addItem(formatItem)
+
item.submenu = menu
return item
}
@@ -285,6 +292,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
return Array(Set(types))
}()
+ @objc private func formatDocument() {
+ NotificationCenter.default.post(name: .formatDocument, object: nil)
+ }
+
@objc private func openSettings() {
SettingsWindowController.show()
}
diff --git a/src/AppState.swift b/src/AppState.swift
index c676824..553a8ea 100644
--- a/src/AppState.swift
+++ b/src/AppState.swift
@@ -66,7 +66,8 @@ enum FileFormat: String, CaseIterable {
switch self {
case .rust, .c, .cpp, .objc, .javascript, .typescript, .jsx, .tsx,
.html, .css, .scss, .less, .python, .go, .ruby, .php, .lua,
- .shell, .java, .kotlin, .swift, .zig, .sql, .makefile, .dockerfile:
+ .shell, .java, .kotlin, .swift, .zig, .sql, .makefile, .dockerfile,
+ .json, .toml, .yaml, .xml, .svg:
return true
default:
return false
@@ -75,6 +76,37 @@ enum FileFormat: String, CaseIterable {
var isMarkdown: Bool { self == .markdown }
var isCSV: Bool { self == .csv }
+
+ var treeSitterLang: String? {
+ switch self {
+ case .rust: return "rust"
+ case .c: return "c"
+ case .cpp: return "cpp"
+ case .javascript: return "javascript"
+ case .jsx: return "jsx"
+ case .typescript: return "typescript"
+ case .tsx: return "tsx"
+ case .python: return "python"
+ case .go: return "go"
+ case .ruby: return "ruby"
+ case .php: return "php"
+ case .lua: return "lua"
+ case .shell: return "bash"
+ case .java: return "java"
+ case .kotlin: return "kotlin"
+ case .swift: return "swift"
+ case .zig: return "zig"
+ case .sql: return "sql"
+ case .html: return "html"
+ case .css, .scss, .less: return "css"
+ case .json: return "json"
+ case .toml: return "toml"
+ case .yaml: return "yaml"
+ case .makefile: return "make"
+ case .dockerfile: return "dockerfile"
+ default: return nil
+ }
+ }
}
class AppState: ObservableObject {
diff --git a/src/ContentView.swift b/src/ContentView.swift
index 76e5533..a9d9170 100644
--- a/src/ContentView.swift
+++ b/src/ContentView.swift
@@ -30,4 +30,5 @@ extension Notification.Name {
static let toggleSidebar = Notification.Name("toggleSidebar")
static let focusEditor = Notification.Name("focusEditor")
static let focusTitle = Notification.Name("focusTitle")
+ static let formatDocument = Notification.Name("formatDocument")
}
diff --git a/src/EditorView.swift b/src/EditorView.swift
index d7db798..7f46982 100644
--- a/src/EditorView.swift
+++ b/src/EditorView.swift
@@ -1045,6 +1045,12 @@ struct EditorTextView: NSViewRepresentable {
tv.window?.makeFirstResponder(tv)
tv.setSelectedRange(NSRange(location: 0, length: 0))
}
+ NotificationCenter.default.addObserver(
+ forName: .formatDocument, object: nil, queue: .main
+ ) { [weak self] _ in
+ self?.formatCurrentDocument()
+ }
+
settingsObserver = NotificationCenter.default.addObserver(
forName: .settingsChanged, object: nil, queue: .main
) { [weak self] _ in
@@ -1097,7 +1103,7 @@ struct EditorTextView: NSViewRepresentable {
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
- textView.insertNewlineIgnoringFieldEditor(nil)
+ insertNewlineWithAutoIndent(textView)
DispatchQueue.main.async { [weak self] in
self?.parent.onEvaluate()
}
@@ -1112,6 +1118,149 @@ struct EditorTextView: NSViewRepresentable {
return false
}
+ func textView(_ textView: NSTextView, shouldChangeTextIn range: NSRange, replacementString text: String?) -> Bool {
+ guard let text = text, text.count == 1 else { return true }
+ let ch = text.first!
+
+ let closers: [Character: Character] = ["}": "{", ")": "(", "]": "["]
+ if let opener = closers[ch] {
+ let str = textView.string as NSString
+ if range.location < str.length {
+ let next = str.character(at: range.location)
+ if next == ch.asciiValue.map({ UInt16($0) }) ?? 0 {
+ textView.setSelectedRange(NSRange(location: range.location + 1, length: 0))
+ return false
+ }
+ }
+ }
+
+ let pairs: [Character: String] = ["{": "}", "(": ")", "[": "]"]
+ if let close = pairs[ch] {
+ textView.insertText(String(ch) + close, replacementRange: range)
+ textView.setSelectedRange(NSRange(location: range.location + 1, length: 0))
+ return false
+ }
+
+ if ch == "\"" || ch == "'" {
+ let str = textView.string as NSString
+ if range.location < str.length {
+ let next = str.character(at: range.location)
+ if next == ch.asciiValue.map({ UInt16($0) }) ?? 0 {
+ textView.setSelectedRange(NSRange(location: range.location + 1, length: 0))
+ return false
+ }
+ }
+ textView.insertText(String(ch) + String(ch), replacementRange: range)
+ textView.setSelectedRange(NSRange(location: range.location + 1, length: 0))
+ return false
+ }
+
+ return true
+ }
+
+ private func formatCurrentDocument() {
+ guard let tv = textView else { return }
+ let format = parent.fileFormat
+ let text = tv.string
+
+ var formatted: String?
+ switch format {
+ case .json:
+ formatted = formatJSON(text)
+ default:
+ if format.isCode {
+ formatted = normalizeIndentation(text)
+ }
+ }
+
+ if let result = formatted, result != text {
+ let sel = tv.selectedRanges
+ tv.string = result
+ parent.text = result
+ if let ts = tv.textStorage {
+ ts.beginEditing()
+ applySyntaxHighlighting(to: ts, format: format)
+ ts.endEditing()
+ }
+ tv.selectedRanges = sel
+ tv.needsDisplay = true
+ }
+ }
+
+ private func formatJSON(_ text: String) -> String? {
+ guard let data = text.data(using: .utf8),
+ let obj = try? JSONSerialization.jsonObject(with: data),
+ let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
+ let result = String(data: pretty, encoding: .utf8) else { return nil }
+ return result
+ }
+
+ private func normalizeIndentation(_ text: String) -> String {
+ let lines = text.components(separatedBy: "\n")
+ var result: [String] = []
+ var depth = 0
+
+ for line in lines {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ if trimmed.isEmpty {
+ result.append("")
+ continue
+ }
+
+ let closesFirst = trimmed.hasPrefix("}") || trimmed.hasPrefix(")") || trimmed.hasPrefix("]")
+ if closesFirst && depth > 0 { depth -= 1 }
+
+ let indent = String(repeating: " ", count: depth)
+ result.append(indent + trimmed)
+
+ let opens = trimmed.filter { "{([".contains($0) }.count
+ let closes = trimmed.filter { "})]".contains($0) }.count
+ depth += opens - closes
+ if closesFirst { depth += 1; depth -= 1 }
+ if depth < 0 { depth = 0 }
+ }
+
+ return result.joined(separator: "\n")
+ }
+
+ private func insertNewlineWithAutoIndent(_ textView: NSTextView) {
+ let str = textView.string as NSString
+ let cursor = textView.selectedRange().location
+ let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0))
+ let currentLine = str.substring(with: lineRange)
+
+ var indent = ""
+ for c in currentLine {
+ if c == " " || c == "\t" { indent.append(c) }
+ else { break }
+ }
+
+ let trimmed = currentLine.trimmingCharacters(in: .whitespacesAndNewlines)
+ let shouldIndent = trimmed.hasSuffix("{") || trimmed.hasSuffix(":") ||
+ trimmed.hasSuffix("do") || trimmed.hasSuffix("then") ||
+ trimmed.hasSuffix("(") || trimmed.hasSuffix("[")
+
+ if shouldIndent {
+ indent += " "
+ }
+
+ let cursorBeforeLineEnd = cursor < NSMaxRange(lineRange) - 1
+ let charBeforeCursor: Character? = cursor > 0 ? Character(UnicodeScalar(str.character(at: cursor - 1))!) : nil
+ let charAtCursor: Character? = cursor < str.length ? Character(UnicodeScalar(str.character(at: cursor))!) : nil
+
+ // Between matching pairs: insert extra newline
+ if let before = charBeforeCursor, let after = charAtCursor,
+ (before == "{" && after == "}") || (before == "(" && after == ")") || (before == "[" && after == "]") {
+ let baseIndent = String(indent.dropLast(4).isEmpty ? "" : indent.dropLast(4))
+ let insertion = "\n" + indent + "\n" + baseIndent
+ textView.insertText(insertion, replacementRange: textView.selectedRange())
+ textView.setSelectedRange(NSRange(location: cursor + 1 + indent.count, length: 0))
+ return
+ }
+
+ textView.insertText("\n" + indent, replacementRange: textView.selectedRange())
+ }
+
func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
var urlString: String?
if let url = link as? URL {
@@ -1412,7 +1561,7 @@ func applySyntaxHighlighting(to textStorage: NSTextStorage, format: FileFormat =
textStorage.setAttributes(baseAttrs, range: fullRange)
if format.isCode {
- applyCodeFileHighlighting(to: textStorage, syn: syn, baseFont: baseFont)
+ applyCodeFileHighlighting(to: textStorage, syn: syn, baseFont: baseFont, format: format)
return
}
@@ -1464,21 +1613,88 @@ func applySyntaxHighlighting(to textStorage: NSTextStorage, format: FileFormat =
highlightAutolinks(textStorage: textStorage, palette: palette, fencedRanges: fencedRanges)
}
-private func applyCodeFileHighlighting(to textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont) {
+private func applyCodeFileHighlighting(to textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont, format: FileFormat = .unknown) {
let text = textStorage.string
+
+ if let lang = format.treeSitterLang {
+ let spans = RustBridge.shared.highlight(source: text, lang: lang)
+ if !spans.isEmpty {
+ applyTreeSitterHighlighting(spans: spans, textStorage: textStorage, syn: syn, baseFont: baseFont)
+ return
+ }
+ }
+
let nsText = text as NSString
var lineStart = 0
-
while lineStart < nsText.length {
let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0))
let line = nsText.substring(with: lineRange)
highlightCodeLine(line, lineRange: lineRange, textStorage: textStorage, syn: syn)
lineStart = NSMaxRange(lineRange)
}
-
highlightBlockComments(textStorage: textStorage, syn: syn, baseFont: baseFont)
}
+private func applyTreeSitterHighlighting(spans: [RustBridge.HighlightSpan], textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont) {
+ let palette = Theme.current
+ let italicFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .italicFontMask)
+ let nsText = textStorage.string as NSString
+ let textLen = nsText.length
+
+ for span in spans {
+ guard span.start < textLen && span.end <= textLen && span.start < span.end else { continue }
+
+ let byteStart = span.start
+ let byteEnd = span.end
+ let str = textStorage.string
+ let bytes = str.utf8
+ let startIdx = bytes.index(bytes.startIndex, offsetBy: byteStart, limitedBy: bytes.endIndex) ?? bytes.endIndex
+ let endIdx = bytes.index(bytes.startIndex, offsetBy: byteEnd, limitedBy: bytes.endIndex) ?? bytes.endIndex
+ let charStart = str.distance(from: str.startIndex, to: String.Index(startIdx, within: str) ?? str.endIndex)
+ let charEnd = str.distance(from: str.startIndex, to: String.Index(endIdx, within: str) ?? str.endIndex)
+
+ guard charStart < textLen && charEnd <= textLen && charStart < charEnd else { continue }
+ let range = NSRange(location: charStart, length: charEnd - charStart)
+
+ let color: NSColor
+ var font: NSFont? = nil
+
+ // kind indices match HIGHLIGHT_NAMES in highlight.rs
+ switch span.kind {
+ case 0: color = syn.keyword // keyword
+ case 1: color = syn.function // function
+ case 2: color = syn.function // function.builtin
+ case 3: color = syn.type // type
+ case 4: color = syn.type // type.builtin
+ case 5: color = syn.type // constructor
+ case 6: color = palette.peach // constant
+ case 7: color = palette.peach // constant.builtin
+ case 8: color = syn.string // string
+ case 9: color = syn.number // number
+ case 10: color = syn.comment; font = italicFont // comment
+ case 11: color = palette.text // variable
+ case 12: color = palette.red // variable.builtin
+ case 13: color = palette.maroon // variable.parameter
+ case 14: color = syn.operator // operator
+ case 15: color = palette.overlay2 // punctuation
+ case 16: color = palette.overlay2 // punctuation.bracket
+ case 17: color = palette.overlay2 // punctuation.delimiter
+ case 18: color = palette.teal // property
+ case 19: color = palette.red // tag (HTML)
+ case 20: color = palette.yellow // attribute
+ case 21: color = palette.sapphire // label
+ case 22: color = palette.pink // escape
+ case 23: color = palette.teal // embedded
+ default: continue
+ }
+
+ textStorage.addAttribute(.foregroundColor, value: color, range: range)
+ if let f = font {
+ textStorage.addAttribute(.font, value: f, range: range)
+ }
+ }
+}
+
private func highlightMarkdownLine(_ trimmed: String, line: String, lineRange: NSRange, textStorage: NSTextStorage, baseFont: NSFont, palette: CatppuccinPalette, isTableHeader: Bool = false) -> Bool {
if trimmed.hasPrefix("### ") {
let hashRange = (textStorage.string as NSString).range(of: "###", range: lineRange)
@@ -2336,12 +2552,13 @@ class LineNumberTextView: NSTextView {
}
}
- // MARK: - Paste (image from clipboard)
+ // MARK: - Paste
override func paste(_ sender: Any?) {
let pb = NSPasteboard.general
- let imageTypes: [NSPasteboard.PasteboardType] = [.tiff, .png]
+ // Image paste
+ let imageTypes: [NSPasteboard.PasteboardType] = [.tiff, .png]
if let imageType = imageTypes.first(where: { pb.data(forType: $0) != nil }),
let data = pb.data(forType: imageType) {
if let image = NSImage(data: data), let pngData = image.tiffRepresentation,
@@ -2357,6 +2574,48 @@ class LineNumberTextView: NSTextView {
} catch {}
}
}
+
+ // Smart text paste with indent adjustment
+ if let text = pb.string(forType: .string) {
+ let lines = text.components(separatedBy: "\n")
+ if lines.count > 1 {
+ let str = string as NSString
+ let cursor = selectedRange().location
+ let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0))
+ let currentLine = str.substring(with: lineRange)
+ var currentIndent = ""
+ for c in currentLine {
+ if c == " " || c == "\t" { currentIndent.append(c) }
+ else { break }
+ }
+
+ var minIndent = Int.max
+ for line in lines where !line.trimmingCharacters(in: .whitespaces).isEmpty {
+ let spaces = line.prefix(while: { $0 == " " || $0 == "\t" }).count
+ minIndent = min(minIndent, spaces)
+ }
+ if minIndent == Int.max { minIndent = 0 }
+
+ var adjusted: [String] = []
+ for (i, line) in lines.enumerated() {
+ if line.trimmingCharacters(in: .whitespaces).isEmpty {
+ adjusted.append("")
+ } else {
+ let stripped = String(line.dropFirst(minIndent))
+ if i == 0 {
+ adjusted.append(stripped)
+ } else {
+ adjusted.append(currentIndent + stripped)
+ }
+ }
+ }
+
+ let result = adjusted.joined(separator: "\n")
+ insertText(result, replacementRange: selectedRange())
+ return
+ }
+ }
+
super.paste(sender)
}
diff --git a/src/RustBridge.swift b/src/RustBridge.swift
index 0ae2840..17c163a 100644
--- a/src/RustBridge.swift
+++ b/src/RustBridge.swift
@@ -101,6 +101,36 @@ class RustBridge {
return parseNoteListJSON(json)
}
+ struct HighlightSpan {
+ let start: Int
+ let end: Int
+ let kind: Int
+ }
+
+ func highlight(source: String, lang: String) -> [HighlightSpan] {
+ guard let cstr = source.withCString({ src in
+ lang.withCString({ lng in
+ swiftly_highlight(src, lng)
+ })
+ }) else { return [] }
+ let json = String(cString: cstr)
+ swiftly_free_string(cstr)
+ return parseHighlightJSON(json)
+ }
+
+ private func parseHighlightJSON(_ json: String) -> [HighlightSpan] {
+ guard let data = json.data(using: .utf8) else { return [] }
+ guard let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
+ var spans: [HighlightSpan] = []
+ for item in arr {
+ guard let start = item["start"] as? Int,
+ let end = item["end"] as? Int,
+ let kind = item["kind"] as? Int else { continue }
+ spans.append(HighlightSpan(start: start, end: end, kind: kind))
+ }
+ return spans
+ }
+
func deleteNote(_ id: UUID) {
freeDocument(id)
let cacheDir = FileManager.default.homeDirectoryForCurrentUser