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:
commit
7aee82d059
22 changed files with 3475 additions and 482 deletions
668
native/Cargo.lock
generated
668
native/Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
494
native/crates/engine/src/fd.rs
Normal file
494
native/crates/engine/src/fd.rs
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
44
native/crates/engine/src/html.rs
Normal file
44
native/crates/engine/src/html.rs
Normal 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}")))
|
||||
}
|
||||
137
native/crates/engine/src/image.rs
Normal file
137
native/crates/engine/src/image.rs
Normal 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}"))),
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
1536
native/crates/engine/src/text.rs
Normal file
1536
native/crates/engine/src/text.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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": [
|
||||
|
|
|
|||
164
packages/native/src/__tests__/fd.test.mjs
Normal file
164
packages/native/src/__tests__/fd.test.mjs
Normal 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})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
98
packages/native/src/__tests__/html.test.mjs
Normal file
98
packages/native/src/__tests__/html.test.mjs
Normal 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");
|
||||
});
|
||||
});
|
||||
137
packages/native/src/__tests__/image.test.mjs
Normal file
137
packages/native/src/__tests__/image.test.mjs
Normal 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/);
|
||||
});
|
||||
});
|
||||
262
packages/native/src/__tests__/text.test.mjs
Normal file
262
packages/native/src/__tests__/text.test.mjs
Normal 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");
|
||||
});
|
||||
});
|
||||
35
packages/native/src/fd/index.ts
Normal file
35
packages/native/src/fd/index.ts
Normal 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;
|
||||
}
|
||||
31
packages/native/src/fd/types.ts
Normal file
31
packages/native/src/fd/types.ts
Normal 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;
|
||||
}
|
||||
24
packages/native/src/html/index.ts
Normal file
24
packages/native/src/html/index.ts
Normal 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;
|
||||
}
|
||||
7
packages/native/src/html/types.ts
Normal file
7
packages/native/src/html/types.ts
Normal 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;
|
||||
}
|
||||
28
packages/native/src/image/index.ts
Normal file
28
packages/native/src/image/index.ts
Normal 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);
|
||||
}
|
||||
41
packages/native/src/image/types.ts
Normal file
41
packages/native/src/image/types.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
125
packages/native/src/text/index.ts
Normal file
125
packages/native/src/text/index.ts
Normal 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;
|
||||
}
|
||||
29
packages/native/src/text/types.ts
Normal file
29
packages/native/src/text/types.ts
Normal 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,
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue