feat: integrate 5 native modules (html, text, fd, image, ast)

feat: integrate 5 native modules (html, text, fd, image, ast)
This commit is contained in:
TÂCHES 2026-03-13 13:25:49 -06:00 committed by GitHub
commit 7aee82d059
22 changed files with 3475 additions and 482 deletions

668
native/Cargo.lock generated
View file

@ -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",
]

View file

@ -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"

View file

@ -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<bool>,
/// Respect .gitignore (default: true).
pub gitignore: Option<bool>,
/// Maximum number of matches to return (default: 100).
#[napi(js_name = "maxResults")]
pub max_results: Option<u32>,
}
/// 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<FuzzyFindMatch>,
/// 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<std::path::PathBuf> {
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<usize> = 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<WalkEntry> {
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<FuzzyFindResult> {
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<char> = 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<FuzzyFindMatch> = 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<char> = "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<char> = "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<char> = "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<char> = 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::<Vec<_>>(),
);
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::<Vec<_>>(),
);
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::<Vec<_>>(),
);
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::<Vec<_>>(),
);
let dir_score = score_fuzzy_path(
"src/main.rs",
true,
"main.rs",
"mainrs",
&"mainrs".chars().collect::<Vec<_>>(),
);
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::<Vec<_>>(),
);
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:?}"
);
}
}

View file

@ -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<bool>,
/// Skip images during conversion.
#[napi(js_name = "skipImages")]
pub skip_images: Option<bool>,
}
/// 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<HtmlToMarkdownOptions>) -> Result<String> {
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}")))
}

View file

@ -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<SamplingFilter> 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<DynamicImage>,
}
type ImageTask = task::Async<NativeImage>;
#[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<Self> {
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<Vec<u8>> {
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<DynamicImage> {
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<Vec<u8>> {
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}"))),
}
}

View file

@ -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;

File diff suppressed because it is too large Load diff

View file

@ -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": [

View file

@ -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})`,
);
}
});
});

View file

@ -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 = "<h1>Hello</h1><p>World</p>";
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 = '<p>Visit <a href="https://example.com">Example</a></p>';
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 = "<ul><li>First</li><li>Second</li><li>Third</li></ul>";
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 = "<p><strong>bold</strong> and <em>italic</em></p>";
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 = '<h1>Title</h1><p>Content with <img src="photo.jpg" alt="photo"> image</p>';
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 = '<nav><a href="/home">Home</a></nav><main><h1>Article</h1><p>Body text.</p></main><footer>Copyright</footer>';
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 = "<pre><code>const x = 1;</code></pre>";
const result = native.htmlToMarkdown(html);
assert.ok(result.includes("const x = 1;"), "Should contain code content");
});
test("converts complex nested HTML", () => {
const html = '<div><h2>Section</h2><p>Text with <a href="https://example.com"><strong>bold link</strong></a>.</p><ul><li>Item one</li><li>Item two</li></ul></div>';
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");
});
});

View file

@ -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/);
});
});

View file

@ -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");
});
});

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<string, unknown>)
.NativeImage as NativeImageConstructor;
interface NativeImageConstructor {
parse(bytes: Uint8Array): Promise<NativeImageHandle>;
}
/**
* 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<NativeImageHandle> {
return NativeImageClass.parse(bytes);
}

View file

@ -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<number[]>;
/** Resize to the specified dimensions. Returns a new NativeImage Promise. */
resize(
width: number,
height: number,
filter: SamplingFilter,
): Promise<NativeImageHandle>;
}

View file

@ -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";

View file

@ -60,4 +60,32 @@ export const native = loadNative() as {
readImageFromClipboard: () => Promise<unknown>;
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;
};

View file

@ -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<string, Function>).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<string, Function>).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<string, Function>).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<string, Function>).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<string, Function>).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<string, Function>).visibleWidth(
text,
tabWidth,
) as number;
}

View file

@ -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,
}