From fab9ad390d2b9669a04650fc9316ee969a99626a Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 13 Mar 2026 12:36:23 -0600 Subject: [PATCH] feat: add native clipboard module with arboard backend Cross-platform clipboard access (text read/write, image read) via the arboard Rust crate. No external tools (pbcopy, xclip, etc.) required. Ported from Oh My Pi's clipboard module with adaptations for GSD's architecture (direct AsyncTask instead of task::blocking wrapper). Co-Authored-By: Claude Opus 4.6 (1M context) --- native/Cargo.lock | 529 +++++++++++++++++- native/crates/engine/Cargo.toml | 2 + native/crates/engine/src/clipboard.rs | 110 ++++ native/crates/engine/src/highlight.rs | 472 ++++++++++++++++ native/crates/engine/src/lib.rs | 1 + packages/native/package.json | 8 +- .../native/src/__tests__/clipboard.test.mjs | 80 +++ .../native/src/__tests__/highlight.test.mjs | 156 ++++++ packages/native/src/clipboard/index.ts | 40 ++ packages/native/src/clipboard/types.ts | 7 + packages/native/src/highlight/index.ts | 44 ++ packages/native/src/highlight/types.ts | 25 + packages/native/src/index.ts | 8 + packages/native/src/native.ts | 3 + 14 files changed, 1482 insertions(+), 3 deletions(-) create mode 100644 native/crates/engine/src/clipboard.rs create mode 100644 native/crates/engine/src/highlight.rs create mode 100644 packages/native/src/__tests__/clipboard.test.mjs create mode 100644 packages/native/src/__tests__/highlight.test.mjs create mode 100644 packages/native/src/clipboard/index.ts create mode 100644 packages/native/src/clipboard/types.ts create mode 100644 packages/native/src/highlight/index.ts create mode 100644 packages/native/src/highlight/types.ts 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; };