merge file handling, tree-sitter, and editor intelligence
This commit is contained in:
commit
df35ed406e
396
Info.plist
396
Info.plist
|
|
@ -28,5 +28,401 @@
|
|||
<false/>
|
||||
<key>NSSupportsSuddenTermination</key>
|
||||
<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>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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]*$"))
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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<UUID> = []
|
||||
@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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
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<String> = ["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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue