diff --git a/native/Cargo.lock b/native/Cargo.lock index ba8fa03da..748f53e2a 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +17,32 @@ dependencies = [ "memchr", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "bitflags" version = "2.11.0" @@ -28,12 +60,33 @@ dependencies = [ "serde", ] +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -43,6 +96,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -68,6 +130,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "ctor" version = "0.2.9" @@ -78,6 +146,16 @@ dependencies = [ "syn", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "either" version = "1.15.0" @@ -102,6 +180,71 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + [[package]] name = "globset" version = "0.4.18" @@ -156,7 +299,9 @@ dependencies = [ name = "gsd-engine" version = "0.1.0" dependencies = [ + "arboard", "gsd-grep", + "image", "napi", "napi-build", "napi-derive", @@ -173,6 +318,17 @@ dependencies = [ "rayon", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "ignore" version = "0.4.25" @@ -189,6 +345,20 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + [[package]] name = "libc" version = "0.2.183" @@ -205,6 +375,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -226,6 +411,26 @@ dependencies = [ "libc", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "napi" version = "2.16.17" @@ -283,12 +488,136 @@ dependencies = [ "libloading", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -298,6 +627,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.45" @@ -327,6 +668,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -356,6 +706,19 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "same-file" version = "1.0.6" @@ -365,6 +728,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.27" @@ -400,6 +769,18 @@ dependencies = [ "syn", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + [[package]] name = "syn" version = "2.0.117" @@ -411,6 +792,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -433,13 +828,19 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -448,6 +849,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -456,3 +866,120 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c" +dependencies = [ + "zune-core", +] diff --git a/native/crates/engine/Cargo.toml b/native/crates/engine/Cargo.toml index dcd61ef0c..90cb772b4 100644 --- a/native/crates/engine/Cargo.toml +++ b/native/crates/engine/Cargo.toml @@ -12,6 +12,8 @@ crate-type = ["cdylib"] [dependencies] gsd-grep = { path = "../grep" } +arboard = "3" +image = { version = "0.25", default-features = false, features = ["png"] } napi = { version = "2", features = ["napi8"] } napi-derive = "2" diff --git a/native/crates/engine/src/clipboard.rs b/native/crates/engine/src/clipboard.rs new file mode 100644 index 000000000..cc376c024 --- /dev/null +++ b/native/crates/engine/src/clipboard.rs @@ -0,0 +1,110 @@ +//! Clipboard utilities backed by arboard. +//! +//! Provides text copy/read and image read support across Linux, macOS, and Windows. +//! Text copy runs synchronously so macOS writes execute on the caller thread, +//! avoiding worker-thread `AppKit` pasteboard warnings in CLI contexts. + +use std::io::Cursor; + +use arboard::{Clipboard, Error as ClipboardError, ImageData}; +use image::{DynamicImage, ImageFormat, RgbaImage}; +use napi::bindgen_prelude::*; +use napi::{Env, Error, Result, Task}; +use napi_derive::napi; + +/// Clipboard image payload encoded as PNG bytes. +#[napi(object)] +pub struct ClipboardImage { + /// PNG-encoded image bytes. + pub data: Uint8Array, + #[napi(js_name = "mimeType")] + /// MIME type for the encoded image payload. + pub mime_type: String, +} + +fn encode_png(image: ImageData<'_>) -> Result> { + let width = u32::try_from(image.width) + .map_err(|_| Error::from_reason("Clipboard image width overflow"))?; + let height = u32::try_from(image.height) + .map_err(|_| Error::from_reason("Clipboard image height overflow"))?; + let bytes = image.bytes.into_owned(); + let buffer = RgbaImage::from_raw(width, height, bytes) + .ok_or_else(|| Error::from_reason("Clipboard image buffer size mismatch"))?; + let capacity = width.saturating_mul(height).saturating_mul(4) as usize; + let mut output = Vec::with_capacity(capacity); + DynamicImage::ImageRgba8(buffer) + .write_to(&mut Cursor::new(&mut output), ImageFormat::Png) + .map_err(|err| Error::from_reason(format!("Failed to encode clipboard image: {err}")))?; + Ok(output) +} + +/// Copy plain text to the system clipboard. +/// +/// Runs synchronously to avoid macOS AppKit pasteboard warnings +/// when writing from worker threads. +#[napi(js_name = "copyToClipboard")] +pub fn copy_to_clipboard(text: String) -> Result<()> { + let mut clipboard = Clipboard::new() + .map_err(|err| Error::from_reason(format!("Failed to access clipboard: {err}")))?; + clipboard + .set_text(text) + .map_err(|err| Error::from_reason(format!("Failed to copy to clipboard: {err}")))?; + Ok(()) +} + +/// Read plain text from the system clipboard. +/// +/// Returns `None` when no text data is available. +#[napi(js_name = "readTextFromClipboard")] +pub fn read_text_from_clipboard() -> Result> { + let mut clipboard = Clipboard::new() + .map_err(|err| Error::from_reason(format!("Failed to access clipboard: {err}")))?; + match clipboard.get_text() { + Ok(text) => Ok(Some(text)), + Err(ClipboardError::ContentNotAvailable) => Ok(None), + Err(err) => Err(Error::from_reason(format!( + "Failed to read clipboard text: {err}" + ))), + } +} + +// ── Async image read task ──────────────────────────────────────────── + +pub(crate) struct ReadImageTask; + +impl Task for ReadImageTask { + type JsValue = Option; + type Output = Option; + + fn compute(&mut self) -> Result { + let mut clipboard = Clipboard::new() + .map_err(|err| Error::from_reason(format!("Failed to access clipboard: {err}")))?; + match clipboard.get_image() { + Ok(image) => { + let bytes = encode_png(image)?; + Ok(Some(ClipboardImage { + data: Uint8Array::from(bytes), + mime_type: "image/png".to_string(), + })) + } + Err(ClipboardError::ContentNotAvailable) => Ok(None), + Err(err) => Err(Error::from_reason(format!( + "Failed to read clipboard image: {err}" + ))), + } + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { + Ok(output) + } +} + +/// Read an image from the system clipboard. +/// +/// Returns a Promise that resolves to a `ClipboardImage` (PNG-encoded bytes) +/// or `null` when no image data is available. Runs on libuv's thread pool +/// to avoid blocking the main JS thread during PNG encoding. +#[napi(js_name = "readImageFromClipboard")] +pub fn read_image_from_clipboard() -> AsyncTask { + AsyncTask::new(ReadImageTask) +} diff --git a/native/crates/engine/src/highlight.rs b/native/crates/engine/src/highlight.rs new file mode 100644 index 000000000..e2ba692da --- /dev/null +++ b/native/crates/engine/src/highlight.rs @@ -0,0 +1,472 @@ +//! Syntax highlighting using syntect. +//! +//! Provides ANSI-colored output for code blocks. Takes theme colors as input +//! and maps syntect scopes to 11 semantic categories: +//! - comment, keyword, function, variable, string, number, type, operator, +//! punctuation, inserted, deleted + +use std::{cell::RefCell, collections::HashMap, sync::OnceLock}; + +use napi_derive::napi; +use syntect::parsing::{ParseState, Scope, ScopeStack, ScopeStackOp, SyntaxReference, SyntaxSet}; + +static SYNTAX_SET: OnceLock = OnceLock::new(); +static SCOPE_MATCHERS: OnceLock = OnceLock::new(); + +// Thread-local cache for scope -> color index lookups +thread_local! { + static SCOPE_COLOR_CACHE: RefCell> = RefCell::new(HashMap::with_capacity(256)); +} + +fn get_syntax_set() -> &'static SyntaxSet { + SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines) +} + +/// Pre-compiled scope patterns for fast matching. +struct ScopeMatchers { + // Comment (index 0) + comment: Scope, + + // String (index 4) + string: Scope, + constant_character: Scope, + meta_string: Scope, + + // Number (index 5) + constant_numeric: Scope, + constant_integer: Scope, + constant: Scope, + + // Keyword (index 1) + keyword: Scope, + storage_type: Scope, + storage_modifier: Scope, + + // Function (index 2) + entity_name_function: Scope, + support_function: Scope, + meta_function_call: Scope, + variable_function: Scope, + + // Type (index 6) + entity_name_type: Scope, + support_type: Scope, + support_class: Scope, + entity_name_class: Scope, + entity_name_struct: Scope, + entity_name_enum: Scope, + entity_name_interface: Scope, + entity_name_trait: Scope, + + // Operator (index 7) + keyword_operator: Scope, + punctuation_accessor: Scope, + + // Punctuation (index 8) + punctuation: Scope, + + // Variable (index 3) + variable: Scope, + entity_name: Scope, + meta_path: Scope, + + // Diff (indices 9, 10) + markup_inserted: Scope, + markup_deleted: Scope, + meta_diff_header: Scope, + meta_diff_range: Scope, +} + +impl ScopeMatchers { + fn new() -> Self { + Self { + comment: Scope::new("comment").unwrap(), + string: Scope::new("string").unwrap(), + constant_character: Scope::new("constant.character").unwrap(), + meta_string: Scope::new("meta.string").unwrap(), + constant_numeric: Scope::new("constant.numeric").unwrap(), + constant_integer: Scope::new("constant.integer").unwrap(), + constant: Scope::new("constant").unwrap(), + keyword: Scope::new("keyword").unwrap(), + storage_type: Scope::new("storage.type").unwrap(), + storage_modifier: Scope::new("storage.modifier").unwrap(), + entity_name_function: Scope::new("entity.name.function").unwrap(), + support_function: Scope::new("support.function").unwrap(), + meta_function_call: Scope::new("meta.function-call").unwrap(), + variable_function: Scope::new("variable.function").unwrap(), + entity_name_type: Scope::new("entity.name.type").unwrap(), + support_type: Scope::new("support.type").unwrap(), + support_class: Scope::new("support.class").unwrap(), + entity_name_class: Scope::new("entity.name.class").unwrap(), + entity_name_struct: Scope::new("entity.name.struct").unwrap(), + entity_name_enum: Scope::new("entity.name.enum").unwrap(), + entity_name_interface: Scope::new("entity.name.interface").unwrap(), + entity_name_trait: Scope::new("entity.name.trait").unwrap(), + keyword_operator: Scope::new("keyword.operator").unwrap(), + punctuation_accessor: Scope::new("punctuation.accessor").unwrap(), + punctuation: Scope::new("punctuation").unwrap(), + variable: Scope::new("variable").unwrap(), + entity_name: Scope::new("entity.name").unwrap(), + meta_path: Scope::new("meta.path").unwrap(), + markup_inserted: Scope::new("markup.inserted").unwrap(), + markup_deleted: Scope::new("markup.deleted").unwrap(), + meta_diff_header: Scope::new("meta.diff.header").unwrap(), + meta_diff_range: Scope::new("meta.diff.range").unwrap(), + } + } +} + +fn get_scope_matchers() -> &'static ScopeMatchers { + SCOPE_MATCHERS.get_or_init(ScopeMatchers::new) +} + +/// Theme colors for syntax highlighting. +/// Each color is an ANSI escape sequence (e.g., "\x1b[38;2;255;0;0m"). +#[derive(Debug)] +#[napi(object)] +pub struct HighlightColors { + /// ANSI color for comments. + pub comment: String, + /// ANSI color for keywords. + pub keyword: String, + /// ANSI color for function names. + pub function: String, + /// ANSI color for variables and identifiers. + pub variable: String, + /// ANSI color for string literals. + pub string: String, + /// ANSI color for numeric literals. + pub number: String, + /// ANSI color for type identifiers. + #[napi(js_name = "type")] + pub r#type: String, + /// ANSI color for operators. + pub operator: String, + /// ANSI color for punctuation tokens. + pub punctuation: String, + /// ANSI color for diff inserted lines. + #[napi(js_name = "inserted")] + pub inserted: Option, + /// ANSI color for diff deleted lines. + #[napi(js_name = "deleted")] + pub deleted: Option, +} + +/// Language alias mappings: (aliases, target syntax name). +/// Used for languages not in syntect's default set or with non-standard names. +const LANG_ALIASES: &[(&[&str], &str)] = &[ + (&["ts", "tsx", "typescript", "js", "jsx", "javascript", "mjs", "cjs"], "JavaScript"), + (&["py", "python"], "Python"), + (&["rb", "ruby"], "Ruby"), + (&["rs", "rust"], "Rust"), + (&["go", "golang"], "Go"), + (&["java"], "Java"), + (&["kt", "kotlin"], "Java"), + (&["swift"], "Objective-C"), + (&["c", "h"], "C"), + (&["cpp", "cc", "cxx", "c++", "hpp", "hxx", "hh"], "C++"), + (&["cs", "csharp"], "C#"), + (&["php"], "PHP"), + (&["sh", "bash", "zsh", "shell"], "Bash"), + (&["fish"], "Shell-Unix-Generic"), + (&["ps1", "powershell"], "PowerShell"), + (&["html", "htm"], "HTML"), + (&["css"], "CSS"), + (&["scss"], "SCSS"), + (&["sass"], "Sass"), + (&["less"], "LESS"), + (&["json"], "JSON"), + (&["yaml", "yml"], "YAML"), + (&["toml"], "TOML"), + (&["xml"], "XML"), + (&["md", "markdown"], "Markdown"), + (&["sql"], "SQL"), + (&["lua"], "Lua"), + (&["perl", "pl"], "Perl"), + (&["r"], "R"), + (&["scala"], "Scala"), + (&["clj", "clojure"], "Clojure"), + (&["ex", "exs", "elixir"], "Ruby"), + (&["erl", "erlang"], "Erlang"), + (&["hs", "haskell"], "Haskell"), + (&["ml", "ocaml"], "OCaml"), + (&["vim"], "VimL"), + (&["graphql", "gql"], "GraphQL"), + (&["proto", "protobuf"], "Protocol Buffers"), + (&["tf", "hcl", "terraform"], "Terraform"), + (&["dockerfile", "docker"], "Dockerfile"), + (&["makefile", "make"], "Makefile"), + (&["cmake"], "CMake"), + (&["ini", "cfg", "conf", "config", "properties"], "INI"), + (&["diff", "patch"], "Diff"), + (&["gitignore", "gitattributes", "gitmodules"], "Git Ignore"), +]; + +/// Find syntax name from alias table using case-insensitive comparison. +#[inline] +fn find_alias(lang: &str) -> Option<&'static str> { + LANG_ALIASES + .iter() + .find(|(aliases, _)| aliases.iter().any(|a| lang.eq_ignore_ascii_case(a))) + .map(|(_, target)| *target) +} + +/// Check if language is in the alias table. +#[inline] +fn is_known_alias(lang: &str) -> bool { + LANG_ALIASES + .iter() + .any(|(aliases, _)| aliases.iter().any(|a| lang.eq_ignore_ascii_case(a))) +} + +/// Compute the color index for a single scope (uncached). +#[inline] +fn compute_scope_color(s: Scope) -> usize { + let m = get_scope_matchers(); + + // Comment (index 0) + if m.comment.is_prefix_of(s) { + return 0; + } + + // Diff inserted (index 9) + if m.markup_inserted.is_prefix_of(s) { + return 9; + } + + // Diff deleted (index 10) + if m.markup_deleted.is_prefix_of(s) { + return 10; + } + + // Diff header/range -> keyword (index 1) + if m.meta_diff_header.is_prefix_of(s) || m.meta_diff_range.is_prefix_of(s) { + return 1; + } + + // String (index 4) + if m.string.is_prefix_of(s) + || m.constant_character.is_prefix_of(s) + || m.meta_string.is_prefix_of(s) + { + return 4; + } + + // Number (index 5) + if m.constant_numeric.is_prefix_of(s) || m.constant_integer.is_prefix_of(s) { + return 5; + } + + // Keyword (index 1) + if m.keyword.is_prefix_of(s) + || m.storage_type.is_prefix_of(s) + || m.storage_modifier.is_prefix_of(s) + { + return 1; + } + + // Function (index 2) + if m.entity_name_function.is_prefix_of(s) + || m.support_function.is_prefix_of(s) + || m.meta_function_call.is_prefix_of(s) + || m.variable_function.is_prefix_of(s) + { + return 2; + } + + // Type (index 6) + if m.entity_name_type.is_prefix_of(s) + || m.support_type.is_prefix_of(s) + || m.support_class.is_prefix_of(s) + || m.entity_name_class.is_prefix_of(s) + || m.entity_name_struct.is_prefix_of(s) + || m.entity_name_enum.is_prefix_of(s) + || m.entity_name_interface.is_prefix_of(s) + || m.entity_name_trait.is_prefix_of(s) + { + return 6; + } + + // Operator (index 7) + if m.keyword_operator.is_prefix_of(s) || m.punctuation_accessor.is_prefix_of(s) { + return 7; + } + + // Punctuation (index 8) + if m.punctuation.is_prefix_of(s) { + return 8; + } + + // Variable (index 3) + if m.variable.is_prefix_of(s) || m.entity_name.is_prefix_of(s) || m.meta_path.is_prefix_of(s) { + return 3; + } + + // Generic constant -> number (index 5) + if m.constant.is_prefix_of(s) { + return 5; + } + + // No match + usize::MAX +} + +/// Determine the semantic color category from a scope stack. +/// Uses per-scope caching to avoid repeated prefix checks. +#[inline] +fn scope_to_color_index(scope: &ScopeStack) -> usize { + SCOPE_COLOR_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + + // Walk from innermost to outermost scope + for s in scope.as_slice().iter().rev() { + let color_idx = *cache.entry(*s).or_insert_with(|| compute_scope_color(*s)); + if color_idx != usize::MAX { + return color_idx; + } + } + + usize::MAX + }) +} + +/// Find the appropriate syntax for a language name. +fn find_syntax<'a>(ss: &'a SyntaxSet, lang: &str) -> Option<&'a SyntaxReference> { + // Direct name/token match (syntect APIs are case-insensitive) + if let Some(syn) = ss.find_syntax_by_token(lang) { + return Some(syn); + } + + // Extension-based match + if let Some(syn) = ss.find_syntax_by_extension(lang) { + return Some(syn); + } + + // Alias lookup for languages not in syntect's default set + let alias = find_alias(lang)?; + + ss.find_syntax_by_name(alias) + .or_else(|| ss.find_syntax_by_token(alias)) +} + +/// Highlight code and return ANSI-colored lines. +/// +/// # Arguments +/// * `code` - The source code to highlight +/// * `lang` - Language identifier (e.g., "rust", "typescript", "python") +/// * `colors` - Theme colors as ANSI escape sequences +/// +/// # Returns +/// Highlighted code with ANSI color codes, or the original code if highlighting +/// fails. +#[napi(js_name = "highlightCode")] +pub fn highlight_code(code: String, lang: Option, colors: HighlightColors) -> String { + let inserted = colors.inserted.as_deref().unwrap_or(""); + let deleted = colors.deleted.as_deref().unwrap_or(""); + + // Color palette as array for quick indexing + let palette = [ + colors.comment.as_str(), // 0 + colors.keyword.as_str(), // 1 + colors.function.as_str(), // 2 + colors.variable.as_str(), // 3 + colors.string.as_str(), // 4 + colors.number.as_str(), // 5 + colors.r#type.as_str(), // 6 + colors.operator.as_str(), // 7 + colors.punctuation.as_str(), // 8 + inserted, // 9 + deleted, // 10 + ]; + + let ss = get_syntax_set(); + + // Find syntax for the language + let syntax = match &lang { + Some(l) => find_syntax(ss, l), + None => None, + } + .unwrap_or_else(|| ss.find_syntax_plain_text()); + + let mut parse_state = ParseState::new(syntax); + let mut scope_stack = ScopeStack::new(); + let mut result = String::with_capacity(code.len() * 2); + + for line in syntect::util::LinesWithEndings::from(code.as_str()) { + let Ok(ops) = parse_state.parse_line(line, ss) else { + // Parse error - append unhighlighted line and continue + result.push_str(line); + continue; + }; + + let mut prev_end = 0; + for (offset, op) in ops { + let offset = offset.min(line.len()); + + // Output text BEFORE this operation using current scope + if offset > prev_end { + let text = &line[prev_end..offset]; + let color_idx = scope_to_color_index(&scope_stack); + + if color_idx < palette.len() && !palette[color_idx].is_empty() { + result.push_str(palette[color_idx]); + result.push_str(text); + result.push_str("\x1b[39m"); + } else { + result.push_str(text); + } + } + prev_end = offset; + + // Now apply scope operation for NEXT segment + match op { + ScopeStackOp::Push(scope) => { + scope_stack.push(scope); + }, + ScopeStackOp::Pop(count) => { + for _ in 0..count { + scope_stack.pop(); + } + }, + ScopeStackOp::Restore | ScopeStackOp::Clear(_) | ScopeStackOp::Noop => {}, + } + } + + // Output remaining text with current scope + if prev_end < line.len() { + let text = &line[prev_end..]; + let color_idx = scope_to_color_index(&scope_stack); + + if color_idx < palette.len() && !palette[color_idx].is_empty() { + result.push_str(palette[color_idx]); + result.push_str(text); + result.push_str("\x1b[39m"); + } else { + result.push_str(text); + } + } + } + + result +} + +/// Check if a language is supported for highlighting. +/// Returns true if the language has either direct support or a fallback +/// mapping. +#[napi(js_name = "supportsLanguage")] +pub fn supports_language(lang: String) -> bool { + if is_known_alias(&lang) { + return true; + } + + // Fall back to direct syntax lookup + let ss = get_syntax_set(); + find_syntax(ss, &lang).is_some() +} + +/// Get list of supported languages. +#[napi(js_name = "getSupportedLanguages")] +pub fn get_supported_languages() -> Vec { + let ss = get_syntax_set(); + ss.syntaxes().iter().map(|s| s.name.clone()).collect() +} diff --git a/native/crates/engine/src/lib.rs b/native/crates/engine/src/lib.rs index 82985849b..8ab224c6c 100644 --- a/native/crates/engine/src/lib.rs +++ b/native/crates/engine/src/lib.rs @@ -8,4 +8,5 @@ #![allow(clippy::needless_pass_by_value)] +mod clipboard; mod grep; diff --git a/packages/native/package.json b/packages/native/package.json index 84de3dfb3..a195cc0af 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -1,14 +1,14 @@ { "name": "@gsd/native", "version": "0.1.0", - "description": "Native Rust bindings for GSD — high-performance grep via N-API", + "description": "Native Rust bindings for GSD — high-performance grep and clipboard via N-API", "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", "scripts": { "build:native": "node ../../native/scripts/build.js", "build:native:dev": "node ../../native/scripts/build.js --dev", - "test": "node --test src/__tests__/grep.test.mjs" + "test": "node --test src/__tests__/grep.test.mjs src/__tests__/clipboard.test.mjs" }, "exports": { ".": { @@ -18,6 +18,10 @@ "./grep": { "types": "./src/grep/index.ts", "import": "./src/grep/index.ts" + }, + "./clipboard": { + "types": "./src/clipboard/index.ts", + "import": "./src/clipboard/index.ts" } }, "files": [ diff --git a/packages/native/src/__tests__/clipboard.test.mjs b/packages/native/src/__tests__/clipboard.test.mjs new file mode 100644 index 000000000..cabec6375 --- /dev/null +++ b/packages/native/src/__tests__/clipboard.test.mjs @@ -0,0 +1,80 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +// Load the native addon directly +const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); +const platformTag = `${process.platform}-${process.arch}`; +const candidates = [ + path.join(addonDir, `gsd_engine.${platformTag}.node`), + path.join(addonDir, "gsd_engine.dev.node"), +]; + +let native; +for (const candidate of candidates) { + try { + native = require(candidate); + break; + } catch { + // try next + } +} + +if (!native) { + console.error("Native addon not found. Run build:native first."); + process.exit(1); +} + +describe("native clipboard: copyToClipboard()", () => { + test("copies text without throwing", () => { + assert.doesNotThrow(() => { + native.copyToClipboard("GSD clipboard test"); + }); + }); + + test("accepts empty string", () => { + assert.doesNotThrow(() => { + native.copyToClipboard(""); + }); + }); + + test("accepts unicode text", () => { + assert.doesNotThrow(() => { + native.copyToClipboard("Hello 世界"); + }); + }); +}); + +describe("native clipboard: readTextFromClipboard()", () => { + test("reads back text that was copied", () => { + const testText = `GSD clipboard roundtrip ${Date.now()}`; + native.copyToClipboard(testText); + const result = native.readTextFromClipboard(); + assert.equal(result, testText); + }); + + test("returns a string or null", () => { + const result = native.readTextFromClipboard(); + assert.ok(result === null || typeof result === "string"); + }); +}); + +describe("native clipboard: readImageFromClipboard()", () => { + test("returns a promise", () => { + const result = native.readImageFromClipboard(); + assert.ok(result instanceof Promise); + }); + + test("resolves to ClipboardImage or null", async () => { + const result = await native.readImageFromClipboard(); + if (result !== null) { + assert.ok(result.data instanceof Uint8Array, "data should be Uint8Array"); + assert.equal(result.mimeType, "image/png"); + } + }); +}); diff --git a/packages/native/src/__tests__/highlight.test.mjs b/packages/native/src/__tests__/highlight.test.mjs new file mode 100644 index 000000000..db16dd5be --- /dev/null +++ b/packages/native/src/__tests__/highlight.test.mjs @@ -0,0 +1,156 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +// Load the native addon directly +const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); +const platformTag = `${process.platform}-${process.arch}`; +const candidates = [ + path.join(addonDir, `gsd_engine.${platformTag}.node`), + path.join(addonDir, "gsd_engine.dev.node"), +]; + +let native; +for (const candidate of candidates) { + try { + native = require(candidate); + break; + } catch { + // try next + } +} + +if (!native) { + console.error("Native addon not found. Run `npm run build:native -w @gsd/native` first."); + process.exit(1); +} + +const testColors = { + comment: "\x1b[38;2;106;153;85m", + keyword: "\x1b[38;2;197;134;192m", + function: "\x1b[38;2;220;220;170m", + variable: "\x1b[38;2;156;220;254m", + string: "\x1b[38;2;206;145;120m", + number: "\x1b[38;2;181;206;168m", + type: "\x1b[38;2;78;201;176m", + operator: "\x1b[38;2;212;212;212m", + punctuation: "\x1b[38;2;212;212;212m", +}; + +describe("native highlight: highlightCode()", () => { + test("highlights JavaScript code with ANSI colors", () => { + const code = 'const x = 42;\n'; + const result = native.highlightCode(code, "javascript", testColors); + + // Result should contain ANSI escape sequences + assert.ok(result.includes("\x1b["), "should contain ANSI escape codes"); + // Result should contain the original tokens + assert.ok(result.includes("const"), "should contain 'const'"); + assert.ok(result.includes("42"), "should contain '42'"); + // Reset codes should be present + assert.ok(result.includes("\x1b[39m"), "should contain ANSI reset codes"); + }); + + test("returns unhighlighted code for unknown language", () => { + const code = "some random text\n"; + const result = native.highlightCode(code, "nonexistent_lang_xyz", testColors); + + // Plain text syntax should pass through without color codes on plain content + assert.ok(typeof result === "string"); + assert.ok(result.includes("some random text")); + }); + + test("handles null language gracefully", () => { + const code = "hello world\n"; + const result = native.highlightCode(code, null, testColors); + + assert.ok(typeof result === "string"); + assert.ok(result.includes("hello world")); + }); + + test("handles empty code", () => { + const result = native.highlightCode("", "javascript", testColors); + assert.equal(result, ""); + }); + + test("handles multiline code", () => { + const code = 'function foo() {\n return "bar";\n}\n'; + const result = native.highlightCode(code, "javascript", testColors); + + assert.ok(result.includes("function")); + assert.ok(result.includes("foo")); + assert.ok(result.includes("return")); + assert.ok(result.includes('"bar"')); + }); + + test("supports optional inserted/deleted colors", () => { + const colorsWithDiff = { + ...testColors, + inserted: "\x1b[38;2;0;255;0m", + deleted: "\x1b[38;2;255;0;0m", + }; + const code = "+added line\n-removed line\n"; + const result = native.highlightCode(code, "diff", colorsWithDiff); + + assert.ok(typeof result === "string"); + assert.ok(result.length > 0); + }); +}); + +describe("native highlight: supportsLanguage()", () => { + test("returns true for known aliases", () => { + assert.ok(native.supportsLanguage("javascript")); + assert.ok(native.supportsLanguage("typescript")); + assert.ok(native.supportsLanguage("python")); + assert.ok(native.supportsLanguage("rust")); + assert.ok(native.supportsLanguage("go")); + assert.ok(native.supportsLanguage("bash")); + }); + + test("returns true case-insensitively", () => { + assert.ok(native.supportsLanguage("JavaScript")); + assert.ok(native.supportsLanguage("PYTHON")); + assert.ok(native.supportsLanguage("Rust")); + }); + + test("returns true for short aliases", () => { + assert.ok(native.supportsLanguage("ts")); + assert.ok(native.supportsLanguage("py")); + assert.ok(native.supportsLanguage("rs")); + assert.ok(native.supportsLanguage("rb")); + assert.ok(native.supportsLanguage("sh")); + }); + + test("returns false for completely unknown languages", () => { + assert.equal(native.supportsLanguage("nonexistent_lang_xyz"), false); + }); +}); + +describe("native highlight: getSupportedLanguages()", () => { + test("returns an array of language names", () => { + const langs = native.getSupportedLanguages(); + assert.ok(Array.isArray(langs)); + assert.ok(langs.length > 0, "should have at least one language"); + }); + + test("includes common languages", () => { + const langs = native.getSupportedLanguages(); + // These are syntect default syntax names + assert.ok(langs.includes("JavaScript"), "should include JavaScript"); + assert.ok(langs.includes("Python"), "should include Python"); + assert.ok(langs.includes("Rust"), "should include Rust"); + assert.ok(langs.includes("C"), "should include C"); + }); + + test("returns strings", () => { + const langs = native.getSupportedLanguages(); + for (const lang of langs) { + assert.equal(typeof lang, "string"); + } + }); +}); diff --git a/packages/native/src/clipboard/index.ts b/packages/native/src/clipboard/index.ts new file mode 100644 index 000000000..a363942af --- /dev/null +++ b/packages/native/src/clipboard/index.ts @@ -0,0 +1,40 @@ +/** + * Native clipboard access using N-API. + * + * Cross-platform clipboard read/write backed by the `arboard` Rust crate. + * No external tools (pbcopy, xclip, etc.) required. + */ + +import { native } from "../native.js"; +import type { ClipboardImage } from "./types.js"; + +export type { ClipboardImage }; + +/** + * Copy plain text to the system clipboard. + * + * Runs synchronously to avoid macOS AppKit pasteboard warnings + * when writing from worker threads. + */ +export function copyToClipboard(text: string): void { + native.copyToClipboard(text); +} + +/** + * Read plain text from the system clipboard. + * + * Returns `null` when no text data is available. + */ +export function readTextFromClipboard(): string | null { + return native.readTextFromClipboard() as string | null; +} + +/** + * Read an image from the system clipboard. + * + * Returns a Promise that resolves to a `ClipboardImage` (PNG-encoded bytes) + * or `null` when no image data is available. + */ +export function readImageFromClipboard(): Promise { + return native.readImageFromClipboard() as Promise; +} diff --git a/packages/native/src/clipboard/types.ts b/packages/native/src/clipboard/types.ts new file mode 100644 index 000000000..0ca3f508e --- /dev/null +++ b/packages/native/src/clipboard/types.ts @@ -0,0 +1,7 @@ +/** Clipboard image payload encoded as PNG bytes. */ +export interface ClipboardImage { + /** PNG-encoded image bytes. */ + data: Uint8Array; + /** MIME type for the encoded image payload (always "image/png"). */ + mimeType: string; +} diff --git a/packages/native/src/highlight/index.ts b/packages/native/src/highlight/index.ts new file mode 100644 index 000000000..85d3f1d07 --- /dev/null +++ b/packages/native/src/highlight/index.ts @@ -0,0 +1,44 @@ +/** + * Syntect-based syntax highlighting via N-API. + * + * Provides ANSI-colored output for code blocks using semantic scope matching + * across 11 token categories. + */ + +import { native } from "../native.js"; +import type { HighlightColors } from "./types.js"; + +export type { HighlightColors }; + +/** + * Highlight source code and return ANSI-colored output. + * + * @param code - The source code to highlight + * @param lang - Language identifier (e.g., "rust", "typescript", "python"), or null for plain text + * @param colors - Theme colors as ANSI escape sequences + * @returns Highlighted code with ANSI color codes + */ +export function highlightCode( + code: string, + lang: string | null, + colors: HighlightColors, +): string { + return native.highlightCode(code, lang, colors) as string; +} + +/** + * Check if a language is supported for highlighting. + * + * Returns true if the language has either direct syntect support or a + * fallback alias mapping. + */ +export function supportsLanguage(lang: string): boolean { + return native.supportsLanguage(lang) as boolean; +} + +/** + * Get list of all supported language names from syntect's default syntax set. + */ +export function getSupportedLanguages(): string[] { + return native.getSupportedLanguages() as string[]; +} diff --git a/packages/native/src/highlight/types.ts b/packages/native/src/highlight/types.ts new file mode 100644 index 000000000..deae5267e --- /dev/null +++ b/packages/native/src/highlight/types.ts @@ -0,0 +1,25 @@ +/** Theme colors for syntax highlighting as ANSI escape sequences. */ +export interface HighlightColors { + /** ANSI color for comments. */ + comment: string; + /** ANSI color for keywords. */ + keyword: string; + /** ANSI color for function names. */ + function: string; + /** ANSI color for variables and identifiers. */ + variable: string; + /** ANSI color for string literals. */ + string: string; + /** ANSI color for numeric literals. */ + number: string; + /** ANSI color for type identifiers. */ + type: string; + /** ANSI color for operators. */ + operator: string; + /** ANSI color for punctuation tokens. */ + punctuation: string; + /** ANSI color for diff inserted lines. */ + inserted?: string; + /** ANSI color for diff deleted lines. */ + deleted?: string; +} diff --git a/packages/native/src/index.ts b/packages/native/src/index.ts index 3c5cfdf83..4e3737609 100644 --- a/packages/native/src/index.ts +++ b/packages/native/src/index.ts @@ -2,9 +2,17 @@ * @gsd/native — High-performance Rust modules exposed via N-API. * * Modules: + * - clipboard: native clipboard access (text + image) * - grep: ripgrep-backed regex search (content + filesystem) */ +export { + copyToClipboard, + readTextFromClipboard, + readImageFromClipboard, +} from "./clipboard/index.js"; +export type { ClipboardImage } from "./clipboard/index.js"; + export { searchContent, grep } from "./grep/index.js"; export type { ContextLine, diff --git a/packages/native/src/native.ts b/packages/native/src/native.ts index 93aa1a09d..613fe3aea 100644 --- a/packages/native/src/native.ts +++ b/packages/native/src/native.ts @@ -43,4 +43,7 @@ function loadNative(): Record { export const native = loadNative() as { search: (content: Buffer | Uint8Array, options: unknown) => unknown; grep: (options: unknown) => unknown; + copyToClipboard: (text: string) => void; + readTextFromClipboard: () => string | null; + readImageFromClipboard: () => Promise; };