diff --git a/native/Cargo.lock b/native/Cargo.lock index d297ecc07..be0931d5b 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -3,10 +3,17 @@ version = 4 [[package]] -name = "adler2" -version = "2.0.1" +name = "ahash" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "once_cell", + "version_check", + "zerocopy", +] [[package]] name = "aho-corasick" @@ -18,24 +25,10 @@ dependencies = [ ] [[package]] -name = "arboard" -version = "3.6.1" +name = "allocator-api2" +version = "0.2.21" 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", -] +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "ast-grep-core" @@ -50,19 +43,19 @@ dependencies = [ ] [[package]] -name = "autocfg" -version = "1.5.0" +name = "astral-tl" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "d90933ffb0f97e2fc2e0de21da9d3f20597b804012d199843a6fe7c2810d28f3" +dependencies = [ + "memchr", +] [[package]] -name = "bincode" -version = "1.3.3" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bit-set" @@ -96,18 +89,6 @@ 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 = "cc" version = "1.2.56" @@ -125,12 +106,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "clipboard-win" -version = "5.4.1" +name = "const-random" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" dependencies = [ - "error-code", + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom", + "once_cell", + "tiny-keccak", ] [[package]] @@ -142,15 +134,6 @@ 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" @@ -192,30 +175,6 @@ dependencies = [ "syn", ] -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[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" @@ -246,68 +205,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[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 = "fancy-regex" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[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 = "find-msvc-tools" version = "0.1.9" @@ -315,29 +218,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "flate2" -version = "1.1.9" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] -name = "fnv" -version = "1.0.7" +name = "getrandom" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "rustix", - "windows-link", + "cfg-if", + "libc", + "wasi", ] [[package]] @@ -444,18 +338,11 @@ dependencies = [ name = "gsd-engine" version = "0.1.0" dependencies = [ - "arboard", - "dashmap", - "globset", - "gsd-ast", "gsd-grep", - "ignore", - "image", - "libc", + "html-to-markdown-rs", "napi", "napi-build", "napi-derive", - "syntect", ] [[package]] @@ -469,28 +356,52 @@ 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 = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + +[[package]] +name = "html-to-markdown-rs" +version = "2.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9377e16af590b764fd98fd176027cf8831c5335f8964f3f643753e38913a4e" +dependencies = [ + "ahash", + "astral-tl", + "base64", + "html-escape", + "html5ever", + "lru", + "once_cell", + "regex", + "thiserror", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] [[package]] name = "ignore" @@ -508,20 +419,6 @@ 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 = "indexmap" version = "2.13.0" @@ -529,7 +426,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown", ] [[package]] @@ -554,12 +451,6 @@ 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" @@ -575,6 +466,26 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "memchr" version = "2.8.0" @@ -590,26 +501,6 @@ 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" @@ -668,86 +559,10 @@ dependencies = [ ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "new_debug_unreachable" +version = "1.0.6" 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", -] +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "once_cell" @@ -778,12 +593,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - [[package]] name = "phf" version = "0.13.1" @@ -795,6 +604,16 @@ dependencies = [ "serde", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -828,17 +647,10 @@ dependencies = [ ] [[package]] -name = "png" -version = "0.18.1" +name = "precomputed-hash" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "proc-macro2" @@ -849,18 +661,6 @@ 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" @@ -928,19 +728,6 @@ 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" @@ -1011,12 +798,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - [[package]] name = "siphasher" version = "1.0.2" @@ -1035,6 +816,30 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "syn" version = "2.0.117" @@ -1047,21 +852,13 @@ dependencies = [ ] [[package]] -name = "syntect" -version = "5.3.0" +name = "tendril" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" dependencies = [ - "bincode", - "fancy-regex", - "flate2", - "fnv", - "once_cell", - "regex-syntax", - "serde", - "serde_derive", - "thiserror", - "walkdir", + "new_debug_unreachable", + "utf-8", ] [[package]] @@ -1085,17 +882,12 @@ dependencies = [ ] [[package]] -name = "tiff" -version = "0.11.3" +name = "tiny-keccak" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg", + "crunchy", ] [[package]] @@ -1500,6 +1292,24 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -1511,10 +1321,22 @@ dependencies = [ ] [[package]] -name = "weezl" -version = "0.1.12" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "web_atoms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] [[package]] name = "winapi-util" @@ -1522,7 +1344,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1531,15 +1353,6 @@ 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" @@ -1549,88 +1362,6 @@ 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" @@ -1656,18 +1387,3 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[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 82917751e..506628fd6 100644 --- a/native/crates/engine/Cargo.toml +++ b/native/crates/engine/Cargo.toml @@ -16,11 +16,20 @@ gsd-grep = { path = "../grep" } arboard = "3" dashmap = "6" globset = "0.4" +html-to-markdown-rs = { version = "2", default-features = false } ignore = "0.4" -image = { version = "0.25", default-features = false, features = ["png"] } +image = { version = "0.25", default-features = false, features = [ + "png", + "jpeg", + "gif", + "webp", +] } napi = { version = "2", features = ["napi8"] } napi-derive = "2" +smallvec = "1" syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "regex-fancy"] } +unicode-segmentation = "1" +unicode-width = "0.2" [build-dependencies] napi-build = "2" diff --git a/native/crates/engine/src/fd.rs b/native/crates/engine/src/fd.rs new file mode 100644 index 000000000..d792d1a0d --- /dev/null +++ b/native/crates/engine/src/fd.rs @@ -0,0 +1,494 @@ +//! Fuzzy file path discovery for autocomplete and @-mention resolution. +//! +//! Searches for files and directories whose paths match a query string via +//! subsequence scoring. Uses the `ignore` crate for directory walking +//! (respects `.gitignore`, hidden files, etc.). + +use std::path::Path; + +use ignore::WalkBuilder; +use napi::bindgen_prelude::*; +use napi_derive::napi; + +// ═══════════════════════════════════════════════════════════════════════════ +// Public types +// ═══════════════════════════════════════════════════════════════════════════ + +/// Options for fuzzy file path search. +#[napi(object)] +pub struct FuzzyFindOptions { + /// Fuzzy query to match against file paths (case-insensitive). + pub query: String, + /// Directory to search. + pub path: String, + /// Include hidden files (default: false). + pub hidden: Option, + /// Respect .gitignore (default: true). + pub gitignore: Option, + /// Maximum number of matches to return (default: 100). + #[napi(js_name = "maxResults")] + pub max_results: Option, +} + +/// A single match in fuzzy find results. +#[napi(object)] +pub struct FuzzyFindMatch { + /// Relative path from the search root (uses `/` separators). + pub path: String, + /// Whether this entry is a directory. + #[napi(js_name = "isDirectory")] + pub is_directory: bool, + /// Match quality score (higher is better). + pub score: u32, +} + +/// Result of fuzzy file path search. +#[napi(object)] +pub struct FuzzyFindResult { + /// Matched entries (up to `maxResults`). + pub matches: Vec, + /// Total number of matches found (may exceed `matches.len()`). + #[napi(js_name = "totalMatches")] + pub total_matches: u32, +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Path utilities +// ═══════════════════════════════════════════════════════════════════════════ + +/// Resolve a search path string to a canonical `PathBuf` (must be a directory). +fn resolve_search_path(path: &str) -> Result { + let candidate = std::path::PathBuf::from(path); + let root = if candidate.is_absolute() { + candidate + } else { + let cwd = std::env::current_dir() + .map_err(|err| Error::from_reason(format!("Failed to resolve cwd: {err}")))?; + cwd.join(candidate) + }; + let metadata = std::fs::metadata(&root) + .map_err(|err| Error::from_reason(format!("Path not found: {err}")))?; + if !metadata.is_dir() { + return Err(Error::from_reason( + "Search path must be a directory".to_string(), + )); + } + Ok(std::fs::canonicalize(&root).unwrap_or(root)) +} + +/// Check if a path component matches a target string. +fn contains_component(path: &Path, target: &str) -> bool { + path.components().any(|component| { + component + .as_os_str() + .to_str() + .is_some_and(|value| value == target) + }) +} + +/// Skip `.git` directories and `node_modules`. +fn should_skip_path(path: &Path) -> bool { + contains_component(path, ".git") || contains_component(path, "node_modules") +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Scoring +// ═══════════════════════════════════════════════════════════════════════════ + +/// Strips separators, whitespace, and punctuation for normalized fuzzy comparison. +fn normalize_fuzzy_text(value: &str) -> String { + value + .chars() + .filter(|ch| !ch.is_whitespace() && !matches!(ch, '/' | '\\' | '.' | '_' | '-')) + .flat_map(|ch| ch.to_lowercase()) + .collect() +} + +/// Scores a query as a subsequence of `target`. Returns 0 if not a subsequence. +fn fuzzy_subsequence_score(query_chars: &[char], target: &str) -> u32 { + if query_chars.is_empty() { + return 1; + } + let mut query_index = 0usize; + let mut gaps = 0u32; + let mut last_match_index: Option = None; + for (target_index, target_ch) in target.chars().enumerate() { + if query_index >= query_chars.len() { + break; + } + if query_chars[query_index] == target_ch { + if let Some(last_index) = last_match_index { + if target_index > last_index + 1 { + gaps = gaps.saturating_add(1); + } + } + last_match_index = Some(target_index); + query_index += 1; + } + } + if query_index != query_chars.len() { + return 0; + } + let gap_penalty = gaps.saturating_mul(5); + 40u32.saturating_sub(gap_penalty).max(1) +} + +/// Composite path scoring: exact > starts-with > contains > fuzzy subsequence. +fn score_fuzzy_path( + path: &str, + is_directory: bool, + query_lower: &str, + normalized_query: &str, + query_chars: &[char], +) -> u32 { + if query_lower.is_empty() { + return if is_directory { 11 } else { 1 }; + } + + let file_name = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(path); + let lower_file_name = file_name.to_lowercase(); + + let mut score = if lower_file_name == query_lower { + 120 + } else if lower_file_name.starts_with(query_lower) { + 100 + } else if lower_file_name.contains(query_lower) { + 80 + } else { + let lower_path = path.to_lowercase(); + if lower_path.contains(query_lower) { + 60 + } else { + let normalized_file_name = normalize_fuzzy_text(file_name); + let file_name_fuzzy = fuzzy_subsequence_score(query_chars, &normalized_file_name); + if file_name_fuzzy > 0 { + 50 + file_name_fuzzy + } else { + let normalized_path = normalize_fuzzy_text(path); + let path_fuzzy = if normalized_path == normalized_query { + 40 + } else { + fuzzy_subsequence_score(query_chars, &normalized_path) + }; + if path_fuzzy > 0 { + 30 + path_fuzzy + } else { + 0 + } + } + } + }; + + if is_directory && score > 0 { + score += 10; + } + + score +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Directory walking +// ═══════════════════════════════════════════════════════════════════════════ + +/// File type classification for discovered entries. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EntryType { + File, + Dir, + Symlink, +} + +/// A filesystem entry discovered during walking. +struct WalkEntry { + /// Relative path from root (forward slashes). + path: String, + /// Entry type. + entry_type: EntryType, +} + +/// Walk a directory tree collecting entries. +fn walk_directory( + root: &Path, + include_hidden: bool, + respect_gitignore: bool, +) -> Vec { + let mut builder = WalkBuilder::new(root); + builder + .hidden(!include_hidden) + .follow_links(false) + .sort_by_file_path(|a, b| a.cmp(b)); + + if respect_gitignore { + builder + .git_ignore(true) + .git_exclude(true) + .git_global(true) + .ignore(true) + .parents(true); + } else { + builder + .git_ignore(false) + .git_exclude(false) + .git_global(false) + .ignore(false) + .parents(false); + } + + let mut entries = Vec::new(); + for entry in builder.build() { + let Ok(entry) = entry else { continue }; + let path = entry.path(); + + if should_skip_path(path) { + continue; + } + + let relative = path.strip_prefix(root).unwrap_or(path); + let relative_str = relative.to_string_lossy(); + if relative_str.is_empty() { + continue; + } + + // Normalize to forward slashes on all platforms. + let relative_str = if cfg!(windows) && relative_str.contains('\\') { + relative_str.replace('\\', "/") + } else { + relative_str.into_owned() + }; + + let Some(metadata) = std::fs::symlink_metadata(path).ok() else { + continue; + }; + let file_type = metadata.file_type(); + let entry_type = if file_type.is_symlink() { + EntryType::Symlink + } else if file_type.is_dir() { + EntryType::Dir + } else { + EntryType::File + }; + + entries.push(WalkEntry { + path: relative_str, + entry_type, + }); + } + entries +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Execution +// ═══════════════════════════════════════════════════════════════════════════ + +/// Saturating cast from u64 to u32. +fn clamp_u32(value: u64) -> u32 { + value.min(u32::MAX as u64) as u32 +} + +/// Fuzzy file path search for autocomplete and @-mention resolution. +/// +/// Searches for files and directories whose paths match the query string. +/// Results are sorted by match quality (higher score = better match). +#[napi(js_name = "fuzzyFind")] +pub fn fuzzy_find(options: FuzzyFindOptions) -> Result { + let root = resolve_search_path(&options.path)?; + let include_hidden = options.hidden.unwrap_or(false); + let respect_gitignore = options.gitignore.unwrap_or(true); + let max_results = options.max_results.unwrap_or(100) as usize; + + if max_results == 0 { + return Ok(FuzzyFindResult { + matches: Vec::new(), + total_matches: 0, + }); + } + + let query_lower = options.query.trim().to_lowercase(); + let normalized_query = normalize_fuzzy_text(&query_lower); + let query_chars: Vec = normalized_query.chars().collect(); + + if !query_lower.is_empty() && normalized_query.is_empty() { + return Ok(FuzzyFindResult { + matches: Vec::new(), + total_matches: 0, + }); + } + + let entries = walk_directory(&root, include_hidden, respect_gitignore); + + let mut scored: Vec = Vec::with_capacity(entries.len().min(256)); + for entry in entries { + if entry.entry_type == EntryType::Symlink { + continue; + } + + let is_directory = entry.entry_type == EntryType::Dir; + let score = score_fuzzy_path( + &entry.path, + is_directory, + &query_lower, + &normalized_query, + &query_chars, + ); + if score == 0 { + continue; + } + + let mut path = entry.path; + if is_directory { + path.push('/'); + } + scored.push(FuzzyFindMatch { + path, + is_directory, + score, + }); + } + + scored.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.path.cmp(&b.path))); + let total_matches = clamp_u32(scored.len() as u64); + let matches = scored.into_iter().take(max_results).collect(); + + Ok(FuzzyFindResult { + matches, + total_matches, + }) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_fuzzy_text() { + assert_eq!(normalize_fuzzy_text("foo/bar.ts"), "foobarts"); + assert_eq!(normalize_fuzzy_text("my_file-name.rs"), "myfilenamers"); + assert_eq!(normalize_fuzzy_text("MyFile"), "myfile"); + assert_eq!(normalize_fuzzy_text(""), ""); + } + + #[test] + fn test_fuzzy_subsequence_score_exact() { + let query: Vec = "abc".chars().collect(); + let score = fuzzy_subsequence_score(&query, "abc"); + assert_eq!(score, 40); + } + + #[test] + fn test_fuzzy_subsequence_score_with_gaps() { + let query: Vec = "ac".chars().collect(); + let score = fuzzy_subsequence_score(&query, "abc"); + assert_eq!(score, 35); + } + + #[test] + fn test_fuzzy_subsequence_score_no_match() { + let query: Vec = "xyz".chars().collect(); + let score = fuzzy_subsequence_score(&query, "abc"); + assert_eq!(score, 0); + } + + #[test] + fn test_fuzzy_subsequence_score_empty_query() { + let query: Vec = Vec::new(); + let score = fuzzy_subsequence_score(&query, "abc"); + assert_eq!(score, 1); + } + + #[test] + fn test_score_fuzzy_path_exact_filename() { + let score = score_fuzzy_path( + "src/main.rs", + false, + "main.rs", + "mainrs", + &"mainrs".chars().collect::>(), + ); + assert_eq!(score, 120); + } + + #[test] + fn test_score_fuzzy_path_starts_with() { + let score = score_fuzzy_path( + "src/main.rs", + false, + "main", + "main", + &"main".chars().collect::>(), + ); + assert_eq!(score, 100); + } + + #[test] + fn test_score_fuzzy_path_contains() { + let score = score_fuzzy_path( + "src/my_main.rs", + false, + "main", + "main", + &"main".chars().collect::>(), + ); + assert_eq!(score, 80); + } + + #[test] + fn test_score_fuzzy_path_directory_bonus() { + let file_score = score_fuzzy_path( + "src/main.rs", + false, + "main.rs", + "mainrs", + &"mainrs".chars().collect::>(), + ); + let dir_score = score_fuzzy_path( + "src/main.rs", + true, + "main.rs", + "mainrs", + &"mainrs".chars().collect::>(), + ); + assert_eq!(dir_score, file_score + 10); + } + + #[test] + fn test_score_fuzzy_path_empty_query() { + let file_score = score_fuzzy_path("src/main.rs", false, "", "", &[]); + let dir_score = score_fuzzy_path("src/", true, "", "", &[]); + assert_eq!(file_score, 1); + assert_eq!(dir_score, 11); + } + + #[test] + fn test_score_fuzzy_path_no_match() { + let score = score_fuzzy_path( + "src/main.rs", + false, + "xyz", + "xyz", + &"xyz".chars().collect::>(), + ); + assert_eq!(score, 0); + } + + #[test] + fn test_walk_directory_real_fs() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let entries = walk_directory(&root, false, true); + let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect(); + assert!( + paths.iter().any(|p| p.contains("fd.rs")), + "Should find fd.rs in {paths:?}" + ); + assert!( + paths.iter().any(|p| p.contains("lib.rs")), + "Should find lib.rs in {paths:?}" + ); + } +} diff --git a/native/crates/engine/src/html.rs b/native/crates/engine/src/html.rs new file mode 100644 index 000000000..2cc44c047 --- /dev/null +++ b/native/crates/engine/src/html.rs @@ -0,0 +1,44 @@ +//! HTML to Markdown conversion via N-API. +//! +//! Wraps `html-to-markdown-rs` and exposes it as a JS-callable N-API export. + +use html_to_markdown_rs::{convert, ConversionOptions, PreprocessingOptions, PreprocessingPreset}; +use napi::bindgen_prelude::*; +use napi_derive::napi; + +/// Options for HTML to Markdown conversion. +#[napi(object)] +#[derive(Debug, Default)] +pub struct HtmlToMarkdownOptions { + /// Remove navigation elements, forms, headers, footers. + #[napi(js_name = "cleanContent")] + pub clean_content: Option, + /// Skip images during conversion. + #[napi(js_name = "skipImages")] + pub skip_images: Option, +} + +/// Convert HTML source to Markdown with optional preprocessing. +/// +/// Strips boilerplate (nav, forms, headers, footers) when `cleanContent` is true. +/// Returns the Markdown string. +#[napi(js_name = "htmlToMarkdown")] +pub fn html_to_markdown(html: String, options: Option) -> Result { + let options = options.unwrap_or_default(); + let clean_content = options.clean_content.unwrap_or(false); + let skip_images = options.skip_images.unwrap_or(false); + + let conversion_opts = ConversionOptions { + skip_images, + preprocessing: PreprocessingOptions { + enabled: clean_content, + preset: PreprocessingPreset::Aggressive, + remove_navigation: true, + remove_forms: true, + }, + ..Default::default() + }; + + convert(&html, Some(conversion_opts)) + .map_err(|err| Error::from_reason(format!("HTML conversion error: {err}"))) +} diff --git a/native/crates/engine/src/image.rs b/native/crates/engine/src/image.rs new file mode 100644 index 000000000..22969ef30 --- /dev/null +++ b/native/crates/engine/src/image.rs @@ -0,0 +1,137 @@ +//! Image decode, encode, and resize via N-API. +//! +//! Provides: +//! - Load image from bytes (PNG, JPEG, WebP, GIF) +//! - Get dimensions +//! - Resize with configurable sampling filter +//! - Export as PNG, JPEG, WebP, or GIF + +use std::{io::Cursor, sync::Arc}; + +use image::{ + DynamicImage, ImageFormat, ImageReader, + codecs::{jpeg::JpegEncoder, webp::WebPEncoder}, + imageops::FilterType, +}; +use napi::bindgen_prelude::*; +use napi_derive::napi; + +use crate::task; + +/// Sampling filter for resize operations. +#[napi] +pub enum SamplingFilter { + /// Nearest-neighbor sampling (fast, low quality). + Nearest = 1, + /// Triangle filter (linear interpolation). + Triangle = 2, + /// Catmull-Rom filter with sharper edges. + CatmullRom = 3, + /// Gaussian filter for smoother results. + Gaussian = 4, + /// Lanczos3 filter for high-quality downscaling. + Lanczos3 = 5, +} + +impl From for FilterType { + fn from(filter: SamplingFilter) -> Self { + match filter { + SamplingFilter::Nearest => Self::Nearest, + SamplingFilter::Triangle => Self::Triangle, + SamplingFilter::CatmullRom => Self::CatmullRom, + SamplingFilter::Gaussian => Self::Gaussian, + SamplingFilter::Lanczos3 => Self::Lanczos3, + } + } +} + +/// Image container for native interop. +#[napi] +pub struct NativeImage { + img: Arc, +} + +type ImageTask = task::Async; + +#[napi] +impl NativeImage { + /// Decode encoded image bytes (PNG, JPEG, WebP, GIF) into a NativeImage. + #[napi(js_name = "parse")] + pub fn parse(bytes: Uint8Array) -> ImageTask { + let bytes = bytes.as_ref().to_vec(); + task::blocking("image.decode", (), move |_| -> Result { + let img = decode_image_from_bytes(&bytes)?; + Ok(Self { img: Arc::new(img) }) + }) + } + + /// Image width in pixels. + #[napi(getter, js_name = "width")] + pub fn get_width(&self) -> u32 { + self.img.width() + } + + /// Image height in pixels. + #[napi(getter, js_name = "height")] + pub fn get_height(&self) -> u32 { + self.img.height() + } + + /// Encode to bytes. Format: 0=PNG, 1=JPEG, 2=WebP, 3=GIF. + #[napi(js_name = "encode")] + pub fn encode(&self, format: u8, quality: u8) -> task::Async> { + let img = Arc::clone(&self.img); + task::blocking("image.encode", (), move |_| encode_image(&img, format, quality)) + } + + /// Resize to exact dimensions. Returns a new NativeImage. + #[napi(js_name = "resize")] + pub fn resize(&self, width: u32, height: u32, filter: SamplingFilter) -> ImageTask { + let img = Arc::clone(&self.img); + task::blocking("image.resize", (), move |_| { + Ok(Self { img: Arc::new(img.resize_exact(width, height, filter.into())) }) + }) + } +} + +fn decode_image_from_bytes(bytes: &[u8]) -> Result { + let reader = ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|e| Error::from_reason(format!("Failed to detect image format: {e}")))?; + reader + .decode() + .map_err(|e| Error::from_reason(format!("Failed to decode image: {e}"))) +} + +fn encode_image(img: &DynamicImage, format: u8, quality: u8) -> Result> { + let (w, h) = (img.width(), img.height()); + match format { + 0 => { + let mut buffer = Vec::with_capacity((w * h * 4) as usize); + img.write_to(&mut Cursor::new(&mut buffer), ImageFormat::Png) + .map_err(|e| Error::from_reason(format!("Failed to encode PNG: {e}")))?; + Ok(buffer) + }, + 1 => { + let mut buffer = Vec::with_capacity((w * h * 3) as usize); + let encoder = JpegEncoder::new_with_quality(&mut buffer, quality); + img.write_with_encoder(encoder) + .map_err(|e| Error::from_reason(format!("Failed to encode JPEG: {e}")))?; + Ok(buffer) + }, + 2 => { + let mut buffer = Vec::with_capacity((w * h * 4) as usize); + let encoder = WebPEncoder::new_lossless(&mut buffer); + img.write_with_encoder(encoder) + .map_err(|e| Error::from_reason(format!("Failed to encode WebP: {e}")))?; + Ok(buffer) + }, + 3 => { + let mut buffer = Vec::with_capacity((w * h) as usize); + img.write_to(&mut Cursor::new(&mut buffer), ImageFormat::Gif) + .map_err(|e| Error::from_reason(format!("Failed to encode GIF: {e}")))?; + Ok(buffer) + }, + _ => Err(Error::from_reason(format!("Invalid image format: {format}"))), + } +} diff --git a/native/crates/engine/src/lib.rs b/native/crates/engine/src/lib.rs index 012a80bd4..0c4d3230e 100644 --- a/native/crates/engine/src/lib.rs +++ b/native/crates/engine/src/lib.rs @@ -1,19 +1,22 @@ //! N-API addon for GSD. //! //! Exposes high-performance Rust modules to Node.js via napi-rs. -//! Architecture mirrors Oh My Pi's pi-natives crate: //! ```text -//! JS (packages/native) -> N-API -> Rust modules (grep, ...) +//! JS (packages/native) -> N-API -> Rust modules (ast, clipboard, grep, image, ...) //! ``` #![allow(clippy::needless_pass_by_value)] +mod ast; mod clipboard; +mod fd; mod fs_cache; mod glob; mod glob_util; -mod ast; mod grep; mod highlight; +mod html; mod ps; mod task; +mod text; +mod image; diff --git a/native/crates/engine/src/text.rs b/native/crates/engine/src/text.rs new file mode 100644 index 000000000..1f080741d --- /dev/null +++ b/native/crates/engine/src/text.rs @@ -0,0 +1,1536 @@ +//! ANSI-aware text measurement and slicing utilities. +//! +//! Optimized for JS string interop (UTF-16). +//! - Single-pass ANSI scanning (no O(n^2) `next_ansi` rescans) +//! - ASCII fast-path (no grapheme segmentation, no UTF-8 conversion) +//! - Non-ASCII uses a reused scratch String for grapheme segmentation +//! - Width checks early-exit +//! - Ellipsis decoded lazily +//! - truncateToWidth returns the original `JsString` when possible + +use std::cell::RefCell; + +use napi::{JsString, bindgen_prelude::*}; +use napi_derive::napi; +use smallvec::{SmallVec, smallvec}; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +const DEFAULT_TAB_WIDTH: usize = 3; +const MIN_TAB_WIDTH: usize = 1; +const MAX_TAB_WIDTH: usize = 16; +const ESC: u16 = 0x1b; + +#[inline] +const fn clamp_tab_width(tab_width: Option) -> usize { + let width = match tab_width { + Some(tab_width) => tab_width as usize, + None => DEFAULT_TAB_WIDTH, + }; + if width < MIN_TAB_WIDTH { + MIN_TAB_WIDTH + } else if width > MAX_TAB_WIDTH { + MAX_TAB_WIDTH + } else { + width + } +} + +/// Clamp a u64 to u32::MAX, returning as u32. +#[inline] +const fn clamp_u32(v: u64) -> u32 { + if v > u32::MAX as u64 { + u32::MAX + } else { + v as u32 + } +} + +fn utf16_to_string(data: impl AsRef<[u16]>) -> String { + let mut slice = data.as_ref(); + // Strip trailing null terminators (from JsStringUtf16::as_slice()) + while slice.last() == Some(&0) { + slice = &slice[..slice.len() - 1]; + } + String::from_utf16_lossy(slice) +} + +// ============================================================================ +// Results +// ============================================================================ + +#[napi(object)] +pub struct SliceResult { + /// UTF-16 slice containing the selected text. + pub text: String, + /// Visible width of the slice in terminal cells. + pub width: u32, +} + +#[napi(object)] +pub struct ExtractSegmentsResult { + /// UTF-16 content before the overlay region. + pub before: String, + #[napi(js_name = "beforeWidth")] + /// Visible width of the `before` segment. + pub before_width: u32, + /// UTF-16 content after the overlay region. + pub after: String, + #[napi(js_name = "afterWidth")] + /// Visible width of the `after` segment. + pub after_width: u32, +} + +// ============================================================================ +// ANSI State Tracking - Zero Allocation +// ============================================================================ + +const ATTR_BOLD: u16 = 1 << 0; +const ATTR_DIM: u16 = 1 << 1; +const ATTR_ITALIC: u16 = 1 << 2; +const ATTR_UNDERLINE: u16 = 1 << 3; +const ATTR_BLINK: u16 = 1 << 4; +const ATTR_INVERSE: u16 = 1 << 6; +const ATTR_HIDDEN: u16 = 1 << 7; +const ATTR_STRIKE: u16 = 1 << 8; + +type ColorVal = u32; +const COLOR_NONE: ColorVal = 0; + +#[derive(Clone, Copy, Default)] +struct AnsiState { + attrs: u16, + fg: ColorVal, + bg: ColorVal, +} + +impl AnsiState { + #[inline] + const fn new() -> Self { + Self { attrs: 0, fg: COLOR_NONE, bg: COLOR_NONE } + } + + #[inline] + const fn is_empty(&self) -> bool { + self.attrs == 0 && self.fg == COLOR_NONE && self.bg == COLOR_NONE + } + + #[inline] + const fn reset(&mut self) { + *self = Self::new(); + } + + fn apply_sgr_u16(&mut self, params: &[u16]) { + if params.is_empty() { + self.reset(); + return; + } + + let mut i = 0; + while i < params.len() { + let (code, next_i) = parse_sgr_num_u16(params, i); + i = next_i; + + match code { + 0 => self.reset(), + 1 => self.attrs |= ATTR_BOLD, + 2 => self.attrs |= ATTR_DIM, + 3 => self.attrs |= ATTR_ITALIC, + 4 => self.attrs |= ATTR_UNDERLINE, + 5 => self.attrs |= ATTR_BLINK, + 7 => self.attrs |= ATTR_INVERSE, + 8 => self.attrs |= ATTR_HIDDEN, + 9 => self.attrs |= ATTR_STRIKE, + + 21 => self.attrs &= !ATTR_BOLD, + 22 => self.attrs &= !(ATTR_BOLD | ATTR_DIM), + 23 => self.attrs &= !ATTR_ITALIC, + 24 => self.attrs &= !ATTR_UNDERLINE, + 25 => self.attrs &= !ATTR_BLINK, + 27 => self.attrs &= !ATTR_INVERSE, + 28 => self.attrs &= !ATTR_HIDDEN, + 29 => self.attrs &= !ATTR_STRIKE, + + 30..=37 => self.fg = (code - 29) as ColorVal, + 39 => self.fg = COLOR_NONE, + 40..=47 => self.bg = (code - 39) as ColorVal, + 49 => self.bg = COLOR_NONE, + 90..=97 => self.fg = (code - 81) as ColorVal, + 100..=107 => self.bg = (code - 91) as ColorVal, + + 38 | 48 => { + let (mode, ni) = parse_sgr_num_u16(params, i); + i = ni; + + let color = match mode { + 5 => { + let (idx, ni) = parse_sgr_num_u16(params, i); + i = ni; + 0x100 | (idx as ColorVal & 0xff) + }, + 2 => { + let (r, ni) = parse_sgr_num_u16(params, i); + let (g, ni) = parse_sgr_num_u16(params, ni); + let (b, ni) = parse_sgr_num_u16(params, ni); + i = ni; + 0x1000000 + | ((r as ColorVal & 0xff) << 16) + | ((g as ColorVal & 0xff) << 8) + | (b as ColorVal & 0xff) + }, + _ => continue, + }; + + if code == 38 { + self.fg = color; + } else { + self.bg = color; + } + }, + + _ => {}, + } + } + } + + fn write_restore_u16(&self, out: &mut Vec) { + if self.is_empty() { + return; + } + + out.extend_from_slice(&[ESC, b'[' as u16]); + let mut first = true; + + macro_rules! push_code { + ($code:expr) => {{ + if !first { + out.push(b';' as u16); + } + first = false; + write_u32_u16(out, $code); + }}; + } + + if self.attrs & ATTR_BOLD != 0 { + push_code!(1); + } + if self.attrs & ATTR_DIM != 0 { + push_code!(2); + } + if self.attrs & ATTR_ITALIC != 0 { + push_code!(3); + } + if self.attrs & ATTR_UNDERLINE != 0 { + push_code!(4); + } + if self.attrs & ATTR_BLINK != 0 { + push_code!(5); + } + if self.attrs & ATTR_INVERSE != 0 { + push_code!(7); + } + if self.attrs & ATTR_HIDDEN != 0 { + push_code!(8); + } + if self.attrs & ATTR_STRIKE != 0 { + push_code!(9); + } + + write_color_u16(out, self.fg, 38, &mut first); + write_color_u16(out, self.bg, 48, &mut first); + + out.push(b'm' as u16); + } +} + +#[inline] +fn write_color_u16(out: &mut Vec, color: ColorVal, base: u32, first: &mut bool) { + if color == COLOR_NONE { + return; + } + + if !*first { + out.push(b';' as u16); + } + *first = false; + + if color < 0x100 { + let code = if color <= 8 { color + 29 } else { color + 81 }; + let code = if base == 48 { code + 10 } else { code }; + write_u32_u16(out, code); + } else if color < 0x1000000 { + write_u32_u16(out, base); + out.extend_from_slice(&[b';' as u16, b'5' as u16, b';' as u16]); + write_u32_u16(out, color & 0xff); + } else { + write_u32_u16(out, base); + out.extend_from_slice(&[b';' as u16, b'2' as u16, b';' as u16]); + write_u32_u16(out, (color >> 16) & 0xff); + out.push(b';' as u16); + write_u32_u16(out, (color >> 8) & 0xff); + out.push(b';' as u16); + write_u32_u16(out, color & 0xff); + } +} + +#[inline] +fn parse_sgr_num_u16(params: &[u16], mut i: usize) -> (u32, usize) { + while i < params.len() && params[i] == b';' as u16 { + i += 1; + } + + let mut val: u32 = 0; + while i < params.len() { + let b = params[i]; + if b == b';' as u16 { + i += 1; + break; + } + if (b'0' as u16..=b'9' as u16).contains(&b) { + val = val + .saturating_mul(10) + .saturating_add((b - b'0' as u16) as u32); + } + i += 1; + } + (val, i) +} + +#[inline] +fn write_u32_u16(out: &mut Vec, mut val: u32) { + if val == 0 { + out.push(b'0' as u16); + return; + } + let start = out.len(); + while val > 0 { + out.push(b'0' as u16 + (val % 10) as u16); + val /= 10; + } + out[start..].reverse(); +} + +// ============================================================================ +// ANSI Sequence Detection - UTF-16 +// ============================================================================ + +#[inline] +fn ansi_seq_len_u16(data: &[u16], pos: usize) -> Option { + if pos >= data.len() || data[pos] != ESC { + return None; + } + if pos + 1 >= data.len() { + return None; + } + + match data[pos + 1] { + 0x5b => { + // '[' CSI + for (i, b) in data[pos + 2..].iter().enumerate() { + if (0x40..=0x7e).contains(b) { + return Some(i + 3); + } + } + None + }, + 0x5d => { + // ']' OSC + for (i, &b) in data[pos + 2..].iter().enumerate() { + if b == 0x07 { + return Some(i + 3); + } + if b == ESC && data.get(pos + 2 + i + 1) == Some(&0x5c) { + return Some(i + 4); + } + } + None + }, + 0x50 | 0x58 | 0x5e | 0x5f => { + // 'P' DCS, 'X' SOS, '^' PM, '_' APC (terminated by ST) + for (i, &b) in data[pos + 2..].iter().enumerate() { + if b == ESC && data.get(pos + 2 + i + 1) == Some(&0x5c) { + return Some(i + 4); + } + } + None + }, + 0x20..=0x2f => { + // ESC + intermediates + final byte + for (i, b) in data[pos + 2..].iter().enumerate() { + if (0x30..=0x7e).contains(b) { + return Some(i + 3); + } + } + None + }, + 0x40..=0x7e => Some(2), + _ => None, + } +} + +#[inline] +fn is_sgr_u16(seq: &[u16]) -> bool { + seq.len() >= 3 && seq[1] == b'[' as u16 && *seq.last().unwrap() == b'm' as u16 +} + +// ============================================================================ +// Grapheme / Width +// ============================================================================ + +#[inline] +const fn ascii_cell_width_u16(u: u16, tab_width: usize) -> usize { + let b = u as u8; + match b { + b'\t' => tab_width, + 0x20..=0x7e => 1, + _ => 0, + } +} + +#[inline] +fn grapheme_width_str(g: &str, tab_width: usize) -> usize { + if g == "\t" { + return tab_width; + } + let mut it = g.chars(); + let Some(c0) = it.next() else { + return 0; + }; + if it.next().is_none() { + return UnicodeWidthChar::width(c0).unwrap_or(0); + } + UnicodeWidthStr::width(g) +} + +thread_local! { + static SCRATCH: RefCell = const { RefCell::new(String::new()) }; +} + +/// Iterate graphemes in a non-ASCII UTF-16 segment. +/// +/// Callback returns `true` to continue, `false` to stop early. +#[inline] +fn for_each_grapheme_u16_slow(segment: &[u16], tab_width: usize, mut f: F) -> bool +where + F: FnMut(&[u16], usize) -> bool, +{ + if segment.is_empty() { + return true; + } + + SCRATCH.with_borrow_mut(|scratch| { + scratch.clear(); + scratch.reserve(segment.len()); + + for r in std::char::decode_utf16(segment.iter().copied()) { + scratch.push(r.unwrap_or('\u{FFFD}')); + } + + let mut utf16_pos = 0usize; + for g in scratch.graphemes(true) { + let w = grapheme_width_str(g, tab_width); + + let g_u16_len: usize = g.chars().map(|c| c.len_utf16()).sum(); + let u16_slice = &segment[utf16_pos..utf16_pos + g_u16_len]; + utf16_pos += g_u16_len; + + if !f(u16_slice, w) { + return false; + } + } + + true + }) +} + +/// Visible width, with early-exit if width exceeds `limit`. +fn visible_width_u16_up_to(data: &[u16], limit: usize, tab_width: usize) -> (usize, bool) { + let mut width = 0usize; + let mut i = 0usize; + let len = data.len(); + + while i < len { + if data[i] == ESC { + if let Some(seq_len) = ansi_seq_len_u16(data, i) { + i += seq_len; + continue; + } + i += 1; + continue; + } + + let start = i; + let mut is_ascii = true; + while i < len && data[i] != ESC { + if data[i] > 0x7f { + is_ascii = false; + } + i += 1; + } + let seg = &data[start..i]; + + if is_ascii { + for &u in seg { + width += ascii_cell_width_u16(u, tab_width); + if width > limit { + return (width, true); + } + } + } else { + let ok = for_each_grapheme_u16_slow(seg, tab_width, |_, w| { + width += w; + width <= limit + }); + if !ok { + return (width, true); + } + } + } + + (width, width > limit) +} + +fn visible_width_u16(data: &[u16], tab_width: usize) -> usize { + visible_width_u16_up_to(data, usize::MAX, tab_width).0 +} + +// ============================================================================ +// wrapTextWithAnsi +// ============================================================================ + +#[inline] +fn write_active_codes(state: &AnsiState, out: &mut Vec) { + if !state.is_empty() { + state.write_restore_u16(out); + } +} + +#[inline] +fn write_line_end_reset(state: &AnsiState, out: &mut Vec) { + let has_underline = state.attrs & ATTR_UNDERLINE != 0; + let has_strike = state.attrs & ATTR_STRIKE != 0; + if !has_underline && !has_strike { + return; + } + + out.extend_from_slice(&[ESC, b'[' as u16]); + if has_underline { + out.extend_from_slice(&[b'2' as u16, b'4' as u16]); + if has_strike { + out.push(b';' as u16); + } + } + if has_strike { + out.extend_from_slice(&[b'2' as u16, b'9' as u16]); + } + out.push(b'm' as u16); +} + +fn update_state_from_text(data: &[u16], state: &mut AnsiState) { + let mut i = 0usize; + while i < data.len() { + if data[i] == ESC { + if let Some(seq_len) = ansi_seq_len_u16(data, i) { + let seq = &data[i..i + seq_len]; + if is_sgr_u16(seq) { + state.apply_sgr_u16(&seq[2..seq_len - 1]); + } + i += seq_len; + continue; + } + } + i += 1; + } +} + +fn token_is_whitespace(token: &[u16]) -> bool { + let mut i = 0usize; + while i < token.len() { + if token[i] == ESC { + if let Some(seq_len) = ansi_seq_len_u16(token, i) { + i += seq_len; + continue; + } + } + if token[i] != b' ' as u16 { + return false; + } + i += 1; + } + true +} + +fn trim_end_spaces_in_place(line: &mut Vec) { + while let Some(&last) = line.last() { + if last == b' ' as u16 { + line.pop(); + } else { + break; + } + } +} + +fn split_into_tokens_with_ansi(line: &[u16]) -> SmallVec<[Vec; 4]> { + let mut tokens = SmallVec::<[Vec; 4]>::new(); + let mut current = Vec::::new(); + let mut pending_ansi = SmallVec::<[u16; 32]>::new(); + let mut in_whitespace = false; + let mut i = 0usize; + + while i < line.len() { + if line[i] == ESC { + if let Some(seq_len) = ansi_seq_len_u16(line, i) { + pending_ansi.extend_from_slice(&line[i..i + seq_len]); + i += seq_len; + continue; + } + } + + let ch = line[i]; + let char_is_space = ch == b' ' as u16; + if char_is_space != in_whitespace && !current.is_empty() { + tokens.push(current); + current = Vec::new(); + } + + if !pending_ansi.is_empty() { + current.extend_from_slice(&pending_ansi); + pending_ansi.clear(); + } + + in_whitespace = char_is_space; + current.push(ch); + i += 1; + } + + if !pending_ansi.is_empty() { + current.extend_from_slice(&pending_ansi); + } + + if !current.is_empty() { + tokens.push(current); + } + + tokens +} + +fn break_long_word( + word: &[u16], + width: usize, + tab_width: usize, + state: &mut AnsiState, +) -> SmallVec<[Vec; 4]> { + let mut lines = SmallVec::<[Vec; 4]>::new(); + let mut current_line = Vec::::new(); + write_active_codes(state, &mut current_line); + let mut current_width = 0usize; + let mut i = 0usize; + + while i < word.len() { + if word[i] == ESC { + if let Some(seq_len) = ansi_seq_len_u16(word, i) { + let seq = &word[i..i + seq_len]; + current_line.extend_from_slice(seq); + if is_sgr_u16(seq) { + state.apply_sgr_u16(&seq[2..seq_len - 1]); + } + i += seq_len; + continue; + } + } + + let start = i; + let mut is_ascii = true; + while i < word.len() && word[i] != ESC { + if word[i] > 0x7f { + is_ascii = false; + } + i += 1; + } + let seg = &word[start..i]; + + if is_ascii { + for &u in seg { + let gw = ascii_cell_width_u16(u, tab_width); + if current_width + gw > width { + write_line_end_reset(state, &mut current_line); + lines.push(current_line); + current_line = Vec::new(); + write_active_codes(state, &mut current_line); + current_width = 0; + } + current_line.push(u); + current_width += gw; + } + } else { + let _ = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| { + if current_width + gw > width { + write_line_end_reset(state, &mut current_line); + lines.push(std::mem::take(&mut current_line)); + write_active_codes(state, &mut current_line); + current_width = 0; + } + current_line.extend_from_slice(gu16); + current_width += gw; + true + }); + } + } + + if !current_line.is_empty() { + lines.push(current_line); + } + + lines +} + +fn wrap_single_line(line: &[u16], width: usize, tab_width: usize) -> SmallVec<[Vec; 4]> { + if line.is_empty() { + return smallvec![Vec::new()]; + } + + if visible_width_u16(line, tab_width) <= width { + return smallvec![line.to_vec()]; + } + + let tokens = split_into_tokens_with_ansi(line); + let mut wrapped = SmallVec::<[Vec; 4]>::new(); + let mut current_line = Vec::::new(); + let mut current_width = 0usize; + let mut state = AnsiState::new(); + + for token in tokens { + let token_width = visible_width_u16(&token, tab_width); + let is_whitespace = token_is_whitespace(&token); + + if token_width > width && !is_whitespace { + if !current_line.is_empty() { + write_line_end_reset(&state, &mut current_line); + wrapped.push(current_line); + current_line = Vec::new(); + current_width = 0; + } + + let mut broken = break_long_word(&token, width, tab_width, &mut state); + if let Some(last) = broken.pop() { + wrapped.extend(broken); + current_line = last; + current_width = visible_width_u16(¤t_line, tab_width); + } + continue; + } + + let total_needed = current_width + token_width; + if total_needed > width && current_width > 0 { + let mut line_to_wrap = current_line; + trim_end_spaces_in_place(&mut line_to_wrap); + write_line_end_reset(&state, &mut line_to_wrap); + wrapped.push(line_to_wrap); + + current_line = Vec::new(); + write_active_codes(&state, &mut current_line); + if is_whitespace { + current_width = 0; + } else { + current_line.extend_from_slice(&token); + current_width = token_width; + } + } else { + current_line.extend_from_slice(&token); + current_width += token_width; + } + + update_state_from_text(&token, &mut state); + } + + if !current_line.is_empty() { + wrapped.push(current_line); + } + + for line in &mut wrapped { + trim_end_spaces_in_place(line); + } + + if wrapped.is_empty() { + wrapped.push(Vec::new()); + } + + wrapped +} + +fn wrap_text_with_ansi_impl( + text: &[u16], + width: usize, + tab_width: usize, +) -> SmallVec<[Vec; 4]> { + if text.is_empty() { + return smallvec![Vec::new()]; + } + + let mut result = SmallVec::<[Vec; 4]>::new(); + let mut state = AnsiState::new(); + let mut line_start = 0usize; + + for i in 0..=text.len() { + if i == text.len() || text[i] == b'\n' as u16 { + let line = &text[line_start..i]; + let mut line_with_prefix: Vec = Vec::new(); + if !result.is_empty() { + write_active_codes(&state, &mut line_with_prefix); + } + line_with_prefix.extend_from_slice(line); + + let wrapped = wrap_single_line(&line_with_prefix, width, tab_width); + result.extend(wrapped); + update_state_from_text(line, &mut state); + line_start = i + 1; + } + } + + if result.is_empty() { + result.push(Vec::new()); + } + + result +} + +/// Wrap text to a visible width, preserving ANSI escape codes across line +/// breaks. +/// +/// Returns UTF-16 lines with active SGR codes carried across line boundaries. +#[napi(js_name = "wrapTextWithAnsi")] +pub fn wrap_text_with_ansi( + text: JsString, + width: u32, + tab_width: Option, +) -> Result> { + let text_u16 = text.into_utf16()?; + let tab_width = clamp_tab_width(tab_width); + let lines = wrap_text_with_ansi_impl(text_u16.as_slice(), width as usize, tab_width); + Ok(lines.into_iter().map(utf16_to_string).collect()) +} + +// ============================================================================ +// truncateToWidth +// ============================================================================ + +/// Truncate text to a visible width, preserving ANSI codes. +/// +/// `ellipsis_kind`: 0 = "\u{2026}", 1 = "...", 2 = "" (omit); pads with +/// spaces when requested. +#[napi(js_name = "truncateToWidth")] +pub fn truncate_to_width( + text: JsString, + max_width: u32, + ellipsis_kind: u8, + pad: bool, + tab_width: Option, +) -> Result { + let max_width = max_width as usize; + let tab_width = clamp_tab_width(tab_width); + + let text_u16 = text.into_utf16()?; + let text = text_u16.as_slice(); + + // Fast path: early-exit width check + let (text_w, exceeded) = visible_width_u16_up_to(text, max_width, tab_width); + if !exceeded { + if !pad || text_w == max_width { + return Ok(utf16_to_string(text.to_vec())); + } + + let mut out = Vec::with_capacity(text.len() + (max_width - text_w)); + out.extend_from_slice(text); + out.resize(out.len() + (max_width - text_w), b' ' as u16); + return Ok(utf16_to_string(out)); + } + + const ELLIPSIS_UNICODE: &[u16] = &[0x2026]; + const ELLIPSIS_ASCII: &[u16] = &[0x2e, 0x2e, 0x2e]; + const ELLIPSIS_OMIT: &[u16] = &[]; + + let (ellipsis, ellipsis_w): (&[u16], usize) = match ellipsis_kind { + 0 => (ELLIPSIS_UNICODE, 1), + 1 => (ELLIPSIS_ASCII, 3), + 2 => (ELLIPSIS_OMIT, 0), + _ => (ELLIPSIS_UNICODE, 1), + }; + + let target_w = max_width.saturating_sub(ellipsis_w); + + if target_w == 0 { + let mut out = Vec::with_capacity(ellipsis.len().min(max_width * 2)); + let mut w = 0usize; + let _ = for_each_grapheme_u16_slow(ellipsis, tab_width, |gu16, gw| { + if w + gw > max_width { + return false; + } + out.extend_from_slice(gu16); + w += gw; + true + }); + + if pad && w < max_width { + out.resize(out.len() + (max_width - w), b' ' as u16); + } + return Ok(utf16_to_string(out)); + } + + let mut out = Vec::with_capacity(text.len().min(max_width * 2) + ellipsis.len() + 8); + let mut w = 0usize; + let mut i = 0usize; + let text_len = text.len(); + + let mut saw_sgr = false; + + while i < text_len { + if text[i] == ESC { + if let Some(seq_len) = ansi_seq_len_u16(text, i) { + let seq = &text[i..i + seq_len]; + out.extend_from_slice(seq); + if is_sgr_u16(seq) { + saw_sgr = true; + } + i += seq_len; + continue; + } + out.push(ESC); + i += 1; + continue; + } + + let start = i; + let mut is_ascii = true; + while i < text_len && text[i] != ESC { + if text[i] > 0x7f { + is_ascii = false; + } + i += 1; + } + let seg = &text[start..i]; + + if is_ascii { + for &u in seg { + let gw = ascii_cell_width_u16(u, tab_width); + if w + gw > target_w { + break; + } + out.push(u); + w += gw; + } + if w >= target_w { + break; + } + } else { + let keep_going = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| { + if w + gw > target_w { + return false; + } + out.extend_from_slice(gu16); + w += gw; + true + }); + if !keep_going { + break; + } + } + } + + if saw_sgr { + out.extend_from_slice(&[ESC, b'[' as u16, b'0' as u16, b'm' as u16]); + } + out.extend_from_slice(ellipsis); + + if pad { + let out_w = w + ellipsis_w; + if out_w < max_width { + out.resize(out.len() + (max_width - out_w), b' ' as u16); + } + } + + Ok(utf16_to_string(out)) +} + +// ============================================================================ +// sliceWithWidth +// ============================================================================ + +fn slice_with_width_impl( + line: &[u16], + start_col: usize, + length: usize, + strict: bool, + tab_width: usize, +) -> (Vec, usize) { + let end_col = start_col.saturating_add(length); + + let mut out = Vec::with_capacity(length * 2); + let mut out_w = 0usize; + + let mut current_col = 0usize; + let mut i = 0usize; + let line_len = line.len(); + + let mut pending_ansi: SmallVec<[(usize, usize); 4]> = SmallVec::new(); + + while i < line_len && current_col < end_col { + if line[i] == ESC { + if let Some(seq_len) = ansi_seq_len_u16(line, i) { + if current_col >= start_col { + out.extend_from_slice(&line[i..i + seq_len]); + } else { + pending_ansi.push((i, seq_len)); + } + i += seq_len; + continue; + } + if current_col >= start_col { + out.push(ESC); + } + i += 1; + continue; + } + + let start = i; + let mut is_ascii = true; + while i < line_len && line[i] != ESC { + if line[i] > 0x7f { + is_ascii = false; + } + i += 1; + } + let seg = &line[start..i]; + + if is_ascii { + for &u in seg { + if current_col >= end_col { + break; + } + let gw = ascii_cell_width_u16(u, tab_width); + let in_range = current_col >= start_col; + let fits = !strict || current_col + gw <= end_col; + + if in_range && fits { + if !pending_ansi.is_empty() { + for &(p, l) in &pending_ansi { + out.extend_from_slice(&line[p..p + l]); + } + pending_ansi.clear(); + } + out.push(u); + out_w += gw; + } + current_col += gw; + } + } else { + let _ = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| { + if current_col >= end_col { + return false; + } + + let in_range = current_col >= start_col; + let fits = !strict || current_col + gw <= end_col; + + if in_range && fits { + if !pending_ansi.is_empty() { + for &(p, l) in &pending_ansi { + out.extend_from_slice(&line[p..p + l]); + } + pending_ansi.clear(); + } + out.extend_from_slice(gu16); + out_w += gw; + } + + current_col += gw; + current_col < end_col + }); + } + } + + // Include trailing ANSI sequences (e.g., reset codes) that immediately follow + while i < line.len() { + if line[i] == ESC { + if let Some(len) = ansi_seq_len_u16(line, i) { + out.extend_from_slice(&line[i..i + len]); + i += len; + continue; + } + } + break; + } + + (out, out_w) +} + +/// Slice a range of visible columns from a line. +/// +/// Counts terminal cells, skipping ANSI escapes, and optionally enforces strict +/// width. +#[napi(js_name = "sliceWithWidth")] +pub fn slice_with_width( + line: JsString, + start_col: u32, + length: u32, + strict: bool, + tab_width: Option, +) -> Result { + let line_u16 = line.into_utf16()?; + let line = line_u16.as_slice(); + + let tab_width = clamp_tab_width(tab_width); + let (out, w) = + slice_with_width_impl(line, start_col as usize, length as usize, strict, tab_width); + + Ok(SliceResult { text: utf16_to_string(out), width: clamp_u32(w as u64) }) +} + +// ============================================================================ +// extractSegments +// ============================================================================ + +fn extract_segments_impl( + line: &[u16], + before_end: usize, + after_start: usize, + after_len: usize, + strict_after: bool, + tab_width: usize, +) -> (Vec, usize, Vec, usize) { + let after_end = after_start.saturating_add(after_len); + + let mut before = Vec::with_capacity(before_end * 2); + let mut before_w = 0usize; + + let mut after = Vec::with_capacity(after_len * 2); + let mut after_w = 0usize; + + let mut current_col = 0usize; + let mut i = 0usize; + let line_len = line.len(); + + let mut pending_before_ansi: SmallVec<[(usize, usize); 4]> = SmallVec::new(); + + let mut after_started = false; + let mut state = AnsiState::new(); + + let done_col = if after_len == 0 { before_end } else { after_end }; + + while i < line_len && current_col < done_col { + if line[i] == ESC { + if let Some(seq_len) = ansi_seq_len_u16(line, i) { + let seq = &line[i..i + seq_len]; + if is_sgr_u16(seq) { + state.apply_sgr_u16(&seq[2..seq_len - 1]); + } + + if current_col < before_end { + pending_before_ansi.push((i, seq_len)); + } else if current_col >= after_start && current_col < after_end && after_started { + after.extend_from_slice(seq); + } + + i += seq_len; + continue; + } + + if current_col < before_end { + before.push(ESC); + } else if current_col >= after_start && current_col < after_end && after_started { + after.push(ESC); + } + i += 1; + continue; + } + + let start = i; + let mut is_ascii = true; + while i < line_len && line[i] != ESC { + if line[i] > 0x7f { + is_ascii = false; + } + i += 1; + } + let seg = &line[start..i]; + + if is_ascii { + for &u in seg { + if current_col >= done_col { + break; + } + let gw = ascii_cell_width_u16(u, tab_width); + + if current_col < before_end { + if !pending_before_ansi.is_empty() { + for &(p, l) in &pending_before_ansi { + before.extend_from_slice(&line[p..p + l]); + } + pending_before_ansi.clear(); + } + before.push(u); + before_w += gw; + } else if current_col >= after_start && current_col < after_end { + let fits = !strict_after || current_col + gw <= after_end; + if fits { + if !after_started { + state.write_restore_u16(&mut after); + after_started = true; + } + after.push(u); + after_w += gw; + } + } + current_col += gw; + } + } else { + let _ = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| { + if current_col >= done_col { + return false; + } + + if current_col < before_end { + if !pending_before_ansi.is_empty() { + for &(p, l) in &pending_before_ansi { + before.extend_from_slice(&line[p..p + l]); + } + pending_before_ansi.clear(); + } + before.extend_from_slice(gu16); + before_w += gw; + } else if current_col >= after_start && current_col < after_end { + let fits = !strict_after || current_col + gw <= after_end; + if fits { + if !after_started { + state.write_restore_u16(&mut after); + after_started = true; + } + after.extend_from_slice(gu16); + after_w += gw; + } + } + + current_col += gw; + true + }); + } + } + + (before, before_w, after, after_w) +} + +/// Extract the before/after slices around an overlay region. +/// +/// Preserves ANSI state so the `after` segment renders correctly after +/// truncation. +#[napi(js_name = "extractSegments")] +pub fn extract_segments( + line: JsString, + before_end: u32, + after_start: u32, + after_len: u32, + strict_after: bool, + tab_width: Option, +) -> Result { + let line_u16 = line.into_utf16()?; + let line = line_u16.as_slice(); + + let tab_width = clamp_tab_width(tab_width); + let (before, bw, after, aw) = extract_segments_impl( + line, + before_end as usize, + after_start as usize, + after_len as usize, + strict_after, + tab_width, + ); + + Ok(ExtractSegmentsResult { + before: utf16_to_string(before), + before_width: clamp_u32(bw as u64), + after: utf16_to_string(after), + after_width: clamp_u32(aw as u64), + }) +} + +// ============================================================================ +// sanitizeText +// ============================================================================ + +/// Strip ANSI escape sequences, remove control characters / lone surrogates, +/// and normalize line endings. +#[napi(js_name = "sanitizeText")] +pub fn sanitize_text(text: JsString) -> Result { + let text_u16 = text.into_utf16()?; + let data = text_u16.as_slice(); + + let mut did_change = false; + let mut out: Vec = Vec::new(); + let mut last = 0usize; + let mut i = 0usize; + let len = data.len(); + + while i < len { + let u = data[i]; + + if u == 0x09 || u == 0x0a { + i += 1; + continue; + } + + let mut remove_len = if u == ESC { + ansi_seq_len_u16(data, i).unwrap_or(0) + } else { + 0usize + }; + + if remove_len == 0 { + if u == 0x0d { + remove_len = 1; + } else if u <= 0x1f || u == 0x7f || (0x80..=0x9f).contains(&u) { + remove_len = 1; + } else if (0xd800..=0xdbff).contains(&u) { + if i + 1 < len { + let lo = data[i + 1]; + if (0xdc00..=0xdfff).contains(&lo) { + i += 2; + continue; + } + } + remove_len = 1; + } else if (0xdc00..=0xdfff).contains(&u) { + remove_len = 1; + } + } + + if remove_len == 0 { + i += 1; + continue; + } + + if !did_change { + did_change = true; + out = Vec::with_capacity(len); + } + if last != i { + out.extend_from_slice(&data[last..i]); + } + i += remove_len; + last = i; + } + + if !did_change { + return Ok(utf16_to_string(data.to_vec())); + } + if last < len { + out.extend_from_slice(&data[last..]); + } + Ok(utf16_to_string(out)) +} + +// ============================================================================ +// visibleWidth +// ============================================================================ + +/// Calculate visible width of text, excluding ANSI escape sequences. +/// +/// Tabs count as a fixed-width cell. +#[napi(js_name = "visibleWidth")] +pub fn visible_width_napi(text: JsString, tab_width: Option) -> Result { + let text_u16 = text.into_utf16()?; + let tab_width = clamp_tab_width(tab_width); + Ok(clamp_u32(visible_width_u16(text_u16.as_slice(), tab_width) as u64)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn to_u16(s: &str) -> Vec { + s.encode_utf16().collect() + } + + #[test] + fn test_visible_width() { + assert_eq!(visible_width_u16(&to_u16("hello"), DEFAULT_TAB_WIDTH), 5); + assert_eq!( + visible_width_u16(&to_u16("\x1b[31mhello\x1b[0m"), DEFAULT_TAB_WIDTH), + 5 + ); + assert_eq!( + visible_width_u16(&to_u16("\x1b[38;5;196mred\x1b[0m"), DEFAULT_TAB_WIDTH), + 3 + ); + assert_eq!( + visible_width_u16(&to_u16("a\tb"), DEFAULT_TAB_WIDTH), + 1 + DEFAULT_TAB_WIDTH + 1 + ); + } + + #[test] + fn test_visible_width_cjk() { + assert_eq!( + visible_width_u16(&to_u16("\u{4e16}\u{754c}"), DEFAULT_TAB_WIDTH), + 4 + ); + assert_eq!(visible_width_u16(&to_u16("a\u{4e16}b"), DEFAULT_TAB_WIDTH), 4); + } + + #[test] + fn test_visible_width_emoji() { + assert_eq!(visible_width_u16(&to_u16("\u{1f600}"), DEFAULT_TAB_WIDTH), 2); + } + + #[test] + fn test_ansi_detection() { + let data = to_u16("\x1b[31mred\x1b[0m"); + assert_eq!(ansi_seq_len_u16(&data, 0), Some(5)); + assert_eq!(ansi_seq_len_u16(&data, 8), Some(4)); + } + + #[test] + fn test_ansi_detection_osc() { + let data = to_u16("\x1b]0;title\x07rest"); + assert_eq!(ansi_seq_len_u16(&data, 0), Some(10)); + } + + #[test] + fn test_slice_basic() { + let data = to_u16("hello world"); + let (out, width) = slice_with_width_impl(&data, 0, 5, false, DEFAULT_TAB_WIDTH); + assert_eq!(String::from_utf16_lossy(&out), "hello"); + assert_eq!(width, 5); + } + + #[test] + fn test_slice_middle() { + let data = to_u16("hello world"); + let (out, width) = slice_with_width_impl(&data, 6, 5, false, DEFAULT_TAB_WIDTH); + assert_eq!(String::from_utf16_lossy(&out), "world"); + assert_eq!(width, 5); + } + + #[test] + fn test_slice_with_ansi() { + let data = to_u16("\x1b[31mhello\x1b[0m world"); + let (out, width) = slice_with_width_impl(&data, 0, 5, false, DEFAULT_TAB_WIDTH); + assert_eq!(String::from_utf16_lossy(&out), "\x1b[31mhello\x1b[0m"); + assert_eq!(width, 5); + } + + #[test] + fn test_early_exit() { + let data = to_u16(&"a]b".repeat(1000)); + let (w, exceeded) = visible_width_u16_up_to(&data, 10, DEFAULT_TAB_WIDTH); + assert!(exceeded); + assert!(w > 10); + } + + #[test] + fn test_wrap_text_basic() { + let data = to_u16("hello world"); + let lines = wrap_text_with_ansi_impl(&data, 5, DEFAULT_TAB_WIDTH); + assert_eq!(lines.len(), 2); + assert_eq!(String::from_utf16_lossy(&lines[0]), "hello"); + assert_eq!(String::from_utf16_lossy(&lines[1]), "world"); + } + + #[test] + fn test_wrap_text_with_ansi_preserves_color() { + let data = to_u16("\x1b[38;2;156;163;176mhello world\x1b[0m"); + let lines = wrap_text_with_ansi_impl(&data, 5, DEFAULT_TAB_WIDTH); + assert_eq!(lines.len(), 2); + let first = String::from_utf16_lossy(&lines[0]); + let second = String::from_utf16_lossy(&lines[1]); + assert!(first.starts_with("\x1b[38;2;156;163;176m")); + assert!(second.starts_with("\x1b[38;2;156;163;176m")); + assert!(second.contains("world")); + } + + #[test] + fn test_wrap_text_with_ansi_resets_strike() { + let data = to_u16( + "\x1b[38;5;196m\x1b[48;5;236m\x1b[9mstrikethrough content wraps\x1b[29m\x1b[0m", + ); + let lines = wrap_text_with_ansi_impl(&data, 12, DEFAULT_TAB_WIDTH); + assert!(lines.len() > 1); + + for line in &lines[..lines.len() - 1] { + let line_text = String::from_utf16_lossy(line); + if line_text.contains("\x1b[9m") { + assert!(line_text.ends_with("\x1b[29m")); + assert!(!line_text.ends_with("\x1b[0m")); + } + } + + for line in &lines[1..] { + let line_text = String::from_utf16_lossy(line); + assert!(line_text.contains("38;5;196")); + assert!(line_text.contains("48;5;236")); + } + } + + #[test] + fn test_wrap_text_multiline() { + let data = to_u16("line one\nline two"); + let lines = wrap_text_with_ansi_impl(&data, 20, DEFAULT_TAB_WIDTH); + assert_eq!(lines.len(), 2); + assert_eq!(String::from_utf16_lossy(&lines[0]), "line one"); + assert_eq!(String::from_utf16_lossy(&lines[1]), "line two"); + } + + #[test] + fn test_wrap_text_empty() { + let data = to_u16(""); + let lines = wrap_text_with_ansi_impl(&data, 10, DEFAULT_TAB_WIDTH); + assert_eq!(lines.len(), 1); + assert!(lines[0].is_empty()); + } + + #[test] + fn test_extract_segments_basic() { + let data = to_u16("hello world test"); + let (before, bw, after, aw) = + extract_segments_impl(&data, 5, 6, 5, false, DEFAULT_TAB_WIDTH); + assert_eq!(String::from_utf16_lossy(&before), "hello"); + assert_eq!(bw, 5); + assert_eq!(String::from_utf16_lossy(&after), "world"); + assert_eq!(aw, 5); + } + + #[test] + fn test_ansi_state_sgr_parsing() { + let mut state = AnsiState::new(); + let params = to_u16("1;31"); + state.apply_sgr_u16(¶ms); + assert!(state.attrs & ATTR_BOLD != 0); + assert_eq!(state.fg, 2); // 31 - 29 = 2 + + let params = to_u16("0"); + state.apply_sgr_u16(¶ms); + assert!(state.is_empty()); + } + + #[test] + fn test_ansi_state_256_color() { + let mut state = AnsiState::new(); + let params = to_u16("38;5;196"); + state.apply_sgr_u16(¶ms); + assert_eq!(state.fg, 0x100 | 196); + } + + #[test] + fn test_ansi_state_rgb_color() { + let mut state = AnsiState::new(); + let params = to_u16("38;2;255;128;0"); + state.apply_sgr_u16(¶ms); + assert_eq!(state.fg, 0x1000000 | (255 << 16) | (128 << 8) | 0); + } + + #[test] + fn test_clamp_u32_helper() { + assert_eq!(clamp_u32(0), 0); + assert_eq!(clamp_u32(42), 42); + assert_eq!(clamp_u32(u32::MAX as u64), u32::MAX); + assert_eq!(clamp_u32(u32::MAX as u64 + 1), u32::MAX); + } +} diff --git a/packages/native/package.json b/packages/native/package.json index adcfb2e48..da7716aaf 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 native modules via N-API", + "description": "Native Rust bindings for GSD \u2014 high-performance native modules 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 src/__tests__/ps.test.mjs src/__tests__/glob.test.mjs src/__tests__/clipboard.test.mjs src/__tests__/highlight.test.mjs" + "test": "node --test src/__tests__/grep.test.mjs src/__tests__/ps.test.mjs src/__tests__/glob.test.mjs src/__tests__/clipboard.test.mjs src/__tests__/highlight.test.mjs src/__tests__/html.test.mjs src/__tests__/text.test.mjs src/__tests__/fd.test.mjs src/__tests__/image.test.mjs" }, "exports": { ".": { @@ -34,6 +34,22 @@ "./ast": { "types": "./src/ast/index.ts", "import": "./src/ast/index.ts" + }, + "./html": { + "types": "./src/html/index.ts", + "import": "./src/html/index.ts" + }, + "./text": { + "types": "./src/text/index.ts", + "import": "./src/text/index.ts" + }, + "./fd": { + "types": "./src/fd/index.ts", + "import": "./src/fd/index.ts" + }, + "./image": { + "types": "./src/image/index.ts", + "import": "./src/image/index.ts" } }, "files": [ diff --git a/packages/native/src/__tests__/fd.test.mjs b/packages/native/src/__tests__/fd.test.mjs new file mode 100644 index 000000000..4a478fad8 --- /dev/null +++ b/packages/native/src/__tests__/fd.test.mjs @@ -0,0 +1,164 @@ +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"; +import * as fs from "node:fs"; +import * as os from "node:os"; + +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); +} + +describe("native fd: fuzzyFind()", () => { + test("finds files matching a query", (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-fd-test-")); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + fs.writeFileSync(path.join(tmpDir, "main.rs"), "fn main() {}"); + fs.writeFileSync(path.join(tmpDir, "lib.rs"), "pub mod lib;"); + fs.writeFileSync(path.join(tmpDir, "utils.ts"), "export {}"); + fs.mkdirSync(path.join(tmpDir, "src")); + fs.writeFileSync(path.join(tmpDir, "src", "helper.rs"), "fn helper() {}"); + + const result = native.fuzzyFind({ query: "main", path: tmpDir }); + + assert.ok(result.matches.length > 0, "Should find at least one match"); + assert.equal(result.matches[0].path, "main.rs"); + assert.equal(result.matches[0].isDirectory, false); + assert.ok(result.matches[0].score > 0); + }); + + test("returns empty results for non-matching query", (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-fd-test-")); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + fs.writeFileSync(path.join(tmpDir, "hello.txt"), "hello"); + + const result = native.fuzzyFind({ + query: "zzzznotexist", + path: tmpDir, + }); + + assert.equal(result.matches.length, 0); + assert.equal(result.totalMatches, 0); + }); + + test("respects maxResults limit", (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-fd-test-")); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + for (let i = 0; i < 10; i++) { + fs.writeFileSync(path.join(tmpDir, `file${i}.txt`), "content"); + } + + const result = native.fuzzyFind({ + query: "file", + path: tmpDir, + maxResults: 3, + }); + + assert.equal(result.matches.length, 3); + assert.ok(result.totalMatches >= 3); + }); + + test("directories have trailing slash and bonus score", (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-fd-test-")); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + fs.mkdirSync(path.join(tmpDir, "models")); + fs.writeFileSync(path.join(tmpDir, "models.ts"), "export {}"); + + const result = native.fuzzyFind({ query: "models", path: tmpDir }); + + const dirMatch = result.matches.find((m) => m.isDirectory); + const fileMatch = result.matches.find((m) => !m.isDirectory); + + assert.ok(dirMatch, "Should find a directory match"); + assert.ok(fileMatch, "Should find a file match"); + assert.ok(dirMatch.path.endsWith("/"), "Directory should have trailing slash"); + assert.ok(dirMatch.score > fileMatch.score, "Directory should score higher"); + }); + + test("empty query returns all entries", (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-fd-test-")); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + fs.writeFileSync(path.join(tmpDir, "a.txt"), "a"); + fs.writeFileSync(path.join(tmpDir, "b.txt"), "b"); + fs.writeFileSync(path.join(tmpDir, "c.txt"), "c"); + + const result = native.fuzzyFind({ query: "", path: tmpDir }); + + assert.equal(result.matches.length, 3); + }); + + test("errors on non-existent path", () => { + assert.throws( + () => native.fuzzyFind({ query: "test", path: "/nonexistent/path" }), + { message: /Path not found/ }, + ); + }); + + test("fuzzy subsequence matching works", (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-fd-test-")); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + fs.writeFileSync(path.join(tmpDir, "MyComponentFile.tsx"), "export {}"); + fs.writeFileSync(path.join(tmpDir, "other.txt"), "other"); + + // "mcf" should fuzzy-match "MyComponentFile" via subsequence + const result = native.fuzzyFind({ query: "mcf", path: tmpDir }); + + assert.ok(result.matches.length > 0, "Fuzzy subsequence should match"); + assert.ok( + result.matches.some((m) => m.path.includes("MyComponentFile")), + "Should find MyComponentFile via fuzzy match", + ); + }); + + test("results are sorted by score descending", (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-fd-test-")); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + fs.writeFileSync(path.join(tmpDir, "main.ts"), ""); + fs.writeFileSync(path.join(tmpDir, "my_main.ts"), ""); + fs.mkdirSync(path.join(tmpDir, "src")); + fs.writeFileSync(path.join(tmpDir, "src", "main.rs"), ""); + + const result = native.fuzzyFind({ + query: "main", + path: tmpDir, + maxResults: 100, + }); + + for (let i = 1; i < result.matches.length; i++) { + assert.ok( + result.matches[i - 1].score >= result.matches[i].score, + `Match ${i - 1} (score ${result.matches[i - 1].score}) should be >= match ${i} (score ${result.matches[i].score})`, + ); + } + }); +}); diff --git a/packages/native/src/__tests__/html.test.mjs b/packages/native/src/__tests__/html.test.mjs new file mode 100644 index 000000000..31e21c463 --- /dev/null +++ b/packages/native/src/__tests__/html.test.mjs @@ -0,0 +1,98 @@ +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); + +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); +} + +describe("native html: htmlToMarkdown()", () => { + test("converts basic HTML to markdown", () => { + const html = "

Hello

World

"; + const result = native.htmlToMarkdown(html); + assert.ok(result.includes("Hello"), "Should contain heading text"); + assert.ok(result.includes("World"), "Should contain paragraph text"); + }); + + test("converts links to markdown links", () => { + const html = '

Visit Example

'; + const result = native.htmlToMarkdown(html); + assert.ok(result.includes("[Example]"), "Should contain markdown link text"); + assert.ok(result.includes("(https://example.com)"), "Should contain markdown link URL"); + }); + + test("converts lists to markdown", () => { + const html = "
  • First
  • Second
  • Third
"; + const result = native.htmlToMarkdown(html); + assert.ok(result.includes("First"), "Should contain first item"); + assert.ok(result.includes("Second"), "Should contain second item"); + assert.ok(result.includes("Third"), "Should contain third item"); + }); + + test("converts bold and italic", () => { + const html = "

bold and italic

"; + const result = native.htmlToMarkdown(html); + assert.ok(result.includes("**bold**") || result.includes("__bold__"), "Should contain bold"); + assert.ok(result.includes("*italic*") || result.includes("_italic_"), "Should contain italic"); + }); + + test("handles empty HTML", () => { + const result = native.htmlToMarkdown(""); + assert.equal(typeof result, "string"); + }); + + test("handles plain text", () => { + const result = native.htmlToMarkdown("Just plain text"); + assert.ok(result.includes("Just plain text"), "Should preserve plain text"); + }); + + test("accepts skipImages option", () => { + const html = '

Title

Content with photo image

'; + const result = native.htmlToMarkdown(html, { skipImages: true }); + assert.ok(result.includes("Title"), "Should contain heading"); + assert.ok(result.includes("Content"), "Should contain paragraph text"); + }); + + test("accepts cleanContent option", () => { + const html = '

Article

Body text.

Copyright
'; + const result = native.htmlToMarkdown(html, { cleanContent: true }); + assert.ok(result.includes("Article") || result.includes("Body text"), "Should contain main content"); + }); + + test("converts code blocks", () => { + const html = "
const x = 1;
"; + const result = native.htmlToMarkdown(html); + assert.ok(result.includes("const x = 1;"), "Should contain code content"); + }); + + test("converts complex nested HTML", () => { + const html = '

Section

Text with bold link.

  • Item one
  • Item two
'; + const result = native.htmlToMarkdown(html); + assert.ok(result.includes("Section"), "Should contain heading"); + assert.ok(result.includes("example.com"), "Should contain link"); + assert.ok(result.includes("one"), "Should contain list items"); + }); +}); diff --git a/packages/native/src/__tests__/image.test.mjs b/packages/native/src/__tests__/image.test.mjs new file mode 100644 index 000000000..91f297ed6 --- /dev/null +++ b/packages/native/src/__tests__/image.test.mjs @@ -0,0 +1,137 @@ +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"; +import { deflateSync } from "node:zlib"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +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); +} + +function crc32(buf) { + let crc = 0xffffffff; + const table = []; + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c; + } + for (let i = 0; i < buf.length; i++) crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function createTestPng() { + const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + const ihdrData = Buffer.alloc(13); + ihdrData.writeUInt32BE(2, 0); + ihdrData.writeUInt32BE(2, 4); + ihdrData[8] = 8; + ihdrData[9] = 2; + const ihdrType = Buffer.from("IHDR"); + const ihdrCrc = Buffer.alloc(4); + ihdrCrc.writeUInt32BE(crc32(Buffer.concat([ihdrType, ihdrData]))); + const ihdr = Buffer.concat([Buffer.from([0, 0, 0, 13]), ihdrType, ihdrData, ihdrCrc]); + + const raw = Buffer.from([ + 0, 255, 0, 0, 255, 0, 0, + 0, 255, 0, 0, 255, 0, 0, + ]); + const compressed = deflateSync(raw); + const idatType = Buffer.from("IDAT"); + const idatLen = Buffer.alloc(4); + idatLen.writeUInt32BE(compressed.length); + const idatCrc = Buffer.alloc(4); + idatCrc.writeUInt32BE(crc32(Buffer.concat([idatType, compressed]))); + const idat = Buffer.concat([idatLen, idatType, compressed, idatCrc]); + + const iendType = Buffer.from("IEND"); + const iendCrc = Buffer.alloc(4); + iendCrc.writeUInt32BE(crc32(iendType)); + const iend = Buffer.concat([Buffer.from([0, 0, 0, 0]), iendType, iendCrc]); + + return Buffer.concat([signature, ihdr, idat, iend]); +} + +const NativeImage = native.NativeImage; + +describe("native image: NativeImage", () => { + test("NativeImage class exists with parse method", () => { + assert.ok(NativeImage, "NativeImage should be exported"); + assert.equal(typeof NativeImage.parse, "function"); + }); + + test("parse decodes PNG with correct dimensions", async () => { + const img = await NativeImage.parse(createTestPng()); + assert.equal(img.width, 2); + assert.equal(img.height, 2); + }); + + test("encode to PNG produces valid PNG", async () => { + const img = await NativeImage.parse(createTestPng()); + const encoded = await img.encode(0, 100); + assert.ok(encoded.length > 0); + assert.equal(encoded[0], 0x89); + assert.equal(encoded[1], 0x50); + assert.equal(encoded[2], 0x4e); + assert.equal(encoded[3], 0x47); + }); + + test("encode to JPEG produces valid JPEG", async () => { + const img = await NativeImage.parse(createTestPng()); + const encoded = await img.encode(1, 80); + assert.ok(encoded.length > 0); + assert.equal(encoded[0], 0xff); + assert.equal(encoded[1], 0xd8); + }); + + test("resize returns correct dimensions", async () => { + const img = await NativeImage.parse(createTestPng()); + const resized = await img.resize(10, 20, 5); + assert.equal(resized.width, 10); + assert.equal(resized.height, 20); + }); + + test("resize + encode round-trip", async () => { + const img = await NativeImage.parse(createTestPng()); + const resized = await img.resize(4, 4, 1); + const encoded = await resized.encode(0, 100); + assert.ok(encoded.length > 0); + const reparsed = await NativeImage.parse(new Uint8Array(encoded)); + assert.equal(reparsed.width, 4); + assert.equal(reparsed.height, 4); + }); + + test("rejects invalid image data", async () => { + await assert.rejects( + () => NativeImage.parse(new Uint8Array([0, 1, 2, 3, 4, 5])), + /Failed to (detect|decode) image/, + ); + }); + + test("rejects invalid format number", async () => { + const img = await NativeImage.parse(createTestPng()); + await assert.rejects(() => img.encode(99, 100), /Invalid image format/); + }); +}); diff --git a/packages/native/src/__tests__/text.test.mjs b/packages/native/src/__tests__/text.test.mjs new file mode 100644 index 000000000..1c101a7e6 --- /dev/null +++ b/packages/native/src/__tests__/text.test.mjs @@ -0,0 +1,262 @@ +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); +} + +// ── visibleWidth ─────────────────────────────────────────────────────── + +describe("visibleWidth", () => { + test("plain ASCII text", () => { + assert.equal(native.visibleWidth("hello"), 5); + }); + + test("empty string", () => { + assert.equal(native.visibleWidth(""), 0); + }); + + test("ignores ANSI SGR codes", () => { + assert.equal(native.visibleWidth("\x1b[31mhello\x1b[0m"), 5); + }); + + test("ignores 256-color ANSI", () => { + assert.equal(native.visibleWidth("\x1b[38;5;196mred\x1b[0m"), 3); + }); + + test("ignores RGB ANSI", () => { + assert.equal( + native.visibleWidth("\x1b[38;2;255;128;0morange\x1b[0m"), + 6, + ); + }); + + test("counts tabs with default width", () => { + // default tab width = 3 + assert.equal(native.visibleWidth("a\tb"), 1 + 3 + 1); + }); + + test("counts tabs with custom width", () => { + assert.equal(native.visibleWidth("a\tb", 4), 1 + 4 + 1); + }); + + test("CJK double-width characters", () => { + assert.equal(native.visibleWidth("\u4e16\u754c"), 4); // 世界 + }); + + test("mixed ASCII and CJK", () => { + assert.equal(native.visibleWidth("a\u4e16b"), 4); // a + 2 + 1 + }); +}); + +// ── wrapTextWithAnsi ─────────────────────────────────────────────────── + +describe("wrapTextWithAnsi", () => { + test("wraps plain text at word boundary", () => { + const lines = native.wrapTextWithAnsi("hello world", 5); + assert.equal(lines.length, 2); + assert.equal(lines[0], "hello"); + assert.equal(lines[1], "world"); + }); + + test("no wrap needed", () => { + const lines = native.wrapTextWithAnsi("hi", 10); + assert.equal(lines.length, 1); + assert.equal(lines[0], "hi"); + }); + + test("empty string produces one empty line", () => { + const lines = native.wrapTextWithAnsi("", 10); + assert.equal(lines.length, 1); + assert.equal(lines[0], ""); + }); + + test("preserves ANSI color across wrap", () => { + const lines = native.wrapTextWithAnsi( + "\x1b[38;2;156;163;176mhello world\x1b[0m", + 5, + ); + assert.equal(lines.length, 2); + assert.ok(lines[0].startsWith("\x1b[38;2;156;163;176m")); + assert.ok(lines[1].startsWith("\x1b[38;2;156;163;176m")); + assert.ok(lines[1].includes("world")); + }); + + test("handles multiline input (newlines)", () => { + const lines = native.wrapTextWithAnsi("line one\nline two", 20); + assert.equal(lines.length, 2); + assert.equal(lines[0], "line one"); + assert.equal(lines[1], "line two"); + }); + + test("breaks long words", () => { + const lines = native.wrapTextWithAnsi("abcdefghij", 5); + assert.equal(lines.length, 2); + assert.equal(lines[0], "abcde"); + assert.equal(lines[1], "fghij"); + }); +}); + +// ── truncateToWidth ──────────────────────────────────────────────────── + +describe("truncateToWidth", () => { + test("returns original when fits", () => { + const result = native.truncateToWidth("hello", 10, 0, false); + assert.equal(result, "hello"); + }); + + test("truncates with unicode ellipsis", () => { + const result = native.truncateToWidth("hello world", 6, 0, false); + assert.equal(native.visibleWidth(result), 6); + assert.ok(result.includes("\u2026")); + }); + + test("truncates with ASCII ellipsis", () => { + const result = native.truncateToWidth("hello world", 8, 1, false); + assert.ok(result.includes("...")); + }); + + test("truncates with no ellipsis", () => { + const result = native.truncateToWidth("hello world", 5, 2, false); + assert.equal(native.visibleWidth(result), 5); + assert.ok(!result.includes("\u2026")); + assert.ok(!result.includes("...")); + }); + + test("pads to width", () => { + const result = native.truncateToWidth("hi", 10, 0, true); + assert.equal(native.visibleWidth(result), 10); + }); + + test("preserves ANSI codes and resets on truncation", () => { + const input = "\x1b[31mhello world\x1b[0m"; + const result = native.truncateToWidth(input, 6, 0, false); + // Should contain the red code and a reset before ellipsis + assert.ok(result.includes("\x1b[31m")); + assert.ok(result.includes("\x1b[0m")); + }); +}); + +// ── sliceWithWidth ───────────────────────────────────────────────────── + +describe("sliceWithWidth", () => { + test("slices from start", () => { + const result = native.sliceWithWidth("hello world", 0, 5, false); + assert.equal(result.text, "hello"); + assert.equal(result.width, 5); + }); + + test("slices from middle", () => { + const result = native.sliceWithWidth("hello world", 6, 5, false); + assert.equal(result.text, "world"); + assert.equal(result.width, 5); + }); + + test("preserves ANSI codes in slice", () => { + const result = native.sliceWithWidth( + "\x1b[31mhello\x1b[0m world", + 0, + 5, + false, + ); + assert.equal(result.text, "\x1b[31mhello\x1b[0m"); + assert.equal(result.width, 5); + }); + + test("empty slice", () => { + const result = native.sliceWithWidth("hello", 0, 0, false); + assert.equal(result.text, ""); + assert.equal(result.width, 0); + }); + + test("beyond string length", () => { + const result = native.sliceWithWidth("hi", 0, 100, false); + assert.equal(result.text, "hi"); + assert.equal(result.width, 2); + }); +}); + +// ── extractSegments ──────────────────────────────────────────────────── + +describe("extractSegments", () => { + test("extracts before and after segments", () => { + const result = native.extractSegments( + "hello world test", + 5, + 6, + 5, + false, + ); + assert.equal(result.before, "hello"); + assert.equal(result.beforeWidth, 5); + assert.equal(result.after, "world"); + assert.equal(result.afterWidth, 5); + }); + + test("handles no after segment", () => { + const result = native.extractSegments("hello world", 5, 0, 0, false); + assert.equal(result.before, "hello"); + assert.equal(result.beforeWidth, 5); + assert.equal(result.after, ""); + assert.equal(result.afterWidth, 0); + }); +}); + +// ── sanitizeText ─────────────────────────────────────────────────────── + +describe("sanitizeText", () => { + test("strips ANSI codes", () => { + assert.equal(native.sanitizeText("\x1b[31mhello\x1b[0m"), "hello"); + }); + + test("returns original when clean", () => { + assert.equal(native.sanitizeText("hello"), "hello"); + }); + + test("removes control characters", () => { + assert.equal(native.sanitizeText("he\x01llo"), "hello"); + }); + + test("preserves tabs and newlines", () => { + assert.equal(native.sanitizeText("a\tb\nc"), "a\tb\nc"); + }); + + test("normalizes CR", () => { + assert.equal(native.sanitizeText("hello\r\nworld"), "hello\nworld"); + }); +}); diff --git a/packages/native/src/fd/index.ts b/packages/native/src/fd/index.ts new file mode 100644 index 000000000..3dc413922 --- /dev/null +++ b/packages/native/src/fd/index.ts @@ -0,0 +1,35 @@ +/** + * Native fuzzy file path discovery using N-API. + * + * High-performance fuzzy file search for autocomplete and @-mention resolution. + * Backed by Rust's `ignore` crate for directory walking with subsequence scoring. + */ + +import { native } from "../native.js"; +import type { + FuzzyFindMatch, + FuzzyFindOptions, + FuzzyFindResult, +} from "./types.js"; + +export type { FuzzyFindMatch, FuzzyFindOptions, FuzzyFindResult }; + +/** + * Fuzzy file path search. + * + * Searches for files and directories whose paths match the query string. + * Results are sorted by match quality (higher score = better match). + * + * Scoring tiers (highest to lowest): + * - 120: exact filename match + * - 100: filename starts with query + * - 80: filename contains query + * - 60: full path contains query + * - 50-90: fuzzy subsequence match on filename + * - 30-70: fuzzy subsequence match on full path + * + * Directories receive a +10 score bonus. + */ +export function fuzzyFind(options: FuzzyFindOptions): FuzzyFindResult { + return native.fuzzyFind(options) as FuzzyFindResult; +} diff --git a/packages/native/src/fd/types.ts b/packages/native/src/fd/types.ts new file mode 100644 index 000000000..dacbe7dca --- /dev/null +++ b/packages/native/src/fd/types.ts @@ -0,0 +1,31 @@ +/** Options for fuzzy file path search. */ +export interface FuzzyFindOptions { + /** Fuzzy query to match against file paths (case-insensitive). */ + query: string; + /** Directory to search. */ + path: string; + /** Include hidden files (default: false). */ + hidden?: boolean; + /** Respect .gitignore (default: true). */ + gitignore?: boolean; + /** Maximum number of matches to return (default: 100). */ + maxResults?: number; +} + +/** A single match in fuzzy find results. */ +export interface FuzzyFindMatch { + /** Relative path from the search root (uses `/` separators). Directories have a trailing `/`. */ + path: string; + /** Whether this entry is a directory. */ + isDirectory: boolean; + /** Match quality score (higher is better). */ + score: number; +} + +/** Result of fuzzy file path search. */ +export interface FuzzyFindResult { + /** Matched entries (up to `maxResults`), sorted by score descending. */ + matches: FuzzyFindMatch[]; + /** Total number of matches found (may exceed `matches.length`). */ + totalMatches: number; +} diff --git a/packages/native/src/html/index.ts b/packages/native/src/html/index.ts new file mode 100644 index 000000000..28886b7a2 --- /dev/null +++ b/packages/native/src/html/index.ts @@ -0,0 +1,24 @@ +/** + * HTML to Markdown conversion via native Rust bindings. + * + * Uses `html-to-markdown-rs` under the hood for high-performance + * conversion with optional content cleaning (stripping nav, forms, etc.). + */ + +import { native } from "../native.js"; +import type { HtmlToMarkdownOptions } from "./types.js"; + +export type { HtmlToMarkdownOptions }; + +/** + * Convert an HTML string to Markdown. + * + * When `cleanContent` is true, boilerplate elements (nav, forms, headers, + * footers) are stripped before conversion. + */ +export function htmlToMarkdown( + html: string, + options?: HtmlToMarkdownOptions, +): string { + return native.htmlToMarkdown(html, options ?? {}) as string; +} diff --git a/packages/native/src/html/types.ts b/packages/native/src/html/types.ts new file mode 100644 index 000000000..a8984c7a8 --- /dev/null +++ b/packages/native/src/html/types.ts @@ -0,0 +1,7 @@ +/** Options for HTML to Markdown conversion. */ +export interface HtmlToMarkdownOptions { + /** Remove navigation elements, forms, headers, footers. */ + cleanContent?: boolean; + /** Skip images during conversion. */ + skipImages?: boolean; +} diff --git a/packages/native/src/image/index.ts b/packages/native/src/image/index.ts new file mode 100644 index 000000000..d27df47bb --- /dev/null +++ b/packages/native/src/image/index.ts @@ -0,0 +1,28 @@ +/** + * Native image processing module using N-API. + * + * High-performance image decode/encode/resize backed by the Rust `image` crate. + */ + +import { native } from "../native.js"; +import type { NativeImageHandle } from "./types.js"; +import { ImageFormat, SamplingFilter } from "./types.js"; + +export { ImageFormat, SamplingFilter }; +export type { NativeImageHandle }; + +const NativeImageClass = (native as Record) + .NativeImage as NativeImageConstructor; + +interface NativeImageConstructor { + parse(bytes: Uint8Array): Promise; +} + +/** + * Decode image bytes (PNG, JPEG, WebP, GIF) into a NativeImage handle. + * + * Format is auto-detected from the byte content. + */ +export function parseImage(bytes: Uint8Array): Promise { + return NativeImageClass.parse(bytes); +} diff --git a/packages/native/src/image/types.ts b/packages/native/src/image/types.ts new file mode 100644 index 000000000..5a9dbb8b5 --- /dev/null +++ b/packages/native/src/image/types.ts @@ -0,0 +1,41 @@ +/** Sampling filter for resize operations. */ +export enum SamplingFilter { + /** Nearest-neighbor sampling (fast, low quality). */ + Nearest = 1, + /** Triangle filter (linear interpolation). */ + Triangle = 2, + /** Catmull-Rom filter with sharper edges. */ + CatmullRom = 3, + /** Gaussian filter for smoother results. */ + Gaussian = 4, + /** Lanczos3 filter for high-quality downscaling. */ + Lanczos3 = 5, +} + +/** Output image format for encoding. */ +export enum ImageFormat { + /** PNG (lossless, quality ignored). */ + PNG = 0, + /** JPEG (lossy, quality 0-100). */ + JPEG = 1, + /** WebP (lossless, quality ignored). */ + WebP = 2, + /** GIF (quality ignored). */ + GIF = 3, +} + +/** Native image handle returned from parse(). */ +export interface NativeImageHandle { + /** Image width in pixels. */ + readonly width: number; + /** Image height in pixels. */ + readonly height: number; + /** Encode to bytes in the specified format. Returns a Promise. */ + encode(format: number, quality: number): Promise; + /** Resize to the specified dimensions. Returns a new NativeImage Promise. */ + resize( + width: number, + height: number, + filter: SamplingFilter, + ): Promise; +} diff --git a/packages/native/src/index.ts b/packages/native/src/index.ts index c43f05ab8..989b59bac 100644 --- a/packages/native/src/index.ts +++ b/packages/native/src/index.ts @@ -7,6 +7,11 @@ * - ps: cross-platform process tree management * - glob: gitignore-respecting filesystem discovery with scan caching * - highlight: syntect-based syntax highlighting + * - html: HTML to Markdown conversion + * - text: ANSI-aware text measurement and slicing + * - fd: fuzzy file path discovery + * - image: decode, encode, and resize images + for autocomplete and @-mention resolution */ export { @@ -54,3 +59,27 @@ export type { AstFindMatch, AstFindOptions, AstFindResult, AstReplaceChange, AstReplaceFileChange, AstReplaceOptions, AstReplaceResult, } from "./ast/index.js"; + +export { htmlToMarkdown } from "./html/index.js"; +export type { HtmlToMarkdownOptions } from "./html/index.js"; + +export { + wrapTextWithAnsi, + truncateToWidth, + sliceWithWidth, + extractSegments, + sanitizeText, + visibleWidth, + EllipsisKind, +} from "./text/index.js"; +export type { SliceResult, ExtractSegmentsResult } from "./text/index.js"; + +export { fuzzyFind } from "./fd/index.js"; +export type { + FuzzyFindMatch, + FuzzyFindOptions, + FuzzyFindResult, +} from "./fd/index.js"; + +export { parseImage, ImageFormat, SamplingFilter } from "./image/index.js"; +export type { NativeImageHandle } from "./image/index.js"; diff --git a/packages/native/src/native.ts b/packages/native/src/native.ts index 039efc10e..9ef18e863 100644 --- a/packages/native/src/native.ts +++ b/packages/native/src/native.ts @@ -60,4 +60,32 @@ export const native = loadNative() as { readImageFromClipboard: () => Promise; astGrep: (options: unknown) => unknown; astEdit: (options: unknown) => unknown; + htmlToMarkdown: (html: string, options: unknown) => unknown; + wrapTextWithAnsi: (text: string, width: number, tabWidth?: number) => string[]; + truncateToWidth: ( + text: string, + maxWidth: number, + ellipsisKind: number, + pad: boolean, + tabWidth?: number, + ) => string; + sliceWithWidth: ( + line: string, + startCol: number, + length: number, + strict: boolean, + tabWidth?: number, + ) => unknown; + extractSegments: ( + line: string, + beforeEnd: number, + afterStart: number, + afterLen: number, + strictAfter: boolean, + tabWidth?: number, + ) => unknown; + sanitizeText: (text: string) => string; + visibleWidth: (text: string, tabWidth?: number) => number; + fuzzyFind: (options: unknown) => unknown; + NativeImage: unknown; }; diff --git a/packages/native/src/text/index.ts b/packages/native/src/text/index.ts new file mode 100644 index 000000000..9c4e5be86 --- /dev/null +++ b/packages/native/src/text/index.ts @@ -0,0 +1,125 @@ +/** + * ANSI-aware text measurement and slicing. + * + * High-performance UTF-16 native implementation with ASCII fast-paths, + * single-pass ANSI scanning, and proper Unicode grapheme cluster support. + */ + +import { native } from "../native.js"; +import type { ExtractSegmentsResult, SliceResult } from "./types.js"; + +export type { ExtractSegmentsResult, SliceResult }; +export { EllipsisKind } from "./types.js"; + +/** + * Word-wrap text to a visible width, preserving ANSI escape codes across + * line breaks. + * + * Active SGR codes (colors, bold, etc.) are carried to continuation lines. + * Underline and strikethrough are reset at line ends and restored on the + * next line. + */ +export function wrapTextWithAnsi( + text: string, + width: number, + tabWidth?: number, +): string[] { + return (native as Record).wrapTextWithAnsi( + text, + width, + tabWidth, + ) as string[]; +} + +/** + * Truncate text to a visible width with an optional ellipsis. + * + * @param text Input string (may contain ANSI codes). + * @param maxWidth Maximum visible width in terminal cells. + * @param ellipsisKind 0 = "\u2026", 1 = "...", 2 = none. + * @param pad When true, pad with spaces to exactly `maxWidth`. + * @param tabWidth Tab stop width (default 3, range 1-16). + */ +export function truncateToWidth( + text: string, + maxWidth: number, + ellipsisKind: number, + pad: boolean, + tabWidth?: number, +): string { + return (native as Record).truncateToWidth( + text, + maxWidth, + ellipsisKind, + pad, + tabWidth, + ) as string; +} + +/** + * Slice a range of visible columns from a line. + * + * Counts terminal cells (skipping ANSI escapes). When `strict` is true, + * wide characters that would exceed the range are excluded. + */ +export function sliceWithWidth( + line: string, + startCol: number, + length: number, + strict: boolean, + tabWidth?: number, +): SliceResult { + return (native as Record).sliceWithWidth( + line, + startCol, + length, + strict, + tabWidth, + ) as SliceResult; +} + +/** + * Extract the before/after segments around an overlay region. + * + * ANSI state is tracked so the `after` segment renders correctly even when + * the overlay truncates styled text. + */ +export function extractSegments( + line: string, + beforeEnd: number, + afterStart: number, + afterLen: number, + strictAfter: boolean, + tabWidth?: number, +): ExtractSegmentsResult { + return (native as Record).extractSegments( + line, + beforeEnd, + afterStart, + afterLen, + strictAfter, + tabWidth, + ) as ExtractSegmentsResult; +} + +/** + * Strip ANSI escape sequences, remove control characters and lone + * surrogates, and normalize line endings (CR removed). + * + * Returns the original string when no changes are needed (zero-copy). + */ +export function sanitizeText(text: string): string { + return (native as Record).sanitizeText(text) as string; +} + +/** + * Calculate visible width of text excluding ANSI escape sequences. + * + * Tabs count as `tabWidth` cells (default 3). + */ +export function visibleWidth(text: string, tabWidth?: number): number { + return (native as Record).visibleWidth( + text, + tabWidth, + ) as number; +} diff --git a/packages/native/src/text/types.ts b/packages/native/src/text/types.ts new file mode 100644 index 000000000..e25e5ca56 --- /dev/null +++ b/packages/native/src/text/types.ts @@ -0,0 +1,29 @@ +/** Result of slicing a line by visible column range. */ +export interface SliceResult { + /** The extracted text (may include ANSI codes). */ + text: string; + /** Visible width of the extracted slice in terminal cells. */ + width: number; +} + +/** Result of extracting before/after segments around an overlay. */ +export interface ExtractSegmentsResult { + /** Text content before the overlay region. */ + before: string; + /** Visible width of the `before` segment. */ + beforeWidth: number; + /** Text content after the overlay region. */ + after: string; + /** Visible width of the `after` segment. */ + afterWidth: number; +} + +/** Ellipsis style for truncation. */ +export enum EllipsisKind { + /** Unicode ellipsis character: \u2026 (width 1) */ + Unicode = 0, + /** ASCII ellipsis: "..." (width 3) */ + Ascii = 1, + /** No ellipsis (hard truncate) */ + None = 2, +}